@metamask/snaps-simulation
Version:
A simulation framework for MetaMask Snaps, enabling headless testing of Snaps in a controlled environment
230 lines • 11 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.registerActions = exports.getPermittedHooks = exports.getRestrictedHooks = exports.installSnap = void 0;
const base_controller_1 = require("@metamask/base-controller");
const json_rpc_middleware_stream_1 = require("@metamask/json-rpc-middleware-stream");
const phishing_controller_1 = require("@metamask/phishing-controller");
const node_1 = require("@metamask/snaps-controllers/node");
const snaps_rpc_methods_1 = require("@metamask/snaps-rpc-methods");
const snaps_utils_1 = require("@metamask/snaps-utils");
const readable_stream_1 = require("readable-stream");
const effects_1 = require("redux-saga/effects");
const controllers_1 = require("./controllers.cjs");
const files_1 = require("./files.cjs");
const helpers_1 = require("./helpers.cjs");
const interface_1 = require("./interface.cjs");
const methods_1 = require("./methods/index.cjs");
const hooks_1 = require("./methods/hooks/index.cjs");
const get_mnemonic_seed_1 = require("./methods/hooks/get-mnemonic-seed.cjs");
const middleware_1 = require("./middleware/index.cjs");
const options_1 = require("./options.cjs");
const store_1 = require("./store/index.cjs");
const account_1 = require("./utils/account.cjs");
/**
* 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.
*/
async function installSnap(snapId, { executionService, executionServiceOptions, options: rawOptions = {}, } = {}) {
const options = (0, options_1.getOptions)(rawOptions);
// Fetch Snap files.
const location = (0, node_1.detectSnapLocation)(snapId, {
allowLocal: true,
});
const snapFiles = await (0, node_1.fetchSnap)(snapId, location);
// Create Redux store.
const { store, runSaga } = (0, store_1.createStore)(options);
const controllerMessenger = new base_controller_1.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 } = (0, controllers_1.getControllers)({
controllerMessenger,
hooks: restrictedHooks,
runSaga,
options,
});
const engine = (0, middleware_1.createJsonRpcEngine)({
store,
restrictedHooks,
permittedHooks,
permissionMiddleware: permissionController.createPermissionMiddleware({
origin: snapId,
}),
});
// Create execution service.
const ExecutionService = executionService ?? node_1.NodeThreadExecutionService;
const service = new ExecutionService({
...executionServiceOptions,
messenger: controllerMessenger.getRestricted({
name: 'ExecutionService',
allowedActions: [],
allowedEvents: [],
}),
setupSnapProvider: (_snapId, rpcStream) => {
const mux = (0, node_1.setupMultiplex)(rpcStream, 'snapStream');
const stream = mux.createStream('metamask-provider');
const providerStream = (0, json_rpc_middleware_stream_1.createEngineStream)({ engine });
// Error function is difficult to test, so we ignore it.
/* istanbul ignore next 2 */
(0, readable_stream_1.pipeline)(stream, providerStream, stream, (error) => {
if (error && !error.message?.match('Premature close')) {
(0, snaps_utils_1.logError)(`Provider stream failure.`, error);
}
});
},
});
// Register the Snap. This sets up the Snap's permissions and subject
// metadata.
await (0, controllers_1.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 (0, methods_1.getEndowments)(permissionController, snapId),
});
const helpers = (0, helpers_1.getHelpers)({
snapId,
store,
controllerMessenger,
runSaga,
executionService: service,
options,
});
return {
snapId,
store,
executionService: service,
controllerMessenger,
runSaga,
...helpers,
};
}
exports.installSnap = installSnap;
/**
* Get the hooks for the simulation.
*
* @param options - The simulation options.
* @returns The hooks for the simulation.
*/
function getRestrictedHooks(options) {
return {
getMnemonic: (0, hooks_1.getGetMnemonicImplementation)(options.secretRecoveryPhrase),
getMnemonicSeed: (0, get_mnemonic_seed_1.getGetMnemonicSeedImplementation)(options.secretRecoveryPhrase),
getIsLocked: () => false,
getClientCryptography: () => ({}),
};
}
exports.getRestrictedHooks = getRestrictedHooks;
/**
* 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.
*/
function getPermittedHooks(snapId, snapFiles, controllerMessenger, runSaga) {
return {
hasPermission: () => true,
getUnlockPromise: (0, methods_1.asyncResolve)(),
getIsLocked: () => false,
getIsActive: () => true,
getSnapFile: async (path, encoding) => await (0, files_1.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: (0, hooks_1.getGetEntropySourcesImplementation)(),
getSnapState: (0, hooks_1.getPermittedGetSnapStateMethodImplementation)(runSaga),
updateSnapState: (0, hooks_1.getPermittedUpdateSnapStateMethodImplementation)(runSaga),
clearSnapState: (0, hooks_1.getPermittedClearSnapStateMethodImplementation)(runSaga),
getSnap: (0, hooks_1.getGetSnapImplementation)(true),
trackError: (0, hooks_1.getTrackErrorImplementation)(runSaga),
trackEvent: (0, hooks_1.getTrackEventImplementation)(runSaga),
startTrace: (0, hooks_1.getStartTraceImplementation)(runSaga),
endTrace: (0, hooks_1.getEndTraceImplementation)(runSaga),
};
}
exports.getPermittedHooks = getPermittedHooks;
/**
* 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.
*/
function registerActions(controllerMessenger, runSaga, options, snapId) {
controllerMessenger.registerActionHandler('PhishingController:testOrigin', () => ({ result: false, type: phishing_controller_1.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 (0, account_1.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 (0, account_1.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) => (0, account_1.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 (0, effects_1.select)(store_1.getCurrentInterface);
return currentInterface;
}
const currentInterface = runSaga(getCurrentInterfaceSaga).result();
return (currentInterface?.type === snaps_rpc_methods_1.DIALOG_APPROVAL_TYPES.default &&
currentInterface?.id === opts?.id);
});
controllerMessenger.registerActionHandler('ApprovalController:acceptRequest', async (_id, value) => {
await runSaga(interface_1.resolveWithSaga, value).toPromise();
return { value };
});
}
exports.registerActions = registerActions;
//# sourceMappingURL=simulation.cjs.map