Skip to content

Commit 0c1f4aa

Browse files
authored
fix: make SnapKeyring :accountCreated idempotent (#399)
Make `notify:accountCreated` idempotent if the same account ID and account address (owned by a given Snap) is sent multiple times. This is used mostly by the Bitcoin Snap which can create `KeyringAccount` in its state during discovery and start to notify MetaMask when we call `keyring_createAccount`. This avoid having a state in the Snap side to check if the event has been emitted once or not. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Makes `AccountCreated` handling idempotent for same account ID and address from the same snap, with accompanying tests and minor refactors. > > - **Core (`packages/keyring-snap-bridge/src/SnapKeyring.ts`)**: > - Make `AccountCreated` idempotent: if an account with the same `id` and normalized `address` already exists for the snap, no-op and return `null`. > - Normalize address once early during account creation; remove duplicate later declaration. > - Keep duplicate-address and duplicate-ID guards intact. > - **Tests (`packages/keyring-snap-bridge/src/SnapKeyring.test.ts`)**: > - Add test verifying idempotent behavior for repeated `AccountCreated` events. > - Adjust duplicate-address test to bypass idempotency by using a new ID. > - Minor updates (import `Json`, mock adjustments) to support new flow. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4e2fdc2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 8185566 commit 0c1f4aa

2 files changed

Lines changed: 49 additions & 2 deletions

File tree

packages/keyring-snap-bridge/src/SnapKeyring.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import type { HandleSnapRequest } from '@metamask/snaps-controllers';
4242
import { type SnapId } from '@metamask/snaps-sdk';
4343
import type { HandlerType } from '@metamask/snaps-utils';
4444
import { assert } from '@metamask/superstruct';
45+
import type { Json } from '@metamask/utils';
4546
import { KnownCaipNamespace, toCaipChainId } from '@metamask/utils';
4647
import { v4 as uuid } from 'uuid';
4748

@@ -430,6 +431,38 @@ describe('SnapKeyring', () => {
430431
);
431432
});
432433

434+
it('is idempotent', async () => {
435+
const account = {
436+
...newEthEoaAccount,
437+
// Even checksummed address will be lower-cased by the bridge.
438+
address: '0x6431726EEE67570BF6f0Cf892aE0a3988F03903F',
439+
};
440+
441+
const createAccount = async (): Promise<Json> =>
442+
await keyring.handleKeyringSnapMessage(snapId, {
443+
method: KeyringEvent.AccountCreated,
444+
params: {
445+
account: {
446+
...(account as unknown as KeyringAccount),
447+
id: '56189183-9b89-4ae6-90d9-99d167b28520',
448+
},
449+
},
450+
});
451+
452+
// Initial creation which will call `addAccount`.
453+
await createAccount();
454+
455+
// Another mock to check if `addAccount` is called again.
456+
const mockAddAccount = jest.fn();
457+
// NOTE: It's ok to change the implementation here because we should not call
458+
// the `addAccount` callback again anyway.
459+
mockCallbacks.addAccount.mockImplementation(() => mockAddAccount());
460+
461+
// Trying to create the same account again will not call `addAccount` (noop).
462+
await createAccount();
463+
expect(mockAddAccount).not.toHaveBeenCalled();
464+
});
465+
433466
it('cannot add an account that already exists (address)', async () => {
434467
mockCallbacks.addressExists.mockResolvedValue(true);
435468
await expect(
@@ -438,7 +471,9 @@ describe('SnapKeyring', () => {
438471
params: {
439472
account: {
440473
...(ethEoaAccount1 as unknown as KeyringAccount),
441-
id: 'c6697bcf-5710-4751-a1cb-340e4b50617a',
474+
// Use a non-registered ID but an existing address to avoid triggering
475+
// idempotency logic.
476+
id: '7d82d1ff-a070-4554-b4a6-2f0c937d058b',
442477
},
443478
},
444479
}),

packages/keyring-snap-bridge/src/SnapKeyring.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ export class SnapKeyring {
329329

330330
// Potentially migrate the account.
331331
const account = transformAccount(newAccountFromEvent);
332+
const address = normalizeAccountAddress(account);
332333

333334
// The `AnyAccountType.Account` generic account type is allowed only during
334335
// development, so we check whether it's allowed before continuing.
@@ -339,10 +340,21 @@ export class SnapKeyring {
339340
throw new Error(`Cannot create generic account '${account.id}'`);
340341
}
341342

343+
// This is idempotent, so we need to check whether the account already exists
344+
// and that the right Snap is trying to "create" it again.
345+
const accountEntry = this.#accounts.get(snapId, account.id);
346+
if (
347+
accountEntry &&
348+
normalizeAccountAddress(accountEntry.account) === address
349+
) {
350+
// NOTE: We are not checking account object equality here. If a Snap
351+
// re-send this event with different account data, we will ignore it.
352+
return null;
353+
}
354+
342355
// The UI still uses the account address to identify accounts, so we need
343356
// to block the creation of duplicate accounts for now to prevent accounts
344357
// from being overwritten.
345-
const address = normalizeAccountAddress(account);
346358
if (await this.#callbacks.addressExists(address)) {
347359
throw new Error(`Account address '${address}' already exists`);
348360
}

0 commit comments

Comments
 (0)