How Nautilus Wallet Implements Multi-Wallet Support in IndexedDB

Nautilus Wallet implements multi-wallet support by storing all wallet data in a single IndexedDB instance and isolating each wallet's records through a walletId foreign key that exists on every table except the wallet metadata table itself.

Nautilus Wallet is an open-source cryptocurrency wallet built for the Ergo blockchain that must handle multiple user wallets simultaneously while maintaining strict data isolation. The application achieves robust multi-wallet support through a relational database pattern implemented in Dexie.js, where every address, asset, UTXO, and DApp connection carries a walletId reference back to its parent wallet.

The Core Architecture: Foreign-Key Isolation

The foundation of Nautilus Wallet's multi-wallet database structure is the walletId foreign-key pattern. Every table that stores wallet-specific data includes a walletId column that references the primary key of the wallets table. This design allows the application to filter all queries by the active wallet ID, ensuring complete data isolation without requiring separate database instances.

Database Tables and Relationships

The schema defines six primary tables, with five containing the walletId foreign key:

Table Primary Key Foreign Key Purpose
wallets ++id (auto-increment) – Stores wallet metadata (name, network, public key, settings)
addresses &script walletId All derived addresses for a wallet
assets &[tokenId+address] walletId Token balances per address
utxos &id walletId UTXO entries belonging to a wallet
connectedDApps &origin walletId DApp-wallet linkages per wallet
assetInfo &id – Global token metadata (shared across wallets)

The assetInfo table is the only data store that lacks a walletId because it contains global token metadata shared across all wallets.

Schema Definition and Versioning

The database schema and migration logic reside in src/database/dbContext.ts. The NautilusDb class extends Dexie and defines the table structures with explicit indexes on the walletId fields to ensure query performance.

// src/database/dbContext.ts
class NautilusDb extends Dexie {
  wallets!: Table<IDbWallet, number>;
  addresses!: Table<IDbAddress, string>;
  assets!: Table<IDbAsset, string[]>;
  connectedDApps!: Table<IDbDAppConnection, string>;
  utxos!: Table<IDbUtxo, string>;
  assetInfo!: Table<IAssetInfo, string>;

  constructor() {
    super("nautilusDb");
    this.version(1).stores({
      wallets: "++id, network, &publicKey",
      addresses: "&script, type, walletId",
      assets: "&[tokenId+address], &[address+tokenId], walletId"
    });
    // … additional versions add fields and indexes (e.g. utxos, assetInfo)
  }
}

The versioning strategy allows Nautilus to add new columns and indexes while preserving existing wallet data. Each version upgrade can perform data migrations that maintain the walletId associations.

Type Safety with walletId

The TypeScript definitions in src/types/database.ts enforce the walletId requirement at the type level, ensuring that every entity creation includes the foreign key reference.

// src/types/database.ts
export interface IDbAddress {
  type: AddressType;
  state: AddressState;
  script: string;
  index: number;
  walletId: number;           // <-- ties address to a wallet
}

export interface IDbAsset {
  tokenId: string;
  confirmedAmount: string;
  address: string;
  walletId: number;           // <-- ties asset balance to a wallet
}

export interface IDbUtxo {
  id: string;
  confirmed: boolean;
  address?: string;
  walletId: number;           // <-- ties UTXO to a wallet
}

These interfaces prevent compile-time errors where a developer might forget to associate a new address or UTXO with its parent wallet.

Service Layer Data Isolation

All database interactions flow through dedicated service classes that enforce the walletId filter on every operation. This pattern ensures that no service can accidentally access another wallet's data.

Wallets Service and Cascading Deletes

The src/database/walletsDbService.ts file handles wallet lifecycle management, including a cascading delete that removes all associated records when a wallet is deleted.

// src/database/walletsDbService.ts
public async delete(walletId: number): Promise<void> {
  await Promise.all([
    dbContext.addresses.where({ walletId }).delete(),
    dbContext.assets.where({ walletId }).delete(),
    dbContext.connectedDApps.where({ walletId }).delete(),
    dbContext.utxos.where({ walletId }).delete(),
    dbContext.wallets.delete(walletId)            // finally delete the wallet record
  ]);
}

This method ensures referential integrity by removing all child records before deleting the parent wallet entry.

Address and Asset Services

The src/database/addressesDbService.ts and src/database/assetsDbService.ts files demonstrate the standard query pattern used across all services.

// src/database/addressesDbService.ts
async getByWalletId(walletId: number): Promise<IDbAddress[]> {
  return dbContext.addresses.where({ walletId }).sortBy("index");
}

