Skip to content

Commit 7ad4e66

Browse files
feat!: Support specified clientVersions in the registry (#3737)
This PR **adds a new required constructor argument to `JsonSnapsRegistry` called `clientConfig`**. By using this constructor argument we can determine if a Snap version is compatible with the client version that the user is running. This can be done by comparing the constructor argument to the optional client metadata in the registry when resolving the allowlist version. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds a required `clientConfig` to `JsonSnapsRegistry` and uses registry `clientVersions` to verify and resolve only client-compatible Snap versions. > > - **snaps-controllers**: > - **Registry compatibility**: > - Add required constructor arg `clientConfig` to `JsonSnapsRegistry` and store as private field. > - When verifying (`SnapsRegistry:get`), require checksum match and client compatibility using `clientVersions[clientConfig.type]` via `satisfiesVersionRange`. > - When resolving (`SnapsRegistry:resolveVersion`), filter allowlisted versions to those compatible with the provided client before selecting with `getTargetVersion`. > - **Tests**: > - Add compatibility scenarios for verified/unverified outcomes and version resolution preferring latest compatible. > - **Dependencies**: > - Bump `@metamask/snaps-registry` to `^3.3.0` in `snaps-controllers` and `snaps-utils` (lockfile updated). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9176886. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent c8affd0 commit 7ad4e66

File tree

5 files changed

+153
-14
lines changed

5 files changed

+153
-14
lines changed

packages/snaps-controllers/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090
"@metamask/phishing-controller": "^15.0.0",
9191
"@metamask/post-message-stream": "^10.0.0",
9292
"@metamask/rpc-errors": "^7.0.3",
93-
"@metamask/snaps-registry": "^3.2.3",
93+
"@metamask/snaps-registry": "^3.3.0",
9494
"@metamask/snaps-rpc-methods": "workspace:^",
9595
"@metamask/snaps-sdk": "workspace:^",
9696
"@metamask/snaps-utils": "workspace:^",

packages/snaps-controllers/src/snaps/registry/json.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ const getRegistry = (args?: Partial<JsonSnapsRegistryArgs>) => {
2323
registry: new JsonSnapsRegistry({
2424
messenger,
2525
publicKey: MOCK_PUBLIC_KEY,
26+
clientConfig: {
27+
type: 'extension',
28+
version: '13.9.0' as SemVerVersion,
29+
},
2630
...args,
2731
}),
2832
messenger,
@@ -84,6 +88,43 @@ const MOCK_EMPTY_SIGNATURE_FILE = {
8488
format: 'DER',
8589
};
8690

91+
const MOCK_DATABASE_COMPATIBILITY = {
92+
verifiedSnaps: {
93+
[MOCK_SNAP_ID]: {
94+
id: MOCK_SNAP_ID,
95+
metadata: {
96+
name: 'Mock Snap',
97+
},
98+
versions: {
99+
['1.0.0' as SemVerVersion]: {
100+
checksum: DEFAULT_SNAP_SHASUM,
101+
clientVersions: {
102+
extension: '>=13.9.0',
103+
},
104+
},
105+
['1.1.0' as SemVerVersion]: {
106+
checksum: DEFAULT_SNAP_SHASUM,
107+
clientVersions: {
108+
extension: '>=15.0.0',
109+
},
110+
},
111+
},
112+
},
113+
},
114+
blockedSnaps: [],
115+
};
116+
117+
/**
118+
* To regenerate the signature, repeat the instructions above but with MOCK_DATABASE_COMPATIBILITY
119+
*/
120+
const MOCK_COMPATIBILITY_SIGNATURE =
121+
'0x3045022100c0dd17483ac052b25a24c43a84de7b7b38194ac770cadb53a83ca950150631bd02204ed1f6b3359901199e2752d148079084cda13439150136055be5d4a3df205115';
122+
const MOCK_COMPATIBILITY_SIGNATURE_FILE = {
123+
signature: MOCK_COMPATIBILITY_SIGNATURE,
124+
curve: 'secp256k1',
125+
format: 'DER',
126+
};
127+
87128
describe('JsonSnapsRegistry', () => {
88129
fetchMock.enableMocks();
89130

@@ -240,6 +281,46 @@ describe('JsonSnapsRegistry', () => {
240281
});
241282
});
242283

284+
it('returns verified for compatible Snaps', async () => {
285+
fetchMock
286+
.mockResponseOnce(JSON.stringify(MOCK_DATABASE_COMPATIBILITY))
287+
.mockResponseOnce(JSON.stringify(MOCK_COMPATIBILITY_SIGNATURE_FILE));
288+
289+
const { messenger } = getRegistry();
290+
const result = await messenger.call('SnapsRegistry:get', {
291+
[MOCK_SNAP_ID]: {
292+
version: '1.0.0' as SemVerVersion,
293+
checksum: DEFAULT_SNAP_SHASUM,
294+
},
295+
});
296+
297+
expect(result).toStrictEqual({
298+
[MOCK_SNAP_ID]: {
299+
status: SnapsRegistryStatus.Verified,
300+
},
301+
});
302+
});
303+
304+
it('returns unverified for non compatible Snaps', async () => {
305+
fetchMock
306+
.mockResponseOnce(JSON.stringify(MOCK_DATABASE_COMPATIBILITY))
307+
.mockResponseOnce(JSON.stringify(MOCK_COMPATIBILITY_SIGNATURE_FILE));
308+
309+
const { messenger } = getRegistry();
310+
const result = await messenger.call('SnapsRegistry:get', {
311+
[MOCK_SNAP_ID]: {
312+
version: '1.1.0' as SemVerVersion,
313+
checksum: DEFAULT_SNAP_SHASUM,
314+
},
315+
});
316+
317+
expect(result).toStrictEqual({
318+
[MOCK_SNAP_ID]: {
319+
status: SnapsRegistryStatus.Unverified,
320+
},
321+
});
322+
});
323+
243324
it('uses existing state if registry is unavailable', async () => {
244325
fetchMock.mockResponse('', { status: 404 });
245326

@@ -372,6 +453,38 @@ describe('JsonSnapsRegistry', () => {
372453
expect(result).toBe('1.0.0');
373454
});
374455

456+
it('resolves to a compatible allowlisted version', async () => {
457+
fetchMock
458+
.mockResponseOnce(JSON.stringify(MOCK_DATABASE_COMPATIBILITY))
459+
.mockResponseOnce(JSON.stringify(MOCK_COMPATIBILITY_SIGNATURE_FILE));
460+
461+
const { messenger } = getRegistry();
462+
const result = await messenger.call(
463+
'SnapsRegistry:resolveVersion',
464+
MOCK_SNAP_ID,
465+
'^1.0.0' as SemVerRange,
466+
);
467+
468+
expect(result).toBe('1.0.0');
469+
});
470+
471+
it('resolves to the latest allowlisted version if compatible', async () => {
472+
fetchMock
473+
.mockResponseOnce(JSON.stringify(MOCK_DATABASE_COMPATIBILITY))
474+
.mockResponseOnce(JSON.stringify(MOCK_COMPATIBILITY_SIGNATURE_FILE));
475+
476+
const { messenger } = getRegistry({
477+
clientConfig: { type: 'extension', version: '15.0.0' as SemVerVersion },
478+
});
479+
const result = await messenger.call(
480+
'SnapsRegistry:resolveVersion',
481+
MOCK_SNAP_ID,
482+
'^1.0.0' as SemVerRange,
483+
);
484+
485+
expect(result).toBe('1.1.0');
486+
});
487+
375488
it('returns version range if snap is not on the allowlist', async () => {
376489
fetchMock
377490
.mockResponseOnce(

packages/snaps-controllers/src/snaps/registry/json.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ type JsonSnapsRegistryUrl = {
3939
signature: string;
4040
};
4141

42+
export type ClientConfig = {
43+
type: 'extension' | 'mobile';
44+
version: SemVerVersion;
45+
};
46+
4247
export type JsonSnapsRegistryArgs = {
4348
messenger: SnapsRegistryMessenger;
4449
state?: SnapsRegistryState;
@@ -47,6 +52,7 @@ export type JsonSnapsRegistryArgs = {
4752
recentFetchThreshold?: number;
4853
refetchOnAllowlistMiss?: boolean;
4954
publicKey?: Hex;
55+
clientConfig: ClientConfig;
5056
};
5157

5258
export type GetResult = {
@@ -117,6 +123,8 @@ export class JsonSnapsRegistry extends BaseController<
117123

118124
readonly #publicKey: Hex;
119125

126+
readonly #clientConfig: ClientConfig;
127+
120128
readonly #fetchFunction: typeof fetch;
121129

122130
readonly #recentFetchThreshold: number;
@@ -133,6 +141,7 @@ export class JsonSnapsRegistry extends BaseController<
133141
signature: SNAP_REGISTRY_SIGNATURE_URL,
134142
},
135143
publicKey = DEFAULT_PUBLIC_KEY,
144+
clientConfig,
136145
fetchFunction = globalThis.fetch.bind(undefined),
137146
recentFetchThreshold = inMilliseconds(5, Duration.Minute),
138147
refetchOnAllowlistMiss = true,
@@ -167,6 +176,7 @@ export class JsonSnapsRegistry extends BaseController<
167176
});
168177
this.#url = url;
169178
this.#publicKey = publicKey;
179+
this.#clientConfig = clientConfig;
170180
this.#fetchFunction = fetchFunction;
171181
this.#recentFetchThreshold = recentFetchThreshold;
172182
this.#refetchOnAllowlistMiss = refetchOnAllowlistMiss;
@@ -284,7 +294,11 @@ export class JsonSnapsRegistry extends BaseController<
284294

285295
const verified = database?.verifiedSnaps[snapId];
286296
const version = verified?.versions?.[snapInfo.version];
287-
if (version && version.checksum === snapInfo.checksum) {
297+
const clientRange = version?.clientVersions?.[this.#clientConfig.type];
298+
const isCompatible =
299+
!clientRange ||
300+
satisfiesVersionRange(this.#clientConfig.version, clientRange);
301+
if (version && version.checksum === snapInfo.checksum && isCompatible) {
288302
return { status: SnapsRegistryStatus.Verified };
289303
}
290304
// For now, if we have an allowlist miss, we can refetch once and try again.
@@ -338,11 +352,23 @@ export class JsonSnapsRegistry extends BaseController<
338352
return versionRange;
339353
}
340354

341-
const targetVersion = getTargetVersion(
342-
Object.keys(versions) as SemVerVersion[],
343-
versionRange,
355+
const compatibleVersions = Object.entries(versions).reduce<SemVerVersion[]>(
356+
(accumulator, [version, metadata]) => {
357+
const clientRange = metadata.clientVersions?.[this.#clientConfig.type];
358+
if (
359+
!clientRange ||
360+
satisfiesVersionRange(this.#clientConfig.version, clientRange)
361+
) {
362+
accumulator.push(version as SemVerVersion);
363+
}
364+
365+
return accumulator;
366+
},
367+
[],
344368
);
345369

370+
const targetVersion = getTargetVersion(compatibleVersions, versionRange);
371+
346372
if (!targetVersion && this.#refetchOnAllowlistMiss && !refetch) {
347373
await this.#triggerUpdate();
348374
return this.#resolveVersion(snapId, versionRange, true);

packages/snaps-utils/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
"@metamask/permission-controller": "^12.1.0",
8686
"@metamask/rpc-errors": "^7.0.3",
8787
"@metamask/slip44": "^4.3.0",
88-
"@metamask/snaps-registry": "^3.2.3",
88+
"@metamask/snaps-registry": "^3.3.0",
8989
"@metamask/snaps-sdk": "workspace:^",
9090
"@metamask/superstruct": "^3.2.1",
9191
"@metamask/utils": "^11.8.1",

yarn.lock

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4262,7 +4262,7 @@ __metadata:
42624262
"@metamask/phishing-controller": "npm:^15.0.0"
42634263
"@metamask/post-message-stream": "npm:^10.0.0"
42644264
"@metamask/rpc-errors": "npm:^7.0.3"
4265-
"@metamask/snaps-registry": "npm:^3.2.3"
4265+
"@metamask/snaps-registry": "npm:^3.3.0"
42664266
"@metamask/snaps-rpc-methods": "workspace:^"
42674267
"@metamask/snaps-sdk": "workspace:^"
42684268
"@metamask/snaps-utils": "workspace:^"
@@ -4413,15 +4413,15 @@ __metadata:
44134413
languageName: unknown
44144414
linkType: soft
44154415

4416-
"@metamask/snaps-registry@npm:^3.2.3":
4417-
version: 3.2.3
4418-
resolution: "@metamask/snaps-registry@npm:3.2.3"
4416+
"@metamask/snaps-registry@npm:^3.3.0":
4417+
version: 3.3.0
4418+
resolution: "@metamask/snaps-registry@npm:3.3.0"
44194419
dependencies:
4420-
"@metamask/superstruct": "npm:^3.1.0"
4421-
"@metamask/utils": "npm:^11.0.1"
4420+
"@metamask/superstruct": "npm:^3.2.1"
4421+
"@metamask/utils": "npm:^11.4.0"
44224422
"@noble/curves": "npm:^1.2.0"
44234423
"@noble/hashes": "npm:^1.3.2"
4424-
checksum: 10/37760f29b7aaa337d815cf0c11fa34af5093d87fdc60a3750c494cf8bae6293cd52da03e7694b467b79733052d75ec6e3781ab3590d7259a050784e5be347d12
4424+
checksum: 10/1a53ad150318cbaf703b639a3a831a6ac57f84b2266ac176e6b0d470df31ecf66f0f885256f17a7acae265ada085c904ba97f1e2cb5371e136bf90778ffaed0a
44254425
languageName: node
44264426
linkType: hard
44274427

@@ -4599,7 +4599,7 @@ __metadata:
45994599
"@metamask/post-message-stream": "npm:^10.0.0"
46004600
"@metamask/rpc-errors": "npm:^7.0.3"
46014601
"@metamask/slip44": "npm:^4.3.0"
4602-
"@metamask/snaps-registry": "npm:^3.2.3"
4602+
"@metamask/snaps-registry": "npm:^3.3.0"
46034603
"@metamask/snaps-sdk": "workspace:^"
46044604
"@metamask/superstruct": "npm:^3.2.1"
46054605
"@metamask/utils": "npm:^11.8.1"

0 commit comments

Comments
 (0)