Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8081592
perf(tron-wallet-snap): reduce createAccounts extension RPC round trips
hmalik88 Aug 13, 2026
8730c70
perf(tron-wallet-snap): fetch entropy once during BIP-44 account disc…
hmalik88 Aug 13, 2026
f729c38
fix(tron-wallet-snap): coalesce concurrent account synchronization runs
hmalik88 Aug 13, 2026
345193f
chore(tron-wallet-snap): update shasum
hmalik88 Aug 13, 2026
4f00b99
refactor(tron-wallet-snap): remove unreachable v1 account-creation path
hmalik88 Aug 14, 2026
183bc25
chore(tron-wallet-snap): update shasum
hmalik88 Aug 14, 2026
90cc05d
fix(tron-wallet-snap): remove AccountDeleted emission that broke v2 a…
hmalik88 Aug 14, 2026
534a6b2
chore(tron-wallet-snap): update shasum
hmalik88 Aug 14, 2026
47373ac
fix(bitcoin-wallet-snap): remove AccountDeleted emission that broke v…
hmalik88 Aug 14, 2026
9bd9de0
refactor(bitcoin-wallet-snap): remove unreachable v1 account-creation…
hmalik88 Aug 14, 2026
d32e4e3
fix(bitcoin-wallet-snap): coalesce concurrent account synchronization…
hmalik88 Aug 14, 2026
a696e89
perf(bitcoin-wallet-snap): fetch entropy once per parent path in batc…
hmalik88 Aug 14, 2026
86b6ca0
perf(bitcoin-wallet-snap): reuse lookup state snapshot for batch inserts
hmalik88 Aug 14, 2026
56156d5
perf(bitcoin-wallet-snap): create requested account ranges in a singl…
hmalik88 Aug 14, 2026
fc32de4
chore(bitcoin-wallet-snap): log phase timings for batch account creation
hmalik88 Aug 14, 2026
40b1667
Merge remote-tracking branch 'origin/main' into hm/bitcoin-perf
hmalik88 Aug 27, 2026
e9ed5fd
chore(bitcoin-wallet-snap): update shasum
hmalik88 Aug 27, 2026
a474526
fix(bitcoin-wallet-snap): lint fix
hmalik88 Aug 27, 2026
4877b2f
fix(bitcoin-wallet-snap): fix changelog
hmalik88 Aug 27, 2026
d95f1ec
Merge remote-tracking branch 'origin/main' into hm/bitcoin-perf
hmalik88 Aug 28, 2026
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
11 changes: 11 additions & 0 deletions packages/bitcoin-wallet-snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Reduce `keyring_createAccounts` entropy RPCs from one per account to one per distinct parent path by fetching the account-level parent node once and deriving hardened children locally ([#221](https://github.com/MetaMask/internal-snaps/pull/221))
- The private parent node is held transiently in memory during the batch — the same trust boundary as the previous per-account implementation — and children are neutered before descriptor construction.
- The creation concurrency throttle is removed: with derivation local, the remaining per-account work is synchronous WASM wallet construction.
- Reduce full-state round trips during batch account creation: the insert step reuses the state snapshot loaded by the existing-accounts lookup instead of re-reading both account maps, and the two state writes now run in parallel ([#221](https://github.com/MetaMask/internal-snaps/pull/221))
- Process the entire requested account range as a single batch instead of chunks of 100, so the existing-accounts lookup and state I/O happen once per request ([#221](https://github.com/MetaMask/internal-snaps/pull/221))

### Fixed

- Coalesce concurrent account synchronization runs so stacked triggers (the 30s cronjob, `onActive`, and background events scheduled by `setSelectedAccounts`) share one run instead of duplicating network fetches, state writes, and keyring events ([#221](https://github.com/MetaMask/internal-snaps/pull/221))
- Fix account deletion failing against keyring v2 clients by removing the `AccountDeleted` event emission from the delete flow ([#221](https://github.com/MetaMask/internal-snaps/pull/221))
- v2 clients reject v1 lifecycle events, which aborted the deletion before the account was removed from state. Deletion is client-initiated in v2, so no event is needed.
- Ensure certain errors are stringified correctly ([#179](https://github.com/MetaMask/internal-snaps/pull/179))

## [2.0.1]
Expand Down
2 changes: 1 addition & 1 deletion packages/bitcoin-wallet-snap/snap.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/internal-snaps.git"
},
"source": {
"shasum": "IYKg+C8y2pzVnMytJQ3qlwy9SCV6ddqF+0spql+22R0=",
"shasum": "ZEMZtj4BZq+pj3bzb81K1Vk5WQuqMA0NArfIhrIppds=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
Expand Down
37 changes: 31 additions & 6 deletions packages/bitcoin-wallet-snap/src/entities/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
} from '@metamask/bitcoindevkit';

import type { Inscription } from './meta-protocols';
import type { AccountStateSnapshot } from './snap';
import type { TransactionBuilder } from './transaction';

/**
Expand Down Expand Up @@ -276,11 +277,14 @@ export type BitcoinAccountRepository = {
* Get accounts by derivation path.
*
* @param derivationPaths - derivation paths.
* @returns the accounts or null if they do not exist, in input order
* @returns the accounts or null if they do not exist (in input order), and
* the state snapshot the lookup was resolved from, reusable by `insertMany`
* within the same account mutation
*/
getByDerivationPaths(
derivationPaths: string[][],
): Promise<(BitcoinAccount | null)[]>;
getByDerivationPaths(derivationPaths: string[][]): Promise<{
accounts: (BitcoinAccount | null)[];
snapshot: AccountStateSnapshot;
}>;

/**
* Create a new account, without persisting it.
Expand All @@ -296,6 +300,21 @@ export type BitcoinAccountRepository = {
addressType: AddressType,
): Promise<BitcoinAccount>;

/**
* Create multiple accounts, without persisting them. Fetches entropy once
* per distinct parent path and derives hardened account children locally.
*
* @param requests - Account creation requests.
* @returns the new accounts, in input order
*/
createMany(
requests: {
derivationPath: string[];
network: Network;
addressType: AddressType;
}[],
): Promise<BitcoinAccount[]>;

/**
* Insert an account.
*
Expand All @@ -307,8 +326,14 @@ export type BitcoinAccountRepository = {
* Insert accounts.
*
* @param accounts - Bitcoin accounts.
*/
insertMany(accounts: BitcoinAccount[]): Promise<BitcoinAccount[]>;
* @param snapshot - Optional state snapshot (from `getByDerivationPaths`)
* to merge into, avoiding a redundant state read. Only safe when the
* snapshot was taken within the same account mutation.
*/
insertMany(
accounts: BitcoinAccount[],
snapshot?: AccountStateSnapshot,
): Promise<BitcoinAccount[]>;

/**
* Update an account.
Expand Down
28 changes: 9 additions & 19 deletions packages/bitcoin-wallet-snap/src/entities/snap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ export type SnapState = {
derivationPaths: Record<string, string>;
};

/**
* In-memory snapshot of the account maps, as loaded from state. Lets a lookup
* and a subsequent insert within the same account mutation share one read.
*/
export type AccountStateSnapshot = {
accounts: SnapState['accounts'] | null;
derivationPaths: SnapState['derivationPaths'] | null;
};

export type AccountState = {
// Split derivation path.
derivationPath: string[];
Expand Down Expand Up @@ -92,25 +101,6 @@ export type SnapClient = {
*/
getPublicEntropy(derivationPath: string[]): Promise<SLIP10Node>;

/**
* Emit an event notifying the extension of a newly created Bitcoin account
*
* @param account - The Bitcoin account.
* @param correlationId - The correlation ID to be used for the event.
*/
emitAccountCreatedEvent(
account: BitcoinAccount,
correlationId?: string,
accountName?: string,
): Promise<void>;

/**
* Emit an event notifying the extension of a deleted Bitcoin account
*
* @param id - The Bitcoin account id.
*/
emitAccountDeletedEvent(id: string): Promise<void>;

/**
* Emit an event notifying the extension of updated balances
*
Expand Down
63 changes: 63 additions & 0 deletions packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,69 @@ describe('CronHandler', () => {
});
});

describe('sync coalescing', () => {
it('coalesces concurrent synchronizeAccounts calls into one run', async () => {
(getSelectedAccounts as jest.Mock).mockResolvedValue([]);
mockAccountUseCases.list.mockResolvedValue([]);

await Promise.all([
handler.synchronizeAccounts(),
handler.synchronizeAccounts(),
handler.synchronizeAccounts(),
]);

expect(mockAccountUseCases.list).toHaveBeenCalledTimes(1);
});

it('runs synchronizeAccounts again once the previous run has finished', async () => {
(getSelectedAccounts as jest.Mock).mockResolvedValue([]);
mockAccountUseCases.list.mockResolvedValue([]);

await handler.synchronizeAccounts();
await handler.synchronizeAccounts();

expect(mockAccountUseCases.list).toHaveBeenCalledTimes(2);
});

it('rejects all coalesced synchronizeAccounts callers on a shared failure', async () => {
const mockAccount = mock<BitcoinAccount>({ id: 'account-1' });
(getSelectedAccounts as jest.Mock).mockResolvedValue(['account-1']);
mockAccountUseCases.list.mockResolvedValue([mockAccount]);
mockAccountUseCases.synchronize.mockRejectedValue(
new Error('sync failed'),
);

const first = handler.synchronizeAccounts();
const second = handler.synchronizeAccounts();

await expect(first).rejects.toThrow('Account synchronization failures');
await expect(second).rejects.toThrow('Account synchronization failures');
expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes(1);
});

it('coalesces concurrent syncSelectedAccounts calls for the same accounts regardless of order', async () => {
mockAccountUseCases.list.mockResolvedValue([]);

await Promise.all([
handler.syncSelectedAccounts(['account-1', 'account-2']),
handler.syncSelectedAccounts(['account-2', 'account-1']),
]);

expect(mockAccountUseCases.list).toHaveBeenCalledTimes(1);
});

it('does not coalesce syncSelectedAccounts calls for different accounts', async () => {
mockAccountUseCases.list.mockResolvedValue([]);

await Promise.all([
handler.syncSelectedAccounts(['account-1']),
handler.syncSelectedAccounts(['account-2']),
]);

expect(mockAccountUseCases.list).toHaveBeenCalledTimes(2);
});
});

describe('fullScanAccount', () => {
const mockAccount = mock<BitcoinAccount>({ id: 'account-1' });
const request = {
Expand Down
143 changes: 82 additions & 61 deletions packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getSelectedAccounts } from '@metamask/keyring-snap-sdk';
import { InFlightCoalescer } from '@metamask/snap-networks-utils/dedupe';
import type { JsonRpcRequest, SnapsProvider } from '@metamask/snaps-sdk';
import { array, assert, object, string } from 'superstruct';

Expand Down Expand Up @@ -36,6 +37,8 @@ export class CronHandler {

readonly #snap: SnapsProvider;

readonly #syncCoalescer = new InFlightCoalescer();

constructor(
accounts: AccountUseCases,
sendFlow: SendFlowUseCases,
Expand Down Expand Up @@ -78,83 +81,101 @@ export class CronHandler {
}

async synchronizeAccounts(): Promise<void> {
const selectedAccounts: Set<string> = new Set(
await getSelectedAccounts(this.#snap),
);
// Sync triggers stack up (the 30s cronjob, `onActive`, background
// events), so concurrent invocations share one in-flight run instead of
// duplicating network fetches, state writes, and keyring events. Note
// that coalesced callers share the run's outcome, including a
// `SynchronizationError` from partial failures.
await this.#syncCoalescer.run('synchronizeAccounts', async () => {
const selectedAccounts: Set<string> = new Set(
await getSelectedAccounts(this.#snap),
);

const accounts = (await this.#accountsUseCases.list()).filter((account) => {
return selectedAccounts.has(account.id);
});
const accounts = (await this.#accountsUseCases.list()).filter(
(account) => {
return selectedAccounts.has(account.id);
},
);

const results = await Promise.allSettled(
accounts.map(async (account) =>
this.#accountsUseCases.synchronize(account, 'cron'),
),
);
const results = await Promise.allSettled(
accounts.map(async (account) =>
this.#accountsUseCases.synchronize(account, 'cron'),
),
);

const successfulResults: SyncResult[] = [];
const successfulResults: SyncResult[] = [];

// TODO: Replace `any` with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errors: Record<string, any> = {};
// TODO: Replace `any` with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errors: Record<string, any> = {};

results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successfulResults.push(result.value);
} else {
const id = accounts[index]?.id;
if (id) {
errors[id] = result.reason;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successfulResults.push(result.value);
} else {
const id = accounts[index]?.id;
if (id) {
errors[id] = result.reason;
}
}
}
});
});

await this.#emitSyncEvents(successfulResults);
await this.#emitSyncEvents(successfulResults);

if (Object.keys(errors).length > 0) {
throw new SynchronizationError(
'Account synchronization failures',
errors,
);
}
if (Object.keys(errors).length > 0) {
throw new SynchronizationError(
'Account synchronization failures',
errors,
);
}
});
}

async syncSelectedAccounts(accountIds: string[]): Promise<void> {
const accountIdSet = new Set(accountIds);
const allAccounts = await this.#accountsUseCases.list();

const selectedAccounts = allAccounts.filter((account) =>
accountIdSet.has(account.id),
);

const results = await Promise.allSettled(
selectedAccounts.map(async (account) =>
this.#accountsUseCases.synchronize(account, 'metamask'),
),
);
// Every `setSelectedAccounts` call schedules a background event with no
// dedupe, so bursts of identical syncs fire together during onboarding
// and imports. Concurrent invocations for the same account set share one
// in-flight run.
const key = `syncSelectedAccounts:${[...accountIds].sort().join(',')}`;

await this.#syncCoalescer.run(key, async () => {
Comment on lines +139 to +141
const accountIdSet = new Set(accountIds);
const allAccounts = await this.#accountsUseCases.list();

const selectedAccounts = allAccounts.filter((account) =>
accountIdSet.has(account.id),
);

const successfulResults = results
.filter(
(result): result is PromiseFulfilledResult<SyncResult> =>
result.status === 'fulfilled',
)
.map((result) => result.value);
const results = await Promise.allSettled(
selectedAccounts.map(async (account) =>
this.#accountsUseCases.synchronize(account, 'metamask'),
),
);

const rejectedResults = results.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected',
);
const successfulResults = results
.filter(
(result): result is PromiseFulfilledResult<SyncResult> =>
result.status === 'fulfilled',
)
.map((result) => result.value);

if (rejectedResults.length > 0) {
await this.#snapClient.emitTrackingError(
new SynchronizationError(
`Failed to synchronize ${rejectedResults.length} selected accounts`,
undefined,
rejectedResults[0]?.reason,
),
const rejectedResults = results.filter(
(result): result is PromiseRejectedResult =>
result.status === 'rejected',
);
}

await this.#emitSyncEvents(successfulResults);
if (rejectedResults.length > 0) {
await this.#snapClient.emitTrackingError(
new SynchronizationError(
`Failed to synchronize ${rejectedResults.length} selected accounts`,
undefined,
rejectedResults[0]?.reason,
),
);
}

await this.#emitSyncEvents(successfulResults);
});
}

/**
Expand Down
Loading