From f4b14f8960595190b278e8ffa8c7f75fb805c0cb Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 18 Sep 2026 20:30:37 -0400 Subject: [PATCH 1/6] feat(examples): add example for batch set state --- .../packages/manage-state/CHANGELOG.md | 4 ++++ .../packages/manage-state/src/index.test.ts | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/examples/packages/manage-state/CHANGELOG.md b/packages/examples/packages/manage-state/CHANGELOG.md index f901986365..15c996d726 100644 --- a/packages/examples/packages/manage-state/CHANGELOG.md +++ b/packages/examples/packages/manage-state/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add tests for `snap_setState` with array `key` parameter + ## [3.0.0] ### Added diff --git a/packages/examples/packages/manage-state/src/index.test.ts b/packages/examples/packages/manage-state/src/index.test.ts index f2c3d114ad..8ee6302831 100644 --- a/packages/examples/packages/manage-state/src/index.test.ts +++ b/packages/examples/packages/manage-state/src/index.test.ts @@ -101,6 +101,29 @@ describe('onRpcRequest', () => { }); }); + it('sets the state for multiple keys', async () => { + const { request } = await installSnap(); + + expect( + await request({ + method: 'setState', + params: { + key: ['foo', 'baz'], + value: { foo: 'bar', baz: 'qux' }, + }, + }), + ).toRespondWith(null); + + expect( + await request({ + method: 'getState', + params: { + key: ['foo', 'baz'], + }, + }), + ).toRespondWith({ foo: 'bar', baz: 'qux' }); + }); + it('throws if the state is not an object and no key is specified', async () => { const { request } = await installSnap(); From 43e6e2bf22a3213b789de6186edb7975a897305c Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 18 Sep 2026 20:31:17 -0400 Subject: [PATCH 2/6] feat(snaps-rpc-methods): allow for multiple keys in snap_setState --- packages/snaps-rpc-methods/CHANGELOG.md | 4 + packages/snaps-rpc-methods/jest.config.js | 4 +- .../src/permitted/setState.test.ts | 142 ++++++++++++++++++ .../src/permitted/setState.ts | 59 ++++++-- packages/snaps-rpc-methods/src/utils.ts | 4 +- 5 files changed, 198 insertions(+), 15 deletions(-) diff --git a/packages/snaps-rpc-methods/CHANGELOG.md b/packages/snaps-rpc-methods/CHANGELOG.md index 06afdad2f4..e52a25702c 100644 --- a/packages/snaps-rpc-methods/CHANGELOG.md +++ b/packages/snaps-rpc-methods/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add support for array `key` parameter in `snap_setState`, setting each key to its corresponding value in the provided object + ## [17.1.2] ### Fixed diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index aa77775adc..9a73a6a547 100644 --- a/packages/snaps-rpc-methods/jest.config.js +++ b/packages/snaps-rpc-methods/jest.config.js @@ -10,10 +10,10 @@ module.exports = deepmerge(baseConfig, { ], coverageThreshold: { global: { - branches: 97.38, + branches: 97.43, functions: 98.92, lines: 99.22, - statements: 98.95, + statements: 98.96, }, }, }); diff --git a/packages/snaps-rpc-methods/src/permitted/setState.test.ts b/packages/snaps-rpc-methods/src/permitted/setState.test.ts index adfbc445f6..b1a9d81cb6 100644 --- a/packages/snaps-rpc-methods/src/permitted/setState.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/setState.test.ts @@ -522,6 +522,148 @@ describe('snap_setState', () => { }); }); + it('sets state for multiple keys', async () => { + const { implementation } = setStateHandler; + + const getUnlockPromise = jest.fn().mockResolvedValue(undefined); + const hooks = { getUnlockPromise }; + + const messenger = getMessenger(); + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequestWithOrigin, + response as PendingJsonRpcResponse, + next, + end, + hooks, + messenger, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_setState', + params: { + key: ['foo', 'baz'], + value: { foo: 'newFoo', baz: 'newBaz' }, + }, + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: null, + }); + + expect(messenger.call).toHaveBeenCalledWith( + 'SnapController:updateSnapState', + MOCK_SNAP_ID, + { foo: 'newFoo', baz: 'newBaz' }, + true, + ); + }); + + it('sets missing keys to `null` when key is an array and value omits them', async () => { + const { implementation } = setStateHandler; + + const getUnlockPromise = jest.fn().mockResolvedValue(undefined); + const hooks = { getUnlockPromise }; + + const messenger = getMessenger(); + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequestWithOrigin, + response as PendingJsonRpcResponse, + next, + end, + hooks, + messenger, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_setState', + params: { + key: ['foo', 'missing'], + value: { foo: 'newFoo' }, + }, + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: null, + }); + + expect(messenger.call).toHaveBeenCalledWith( + 'SnapController:updateSnapState', + MOCK_SNAP_ID, + { foo: 'newFoo', missing: null }, + true, + ); + }); + + it('throws if key is an array and value is not an object', async () => { + const { implementation } = setStateHandler; + + const getUnlockPromise = jest.fn().mockResolvedValue(undefined); + const hooks = { getUnlockPromise }; + + const messenger = getMessenger(); + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequestWithOrigin, + response, + next, + end, + hooks, + messenger, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_setState', + params: { + key: ['foo', 'baz'], + value: 'not-an-object', + }, + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + error: { + code: errorCodes.rpc.invalidParams, + message: + 'Invalid params: Value must be an object if key is an array.', + stack: expect.any(String), + }, + }); + }); + it('throws if the new state is not JSON serialisable', async () => { const { implementation } = setStateHandler; diff --git a/packages/snaps-rpc-methods/src/permitted/setState.ts b/packages/snaps-rpc-methods/src/permitted/setState.ts index 96842dabe7..34b2ae67d9 100644 --- a/packages/snaps-rpc-methods/src/permitted/setState.ts +++ b/packages/snaps-rpc-methods/src/permitted/setState.ts @@ -5,10 +5,11 @@ import type { import type { Messenger } from '@metamask/messenger'; import type { PermissionControllerHasPermissionAction } from '@metamask/permission-controller'; import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; -import type { - SetStateParams, - SetStateResult, - SnapId, +import { + selectiveUnion, + type SetStateParams, + type SetStateResult, + type SnapId, } from '@metamask/snaps-sdk'; import type { JsonObject } from '@metamask/snaps-sdk/jsx'; import { getJsonSizeUnsafe, type InferMatching } from '@metamask/snaps-utils'; @@ -34,7 +35,7 @@ import type { SnapControllerUpdateSnapStateAction, } from '../types'; import type { MethodHooksObject } from '../utils'; -import { FORBIDDEN_KEYS, StateKeyStruct } from '../utils'; +import { FORBIDDEN_KEYS, StateKeysStruct, StateKeyStruct } from '../utils'; const hookNames: MethodHooksObject = { getUnlockPromise: true, @@ -136,7 +137,14 @@ function getMutex(snapId: SnapId) { } const SetStateParametersStruct = objectStruct({ - key: optional(StateKeyStruct), + key: optional( + selectiveUnion((value) => { + if (Array.isArray(value)) { + return StateKeysStruct; + } + return StateKeyStruct; + }), + ), value: JsonStruct, encrypted: optional(boolean()), }); @@ -191,6 +199,14 @@ async function setStateImplementation( ); } + if (Array.isArray(key) && !isObject(value)) { + return end( + rpcErrors.invalidParams( + 'Invalid params: Value must be an object if key is an array.', + ), + ); + } + if (encrypted) { await getUnlockPromise(true); } @@ -267,20 +283,24 @@ function getValidatedParams(params?: unknown) { * If the key is `undefined`, the value is expected to be an object. In this * case, the value is returned as the new state. * - * If the key is not `undefined`, the value is set in the state at the key. If - * the key does not exist, it is created (and any missing intermediate keys are - * created as well). + * If the key is a string, the value is set in the state at the key. If the key + * does not exist, it is created (and any missing intermediate keys are created + * as well). + * + * If the key is an array of strings, the value is expected to be an object + * mapping each key to its new value. Each key is set in the state. * * @param snapId - The Snap ID. - * @param key - The key to set. - * @param value - The value to set the key to. + * @param key - The key or keys to set. + * @param value - The value to set the key to. If `key` is an array, this must + * be an object mapping each key to its new value. * @param encrypted - Whether the state is encrypted. * @param messenger - The messenger used to call controller actions. * @returns The new state of the Snap. */ async function getNewState( snapId: SnapId, - key: string | undefined, + key: string | string[] | undefined, value: Json, encrypted: boolean, messenger: Messenger, @@ -295,6 +315,21 @@ async function getNewState( snapId, encrypted, ); + + if (Array.isArray(key)) { + assert(isObject(value)); + let newState = state; + + // Intentionally using a classic for loop here for performance reasons. + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < key.length; i++) { + const currentKey = key[i]; + newState = set(newState, currentKey, value[currentKey] ?? null); + } + + return newState; + } + return set(state, key, value); } diff --git a/packages/snaps-rpc-methods/src/utils.ts b/packages/snaps-rpc-methods/src/utils.ts index 6b56fc6b24..b5a9a2928a 100644 --- a/packages/snaps-rpc-methods/src/utils.ts +++ b/packages/snaps-rpc-methods/src/utils.ts @@ -9,7 +9,7 @@ import { SLIP10Node } from '@metamask/key-tree'; import type { Messenger } from '@metamask/messenger'; import { rpcErrors } from '@metamask/rpc-errors'; import type { MagicValue } from '@metamask/snaps-utils'; -import { refine, string } from '@metamask/superstruct'; +import { array, refine, string } from '@metamask/superstruct'; import { assertExhaustive, add0x, @@ -308,6 +308,8 @@ export const StateKeyStruct = refine(string(), 'state key', (value) => { return true; }); +export const StateKeysStruct = array(StateKeyStruct); + /** * Get a value using the entropy source hooks: getMnemonic or getMnemonicSeed. * This function calls the passed hook and handles any errors that occur, From 5c52286dbe978d2121a193a7b8caff0b9a98a9d8 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 18 Sep 2026 20:31:50 -0400 Subject: [PATCH 3/6] feat(test-snaps): update to allow for multiple keys for set state --- packages/test-snaps/CHANGELOG.md | 4 ++++ .../src/features/snaps/state/components/SetState.tsx | 7 +++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/test-snaps/CHANGELOG.md b/packages/test-snaps/CHANGELOG.md index b5bd168a68..a86e80a17b 100644 --- a/packages/test-snaps/CHANGELOG.md +++ b/packages/test-snaps/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Update `SetState` component to support comma-separated keys via `snap_setState` array `key` parameter + ## [3.5.2] ### Fixed diff --git a/packages/test-snaps/src/features/snaps/state/components/SetState.tsx b/packages/test-snaps/src/features/snaps/state/components/SetState.tsx index 877350dfcd..737ef6397a 100644 --- a/packages/test-snaps/src/features/snaps/state/components/SetState.tsx +++ b/packages/test-snaps/src/features/snaps/state/components/SetState.tsx @@ -25,11 +25,14 @@ export const SetState: FunctionComponent<{ encrypted: boolean }> = ({ const handleSubmit = (event: FormEvent) => { event.preventDefault(); + const parsedKey = key.includes(',') + ? key.split(',').map((k) => k.trim()) + : key || undefined; invokeSnap({ snapId: getSnapId(MANAGE_STATE_SNAP_ID, MANAGE_STATE_PORT), method: 'setState', params: { - key, + key: parsedKey, value: JSON.parse(value), encrypted, }, @@ -44,7 +47,7 @@ export const SetState: FunctionComponent<{ encrypted: boolean }> = ({ Key Date: Fri, 18 Sep 2026 20:32:33 -0400 Subject: [PATCH 4/6] feat(snaps-sdk): update set state params type --- packages/snaps-sdk/CHANGELOG.md | 4 ++++ packages/snaps-sdk/src/types/methods/set-state.ts | 12 +++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/snaps-sdk/CHANGELOG.md b/packages/snaps-sdk/CHANGELOG.md index 4299a63a95..1c77608eeb 100644 --- a/packages/snaps-sdk/CHANGELOG.md +++ b/packages/snaps-sdk/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add support for array `key` parameter in `SetStateParams` for `snap_setState` + ## [12.0.1] ### Fixed diff --git a/packages/snaps-sdk/src/types/methods/set-state.ts b/packages/snaps-sdk/src/types/methods/set-state.ts index 8312b290fa..04c90fd5f6 100644 --- a/packages/snaps-sdk/src/types/methods/set-state.ts +++ b/packages/snaps-sdk/src/types/methods/set-state.ts @@ -3,10 +3,12 @@ import type { Json } from '@metamask/utils'; /** * The request parameters for the `snap_setState` method. * - * @property key - The key of the state to update. If not provided, the entire - * state is updated. This may contain Lodash-style path syntax, for example, - * `a.b.c`, with the exception of array syntax. - * @property value - The value to set the state to. + * @property key - The key or keys of the state to update. If not provided, the + * entire state is updated. This may contain Lodash-style path syntax, for + * example, `a.b.c`, with the exception of array syntax. If an array of keys is + * provided, the value must be an object mapping each key to its new value. + * @property value - The value to set the state to. If `key` is an array, this + * must be an object mapping each key to its new value. * @property encrypted - Whether to use the separate encrypted state, or the * unencrypted state. Defaults to the encrypted state. Encrypted state can only * be used if the client is unlocked, while unencrypted state can be used @@ -17,7 +19,7 @@ import type { Json } from '@metamask/utils'; * while the client is locked. */ export type SetStateParams = { - key?: string; + key?: string | string[]; value: Json; encrypted?: boolean; }; From 2a8efdc66460049b43ea0157e8fec53bf4334647 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 18 Sep 2026 20:54:36 -0400 Subject: [PATCH 5/6] chore: update changelog entries --- packages/examples/packages/manage-state/CHANGELOG.md | 2 +- packages/snaps-rpc-methods/CHANGELOG.md | 2 +- packages/snaps-sdk/CHANGELOG.md | 2 +- packages/test-snaps/CHANGELOG.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/examples/packages/manage-state/CHANGELOG.md b/packages/examples/packages/manage-state/CHANGELOG.md index 15c996d726..435307f1ae 100644 --- a/packages/examples/packages/manage-state/CHANGELOG.md +++ b/packages/examples/packages/manage-state/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add tests for `snap_setState` with array `key` parameter +- Add tests for `snap_setState` with array `key` parameter ([#4126](https://github.com/MetaMask/snaps/pull/4126)) ## [3.0.0] diff --git a/packages/snaps-rpc-methods/CHANGELOG.md b/packages/snaps-rpc-methods/CHANGELOG.md index e52a25702c..2cfb9f18e8 100644 --- a/packages/snaps-rpc-methods/CHANGELOG.md +++ b/packages/snaps-rpc-methods/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add support for array `key` parameter in `snap_setState`, setting each key to its corresponding value in the provided object +- Add support for array `key` parameter in `snap_setState`, setting each key to its corresponding value in the provided object ([#4126](https://github.com/MetaMask/snaps/pull/4126)) ## [17.1.2] diff --git a/packages/snaps-sdk/CHANGELOG.md b/packages/snaps-sdk/CHANGELOG.md index 1c77608eeb..c39522b1c0 100644 --- a/packages/snaps-sdk/CHANGELOG.md +++ b/packages/snaps-sdk/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add support for array `key` parameter in `SetStateParams` for `snap_setState` +- Add support for array `key` parameter in `SetStateParams` for `snap_setState` ([#4126](https://github.com/MetaMask/snaps/pull/4126)) ## [12.0.1] diff --git a/packages/test-snaps/CHANGELOG.md b/packages/test-snaps/CHANGELOG.md index a86e80a17b..6b3e0f5cc0 100644 --- a/packages/test-snaps/CHANGELOG.md +++ b/packages/test-snaps/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Update `SetState` component to support comma-separated keys via `snap_setState` array `key` parameter +- Update `SetState` component to support comma-separated keys via `snap_setState` array `key` parameter ([#4126](https://github.com/MetaMask/snaps/pull/4126)) ## [3.5.2] From 67c5e9096403ceec283bb26be2263b641aa0b48b Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 18 Sep 2026 20:59:39 -0400 Subject: [PATCH 6/6] fix(snaps-rpc-methods): rename var to avoid lint issue --- packages/snaps-rpc-methods/src/utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/snaps-rpc-methods/src/utils.ts b/packages/snaps-rpc-methods/src/utils.ts index b5a9a2928a..047ad11176 100644 --- a/packages/snaps-rpc-methods/src/utils.ts +++ b/packages/snaps-rpc-methods/src/utils.ts @@ -44,7 +44,7 @@ export type MethodHooksObject> = { * @returns The derived indices as a {@link HardenedBIP32Node} array. */ function getDerivationPathArray(hash: Uint8Array): HardenedBIP32Node[] { - const array: HardenedBIP32Node[] = []; + const nodeArray: HardenedBIP32Node[] = []; const view = createDataView(hash); for (let index = 0; index < 8; index++) { @@ -55,10 +55,10 @@ function getDerivationPathArray(hash: Uint8Array): HardenedBIP32Node[] { // the result is a positive number. // eslint-disable-next-line no-bitwise const pathIndex = (uint32 | HARDENED_VALUE) >>> 0; - array.push(`bip32:${pathIndex - HARDENED_VALUE}'` as const); + nodeArray.push(`bip32:${pathIndex - HARDENED_VALUE}'` as const); } - return array; + return nodeArray; } type BaseDeriveEntropyOptions = {