@metamask/snaps-simulation
Version:
A simulation framework for MetaMask Snaps, enabling headless testing of Snaps in a controlled environment
223 lines • 10.6 kB
JavaScript
import { Messenger } from "@metamask/base-controller";
import { createEngineStream } from "@metamask/json-rpc-middleware-stream";
import { PhishingDetectorResultType } from "@metamask/phishing-controller";
import { detectSnapLocation, fetchSnap, NodeThreadExecutionService, setupMultiplex } from "@metamask/snaps-controllers/node";
import { DIALOG_APPROVAL_TYPES } from "@metamask/snaps-rpc-methods";
import { logError } from "@metamask/snaps-utils";
import { pipeline } from "readable-stream";
import { select } from "redux-saga/effects";
import { getControllers, registerSnap } from "./controllers.mjs";
import { getSnapFile } from "./files.mjs";
import { getHelpers } from "./helpers.mjs";
import { resolveWithSaga } from "./interface.mjs";
import { asyncResolve, getEndowments } from "./methods/index.mjs";
import { getPermittedClearSnapStateMethodImplementation, getPermittedGetSnapStateMethodImplementation, getPermittedUpdateSnapStateMethodImplementation, getGetEntropySourcesImplementation, getGetMnemonicImplementation, getGetSnapImplementation, getTrackEventImplementation, getTrackErrorImplementation, getEndTraceImplementation, getStartTraceImplementation } from "./methods/hooks/index.mjs";
import { getGetMnemonicSeedImplementation } from "./methods/hooks/get-mnemonic-seed.mjs";
import { createJsonRpcEngine } from "./middleware/index.mjs";
import { getOptions } from "./options.mjs";
import { createStore, getCurrentInterface } from "./store/index.mjs";
import { addSnapMetadataToAccount } from "./utils/account.mjs";
/**
* Install a Snap in a simulated environment. This will fetch the Snap files,
* create a Redux store, set up the controllers and JSON-RPC stack, register the
* Snap, and run the Snap code in the execution service.
*
* @param snapId - The ID of the Snap to install.
* @param options - The options to use when installing the Snap.
* @param options.executionService - The execution service to use.
* @param options.executionServiceOptions - The options to use when creating the
* execution service, if any. This should only include options specific to the
* provided execution service.
* @param options.options - The simulation options.
* @returns The installed Snap object.
* @template Service - The type of the execution service.
*/
export async function installSnap(snapId, { executionService, executionServiceOptions, options: rawOptions = {}, } = {}) {
const options = getOptions(rawOptions);
// Fetch Snap files.
const location = detectSnapLocation(snapId, {
allowLocal: true,
});
const snapFiles = await fetchSnap(snapId, location);
// Create Redux store.
const { store, runSaga } = createStore(options);
const controllerMessenger = new Messenger();
registerActions(controllerMessenger, runSaga, options, snapId);
// Set up controllers and JSON-RPC stack.
const restrictedHooks = getRestrictedHooks(options);
const permittedHooks = getPermittedHooks(snapId, snapFiles, controllerMessenger, runSaga);
const { subjectMetadataController, permissionController } = getControllers({
controllerMessenger,
hooks: restrictedHooks,
runSaga,
options,
});
const engine = createJsonRpcEngine({
store,
restrictedHooks,
permittedHooks,
permissionMiddleware: permissionController.createPermissionMiddleware({
origin: snapId,
}),
});
// Create execution service.
const ExecutionService = executionService ?? NodeThreadExecutionService;
const service = new ExecutionService({
...executionServiceOptions,
messenger: controllerMessenger.getRestricted({
name: 'ExecutionService',
allowedActions: [],
allowedEvents: [],
}),
setupSnapProvider: (_snapId, rpcStream) => {
const mux = setupMultiplex(rpcStream, 'snapStream');
const stream = mux.createStream('metamask-provider');
const providerStream = createEngineStream({ engine });
// Error function is difficult to test, so we ignore it.
/* istanbul ignore next 2 */
pipeline(stream, providerStream, stream, (error) => {
if (error && !error.message?.match('Premature close')) {
logError(`Provider stream failure.`, error);
}
});
},
});
// Register the Snap. This sets up the Snap's permissions and subject
// metadata.
await registerSnap(snapId, snapFiles.manifest.result, {
permissionController,
subjectMetadataController,
});
// Run the Snap code in the execution service.
await service.executeSnap({
snapId,
sourceCode: snapFiles.sourceCode.toString('utf8'),
endowments: await getEndowments(permissionController, snapId),
});
const helpers = getHelpers({
snapId,
store,
controllerMessenger,
runSaga,
executionService: service,
options,
});
return {
snapId,
store,
executionService: service,
controllerMessenger,
runSaga,
...helpers,
};
}
/**
* Get the hooks for the simulation.
*
* @param options - The simulation options.
* @returns The hooks for the simulation.
*/
export function getRestrictedHooks(options) {
return {
getMnemonic: getGetMnemonicImplementation(options.secretRecoveryPhrase),
getMnemonicSeed: getGetMnemonicSeedImplementation(options.secretRecoveryPhrase),
getIsLocked: () => false,
getClientCryptography: () => ({}),
};
}
/**
* Get the permitted hooks for the simulation.
*
* @param snapId - The ID of the Snap.
* @param snapFiles - The fetched Snap files.
* @param controllerMessenger - The controller messenger.
* @param runSaga - The run saga function.
* @returns The permitted hooks for the simulation.
*/
export function getPermittedHooks(snapId, snapFiles, controllerMessenger, runSaga) {
return {
hasPermission: () => true,
getUnlockPromise: asyncResolve(),
getIsLocked: () => false,
getIsActive: () => true,
getSnapFile: async (path, encoding) => await getSnapFile(snapFiles.auxiliaryFiles, path, encoding),
createInterface: async (...args) => controllerMessenger.call('SnapInterfaceController:createInterface', snapId, ...args),
updateInterface: async (...args) => controllerMessenger.call('SnapInterfaceController:updateInterface', snapId, ...args),
getInterfaceState: (...args) => controllerMessenger.call('SnapInterfaceController:getInterface', snapId, ...args).state,
getInterfaceContext: (...args) => controllerMessenger.call('SnapInterfaceController:getInterface', snapId, ...args).context,
resolveInterface: async (...args) => controllerMessenger.call('SnapInterfaceController:resolveInterface', snapId, ...args),
getEntropySources: getGetEntropySourcesImplementation(),
getSnapState: getPermittedGetSnapStateMethodImplementation(runSaga),
updateSnapState: getPermittedUpdateSnapStateMethodImplementation(runSaga),
clearSnapState: getPermittedClearSnapStateMethodImplementation(runSaga),
getSnap: getGetSnapImplementation(true),
trackError: getTrackErrorImplementation(runSaga),
trackEvent: getTrackEventImplementation(runSaga),
startTrace: getStartTraceImplementation(runSaga),
endTrace: getEndTraceImplementation(runSaga),
};
}
/**
* Register mocked action handlers.
*
* @param controllerMessenger - The controller messenger.
* @param runSaga - The run saga function.
* @param options - The simulation options.
* @param snapId - The ID of the Snap.
*/
export function registerActions(controllerMessenger, runSaga, options, snapId) {
controllerMessenger.registerActionHandler('PhishingController:testOrigin', () => ({ result: false, type: PhishingDetectorResultType.All }));
controllerMessenger.registerActionHandler('AccountsController:getAccountByAddress',
// @ts-expect-error - This is a partial account with only the necessary
// data used by the interface controller.
(address) => {
const matchingAccount = options.accounts.find((account) => address === account.address);
if (!matchingAccount) {
return undefined;
}
return addSnapMetadataToAccount(matchingAccount, snapId);
});
controllerMessenger.registerActionHandler('AccountsController:getSelectedMultichainAccount',
// @ts-expect-error - This is a partial account with only the necessary
// data used by the interface controller.
() => {
const selectedAccount = options.accounts.find((account) => account.selected);
if (!selectedAccount) {
return undefined;
}
return addSnapMetadataToAccount(selectedAccount, snapId);
});
controllerMessenger.registerActionHandler('AccountsController:listMultichainAccounts', () =>
// @ts-expect-error - These are partial accounts with only the necessary
// data used by the interface controller.
options.accounts.map((account) => addSnapMetadataToAccount(account, snapId)));
controllerMessenger.registerActionHandler('MultichainAssetsController:getState', () => ({
// @ts-expect-error - These are partial assets with only the
// necessary data used by the interface controller.
assetsMetadata: options.assets,
accountsAssets: options.accounts.reduce((acc, account) => {
acc[account.id] = account.assets ?? [];
return acc;
}, {}),
}));
controllerMessenger.registerActionHandler('ApprovalController:hasRequest', (opts) => {
/**
* Get the current interface from the store.
*
* @yields Selects the current interface from the store.
* @returns The current interface.
*/
function* getCurrentInterfaceSaga() {
const currentInterface = yield select(getCurrentInterface);
return currentInterface;
}
const currentInterface = runSaga(getCurrentInterfaceSaga).result();
return (currentInterface?.type === DIALOG_APPROVAL_TYPES.default &&
currentInterface?.id === opts?.id);
});
controllerMessenger.registerActionHandler('ApprovalController:acceptRequest', async (_id, value) => {
await runSaga(resolveWithSaga, value).toPromise();
return { value };
});
}
//# sourceMappingURL=simulation.mjs.map