Skip to content

Commit d1d8459

Browse files
authored
refactor(keyring-snap-bridge)!: remove SnapKeyringV1 inheritance (#584)
Removing inheritance so we the remaining v1 usage are explicit, since `SnapKeyring` (v2) was implicitly exposing v1 methods, it made it pretty difficult to sunset v1 completely. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Breaking public API and behavior split across account creation, signing, and snap messages in security-sensitive keyring code; incorrect v1/v2 routing could break snaps or block v2-only accounts. > > **Overview** > **Breaking:** v2 `SnapKeyring` no longer extends `SnapKeyringV1`. V1 behavior is exposed only via an optional `.v1` getter (set when the snap manifest has no `endowment:keyring` capabilities; `undefined` for v2-only snaps). > > The per-snap v2 class now owns its registry and v2 `KeyringInternalSnapClient`, and on `deserialize` may compose a `SnapKeyringV1` that shares the same registry. `createAccounts` / `submitRequest` route to v1 or the v2 client depending on snap version (including the v1 `{ options }` wrapper vs flat v2 options). > > The top-level `SnapKeyring` coordinator routes legacy v1 entry points (messages, signing, user ops, etc.) through `getKeyringV1For` and throws clear errors when the target snap is v2-only. `setSelectedAccounts` uses `keyring.v1?.` so v2 snaps are skipped. > > `SnapKeyringV1` gains optional shared `registry`, public `bindSnapId`, and `createAccounts` for v1 RPC. Changelog and tests document the API shift and v1/v2 routing. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 693e6f3. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 97f4634 commit d1d8459

8 files changed

Lines changed: 712 additions & 103 deletions

File tree

eslint-suppressions.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@
210210
},
211211
"packages/keyring-snap-bridge/src/v2/SnapKeyring.test.ts": {
212212
"@typescript-eslint/no-explicit-any": {
213-
"count": 4
213+
"count": 1
214214
}
215215
},
216216
"packages/keyring-snap-sdk/src/rpc-handler.test.ts": {

packages/keyring-snap-bridge/CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Changed
11+
12+
- **BREAKING:** `SnapKeyring` (v2) no longer inherits `SnapKeyringV1` ([#584](https://github.com/MetaMask/accounts/pull/584))
13+
- Use `.v1` getter to access a v1 instance instead.
14+
- `.v1` will yield `undefined` if the Snap declares v2 `capabilities` in its `endowment:keyring`.
15+
1016
## [22.4.0]
1117

1218
### Added

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

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3738,4 +3738,143 @@ describe('SnapKeyring', () => {
37383738
);
37393739
});
37403740
});
3741+
3742+
describe('v2 snap error paths', () => {
3743+
const v2SnapId = 'local:snap.v2mock' as SnapId;
3744+
let v2Keyring: SnapKeyring;
3745+
3746+
beforeEach(async () => {
3747+
// After the outer beforeEach resets mockMessenger.get, configure it to
3748+
// return capabilities for the v2 snap so inner SnapKeyring treats it as
3749+
// a v2 snap (v1 === undefined).
3750+
mockMessenger.get.mockImplementation((id: SnapId) =>
3751+
id === v2SnapId
3752+
? {
3753+
manifest: {
3754+
initialPermissions: {
3755+
'endowment:keyring': {
3756+
capabilities: { scopes: [EthScope.Eoa] },
3757+
},
3758+
},
3759+
},
3760+
}
3761+
: undefined,
3762+
);
3763+
3764+
v2Keyring = new SnapKeyring({
3765+
messenger: mockSnapKeyringMessenger,
3766+
callbacks: mockCallbacks,
3767+
isAnyAccountTypeAllowed: true,
3768+
});
3769+
3770+
// Load an account for the v2 snap so #snapKeyrings and #accountIndex are
3771+
// populated, enabling tests for address-based methods.
3772+
await v2Keyring.deserialize({
3773+
accounts: {
3774+
[ethEoaAccount1.id]: { account: ethEoaAccount1, snapId: v2SnapId },
3775+
},
3776+
});
3777+
});
3778+
3779+
it('handleKeyringSnapMessage throws for a v2 snap', async () => {
3780+
await expect(
3781+
v2Keyring.handleKeyringSnapMessage(v2SnapId, {
3782+
method: KeyringEvent.AccountUpdated,
3783+
params: { account: ethEoaAccount1 },
3784+
}),
3785+
).rejects.toThrow(`Snap '${v2SnapId}' does not support v1 messages`);
3786+
});
3787+
3788+
it('createAccount throws for a v2 snap', async () => {
3789+
await expect(v2Keyring.createAccount(v2SnapId, {})).rejects.toThrow(
3790+
`Snap '${v2SnapId}' does not support v1 account creation`,
3791+
);
3792+
});
3793+
3794+
it('resolveAccountAddress throws for a v2 snap', async () => {
3795+
await expect(
3796+
v2Keyring.resolveAccountAddress(v2SnapId, EthScope.Eoa, {
3797+
id: 1,
3798+
jsonrpc: '2.0',
3799+
method: 'eth_sign',
3800+
} as JsonRpcRequest),
3801+
).rejects.toThrow(
3802+
`Snap '${v2SnapId}' does not support v1 address resolution`,
3803+
);
3804+
});
3805+
3806+
it('submitRequest throws for a v2 snap', async () => {
3807+
await expect(
3808+
v2Keyring.submitRequest({
3809+
origin: 'metamask',
3810+
account: ethEoaAccount1.id,
3811+
method: EthMethod.Sign,
3812+
scope: EthScope.Eoa,
3813+
}),
3814+
).rejects.toThrow(`Snap '${v2SnapId}' does not support v1 requests`);
3815+
});
3816+
3817+
it('signTransaction throws for a v2 snap', async () => {
3818+
await expect(
3819+
v2Keyring.signTransaction(
3820+
ethEoaAccount1.address,
3821+
TransactionFactory.fromTxData({ type: '0x0' }),
3822+
),
3823+
).rejects.toThrow(`Snap '${v2SnapId}' does not support v1 signing`);
3824+
});
3825+
3826+
it('signTypedData throws for a v2 snap', async () => {
3827+
await expect(
3828+
v2Keyring.signTypedData(ethEoaAccount1.address, []),
3829+
).rejects.toThrow(`Snap '${v2SnapId}' does not support v1 signing`);
3830+
});
3831+
3832+
it('signMessage throws for a v2 snap', async () => {
3833+
await expect(
3834+
v2Keyring.signMessage(ethEoaAccount1.address, '0xdeadbeef'),
3835+
).rejects.toThrow(`Snap '${v2SnapId}' does not support v1 signing`);
3836+
});
3837+
3838+
it('signPersonalMessage throws for a v2 snap', async () => {
3839+
await expect(
3840+
v2Keyring.signPersonalMessage(ethEoaAccount1.address, '0xdeadbeef'),
3841+
).rejects.toThrow(`Snap '${v2SnapId}' does not support v1 signing`);
3842+
});
3843+
3844+
it('prepareUserOperation throws for a v2 snap', async () => {
3845+
await expect(
3846+
v2Keyring.prepareUserOperation(
3847+
ethEoaAccount1.address,
3848+
[],
3849+
executionContext,
3850+
),
3851+
).rejects.toThrow(
3852+
`Snap '${v2SnapId}' does not support v1 prepareUserOperation`,
3853+
);
3854+
});
3855+
3856+
it('patchUserOperation throws for a v2 snap', async () => {
3857+
await expect(
3858+
v2Keyring.patchUserOperation(
3859+
ethEoaAccount1.address,
3860+
{} as EthUserOperation,
3861+
executionContext,
3862+
),
3863+
).rejects.toThrow(
3864+
`Snap '${v2SnapId}' does not support v1 patchUserOperation`,
3865+
);
3866+
});
3867+
3868+
it('signUserOperation throws for a v2 snap', async () => {
3869+
await expect(
3870+
v2Keyring.signUserOperation(
3871+
ethEoaAccount1.address,
3872+
{} as EthUserOperation,
3873+
executionContext,
3874+
),
3875+
).rejects.toThrow(
3876+
`Snap '${v2SnapId}' does not support v1 signUserOperation`,
3877+
);
3878+
});
3879+
});
37413880
});

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

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,29 @@ import { normalizeAccountAddress } from './account';
2424
import type { SnapKeyringInternalOptions } from './options';
2525
import type { SnapKeyringMessenger } from './SnapKeyringMessenger';
2626
import { SNAP_KEYRING_NAME } from './SnapKeyringMessenger';
27-
import type { AccountMethod } from './SnapKeyringV1';
27+
import type { AccountMethod, SnapKeyringV1 } from './SnapKeyringV1';
2828
import type { SnapMessage } from './types';
2929
import { throwError, unique } from './util';
3030
import { SnapKeyring as SnapKeyringV2 } from './v2/SnapKeyring';
3131

