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
4 changes: 4 additions & 0 deletions packages/snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `createMany` use case ([#610](https://github.com/MetaMask/snap-bitcoin-wallet/pull/610))

### Changed

- Show a confirmation dialog before signing a PSBT from KeyringHandler and sending a transfer ([#591](https://github.com/MetaMask/snap-bitcoin-wallet/pull/591))
Expand Down
17 changes: 17 additions & 0 deletions packages/snap/src/entities/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,16 @@ export type BitcoinAccountRepository = {
*/
getByDerivationPath(derivationPath: string[]): Promise<BitcoinAccount | null>;

/**
* Get accounts by derivation path.
*
* @param derivationPaths - derivation paths.
* @returns the accounts or null if they do not exist, in input order
*/
getByDerivationPaths(
derivationPaths: string[][],
): Promise<(BitcoinAccount | null)[]>;

/**
* Create a new account, without persisting it.
*
Expand All @@ -283,6 +293,13 @@ export type BitcoinAccountRepository = {
*/
insert(account: BitcoinAccount): Promise<BitcoinAccount>;

/**
* Insert accounts.
*
* @param accounts - Bitcoin accounts.
*/
insertMany(accounts: BitcoinAccount[]): Promise<BitcoinAccount[]>;

/**
* Update an account.
*
Expand Down
2 changes: 1 addition & 1 deletion packages/snap/src/entities/snap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { Inscription } from './meta-protocols';

export type SnapState = {
// accountId -> account state. This is the main state of the snap.
accounts: Record<string, AccountState>;
accounts: Record<string, AccountState | null>;
// derivationPath -> accountId. Only needed for fast lookup.
derivationPaths: Record<string, string>;
};
Expand Down
167 changes: 167 additions & 0 deletions packages/snap/src/store/BdkAccountRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,94 @@ describe('BdkAccountRepository', () => {
});
});

describe('getByDerivationPaths', () => {
const derivationPath1 = ['m', "84'", "0'", "1'"];
const derivationPath2 = ['m', "84'", "0'", "2'"];
const accountState1 = {
...mockAccountState,
derivationPath: derivationPath1,
};
const accountState2 = {
...mockAccountState,
derivationPath: derivationPath2,
};
const mockAccount1 = mock<BitcoinAccount>({
...mockAccount,
id: 'some-id-1',
derivationPath: derivationPath1,
});
const mockAccount2 = mock<BitcoinAccount>({
...mockAccount,
id: 'some-id-2',
derivationPath: derivationPath2,
});

it('returns accounts in derivation path order with one state read per namespace', async () => {
mockSnapClient.getState
.mockResolvedValueOnce({
"m/84'/0'/1'": 'some-id-1',
"m/84'/0'/2'": 'some-id-2',
})
.mockResolvedValueOnce({
'some-id-1': accountState1,
'some-id-2': accountState2,
});
(BdkAccountAdapter.load as jest.Mock)
.mockReturnValueOnce(mockAccount2)
.mockReturnValueOnce(mockAccount1);

const result = await repo.getByDerivationPaths([
derivationPath2,
derivationPath1,
]);

expect(mockSnapClient.getState).toHaveBeenCalledWith('derivationPaths');
expect(mockSnapClient.getState).toHaveBeenCalledWith('accounts');
expect(mockSnapClient.getState).toHaveBeenCalledTimes(2);
expect(result).toStrictEqual([mockAccount2, mockAccount1]);
expect(mockSnapClient.setState).not.toHaveBeenCalled();
});

it('repairs missing derivation path indexes from account state', async () => {
mockSnapClient.getState
.mockResolvedValueOnce({
"m/84'/0'/1'": 'some-id-1',
})
.mockResolvedValueOnce({
'some-id-1': accountState1,
'some-id-2': accountState2,
});
(BdkAccountAdapter.load as jest.Mock)
.mockReturnValueOnce(mockAccount1)
.mockReturnValueOnce(mockAccount2);

const result = await repo.getByDerivationPaths([
derivationPath1,
derivationPath2,
]);

expect(result).toStrictEqual([mockAccount1, mockAccount2]);
expect(mockSnapClient.setState).toHaveBeenCalledWith('derivationPaths', {
"m/84'/0'/1'": 'some-id-1',
"m/84'/0'/2'": 'some-id-2',
});
});

it('repairs a missing derivation path index for a single lookup', async () => {
mockSnapClient.getState.mockResolvedValueOnce({}).mockResolvedValueOnce({
'some-id-1': accountState1,
});
(BdkAccountAdapter.load as jest.Mock).mockReturnValueOnce(mockAccount1);

const result = await repo.getByDerivationPaths([derivationPath1]);

expect(result).toStrictEqual([mockAccount1]);
expect(mockSnapClient.setState).toHaveBeenCalledWith('derivationPaths', {
"m/84'/0'/1'": 'some-id-1',
});
});
});

describe('getWithSigner', () => {
it('returns null if account not found', async () => {
mockSnapClient.getState.mockResolvedValue(null);
Expand Down Expand Up @@ -258,6 +346,85 @@ describe('BdkAccountRepository', () => {
});
});

describe('insertMany', () => {
it('returns an empty array when there are no accounts to insert', async () => {
const result = await repo.insertMany([]);

expect(result).toStrictEqual([]);
expect(mockSnapClient.getState).not.toHaveBeenCalled();
expect(mockSnapClient.setState).not.toHaveBeenCalled();
});

it('throws an error if any account has no wallet data', async () => {
await expect(
repo.insertMany([
{
...mockAccount,
id: 'missing-wallet',
takeStaged: jest.fn().mockReturnValue(undefined),
},
mockAccount,
]),
).rejects.toThrow(
'Missing changeset data for account "missing-wallet" for insertion.',
);
expect(mockSnapClient.setState).not.toHaveBeenCalled();
});

it('inserts multiple accounts with one accounts write and one derivation path write', async () => {
const existingAccountState: AccountState = {
wallet: mockWalletData,
inscriptions: [],
derivationPath: mockDerivationPath,
};
const account1 = mock<BitcoinAccount>();
account1.id = 'some-id-1';
account1.derivationPath = ['m', "84'", "0'", "1'"];
const account2 = mock<BitcoinAccount>();
account2.id = 'some-id-2';
account2.derivationPath = ['m', "84'", "0'", "2'"];
(account1.takeStaged as jest.Mock) = jest
.fn()
.mockReturnValue(mockChangeSet);
(account2.takeStaged as jest.Mock) = jest
.fn()
.mockReturnValue(mockChangeSet);
mockSnapClient.getState
.mockResolvedValueOnce({
'existing-id': existingAccountState,
})
.mockResolvedValueOnce({
"m/84'/0'/0'": 'existing-id',
});

const result = await repo.insertMany([account1, account2]);

expect(result).toStrictEqual([account1, account2]);
expect(mockSnapClient.setState).toHaveBeenNthCalledWith(1, 'accounts', {
'existing-id': existingAccountState,
'some-id-1': {
wallet: mockWalletData,
inscriptions: [],
derivationPath: ['m', "84'", "0'", "1'"],
},
'some-id-2': {
wallet: mockWalletData,
inscriptions: [],
derivationPath: ['m', "84'", "0'", "2'"],
},
});
expect(mockSnapClient.setState).toHaveBeenNthCalledWith(
2,
'derivationPaths',
{
"m/84'/0'/0'": 'existing-id',
"m/84'/0'/1'": 'some-id-1',
"m/84'/0'/2'": 'some-id-2',
},
);
});
});

describe('update', () => {
it('does nothing if no wallet data', async () => {
await repo.update({
Expand Down
Loading