@metamask/snaps-rpc-methods
Version:
MetaMask Snaps JSON-RPC method implementations
229 lines • 8.52 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.set = exports.setStateHandler = void 0;
const rpc_errors_1 = require("@metamask/rpc-errors");
const snaps_utils_1 = require("@metamask/snaps-utils");
const superstruct_1 = require("@metamask/superstruct");
const utils_1 = require("@metamask/utils");
const async_mutex_1 = require("async-mutex");
const manageState_1 = require("../restricted/manageState.cjs");
const utils_2 = require("../utils.cjs");
const hookNames = {
getUnlockPromise: true,
};
/**
* Allow the Snap to persist up to 64 MB of data to disk and retrieve it at
* will. By default, the data is automatically encrypted using a Snap-specific
* key and automatically decrypted when retrieved. You can set `encrypted` to
* `false` to use unencrypted storage (available when the client is locked).
*
* If the key is `undefined`, the value is expected to be an object. In this
* case, the value is set as the new root 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).
*
* @example
* ```json name="Manifest"
* {
* "initialPermissions": {
* "snap_manageState": {}
* }
* }
* ```
* ```ts name="Usage"
* // Set the entire state:
* await snap.request({
* method: 'snap_setState',
* params: {
* value: {
* some: {
* nested: {
* value: 'Hello, world!',
* },
* },
* },
* encrypted: true, // Optional, defaults to `true`
* },
* });
*
* // Set a specific value within the state:
* await snap.request({
* method: 'snap_setState',
* params: {
* key: 'some.nested.value',
* value: 'Hello, world!',
* encrypted: true, // Optional, defaults to `true`
* },
* });
* ```
*/
exports.setStateHandler = {
implementation: setStateImplementation,
hookNames,
actionNames: [
'PermissionController:hasPermission',
'SnapController:getSnapState',
'SnapController:updateSnapState',
'SnapController:getSnap',
],
};
const mutexes = new Map();
/**
* Get the corresponding state modification mutex for a given Snap ID.
*
* @param snapId - The Snap ID.
* @returns A mutex for that specific Snap.
*/
function getMutex(snapId) {
if (!mutexes.has(snapId)) {
mutexes.set(snapId, new async_mutex_1.Mutex());
}
return mutexes.get(snapId);
}
const SetStateParametersStruct = (0, superstruct_1.object)({
key: (0, superstruct_1.optional)(utils_2.StateKeyStruct),
value: utils_1.JsonStruct,
encrypted: (0, superstruct_1.optional)((0, superstruct_1.boolean)()),
});
/**
* The `snap_setState` method implementation.
*
* @param request - The JSON-RPC request object.
* @param response - The JSON-RPC response object.
* @param _next - The `json-rpc-engine` "next" callback. Not used by this
* function.
* @param end - The `json-rpc-engine` "end" callback.
* @param hooks - The RPC method hooks.
* @param hooks.getUnlockPromise - Wait for the extension to be unlocked.
* @param messenger - The messenger used to call controller actions.
* @returns Nothing.
*/
async function setStateImplementation(request, response, _next, end, { getUnlockPromise }, messenger) {
const { params, origin } = request;
if (!messenger.call('PermissionController:hasPermission', origin, manageState_1.manageStateBuilder.targetName)) {
return end(rpc_errors_1.providerErrors.unauthorized());
}
try {
const validatedParams = getValidatedParams(params);
const { key, value, encrypted = true } = validatedParams;
if (key === undefined && !(0, utils_1.isObject)(value)) {
return end(rpc_errors_1.rpcErrors.invalidParams('Invalid params: Value must be an object if key is not provided.'));
}
if (encrypted) {
await getUnlockPromise(true);
}
const snapId = origin;
const mutex = getMutex(snapId);
// The expectation when using `snap_setState` is for the operation to safe
// to do in parallel. The mutex ensures that and prevents a bug that was
// mostly prevalent on mobile and caused data loss.
await mutex.runExclusive(async () => {
const newState = await getNewState(snapId, key, value, encrypted, messenger);
const snap = messenger.call('SnapController:getSnap', origin);
if (!snap?.preinstalled) {
// We know that the state is valid JSON as per previous validation.
const size = (0, snaps_utils_1.getJsonSizeUnsafe)(newState, true);
if (size > manageState_1.STORAGE_SIZE_LIMIT) {
throw rpc_errors_1.rpcErrors.invalidParams({
message: `Invalid params: The new state must not exceed ${manageState_1.STORAGE_SIZE_LIMIT / 1000000} MB in size.`,
});
}
}
await messenger.call('SnapController:updateSnapState', origin, newState, encrypted);
response.result = null;
});
}
catch (error) {
return end(error);
}
return end();
}
/**
* Validate the parameters of the `snap_setState` method.
*
* @param params - The parameters to validate.
* @returns The validated parameters.
*/
function getValidatedParams(params) {
try {
return (0, superstruct_1.create)(params, SetStateParametersStruct);
}
catch (error) {
if (error instanceof superstruct_1.StructError) {
throw rpc_errors_1.rpcErrors.invalidParams({
message: `Invalid params: ${error.message}.`,
});
}
/* istanbul ignore next */
throw rpc_errors_1.rpcErrors.internal();
}
}
/**
* Get the new state of the Snap.
*
* 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).
*
* @param snapId - The Snap ID.
* @param key - The key to set.
* @param value - The value to set the key to.
* @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, key, value, encrypted, messenger) {
if (key === undefined) {
(0, utils_1.assert)((0, utils_1.isObject)(value));
return value;
}
const state = await messenger.call('SnapController:getSnapState', snapId, encrypted);
return set(state, key, value);
}
/**
* Set the value of a key in an object. The key may contain Lodash-style path
* syntax, e.g., `a.b.c` (with the exception of array syntax). If the key does
* not exist, it is created (and any missing intermediate keys are created as
* well).
*
* This is a simplified version of Lodash's `set` function, but Lodash doesn't
* seem to be maintained anymore, so we're using our own implementation.
*
* @param object - The object to get the key from.
* @param key - The key to set.
* @param value - The value to set the key to.
* @returns The new object with the key set to the value.
*/
function set(object, key, value) {
const keys = key.split('.');
const requiredObject = object ?? {};
let currentObject = requiredObject;
for (let i = 0; i < keys.length; i++) {
const currentKey = keys[i];
if (utils_2.FORBIDDEN_KEYS.includes(currentKey)) {
throw rpc_errors_1.rpcErrors.invalidParams('Invalid params: Key contains forbidden characters.');
}
if (i === keys.length - 1) {
currentObject[currentKey] = value;
return requiredObject;
}
if (!(0, utils_1.hasProperty)(currentObject, currentKey) ||
currentObject[currentKey] === null) {
currentObject[currentKey] = {};
}
else if (!(0, utils_1.isObject)(currentObject[currentKey])) {
throw rpc_errors_1.rpcErrors.invalidParams('Invalid params: Cannot overwrite non-object value.');
}
currentObject = currentObject[currentKey];
}
// This should never be reached.
/* istanbul ignore next */
throw new Error('Unexpected error while setting the state.');
}
exports.set = set;
//# sourceMappingURL=setState.cjs.map