32+
/**
33+
* Return the v1 instance of a per-snap keyring, throwing if the snap only
34+
* supports the v2 protocol.
35+
*
36+
* @param keyring - The per-snap v2 keyring.
37+
* @param feature - Human-readable feature name used in the error message.
38+
* @returns The v1 instance.
39+
*/
40+
function getKeyringV1For(
41+
keyring: SnapKeyringV2,
42+
feature: string,
43+
): SnapKeyringV1 {
44+
return (
45+
keyring.v1 ??
46+
throwError(`Snap '${keyring.snapId}' does not support v1 ${feature}`)
47+
);
48+
}
49+
3250
export const SNAP_KEYRING_TYPE = 'Snap Keyring';
3351

3452
/**
@@ -88,9 +106,10 @@ export class SnapKeyring {
88106
readonly #messenger: SnapKeyringMessenger;
89107

90108
/**
91-
* Per-snap keyring instances. Each `SnapKeyringV2` (which extends
92-
* `SnapKeyringV1`) owns a single `KeyringAccountRegistry` and handles
93-
* both the event-driven v1 flow and the `KeyringV2` batch interface.
109+
* Per-snap keyring instances. Each `SnapKeyringV2` owns the
110+
* `KeyringAccountRegistry` and handles the `KeyringV2` batch interface.
111+
* For v1 snaps, `SnapKeyringV2.v1` holds a `SnapKeyringV1` instance
112+
* (with a shared registry reference) for the event-driven flow.
94113
*/
95114
readonly #snapKeyrings: Map<SnapId, SnapKeyringV2>;
96115