// src/database/assetsDbService.ts
public async getByWalletId(walletId: number): Promise<IDbAsset[]> {
  return dbContext.assets.where({ walletId }).toArray();
}

Both services use Dexie's where({ walletId }) method to filter results, ensuring that the UI layer receives only the data relevant to the currently active wallet.

Practical Implementation: Creating and Querying Wallets

The multi-wallet architecture enables straightforward workflows for adding new wallets and retrieving their isolated data.

Adding a New Wallet

When creating a wallet, the application generates the metadata record first, then associates all derived data with the returned auto-increment ID.

import { walletsDbService } from '@/database/walletsDbService';
import { addressesDbService } from '@/database/addressesDbService';
import { assetsDbService } from '@/database/assetsDbService';

// 1️⃣ Create the wallet meta record (Dexie will assign an auto-increment id)
const walletId = await walletsDbService.put({
  name: 'My second wallet',
  network: 'ergo',
  type: 'hd',
  publicKey: '02ab…',
  chainCode: '…',
  settings: { avoidAddressReuse: false, hideUsedAddresses: false, defaultChangeIndex: 0 }
});

// 2️⃣ Generate derived addresses (example for index 0‑4) and store them
const derived = [
  { type: 'receive', state: 'unused', script: '0x01…', index: 0, walletId },
  { type: 'receive', state: 'unused', script: '0x02…', index: 1, walletId },
  // …
];
await addressesDbService.bulkPut(derived);

// 3️⃣ Initialise empty asset entries (e.g., ERG balance)
await assetsDbService.bulkPut([
  { tokenId: 'ERG_TOKEN_ID', confirmedAmount: '0', address: derived[0].script, walletId }
]);

All subsequent queries using walletId will return only the records associated with this specific wallet.

Retrieving Wallet-Specific Data

To load a wallet's complete context, services filter by the walletId foreign key.

import { walletsDbService } from '@/database/walletsDbService';
import { addressesDbService } from '@/database/addressesDbService';
import { assetsDbService } from '@/database/assetsDbService';

async function loadWalletContext(id: number) {
  const wallet = await walletsDbService.getById(id);
  const addresses = await addressesDbService.getByWalletId(id);
  const assets = await assetsDbService.getByWalletId(id);
  return { wallet, addresses, assets };
}

Because each service internally uses where({ walletId }), the returned collections are strictly isolated to the requested wallet.

Summary

  • Foreign-key isolation via the walletId column on every table except wallets ensures complete data separation between multiple wallets in a single IndexedDB instance.
  • Dexie.js schema in src/database/dbContext.ts defines indexed tables with walletId fields for performant queries and supports versioned migrations.
  • Type safety through src/types/database.ts enforces walletId requirements at compile time, preventing orphaned records.
  • Service layer classes in src/database/*DbService.ts isolate data by filtering every query with where({ walletId }), including cascading deletes that clean up all child records when a wallet is removed.
  • Scalable architecture allows users to create unlimited wallets while maintaining O(1) lookup performance through IndexedDB indexing.

Frequently Asked Questions

How does Nautilus Wallet prevent data leakage between wallets?

Nautilus Wallet prevents data leakage by requiring a walletId foreign key on every database table except the global assetInfo store. All service-layer queries in files like src/database/addressesDbService.ts and src/database/assetsDbService.ts use Dexie's where({ walletId }) method to filter results, ensuring that the UI layer can only access records explicitly linked to the currently active wallet ID.

What happens to a wallet's data when it is deleted?

When a wallet is deleted through walletsDbService.delete() in src/database/walletsDbService.ts, the method performs a cascading cleanup operation. It executes Promise.all() to simultaneously delete all records from the addresses, assets, connectedDApps, and utxos tables where walletId matches the deleted wallet, followed by the wallet record itself, ensuring no orphan data remains in IndexedDB.

Can Nautilus Wallet handle an unlimited number of wallets?

Yes, the architecture supports an effectively unlimited number of wallets limited only by the browser's IndexedDB storage quota. Because each wallet's data is tagged with a numeric walletId and indexed in Dexie, queries remain performant at O(1) complexity regardless of how many wallets exist in the database, allowing users to create, switch between, and delete wallets without performance degradation.

How does the database schema handle migrations when adding new features?

The database schema handles migrations through Dexie's versioning system defined in src/database/dbContext.ts. Each schema change increments the version number and specifies new indexes or columns; Dexie automatically handles the migration by preserving existing data and adding new fields, while the TypeScript interfaces in src/types/database.ts ensure that new code accounts for the walletId field on any newly added tables.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →