diff --git a/packages/snaps-controllers/package.json b/packages/snaps-controllers/package.json index 7291d92efe..2170f0371c 100644 --- a/packages/snaps-controllers/package.json +++ b/packages/snaps-controllers/package.json @@ -90,7 +90,7 @@ "@metamask/phishing-controller": "^15.0.0", "@metamask/post-message-stream": "^10.0.0", "@metamask/rpc-errors": "^7.0.3", - "@metamask/snaps-registry": "^3.2.3", + "@metamask/snaps-registry": "^3.3.0", "@metamask/snaps-rpc-methods": "workspace:^", "@metamask/snaps-sdk": "workspace:^", "@metamask/snaps-utils": "workspace:^", diff --git a/packages/snaps-controllers/src/snaps/registry/json.test.ts b/packages/snaps-controllers/src/snaps/registry/json.test.ts index 3dd5448eaa..dcf710327e 100644 --- a/packages/snaps-controllers/src/snaps/registry/json.test.ts +++ b/packages/snaps-controllers/src/snaps/registry/json.test.ts @@ -23,6 +23,10 @@ const getRegistry = (args?: Partial) => { registry: new JsonSnapsRegistry({ messenger, publicKey: MOCK_PUBLIC_KEY, + clientConfig: { + type: 'extension', + version: '13.9.0' as SemVerVersion, + }, ...args, }), messenger, @@ -84,6 +88,43 @@ const MOCK_EMPTY_SIGNATURE_FILE = { format: 'DER', }; +const MOCK_DATABASE_COMPATIBILITY = { + verifiedSnaps: { + [MOCK_SNAP_ID]: { + id: MOCK_SNAP_ID, + metadata: { + name: 'Mock Snap', + }, + versions: { + ['1.0.0' as SemVerVersion]: { + checksum: DEFAULT_SNAP_SHASUM, + clientVersions: { + extension: '>=13.9.0', + }, + }, + ['1.1.0' as SemVerVersion]: { + checksum: DEFAULT_SNAP_SHASUM, + clientVersions: { + extension: '>=15.0.0', + }, + }, + }, + }, + }, + blockedSnaps: [], +}; + +/** + * To regenerate the signature, repeat the instructions above but with MOCK_DATABASE_COMPATIBILITY + */ +const MOCK_COMPATIBILITY_SIGNATURE = + '0x3045022100c0dd17483ac052b25a24c43a84de7b7b38194ac770cadb53a83ca950150631bd02204ed1f6b3359901199e2752d148079084cda13439150136055be5d4a3df205115'; +const MOCK_COMPATIBILITY_SIGNATURE_FILE = { + signature: MOCK_COMPATIBILITY_SIGNATURE, + curve: 'secp256k1', + format: 'DER', +}; + describe('JsonSnapsRegistry', () => { fetchMock.enableMocks(); @@ -240,6 +281,46 @@ describe('JsonSnapsRegistry', () => { }); }); + it('returns verified for compatible Snaps', async () => { + fetchMock + .mockResponseOnce(JSON.stringify(MOCK_DATABASE_COMPATIBILITY)) + .mockResponseOnce(JSON.stringify(MOCK_COMPATIBILITY_SIGNATURE_FILE)); + + const { messenger } = getRegistry(); + const result = await messenger.call('SnapsRegistry:get', { + [MOCK_SNAP_ID]: { + version: '1.0.0' as SemVerVersion, + checksum: DEFAULT_SNAP_SHASUM, + }, + }); + + expect(result).toStrictEqual({ + [MOCK_SNAP_ID]: { + status: SnapsRegistryStatus.Verified, + }, + }); + }); + + it('returns unverified for non compatible Snaps', async () => { + fetchMock + .mockResponseOnce(JSON.stringify(MOCK_DATABASE_COMPATIBILITY)) + .mockResponseOnce(JSON.stringify(MOCK_COMPATIBILITY_SIGNATURE_FILE)); + + const { messenger } = getRegistry(); + const result = await messenger.call('SnapsRegistry:get', { + [MOCK_SNAP_ID]: { + version: '1.1.0' as SemVerVersion, + checksum: DEFAULT_SNAP_SHASUM, + }, + }); + + expect(result).toStrictEqual({ + [MOCK_SNAP_ID]: { + status: SnapsRegistryStatus.Unverified, + }, + }); + }); + it('uses existing state if registry is unavailable', async () => { fetchMock.mockResponse('', { status: 404 }); @@ -372,6 +453,38 @@ describe('JsonSnapsRegistry', () => { expect(result).toBe('1.0.0'); }); + it('resolves to a compatible allowlisted version', async () => { + fetchMock + .mockResponseOnce(JSON.stringify(MOCK_DATABASE_COMPATIBILITY)) + .mockResponseOnce(JSON.stringify(MOCK_COMPATIBILITY_SIGNATURE_FILE)); + + const { messenger } = getRegistry(); + const result = await messenger.call( + 'SnapsRegistry:resolveVersion', + MOCK_SNAP_ID, + '^1.0.0' as SemVerRange, + ); + + expect(result).toBe('1.0.0'); + }); + + it('resolves to the latest allowlisted version if compatible', async () => { + fetchMock + .mockResponseOnce(JSON.stringify(MOCK_DATABASE_COMPATIBILITY)) + .mockResponseOnce(JSON.stringify(MOCK_COMPATIBILITY_SIGNATURE_FILE)); + + const { messenger } = getRegistry({ + clientConfig: { type: 'extension', version: '15.0.0' as SemVerVersion }, + }); + const result = await messenger.call( + 'SnapsRegistry:resolveVersion', + MOCK_SNAP_ID, + '^1.0.0' as SemVerRange, + ); + + expect(result).toBe('1.1.0'); + }); + it('returns version range if snap is not on the allowlist', async () => { fetchMock .mockResponseOnce( diff --git a/packages/snaps-controllers/src/snaps/registry/json.ts b/packages/snaps-controllers/src/snaps/registry/json.ts index e9fea8fd74..6421bcfdff 100644 --- a/packages/snaps-controllers/src/snaps/registry/json.ts +++ b/packages/snaps-controllers/src/snaps/registry/json.ts @@ -39,6 +39,11 @@ type JsonSnapsRegistryUrl = { signature: string; }; +export type ClientConfig = { + type: 'extension' | 'mobile'; + version: SemVerVersion; +}; + export type JsonSnapsRegistryArgs = { messenger: SnapsRegistryMessenger; state?: SnapsRegistryState; @@ -47,6 +52,7 @@ export type JsonSnapsRegistryArgs = { recentFetchThreshold?: number; refetchOnAllowlistMiss?: boolean; publicKey?: Hex; + clientConfig: ClientConfig; }; export type GetResult = { @@ -117,6 +123,8 @@ export class JsonSnapsRegistry extends BaseController< readonly #publicKey: Hex; + readonly #clientConfig: ClientConfig; + readonly #fetchFunction: typeof fetch; readonly #recentFetchThreshold: number; @@ -133,6 +141,7 @@ export class JsonSnapsRegistry extends BaseController< signature: SNAP_REGISTRY_SIGNATURE_URL, }, publicKey = DEFAULT_PUBLIC_KEY, + clientConfig, fetchFunction = globalThis.fetch.bind(undefined), recentFetchThreshold = inMilliseconds(5, Duration.Minute), refetchOnAllowlistMiss = true, @@ -167,6 +176,7 @@ export class JsonSnapsRegistry extends BaseController< }); this.#url = url; this.#publicKey = publicKey; + this.#clientConfig = clientConfig; this.#fetchFunction = fetchFunction; this.#recentFetchThreshold = recentFetchThreshold; this.#refetchOnAllowlistMiss = refetchOnAllowlistMiss; @@ -284,7 +294,11 @@ export class JsonSnapsRegistry extends BaseController< const verified = database?.verifiedSnaps[snapId]; const version = verified?.versions?.[snapInfo.version]; - if (version && version.checksum === snapInfo.checksum) { + const clientRange = version?.clientVersions?.[this.#clientConfig.type]; + const isCompatible = + !clientRange || + satisfiesVersionRange(this.#clientConfig.version, clientRange); + if (version && version.checksum === snapInfo.checksum && isCompatible) { return { status: SnapsRegistryStatus.Verified }; } // For now, if we have an allowlist miss, we can refetch once and try again. @@ -338,11 +352,23 @@ export class JsonSnapsRegistry extends BaseController< return versionRange; } - const targetVersion = getTargetVersion( - Object.keys(versions) as SemVerVersion[], - versionRange, + const compatibleVersions = Object.entries(versions).reduce( + (accumulator, [version, metadata]) => { + const clientRange = metadata.clientVersions?.[this.#clientConfig.type]; + if ( + !clientRange || + satisfiesVersionRange(this.#clientConfig.version, clientRange) + ) { + accumulator.push(version as SemVerVersion); + } + + return accumulator; + }, + [], ); + const targetVersion = getTargetVersion(compatibleVersions, versionRange); + if (!targetVersion && this.#refetchOnAllowlistMiss && !refetch) { await this.#triggerUpdate(); return this.#resolveVersion(snapId, versionRange, true); diff --git a/packages/snaps-utils/package.json b/packages/snaps-utils/package.json index 6fcc6efc44..c9b33e1f21 100644 --- a/packages/snaps-utils/package.json +++ b/packages/snaps-utils/package.json @@ -85,7 +85,7 @@ "@metamask/permission-controller": "^12.1.0", "@metamask/rpc-errors": "^7.0.3", "@metamask/slip44": "^4.3.0", - "@metamask/snaps-registry": "^3.2.3", + "@metamask/snaps-registry": "^3.3.0", "@metamask/snaps-sdk": "workspace:^", "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.8.1", diff --git a/yarn.lock b/yarn.lock index b970d42388..78eda15f36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4262,7 +4262,7 @@ __metadata: "@metamask/phishing-controller": "npm:^15.0.0" "@metamask/post-message-stream": "npm:^10.0.0" "@metamask/rpc-errors": "npm:^7.0.3" - "@metamask/snaps-registry": "npm:^3.2.3" + "@metamask/snaps-registry": "npm:^3.3.0" "@metamask/snaps-rpc-methods": "workspace:^" "@metamask/snaps-sdk": "workspace:^" "@metamask/snaps-utils": "workspace:^" @@ -4413,15 +4413,15 @@ __metadata: languageName: unknown linkType: soft -"@metamask/snaps-registry@npm:^3.2.3": - version: 3.2.3 - resolution: "@metamask/snaps-registry@npm:3.2.3" +"@metamask/snaps-registry@npm:^3.3.0": + version: 3.3.0 + resolution: "@metamask/snaps-registry@npm:3.3.0" dependencies: - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.0.1" + "@metamask/superstruct": "npm:^3.2.1" + "@metamask/utils": "npm:^11.4.0" "@noble/curves": "npm:^1.2.0" "@noble/hashes": "npm:^1.3.2" - checksum: 10/37760f29b7aaa337d815cf0c11fa34af5093d87fdc60a3750c494cf8bae6293cd52da03e7694b467b79733052d75ec6e3781ab3590d7259a050784e5be347d12 + checksum: 10/1a53ad150318cbaf703b639a3a831a6ac57f84b2266ac176e6b0d470df31ecf66f0f885256f17a7acae265ada085c904ba97f1e2cb5371e136bf90778ffaed0a languageName: node linkType: hard @@ -4599,7 +4599,7 @@ __metadata: "@metamask/post-message-stream": "npm:^10.0.0" "@metamask/rpc-errors": "npm:^7.0.3" "@metamask/slip44": "npm:^4.3.0" - "@metamask/snaps-registry": "npm:^3.2.3" + "@metamask/snaps-registry": "npm:^3.3.0" "@metamask/snaps-sdk": "workspace:^" "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.8.1"