@@ -344,7 +363,10 @@ export class SnapKeyring {
344363
}
345364

346365
try {
347-
return await keyring.handleKeyringSnapMessage(message);
366+
return await getKeyringV1For(
367+
keyring,
368+
'messages',
369+
).handleKeyringSnapMessage(message);
348370
} finally {
349371
// Clean up if AccountCreated was rejected (e.g. duplicate address,
350372
// invalid account), leaving the snap with no registered accounts.
@@ -468,7 +490,7 @@ export class SnapKeyring {
468490
/**
469491
* Create an account (v1 event-driven flow).
470492
*
471-
* Delegates to the per-snap SnapKeyringV1 instance.
493+
* Delegates to the per-snap SnapKeyringV2 instance (v1 flow).
472494
*
473495
* @param snapId - Snap ID to create the account for.
474496
* @param options - Account creation options. Differs between keyrings.
@@ -481,7 +503,10 @@ export class SnapKeyring {
481503
internalOptions?: SnapKeyringInternalOptions,
482504
): Promise<KeyringAccount> {
483505
const keyring = await this.#getOrCreateKeyring(snapId);
484-
return keyring.createAccount(options, internalOptions);
506+
return getKeyringV1For(keyring, 'account creation').createAccount(
507+
options,
508+
internalOptions,
509+
);
485510
}
486511

487512
/**
@@ -535,7 +560,10 @@ export class SnapKeyring {
535560
}
536561

537562
const keyring = await this.#getOrCreateKeyring(snapId);
538-
return keyring.resolveAccountAddress(scope, request);
563+
return getKeyringV1For(keyring, 'address resolution').resolveAccountAddress(
564+
scope,
565+
request,
566+
);
539567
}
540568

541569
/**
@@ -572,7 +600,7 @@ export class SnapKeyring {
572600
this.#snapKeyrings.get(snapId) ??
573601
throwError(`No keyring found for snap '${snapId}'`);
574602

575-
return await keyring.submitSnapRequest({
603+
return await getKeyringV1For(keyring, 'requests').submitSnapRequest({
576604
origin,
577605
account,
578606
method: method as AccountMethod,
@@ -598,7 +626,11 @@ export class SnapKeyring {
598626
_opts = {},
599627
): Promise<Json | TypedTransaction> {
600628
const { account, keyring } = this.#resolveAddress(address);
601-
return keyring.signTransaction(account, transaction, _opts);
629+
return getKeyringV1For(keyring, 'signing').signTransaction(
630+
account,
631+
transaction,
632+
_opts,
633+
);
602634
}
603635

604636
/**
@@ -615,7 +647,11 @@ export class SnapKeyring {
615647
opts = { version: SignTypedDataVersion.V1 },
616648
): Promise<string> {
617649
const { account, keyring } = this.#resolveAddress(address);
618-
return keyring.signTypedData(account, data, opts);
650+
return getKeyringV1For(keyring, 'signing').signTypedData(
651+
account,
652+
data,
653+
opts,
654+
);
619655
}
620656

621657
/**
@@ -627,7 +663,7 @@ export class SnapKeyring {
627663
*/
628664
async signMessage(address: string, hash: any): Promise<string> {
629665
const { account, keyring } = this.#resolveAddress(address);
630-
return keyring.signMessage(account, hash);
666+
return getKeyringV1For(keyring, 'signing').signMessage(account, hash);
631667
}
632668

633669
/**
@@ -642,7 +678,10 @@ export class SnapKeyring {
642678
*/
643679
async signPersonalMessage(address: string, data: any): Promise<string> {
644680
const { account, keyring } = this.#resolveAddress(address);
645-
return keyring.signPersonalMessage(account, data);
681+
return getKeyringV1For(keyring, 'signing').signPersonalMessage(
682+
account,
683+
data,
684+
);
646685
}
647686

648687
/**
@@ -659,7 +698,10 @@ export class SnapKeyring {
659698
context: KeyringExecutionContext,
660699
): Promise<EthBaseUserOperation> {
661700
const { account, keyring } = this.#resolveAddress(address);
662-
return keyring.prepareUserOperation(account, transactions, context);
701+
return getKeyringV1For(
702+
keyring,
703+
'prepareUserOperation',
704+
).prepareUserOperation(account, transactions, context);
663705
}
664706

665707
/**
@@ -677,7 +719,11 @@ export class SnapKeyring {
677719
context: KeyringExecutionContext,
678720
): Promise<EthUserOperationPatch> {
679721
const { account, keyring } = this.#resolveAddress(address);
680-
return keyring.patchUserOperation(account, userOp, context);
722+
return getKeyringV1For(keyring, 'patchUserOperation').patchUserOperation(
723+
account,
724+
userOp,
725+
context,
726+
);
681727
}
682728

683729
/**
@@ -694,7 +740,11 @@ export class SnapKeyring {
694740
context: KeyringExecutionContext,
695741
): Promise<string> {
696742
const { account, keyring } = this.#resolveAddress(address);
697-
return keyring.signUserOperation(account, userOp, context);
743+
return getKeyringV1For(keyring, 'signUserOperation').signUserOperation(
744+
account,
745+
userOp,
746+
context,
747+
);
698748
}
699749

700750
/**
@@ -766,9 +816,11 @@ export class SnapKeyring {
766816
bySnap.set(snapId, snapAccounts);
767817
}
768818

819+
// v1 snaps receive a keyring_setSelectedAccounts notification; v2 snaps
820+
// have no equivalent concept in the protocol so they are intentionally skipped.
769821
await Promise.all(
770822
[...this.#snapKeyrings.entries()].map(async ([snapId, keyring]) =>
771-
keyring.setSelectedAccounts(
823+
keyring.v1?.setSelectedAccounts(
772824
/* istanbul ignore next */
773825
bySnap.get(snapId) ?? [],
774826
),

0 commit comments

Comments
 (0)