diff --git a/packages/snaps-controllers/package.json b/packages/snaps-controllers/package.json index 0379c8457f..8da65dd465 100644 --- a/packages/snaps-controllers/package.json +++ b/packages/snaps-controllers/package.json @@ -94,6 +94,7 @@ "@metamask/snaps-rpc-methods": "workspace:^", "@metamask/snaps-sdk": "workspace:^", "@metamask/snaps-utils": "workspace:^", + "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.8.1", "@xstate/fsm": "^2.0.0", "async-mutex": "^0.5.0", diff --git a/packages/snaps-controllers/src/snaps/registry/json.test.ts b/packages/snaps-controllers/src/snaps/registry/json.test.ts index dcf710327e..3aaada161b 100644 --- a/packages/snaps-controllers/src/snaps/registry/json.test.ts +++ b/packages/snaps-controllers/src/snaps/registry/json.test.ts @@ -617,6 +617,27 @@ describe('JsonSnapsRegistry', () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it('skips update if the signature matches the existing one', async () => { + const spy = jest.spyOn(globalThis.crypto.subtle, 'digest'); + + fetchMock + .mockResponseOnce(JSON.stringify(MOCK_DATABASE)) + .mockResponseOnce(JSON.stringify(MOCK_SIGNATURE_FILE)); + + const { messenger } = getRegistry({ + state: { + database: MOCK_DATABASE, + signature: MOCK_SIGNATURE, + lastUpdated: 0, + databaseUnavailable: false, + }, + }); + await messenger.call('SnapsRegistry:update'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(spy).not.toHaveBeenCalled(); + }); + it('does not fetch if a second call is made under the threshold', async () => { fetchMock .mockResponseOnce(JSON.stringify(MOCK_DATABASE)) @@ -667,6 +688,7 @@ describe('JsonSnapsRegistry', () => { "database": null, "databaseUnavailable": false, "lastUpdated": null, + "signature": null, } `); }); @@ -681,6 +703,7 @@ describe('JsonSnapsRegistry', () => { "database": null, "databaseUnavailable": false, "lastUpdated": null, + "signature": null, } `); }); diff --git a/packages/snaps-controllers/src/snaps/registry/json.ts b/packages/snaps-controllers/src/snaps/registry/json.ts index ea86ed2c8d..3c5310f5a5 100644 --- a/packages/snaps-controllers/src/snaps/registry/json.ts +++ b/packages/snaps-controllers/src/snaps/registry/json.ts @@ -4,9 +4,13 @@ import type { } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; -import type { SnapsRegistryDatabase } from '@metamask/snaps-registry'; +import type { + SnapsRegistryDatabase, + SignatureStruct, +} from '@metamask/snaps-registry'; import { verify } from '@metamask/snaps-registry'; import { getTargetVersion } from '@metamask/snaps-utils'; +import type { Infer } from '@metamask/superstruct'; import type { Hex, SemVerRange, SemVerVersion } from '@metamask/utils'; import { assert, @@ -102,6 +106,7 @@ export type SnapsRegistryMessenger = Messenger< export type SnapsRegistryState = { database: SnapsRegistryDatabase | null; + signature: string | null; lastUpdated: number | null; databaseUnavailable: boolean; }; @@ -110,6 +115,7 @@ const controllerName = 'SnapsRegistry'; const defaultState = { database: null, + signature: null, lastUpdated: null, databaseUnavailable: false, }; @@ -155,6 +161,12 @@ export class JsonSnapsRegistry extends BaseController< includeInDebugSnapshot: false, usedInUi: true, }, + signature: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: false, + }, lastUpdated: { includeInStateLogs: true, persist: true, @@ -244,12 +256,24 @@ export class JsonSnapsRegistry extends BaseController< this.#safeFetch(this.#url.signature), ]); - await this.#verifySignature(database, signature); + const signatureJson = JSON.parse(signature); + + // If the signature matches the existing state, we can skip verification and don't need to update the database. + if (signatureJson.signature === this.state.signature) { + this.update((state) => { + state.lastUpdated = Date.now(); + state.databaseUnavailable = false; + }); + return; + } + + await this.#verifySignature(database, signatureJson); this.update((state) => { state.database = JSON.parse(database); state.lastUpdated = Date.now(); state.databaseUnavailable = false; + state.signature = signatureJson.signature; }); } catch { // Ignore @@ -402,12 +426,15 @@ export class JsonSnapsRegistry extends BaseController< * @param signature - The signature of the registry. * @throws If the signature is invalid. */ - async #verifySignature(database: string, signature: string) { + async #verifySignature( + database: string, + signature: Infer, + ) { assert(this.#publicKey, 'No public key provided.'); const valid = await verify({ registry: database, - signature: JSON.parse(signature), + signature, publicKey: this.#publicKey, }); diff --git a/yarn.lock b/yarn.lock index c966e2ec9d..ff91fd6cbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4269,6 +4269,7 @@ __metadata: "@metamask/snaps-rpc-methods": "workspace:^" "@metamask/snaps-sdk": "workspace:^" "@metamask/snaps-utils": "workspace:^" + "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.8.1" "@noble/hashes": "npm:^1.7.1" "@swc/core": "npm:1.11.31"