Skip to content

Commit 8ac8519

Browse files
authored
feat(keyring-snap-bridge): populate v2 SnapKeyring capabilities from the manifest (#581)
- Populate the v2 `SnapKeyring.capabilities` from the Snap manifest (`endowment:keyring`) on `deserialize`, via `SnapController:getSnap`, fulfilling the v2 `Keyring` contract's required `capabilities` field. Falls back to the empty default when the Snap declares none. - Guard v2 `SnapKeyring` operations (`getAccounts`/`getAccount`/`createAccounts`/`deleteAccount`/`submitRequest`) until `deserialize` has run; they now throw `"SnapKeyring has not been initialized"` instead of operating on an unbound keyring. - Bump `@metamask/snaps-utils` to `^12.2.1` (`keyring-snap-bridge` + `keyring-internal-snap-client`) for the `capabilities` field on the `endowment:keyring` manifest type. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes snap keyring lifecycle and when account operations are allowed; capabilities are taken from the manifest without local shape validation, so struct drift could cause runtime issues. > > **Overview** > v2 **`SnapKeyring`** now sets **`capabilities`** during **`deserialize`** by reading the Snap manifest’s **`endowment:keyring`** entry via **`SnapController:getSnap`**, with **`EMPTY_CAPABILITIES`** as the typed default when the snap is missing or declares none (including clearing stale values on re-hydrate). > > **`getAccounts`**, **`getAccount`**, **`createAccounts`**, **`deleteAccount`**, and **`submitRequest`** are blocked until **`deserialize`** finishes, throwing *SnapKeyring has not been initialized* instead of running on an unbound keyring. > > **`@metamask/snaps-utils`** is bumped to **`^12.2.1`** in **`keyring-snap-bridge`** and **`keyring-internal-snap-client`** for manifest typing around **`capabilities`**. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 39a8b94. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 54ce1af commit 8ac8519

6 files changed

Lines changed: 280 additions & 35 deletions

File tree

packages/keyring-internal-snap-client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
"@metamask/auto-changelog": "^6.1.0",
7979
"@metamask/snaps-controllers": "^19.0.1",
8080
"@metamask/snaps-sdk": "^11.0.0",
81-
"@metamask/snaps-utils": "^12.1.3",
81+
"@metamask/snaps-utils": "^12.2.1",
8282
"@metamask/utils": "^11.11.0",
8383
"@ts-bridge/cli": "^0.6.3",
8484
"@types/jest": "^29.5.12",

packages/keyring-snap-bridge/CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- Populate v2 `SnapKeyring` `capabilities` from the Snap manifest (`endowment:keyring`) on `deserialize` ([#581](https://github.com/MetaMask/accounts/pull/581))
13+
- Guard v2 `SnapKeyring` operations until `deserialize` has run (throws "SnapKeyring has not been initialized") ([#581](https://github.com/MetaMask/accounts/pull/581))
14+
15+
### Changed
16+
17+
- Bump `@metamask/snaps-utils` from `^12.1.3` to `^12.2.1` ([#581](https://github.com/MetaMask/accounts/pull/581))
18+
1019
## [22.3.0]
1120

1221
### Added

packages/keyring-snap-bridge/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
"@metamask/messenger": "^1.1.1",
7878
"@metamask/snaps-controllers": "^19.0.1",
7979
"@metamask/snaps-sdk": "^11.0.0",
80-
"@metamask/snaps-utils": "^12.1.3",
80+
"@metamask/snaps-utils": "^12.2.1",
8181
"@metamask/superstruct": "^3.1.0",
8282
"@metamask/utils": "^11.11.0",
8383
"@types/uuid": "^9.0.8",

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

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { SnapId } from '@metamask/snaps-sdk';
1010

1111
import type { SnapKeyringMessenger } from '../SnapKeyringMessenger';
1212
import type { SnapKeyringCallbacks } from './SnapKeyring';
13-
import { isSnapKeyring, SnapKeyring } from './SnapKeyring';
13+
import { EMPTY_CAPABILITIES, isSnapKeyring, SnapKeyring } from './SnapKeyring';
1414

1515
const SNAP_ID = 'npm:@metamask/test-snap' as SnapId;
1616

@@ -101,6 +101,144 @@ describe('SnapKeyring', () => {
101101
});
102102
});
103103

104+
describe('capabilities', () => {
105+
const manifestCapabilities = {
106+
scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'],
107+
bip44: { deriveIndex: true, deriveIndexRange: true, discover: true },
108+
};
109+
110+
/**
111+
* Build and deserialize a keyring whose messenger returns a snap with the
112+
* given `endowment:keyring` permission value from `SnapController:getSnap`.
113+
*
114+
* @param keyringPermission - The `endowment:keyring` initial-permission value
115+
* (or `undefined` to simulate a snap that is not found).
116+
* @returns The deserialized keyring.
117+
*/
118+
async function makeKeyringWithSnap(
119+
keyringPermission: unknown,
120+
): Promise<SnapKeyring> {
121+
const messenger = {
122+
call: jest.fn((action: string) =>
123+
action === 'SnapController:getSnap' && keyringPermission !== undefined
124+
? {
125+
manifest: {
126+
initialPermissions: {
127+
'endowment:keyring': keyringPermission,
128+
},
129+
},
130+
}
131+
: undefined,
132+
),
133+
publish: jest.fn(),
134+
} as unknown as SnapKeyringMessenger;
135+
const keyring = new SnapKeyring({
136+
messenger,
137+
callbacks: makeMockCallbacks(),
138+
});
139+
await keyring.deserialize({ snapId: SNAP_ID, accounts: {} });
140+
return keyring;
141+
}
142+
143+
it('populates capabilities from the snap manifest on deserialize', async () => {
144+
const keyring = await makeKeyringWithSnap({
145+
capabilities: manifestCapabilities,
146+
});
147+
expect(keyring.capabilities).toStrictEqual(manifestCapabilities);
148+
});
149+
150+
it('keeps the empty default when the snap declares no capabilities', async () => {
151+
const keyring = await makeKeyringWithSnap({
152+
allowedOrigins: ['https://portfolio.metamask.io'],
153+
});
154+
expect(keyring.capabilities).toStrictEqual(EMPTY_CAPABILITIES);
155+
});
156+
157+
it('keeps the empty default when the snap cannot be found', async () => {
158+
const keyring = await makeKeyringWithSnap(undefined);
159+
expect(keyring.capabilities).toStrictEqual(EMPTY_CAPABILITIES);
160+
});
161+
162+
it('clears stale capabilities on a later deserialize without capabilities', async () => {
163+
const snapWith = (keyringPermission: unknown): unknown => ({
164+
manifest: {
165+
initialPermissions: { 'endowment:keyring': keyringPermission },
166+
},
167+
});
168+
const mockGetSnap = jest.fn();
169+
const messenger = {
170+
call: jest.fn((action: string) =>
171+
action === 'SnapController:getSnap' ? mockGetSnap() : undefined,
172+
),
173+
publish: jest.fn(),
174+
} as unknown as SnapKeyringMessenger;
175+
const keyring = new SnapKeyring({
176+
messenger,
177+
callbacks: makeMockCallbacks(),
178+
});
179+
mockGetSnap.mockReturnValueOnce(
180+
snapWith({ capabilities: manifestCapabilities }),
181+
);
182+
await keyring.deserialize({ snapId: SNAP_ID, accounts: {} });
183+
expect(keyring.capabilities).toStrictEqual(manifestCapabilities);
184+
// Manifest won't yield any capabilities this time, so we end up
185+
// clearing the old ones and fallback to the `EMPTY_CAPABILITIES`.
186+
mockGetSnap.mockReturnValueOnce(snapWith({ allowedOrigins: [] }));
187+
await keyring.deserialize({ snapId: SNAP_ID, accounts: {} });
188+
expect(keyring.capabilities).toStrictEqual(EMPTY_CAPABILITIES);
189+
});
190+
});
191+
192+
describe('initialization guard', () => {
193+
const ERROR = 'SnapKeyring has not been initialized';
194+
195+
/**
196+
* Build a `SnapKeyring` WITHOUT calling `deserialize`.
197+
*
198+
* @returns The uninitialized keyring.
199+
*/
200+
function makeUninitializedKeyring(): SnapKeyring {
201+
const messenger = {
202+
call: jest.fn(),
203+
publish: jest.fn(),
204+
} as unknown as SnapKeyringMessenger;
205+
return new SnapKeyring({ messenger, callbacks: makeMockCallbacks() });
206+
}
207+
208+
it('rejects getAccounts before deserialize is called', async () => {
209+
await expect(makeUninitializedKeyring().getAccounts()).rejects.toThrow(
210+
ERROR,
211+
);
212+
});
213+
214+
it('rejects getAccount before deserialize is called', async () => {
215+
await expect(
216+
makeUninitializedKeyring().getAccount(account1.id),
217+
).rejects.toThrow(ERROR);
218+
});
219+
220+
it('rejects createAccounts before deserialize is called', async () => {
221+
await expect(
222+
makeUninitializedKeyring().createAccounts({
223+
type: 'bip44:derive-index',
224+
entropySource: 'mock-entropy-source',
225+
groupIndex: 0,
226+
} as CreateAccountOptions),
227+
).rejects.toThrow(ERROR);
228+
});
229+
230+
it('rejects deleteAccount before deserialize is called', async () => {
231+
await expect(
232+
makeUninitializedKeyring().deleteAccount(account1.id),
233+
).rejects.toThrow(ERROR);
234+
});
235+
236+
it('allows operations after deserialize', async () => {
237+
const { keyring } = await makeKeyring();
238+
expect(await keyring.getAccounts()).toStrictEqual([]);
239+
});
240+
});
241+
104242
describe('snapId', () => {
105243
it('returns the snap ID set during deserialize', async () => {
106244
const { keyring } = await makeKeyring();

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

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ import type {
2727
} from '../SnapKeyringV1';
2828
import { equalsIgnoreCase } from '../util';
2929

30+
/**
31+
* Default, empty capabilities used until the snap manifest is read on
32+
* `deserialize`. Typed (no cast) so that adding a new required
33+
* `KeyringCapabilities` field surfaces here as a compile error.
34+
*/
35+
export const EMPTY_CAPABILITIES: KeyringCapabilities = Object.freeze({
36+
scopes: [],
37+
});
38+
3039
/**
3140
* Superstruct schema for {@link SnapKeyringState}.
3241
*
@@ -108,6 +117,12 @@ export class SnapKeyring extends SnapKeyringV1 implements Keyring {
108117
*/
109118
readonly #lock: Mutex;
110119

120+
/**
121+
* Whether `deserialize` has completed. Keyring operations are guarded against
122+
* use before the keyring has been bound to a snap and its state loaded.
123+
*/
124+
#initialized = false;
125+
111126
// ──────────────────────────────────────────────
112127
// Keyring properties
113128
// ──────────────────────────────────────────────
@@ -127,10 +142,8 @@ export class SnapKeyring extends SnapKeyringV1 implements Keyring {
127142
this.#callbacks = options.callbacks;
128143
this.#lock = new Mutex();
129144

130-
// Default capabilities — parent updates this when snap metadata is available.
131-
// We cast here because KeyringCapabilities requires a non-empty scopes array,
132-
// but we don't have the snap metadata at construction time (e.g. during deserialization).
133-
this.capabilities = { scopes: [] } as unknown as KeyringCapabilities;
145+
// Default capabilities; replaced from the snap manifest on `deserialize`.
146+
this.capabilities = EMPTY_CAPABILITIES;
134147
}
135148

136149
/**
@@ -172,6 +185,7 @@ export class SnapKeyring extends SnapKeyringV1 implements Keyring {
172185
* @returns A promise that resolves to an array of all accounts.
173186
*/
174187
async getAccounts(): Promise<KeyringAccount[]> {
188+
this.#assertInitialized();
175189
return this.accounts();
176190
}
177191

@@ -182,6 +196,7 @@ export class SnapKeyring extends SnapKeyringV1 implements Keyring {
182196
* @returns A promise that resolves to the account.
183197
*/
184198
async getAccount(accountId: AccountId): Promise<KeyringAccount> {
199+
this.#assertInitialized();
185200
const account = this.lookupAccount(accountId);
186201
if (!account) {
187202
throw new Error(
@@ -209,6 +224,7 @@ export class SnapKeyring extends SnapKeyringV1 implements Keyring {
209224
async createAccounts(
210225
options: CreateAccountOptions,
211226
): Promise<KeyringAccount[]> {
227+
this.#assertInitialized();
212228
return this.#withLock(async () => {
213229
// Keep track of address/account ID part of this batch, to avoid having duplicates.
214230
const batchAddresses = new Set<string>();
@@ -294,6 +310,7 @@ export class SnapKeyring extends SnapKeyringV1 implements Keyring {
294310
* @param accountId - ID of the account to delete.
295311
*/
296312
async deleteAccount(accountId: AccountId): Promise<void> {
313+
this.#assertInitialized();
297314
// Always remove the account from the registry, even if the Snap is going to
298315
// fail to delete it. removeAccount fires onUnregister to clean #accountIndex.
299316
this.removeAccount(accountId);
@@ -338,6 +355,7 @@ export class SnapKeyring extends SnapKeyringV1 implements Keyring {
338355
params?: Json[] | Record<string, Json>;
339356
};
340357
}): Promise<Json> {
358+
this.#assertInitialized();
341359
const account = this.lookupAccount(request.account);
342360
if (!account) {
343361
throw new Error(
@@ -487,6 +505,12 @@ export class SnapKeyring extends SnapKeyringV1 implements Keyring {
487505
// mismatch to prevent swapping a keyring's identity via deserialize).
488506
this.bindSnapId(state.snapId as SnapId);
489507

508+
// Refresh capabilities from the snap manifest on every deserialize, falling
509+
// back to the empty default so a re-hydrate clears any previously-loaded
510+
// capabilities when the snap no longer declares them.
511+
this.capabilities =
512+
this.#resolveKeyringCapabilities() ?? EMPTY_CAPABILITIES;
513+
490514
// Migrate v1 accounts to v2.
491515
const migratedAccounts: Record<string, KeyringAccount> = {};
492516
for (const [id, rawAccount] of Object.entries(state.accounts)) {
@@ -508,9 +532,41 @@ export class SnapKeyring extends SnapKeyringV1 implements Keyring {
508532
for (const account of Object.values(migratedAccounts)) {
509533
this.setAccount(account);
510534
}
535+
536+
this.#initialized = true;
511537
}
512538

513539
// ──────────────────────────────────────────────
514540
// Private helpers
515541
// ──────────────────────────────────────────────
542+
543+
/**
544+
* Assert that the keyring has been initialized.
545+
*
546+
* @throws An error if the keyring has not been initialized.
547+
*/
548+
#assertInitialized(): void {
549+
if (!this.#initialized) {
550+
throw new Error(
551+
'SnapKeyring has not been initialized: call deserialize() first',
552+
);
553+
}
554+
}
555+
556+
/**
557+
* Resolve the keyring capabilities from the snap manifest.
558+
*
559+
* @returns The keyring capabilities, or `undefined` if the snap manifest does not declare any capabilities.
560+
*/
561+
#resolveKeyringCapabilities(): KeyringCapabilities | undefined {
562+
const snap = this.messenger.call('SnapController:getSnap', this.snapId);
563+
// READ THIS CAREFULLY:
564+
// We are not validating the shape of the capabilities here, because there is
565+
// manifest validation done already on the snaps side, the snaps repo maintains
566+
// a copy of the `KeyringCapabilitiesStruct`.
567+
// We must ensure that both structs are always in-sync, otherwise the type-cast
568+
// could cause runtime issues!
569+
return snap?.manifest.initialPermissions['endowment:keyring']
570+
?.capabilities as KeyringCapabilities | undefined;
571+
}
516572
}

0 commit comments

Comments
 (0)