Skip to content

Commit 2be861c

Browse files
authored
refactor: cache account response metadata (Batch accounts 5/5) (#612)
1 parent 9fb60d2 commit 2be861c

6 files changed

Lines changed: 301 additions & 5 deletions

File tree

packages/snap/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- Cache account response metadata to avoid loading full BDK wallets for faster checks ([#612](https://github.com/MetaMask/snap-bitcoin-wallet/pull/612/changes))
13+
1014
## [1.12.0]
1115

1216
### Added

packages/snap/src/entities/snap.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { WalletTx } from '@metamask/bitcoindevkit';
1+
import type { AddressType, Network, WalletTx } from '@metamask/bitcoindevkit';
22
import type { JsonSLIP10Node, SLIP10Node } from '@metamask/key-tree';
33
import type {
44
ComponentOrElement,
@@ -25,6 +25,19 @@ export type AccountState = {
2525
wallet: string;
2626
// Wallet inscriptions for meta protocols (ordinals, etc.)
2727
inscriptions: Inscription[];
28+
// Metadata used by keyring account responses without loading the BDK wallet.
29+
metadata?: AccountMetadata;
30+
};
31+
32+
export type AccountMetadata = {
33+
// Public receive address at account address index 0.
34+
address: string;
35+
// Account address type.
36+
addressType: AddressType;
37+
// Bitcoin network.
38+
network: Network;
39+
// Public descriptor for read-only descriptor requests.
40+
publicDescriptor: string;
2841
};
2942

3043
export type SyncResult = {
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import type {
2+
AddressInfo,
3+
Amount,
4+
Balance,
5+
FullScanRequest,
6+
LocalOutput,
7+
Psbt,
8+
ScriptBuf,
9+
SyncRequest,
10+
Transaction,
11+
Update,
12+
WalletTx,
13+
ChangeSet,
14+
AddressType,
15+
Network,
16+
} from '@metamask/bitcoindevkit';
17+
import { Address } from '@metamask/bitcoindevkit';
18+
19+
import {
20+
AccountCapability,
21+
WalletError,
22+
type AccountMetadata,
23+
type AccountState,
24+
type BitcoinAccount,
25+
type TransactionBuilder,
26+
} from '../entities';
27+
28+
type AccountStateWithMetadata = AccountState & {
29+
metadata: AccountMetadata;
30+
};
31+
32+
export class StoredAccountAdapter implements BitcoinAccount {
33+
readonly #id: string;
34+
35+
readonly #derivationPath: string[];
36+
37+
readonly #metadata: AccountMetadata;
38+
39+
readonly #capabilities: AccountCapability[];
40+
41+
constructor(id: string, account: AccountStateWithMetadata) {
42+
this.#id = id;
43+
this.#derivationPath = account.derivationPath;
44+
this.#metadata = account.metadata;
45+
this.#capabilities = Object.values(AccountCapability);
46+
}
47+
48+
static canLoad(account: AccountState): account is AccountStateWithMetadata {
49+
return Boolean(account.metadata);
50+
}
51+
52+
static load(
53+
id: string,
54+
account: AccountStateWithMetadata,
55+
): StoredAccountAdapter {
56+
return new StoredAccountAdapter(id, account);
57+
}
58+
59+
get id(): string {
60+
return this.#id;
61+
}
62+
63+
get derivationPath(): string[] {
64+
return this.#derivationPath;
65+
}
66+
67+
get entropySource(): string {
68+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
69+
return this.#derivationPath[0]!;
70+
}
71+
72+
get accountIndex(): number {
73+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
74+
const segment = this.#derivationPath[3]!;
75+
const numericPart = segment.endsWith("'") ? segment.slice(0, -1) : segment;
76+
return Number(numericPart);
77+
}
78+
79+
get balance(): Balance {
80+
return this.#unsupported();
81+
}
82+
83+
get addressType(): AddressType {
84+
return this.#metadata.addressType;
85+
}
86+
87+
get network(): Network {
88+
return this.#metadata.network;
89+
}
90+
91+
get publicAddress(): Address {
92+
return Address.from_string(this.#metadata.address, this.#metadata.network);
93+
}
94+
95+
get publicDescriptor(): string {
96+
return this.#metadata.publicDescriptor;
97+
}
98+
99+
get capabilities(): AccountCapability[] {
100+
return this.#capabilities;
101+
}
102+
103+
peekAddress(_index: number): AddressInfo {
104+
return this.#unsupported();
105+
}
106+
107+
nextUnusedAddress(): AddressInfo {
108+
return this.#unsupported();
109+
}
110+
111+
revealNextAddress(): AddressInfo {
112+
return this.#unsupported();
113+
}
114+
115+
startFullScan(): FullScanRequest {
116+
return this.#unsupported();
117+
}
118+
119+
startSync(): SyncRequest {
120+
return this.#unsupported();
121+
}
122+
123+
applyUpdate(_update: Update): void {
124+
this.#unsupported();
125+
}
126+
127+
takeStaged(): ChangeSet | undefined {
128+
return this.#unsupported();
129+
}
130+
131+
hasStaged(): boolean {
132+
return false;
133+
}
134+
135+
buildTx(): TransactionBuilder {
136+
return this.#unsupported();
137+
}
138+
139+
sign(_psbt: Psbt): Psbt {
140+
return this.#unsupported();
141+
}
142+
143+
extractTransaction(_psbt: Psbt, _maxFeeRate?: number): Transaction {
144+
return this.#unsupported();
145+
}
146+
147+
getUtxo(_outpoint: string): LocalOutput | undefined {
148+
return this.#unsupported();
149+
}
150+
151+
listUnspent(): LocalOutput[] {
152+
return this.#unsupported();
153+
}
154+
155+
listTransactions(): WalletTx[] {
156+
return this.#unsupported();
157+
}
158+
159+
getTransaction(_txid: string): WalletTx | undefined {
160+
return this.#unsupported();
161+
}
162+
163+
calculateFee(_tx: Transaction): Amount {
164+
return this.#unsupported();
165+
}
166+
167+
isMine(_script: ScriptBuf): boolean {
168+
return this.#unsupported();
169+
}
170+
171+
sentAndReceived(_tx: Transaction): [Amount, Amount] {
172+
return this.#unsupported();
173+
}
174+
175+
applyUnconfirmedTx(_tx: Transaction, _lastSeen: number): void {
176+
this.#unsupported();
177+
}
178+
179+
#unsupported(): never {
180+
throw new WalletError(
181+
'Stored account metadata cannot be used for wallet operations; load the full account first.',
182+
{ id: this.#id },
183+
);
184+
}
185+
}

packages/snap/src/infra/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './BdkAccountAdapter';
2+
export * from './StoredAccountAdapter';
23
export * from './SnapClientAdapter';
34
export * from './EsploraClientAdapter';
45
export * from './PriceApiClientAdapter';

packages/snap/src/store/BdkAccountRepository.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import type { DescriptorPair } from '@metamask/bitcoindevkit';
55
import {
6+
Address,
67
ChangeSet,
78
xpriv_to_descriptor,
89
xpub_to_descriptor,
@@ -26,6 +27,9 @@ jest.mock('@metamask/bitcoindevkit', () => {
2627
ChangeSet: {
2728
from_json: jest.fn(),
2829
},
30+
Address: {
31+
from_string: jest.fn(),
32+
},
2933
slip10_to_extended: jest.fn().mockReturnValue('mock-extended'),
3034
xpub_to_descriptor: jest.fn(),
3135
xpriv_to_descriptor: jest.fn(),
@@ -57,11 +61,16 @@ describe('BdkAccountRepository', () => {
5761
derivationPath: mockDerivationPath,
5862
});
5963
const mockChangeSet = mock<ChangeSet>();
64+
const mockAddress = mock<Address>({
65+
toString: () => 'bc1qaddress...',
66+
});
6067
const mockAccount = mock<BitcoinAccount>({
6168
id: 'some-id',
6269
derivationPath: mockDerivationPath,
6370
network: 'bitcoin',
6471
addressType: 'p2wpkh',
72+
publicAddress: mockAddress,
73+
publicDescriptor: 'mock-public-descriptor',
6574
});
6675

6776
const repo = new BdkAccountRepository(mockSnapClient);
@@ -74,6 +83,7 @@ describe('BdkAccountRepository', () => {
7483
mockSnapClient.getPublicEntropy.mockResolvedValue(mockSlip10Node);
7584
(xpriv_to_descriptor as jest.Mock).mockReturnValue(mockDescriptors);
7685
(xpub_to_descriptor as jest.Mock).mockReturnValue(mockDescriptors);
86+
jest.mocked(Address.from_string).mockReturnValue(mockAddress);
7787
(mockAccount.takeStaged as jest.Mock) = jest
7888
.fn()
7989
.mockReturnValue(mockChangeSet);
@@ -234,6 +244,40 @@ describe('BdkAccountRepository', () => {
234244
expect(mockSnapClient.setState).not.toHaveBeenCalled();
235245
});
236246

247+
it('uses cached account metadata without loading BDK wallets', async () => {
248+
const accountStateWithMetadata: AccountState = {
249+
...accountState1,
250+
metadata: {
251+
address: 'bc1qcached...',
252+
addressType: 'p2wpkh',
253+
network: 'bitcoin',
254+
publicDescriptor: 'cached-public-descriptor',
255+
},
256+
};
257+
mockSnapClient.getState
258+
.mockResolvedValueOnce({
259+
"m/84'/0'/1'": 'some-id-1',
260+
})
261+
.mockResolvedValueOnce({
262+
'some-id-1': accountStateWithMetadata,
263+
});
264+
(BdkAccountAdapter.load as jest.Mock).mockClear();
265+
(ChangeSet.from_json as jest.Mock).mockClear();
266+
267+
const result = await repo.getByDerivationPaths([derivationPath1]);
268+
const account = result[0];
269+
270+
expect(account?.id).toBe('some-id-1');
271+
expect(account?.publicAddress.toString()).toBe('bc1qaddress...');
272+
expect(account?.publicDescriptor).toBe('cached-public-descriptor');
273+
expect(jest.mocked(Address.from_string)).toHaveBeenCalledWith(
274+
'bc1qcached...',
275+
'bitcoin',
276+
);
277+
expect(ChangeSet.from_json).not.toHaveBeenCalled();
278+
expect(BdkAccountAdapter.load).not.toHaveBeenCalled();
279+
});
280+
237281
it('repairs missing derivation path indexes from account state', async () => {
238282
mockSnapClient.getState
239283
.mockResolvedValueOnce({
@@ -342,6 +386,12 @@ describe('BdkAccountRepository', () => {
342386
wallet: mockWalletData,
343387
inscriptions: [],
344388
derivationPath: mockDerivationPath,
389+
metadata: {
390+
address: 'bc1qaddress...',
391+
addressType: 'p2wpkh',
392+
network: 'bitcoin',
393+
publicDescriptor: 'mock-public-descriptor',
394+
},
345395
},
346396
);
347397
});
@@ -415,9 +465,17 @@ describe('BdkAccountRepository', () => {
415465
const account1 = mock<BitcoinAccount>();
416466
account1.id = 'some-id-1';
417467
account1.derivationPath = ['m', "84'", "0'", "1'"];
468+
account1.network = 'bitcoin';
469+
account1.addressType = 'p2wpkh';
470+
account1.publicAddress = mockAddress;
471+
account1.publicDescriptor = 'mock-public-descriptor-1';
418472
const account2 = mock<BitcoinAccount>();
419473
account2.id = 'some-id-2';
420474
account2.derivationPath = ['m', "84'", "0'", "2'"];
475+
account2.network = 'bitcoin';
476+
account2.addressType = 'p2wpkh';
477+
account2.publicAddress = mockAddress;
478+
account2.publicDescriptor = 'mock-public-descriptor-2';
421479
(account1.takeStaged as jest.Mock) = jest
422480
.fn()
423481
.mockReturnValue(mockChangeSet);
@@ -443,11 +501,23 @@ describe('BdkAccountRepository', () => {
443501
wallet: mockWalletData,
444502
inscriptions: [],
445503
derivationPath: ['m', "84'", "0'", "1'"],
504+
metadata: {
505+
address: 'bc1qaddress...',
506+
addressType: 'p2wpkh',
507+
network: 'bitcoin',
508+
publicDescriptor: 'mock-public-descriptor-1',
509+
},
446510
},
447511
'some-id-2': {
448512
wallet: mockWalletData,
449513
inscriptions: [],
450514
derivationPath: ['m', "84'", "0'", "2'"],
515+
metadata: {
516+
address: 'bc1qaddress...',
517+
addressType: 'p2wpkh',
518+
network: 'bitcoin',
519+
publicDescriptor: 'mock-public-descriptor-2',
520+
},
451521
},
452522
});
453523
expect(mockSnapClient.setState).toHaveBeenNthCalledWith(

0 commit comments

Comments
 (0)