Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion packages/snap/src/entities/snap.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 = {
Expand Down
181 changes: 181 additions & 0 deletions packages/snap/src/infra/StoredAccountAdapter.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
1 change: 1 addition & 0 deletions packages/snap/src/infra/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './BdkAccountAdapter';
export * from './StoredAccountAdapter';
export * from './SnapClientAdapter';
export * from './EsploraClientAdapter';
export * from './PriceApiClientAdapter';
Expand Down
70 changes: 70 additions & 0 deletions packages/snap/src/store/BdkAccountRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import type { DescriptorPair } from '@metamask/bitcoindevkit';
import {
Address,
ChangeSet,
xpriv_to_descriptor,
xpub_to_descriptor,
Expand All @@ -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(),
Expand Down Expand Up @@ -57,11 +61,16 @@ describe('BdkAccountRepository', () => {
derivationPath: mockDerivationPath,
});
const mockChangeSet = mock<ChangeSet>();
const mockAddress = mock<Address>({
toString: () => 'bc1qaddress...',
});
const mockAccount = mock<BitcoinAccount>({
id: 'some-id',
derivationPath: mockDerivationPath,
network: 'bitcoin',
addressType: 'p2wpkh',
publicAddress: mockAddress,
publicDescriptor: 'mock-public-descriptor',
});

const repo = new BdkAccountRepository(mockSnapClient);
Expand All @@ -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);
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -341,6 +385,12 @@ describe('BdkAccountRepository', () => {
wallet: mockWalletData,
inscriptions: [],
derivationPath: mockDerivationPath,
metadata: {
address: 'bc1qaddress...',
addressType: 'p2wpkh',
network: 'bitcoin',
publicDescriptor: 'mock-public-descriptor',
},
},
);
});
Expand Down Expand Up @@ -380,9 +430,17 @@ describe('BdkAccountRepository', () => {
const account1 = mock<BitcoinAccount>();
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<BitcoinAccount>();
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);
Expand All @@ -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(
Expand Down
Loading
Loading