UNPKG

@metamask/snaps-simulation

Version:

A simulation framework for MetaMask Snaps, enabling headless testing of Snaps in a controlled environment

223 lines 10.6 kB
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