From 5319470a28fd8a8c43bf960656dc3316ffd12527 Mon Sep 17 00:00:00 2001 From: gantunesr <17601467+gantunesr@users.noreply.github.com> Date: Thu, 14 May 2026 17:00:29 -0400 Subject: [PATCH] feat: cache account response metadata --- packages/snap/src/entities/snap.ts | 15 +- .../snap/src/infra/StoredAccountAdapter.ts | 181 ++++++++++++++++++ packages/snap/src/infra/index.ts | 1 + .../src/store/BdkAccountRepository.test.ts | 70 +++++++ .../snap/src/store/BdkAccountRepository.ts | 31 ++- 5 files changed, 293 insertions(+), 5 deletions(-) create mode 100644 packages/snap/src/infra/StoredAccountAdapter.ts diff --git a/packages/snap/src/entities/snap.ts b/packages/snap/src/entities/snap.ts index 8d150ce32..f84bcfa0d 100644 --- a/packages/snap/src/entities/snap.ts +++ b/packages/snap/src/entities/snap.ts @@ -1,4 +1,4 @@ -import type { WalletTx } from '@metamask/bitcoindevkit'; +import type { AddressType, Network, WalletTx } from '@metamask/bitcoindevkit'; import type { JsonSLIP10Node, SLIP10Node } from '@metamask/key-tree'; import type { ComponentOrElement, @@ -25,6 +25,19 @@ export type AccountState = { wallet: string; // Wallet inscriptions for meta protocols (ordinals, etc.) inscriptions: Inscription[]; + // Metadata used by keyring account responses without loading the BDK wallet. + metadata?: AccountMetadata; +}; + +export type AccountMetadata = { + // Public receive address at account address index 0. + address: string; + // Account address type. + addressType: AddressType; + // Bitcoin network. + network: Network; + // Public descriptor for read-only descriptor requests. + publicDescriptor: string; }; export type SyncResult = { diff --git a/packages/snap/src/infra/StoredAccountAdapter.ts b/packages/snap/src/infra/StoredAccountAdapter.ts new file mode 100644 index 000000000..92f12464f --- /dev/null +++ b/packages/snap/src/infra/StoredAccountAdapter.ts @@ -0,0 +1,181 @@ +import type { + AddressInfo, + Amount, + Balance, + FullScanRequest, + LocalOutput, + Psbt, + ScriptBuf, + SyncRequest, + Transaction, + Update, + WalletTx, + ChangeSet, + AddressType, + Network, +} from '@metamask/bitcoindevkit'; +import { Address } from '@metamask/bitcoindevkit'; + +import { + AccountCapability, + WalletError, + type AccountMetadata, + type AccountState, + type BitcoinAccount, + type TransactionBuilder, +} from '../entities'; + +type AccountStateWithMetadata = AccountState & { + metadata: AccountMetadata; +}; + +export class StoredAccountAdapter implements BitcoinAccount { + readonly #id: string; + + readonly #derivationPath: string[]; + + readonly #metadata: AccountMetadata; + + readonly #capabilities: AccountCapability[]; + + constructor(id: string, account: AccountStateWithMetadata) { + this.#id = id; + this.#derivationPath = account.derivationPath; + this.#metadata = account.metadata; + this.#capabilities = Object.values(AccountCapability); + } + + static canLoad(account: AccountState): account is AccountStateWithMetadata { + return Boolean(account.metadata); + } + + static load( + id: string, + account: AccountStateWithMetadata, + ): StoredAccountAdapter { + return new StoredAccountAdapter(id, account); + } + + get id(): string { + return this.#id; + } + + get derivationPath(): string[] { + return this.#derivationPath; + } + + get entropySource(): string { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.#derivationPath[0]!; + } + + get accountIndex(): number { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const segment = this.#derivationPath[3]!; + const numericPart = segment.endsWith("'") ? segment.slice(0, -1) : segment; + return Number(numericPart); + } + + get balance(): Balance { + return this.#unsupported(); + } + + get addressType(): AddressType { + return this.#metadata.addressType; + } + + get network(): Network { + return this.#metadata.network; + } + + get publicAddress(): Address { + return Address.from_string(this.#metadata.address, this.#metadata.network); + } + + get publicDescriptor(): string { + return this.#metadata.publicDescriptor; + } + + get capabilities(): AccountCapability[] { + return this.#capabilities; + } + + peekAddress(_index: number): AddressInfo { + return this.#unsupported(); + } + + nextUnusedAddress(): AddressInfo { + return this.#unsupported(); + } + + revealNextAddress(): AddressInfo { + return this.#unsupported(); + } + + startFullScan(): FullScanRequest { + return this.#unsupported(); + } + + startSync(): SyncRequest { + return this.#unsupported(); + } + + applyUpdate(_update: Update): void { + this.#unsupported(); + } + + takeStaged(): ChangeSet | undefined { + return this.#unsupported(); + } + + buildTx(): TransactionBuilder { + return this.#unsupported(); + } + + sign(_psbt: Psbt): Psbt { + return this.#unsupported(); + } + + extractTransaction(_psbt: Psbt, _maxFeeRate?: number): Transaction { + return this.#unsupported(); + } + + getUtxo(_outpoint: string): LocalOutput | undefined { + return this.#unsupported(); + } + + listUnspent(): LocalOutput[] { + return this.#unsupported(); + } + + listTransactions(): WalletTx[] { + return this.#unsupported(); + } + + getTransaction(_txid: string): WalletTx | undefined { + return this.#unsupported(); + } + + calculateFee(_tx: Transaction): Amount { + return this.#unsupported(); + } + + isMine(_script: ScriptBuf): boolean { + return this.#unsupported(); + } + + sentAndReceived(_tx: Transaction): [Amount, Amount] { + return this.#unsupported(); + } + + applyUnconfirmedTx(_tx: Transaction, _lastSeen: number): void { + this.#unsupported(); + } + + #unsupported(): never { + throw new WalletError( + 'Stored account metadata cannot be used for wallet operations; load the full account first.', + { id: this.#id }, + ); + } +} diff --git a/packages/snap/src/infra/index.ts b/packages/snap/src/infra/index.ts index dbc4a2f30..fa4ce4fd2 100644 --- a/packages/snap/src/infra/index.ts +++ b/packages/snap/src/infra/index.ts @@ -1,4 +1,5 @@ export * from './BdkAccountAdapter'; +export * from './StoredAccountAdapter'; export * from './SnapClientAdapter'; export * from './EsploraClientAdapter'; export * from './PriceApiClientAdapter'; diff --git a/packages/snap/src/store/BdkAccountRepository.test.ts b/packages/snap/src/store/BdkAccountRepository.test.ts index da29f74ab..c6c684f24 100644 --- a/packages/snap/src/store/BdkAccountRepository.test.ts +++ b/packages/snap/src/store/BdkAccountRepository.test.ts @@ -3,6 +3,7 @@ import type { DescriptorPair } from '@metamask/bitcoindevkit'; import { + Address, ChangeSet, xpriv_to_descriptor, xpub_to_descriptor, @@ -26,6 +27,9 @@ jest.mock('@metamask/bitcoindevkit', () => { ChangeSet: { from_json: jest.fn(), }, + Address: { + from_string: jest.fn(), + }, slip10_to_extended: jest.fn().mockReturnValue('mock-extended'), xpub_to_descriptor: jest.fn(), xpriv_to_descriptor: jest.fn(), @@ -57,11 +61,16 @@ describe('BdkAccountRepository', () => { derivationPath: mockDerivationPath, }); const mockChangeSet = mock(); + const mockAddress = mock
({ + toString: () => 'bc1qaddress...', + }); const mockAccount = mock({ id: 'some-id', derivationPath: mockDerivationPath, network: 'bitcoin', addressType: 'p2wpkh', + publicAddress: mockAddress, + publicDescriptor: 'mock-public-descriptor', }); const repo = new BdkAccountRepository(mockSnapClient); @@ -74,6 +83,7 @@ describe('BdkAccountRepository', () => { mockSnapClient.getPublicEntropy.mockResolvedValue(mockSlip10Node); (xpriv_to_descriptor as jest.Mock).mockReturnValue(mockDescriptors); (xpub_to_descriptor as jest.Mock).mockReturnValue(mockDescriptors); + jest.mocked(Address.from_string).mockReturnValue(mockAddress); (mockAccount.takeStaged as jest.Mock) = jest .fn() .mockReturnValue(mockChangeSet); @@ -233,6 +243,40 @@ describe('BdkAccountRepository', () => { expect(mockSnapClient.setState).not.toHaveBeenCalled(); }); + it('uses cached account metadata without loading BDK wallets', async () => { + const accountStateWithMetadata: AccountState = { + ...accountState1, + metadata: { + address: 'bc1qcached...', + addressType: 'p2wpkh', + network: 'bitcoin', + publicDescriptor: 'cached-public-descriptor', + }, + }; + mockSnapClient.getState + .mockResolvedValueOnce({ + "m/84'/0'/1'": 'some-id-1', + }) + .mockResolvedValueOnce({ + 'some-id-1': accountStateWithMetadata, + }); + (BdkAccountAdapter.load as jest.Mock).mockClear(); + (ChangeSet.from_json as jest.Mock).mockClear(); + + const result = await repo.getByDerivationPaths([derivationPath1]); + const account = result[0]; + + expect(account?.id).toBe('some-id-1'); + expect(account?.publicAddress.toString()).toBe('bc1qaddress...'); + expect(account?.publicDescriptor).toBe('cached-public-descriptor'); + expect(jest.mocked(Address.from_string)).toHaveBeenCalledWith( + 'bc1qcached...', + 'bitcoin', + ); + expect(ChangeSet.from_json).not.toHaveBeenCalled(); + expect(BdkAccountAdapter.load).not.toHaveBeenCalled(); + }); + it('repairs missing derivation path indexes from account state', async () => { mockSnapClient.getState .mockResolvedValueOnce({ @@ -341,6 +385,12 @@ describe('BdkAccountRepository', () => { wallet: mockWalletData, inscriptions: [], derivationPath: mockDerivationPath, + metadata: { + address: 'bc1qaddress...', + addressType: 'p2wpkh', + network: 'bitcoin', + publicDescriptor: 'mock-public-descriptor', + }, }, ); }); @@ -380,9 +430,17 @@ describe('BdkAccountRepository', () => { const account1 = mock(); account1.id = 'some-id-1'; account1.derivationPath = ['m', "84'", "0'", "1'"]; + account1.network = 'bitcoin'; + account1.addressType = 'p2wpkh'; + account1.publicAddress = mockAddress; + account1.publicDescriptor = 'mock-public-descriptor-1'; const account2 = mock(); account2.id = 'some-id-2'; account2.derivationPath = ['m', "84'", "0'", "2'"]; + account2.network = 'bitcoin'; + account2.addressType = 'p2wpkh'; + account2.publicAddress = mockAddress; + account2.publicDescriptor = 'mock-public-descriptor-2'; (account1.takeStaged as jest.Mock) = jest .fn() .mockReturnValue(mockChangeSet); @@ -406,11 +464,23 @@ describe('BdkAccountRepository', () => { wallet: mockWalletData, inscriptions: [], derivationPath: ['m', "84'", "0'", "1'"], + metadata: { + address: 'bc1qaddress...', + addressType: 'p2wpkh', + network: 'bitcoin', + publicDescriptor: 'mock-public-descriptor-1', + }, }, 'some-id-2': { wallet: mockWalletData, inscriptions: [], derivationPath: ['m', "84'", "0'", "2'"], + metadata: { + address: 'bc1qaddress...', + addressType: 'p2wpkh', + network: 'bitcoin', + publicDescriptor: 'mock-public-descriptor-2', + }, }, }); expect(mockSnapClient.setState).toHaveBeenNthCalledWith( diff --git a/packages/snap/src/store/BdkAccountRepository.ts b/packages/snap/src/store/BdkAccountRepository.ts index 3e630dcc8..7eab93b31 100644 --- a/packages/snap/src/store/BdkAccountRepository.ts +++ b/packages/snap/src/store/BdkAccountRepository.ts @@ -16,10 +16,11 @@ import { type SnapClient, type Inscription, type AccountState, + type AccountMetadata, type SnapState, StorageError, } from '../entities'; -import { BdkAccountAdapter } from '../infra'; +import { BdkAccountAdapter, StoredAccountAdapter } from '../infra'; /** * Encode a fingerprint to a 4-bytes hex-string (required by the BDK). @@ -42,10 +43,23 @@ function getDerivationPathKey(derivationPath: string[]): string { return derivationPath.join('/'); } +/** + * @param account - Account to persist. + * @returns Metadata needed for keyring account responses. + */ +function getAccountMetadata(account: BitcoinAccount): AccountMetadata { + return { + address: account.publicAddress.toString(), + addressType: account.addressType, + network: account.network, + publicDescriptor: account.publicDescriptor, + }; +} + /** * @param account - Account to persist. * @param walletData - Serialized wallet data. - * @returns Account state. + * @returns Account state with cached response metadata. */ function getAccountState( account: BitcoinAccount, @@ -55,6 +69,7 @@ function getAccountState( wallet: walletData.to_json(), inscriptions: [], derivationPath: account.derivationPath, + metadata: getAccountMetadata(account), }; } @@ -138,7 +153,7 @@ export class BdkAccountRepository implements BitcoinAccountRepository { const indexedAccount = indexedId ? accountsById[indexedId] : null; if (indexedId && indexedAccount) { - return this.#loadAccount(indexedId, indexedAccount); + return this.#loadPersistedAccount(indexedId, indexedAccount); } const fallback = accountsByDerivationPath.get(pathKey); @@ -148,7 +163,7 @@ export class BdkAccountRepository implements BitcoinAccountRepository { const [id, account] = fallback; repairs[pathKey] = id; - return this.#loadAccount(id, account); + return this.#loadPersistedAccount(id, account); }); if (Object.keys(repairs).length > 0) { @@ -373,4 +388,12 @@ export class BdkAccountRepository implements BitcoinAccountRepository { ChangeSet.from_json(account.wallet), ); } + + #loadPersistedAccount(id: string, account: AccountState): BitcoinAccount { + if (StoredAccountAdapter.canLoad(account)) { + return StoredAccountAdapter.load(id, account); + } + + return this.#loadAccount(id, account); + } }