diff --git a/packages/evm-wallet-experiment/README.md b/packages/evm-wallet-experiment/README.md index b7fae4307e..c1036a3c2b 100644 --- a/packages/evm-wallet-experiment/README.md +++ b/packages/evm-wallet-experiment/README.md @@ -9,7 +9,6 @@ For a deeper explanation of the components and data flow, see [How It Works](./d - **Peer signing has no interactive approval for message/typed-data requests.** Transaction signing over peer requests is now disabled and peer-connected wallets must use delegation redemption for sends, but message and typed-data peer signing still execute immediately without an approval prompt. - **`revokeDelegation()` and hybrid redemption require a bundler or peer relay.** Hybrid accounts submit on-chain `disableDelegation` / redemption via ERC-4337 UserOps; configure a bundler (and optional paymaster). **Stateless 7702** accounts use a direct EIP-1559 transaction instead; only the JSON-RPC provider must be configured. **Away wallets without a bundler** relay delegation redemptions to the home wallet via CapTP (requires the home wallet to be online). If the on-chain transaction fails, the local delegation status is not changed. - **Mnemonic encryption is optional.** The keyring vat can encrypt the mnemonic at rest using AES-256-GCM with a PBKDF2-derived key. Pass a `password` and `salt` to `initializeKeyring()` to enable encryption. Without a password, the mnemonic is stored in plaintext. When encrypted, the keyring starts in a locked state on daemon restart and must be unlocked with `unlockKeyring(password)` before signing operations work. -- **Throwaway keyring needs secure entropy.** `initializeKeyring({ type: 'throwaway' })` requires either `crypto.getRandomValues` in the runtime or caller-provided entropy via `{ type: 'throwaway', entropy: '0x...' }`. Under SES lockdown (where `crypto` is unavailable inside vat compartments), the caller must generate 32 bytes of entropy externally and pass it in. ## Architecture @@ -126,9 +125,7 @@ import { makeWalletClusterConfig } from '@ocap/evm-wallet-experiment'; // 1. Launch the wallet subcluster with a throwaway keyring const config = makeWalletClusterConfig({ bundleBaseUrl: '/bundles' }); const { rootKref } = await kernel.launchSubcluster(config); -// Under SES lockdown, pass entropy generated outside the vat: -const entropy = `0x${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex')}`; -await coordinator.initializeKeyring({ type: 'throwaway', entropy }); +await coordinator.initializeKeyring({ type: 'throwaway' }); // 2. Connect to the home kernel via the OCAP URL // This automatically: @@ -315,15 +312,15 @@ const userOpHash = await coordinator.redeemDelegation({ ### Coordinator -- Lifecycle -| Method | Description | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `bootstrap(vats, services)` | Called by the kernel during subcluster launch. Wires up vat references. | -| `initializeKeyring(options)` | Initialize the keyring vat. Options: `{ type: 'srp', mnemonic, password?, salt? }` or `{ type: 'throwaway', entropy? }`. Under SES lockdown, pass `entropy` (32-byte hex) for throwaway keys. When `password` is provided for SRP, the mnemonic is encrypted at rest (requires a random `salt` hex string). | -| `unlockKeyring(password)` | Unlock an encrypted keyring after daemon restart. Required before any signing operations when the mnemonic was encrypted with a password. | -| `isKeyringLocked()` | Returns `true` if the keyring is encrypted and has not been unlocked yet. | -| `configureProvider(chainConfig)` | Configure the provider vat with an RPC URL and chain ID. | -| `connectExternalSigner(signer)` | Connect an external signing backend (e.g., MetaMask). | -| `configureBundler(config)` | Configure the ERC-4337 bundler. Accepts `{ bundlerUrl, chainId, entryPoint?, usePaymaster?, sponsorshipPolicyId? }`. | +| Method | Description | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bootstrap(vats, services)` | Called by the kernel during subcluster launch. Wires up vat references. | +| `initializeKeyring(options)` | Initialize the keyring vat. Options: `{ type: 'srp', mnemonic, password?, salt? }` or `{ type: 'throwaway' }`. When `password` is provided for SRP, the mnemonic is encrypted at rest (requires a random `salt` hex string). | +| `unlockKeyring(password)` | Unlock an encrypted keyring after daemon restart. Required before any signing operations when the mnemonic was encrypted with a password. | +| `isKeyringLocked()` | Returns `true` if the keyring is encrypted and has not been unlocked yet. | +| `configureProvider(chainConfig)` | Configure the provider vat with an RPC URL and chain ID. | +| `connectExternalSigner(signer)` | Connect an external signing backend (e.g., MetaMask). | +| `configureBundler(config)` | Configure the ERC-4337 bundler. Accepts `{ bundlerUrl, chainId, entryPoint?, usePaymaster?, sponsorshipPolicyId? }`. | ### Coordinator -- Signing @@ -691,7 +688,7 @@ const config = makeWalletClusterConfig({ const { rootKref } = await kernel.launchSubcluster(config); ``` -The configuration creates four vats (`coordinator`, `keyring`, `provider`, `delegation`) and registers the coordinator as the bootstrap vat. The `keyring`, `provider`, and `delegation` vats receive `TextEncoder` and `TextDecoder` as globals since they perform binary encoding. +The configuration creates four vats: `coordinator`, `keyring`, `provider`, and either `delegator` (home role — default) or `redeemer` (away role). The coordinator is registered as the bootstrap vat. Every vat receives `TextEncoder` and `TextDecoder` for binary encoding. The `keyring` vat additionally receives `crypto` for throwaway-key generation; the `delegator` vat receives `crypto` for delegation-salt generation (the `redeemer` vat does not). The `coordinator` vat receives `Date` and `setTimeout` for on-chain confirmation polling. ## SES Compatibility @@ -849,6 +846,7 @@ The package exports chain contract addresses used by the Delegation Framework: - **Error handling** -- Decryption with a wrong password now returns a clear error message. EIP-7702 gas estimation failures are no longer silently swallowed for all error types. - **Timer cleanup** -- The internal `raceWithTimeout` helper (used for peer communication timeouts) now properly cleans up timers to prevent resource leaks. - **SES lockdown compliance** -- Module-level counters (`bundlerRequestId`, `rpcRequestId`) have been moved into per-client-instance closures, eliminating shared mutable state that conflicts with SES lockdown requirements. +- **Explicit vat endowments** -- Throwaway keyrings and delegation salts now use `crypto.getRandomValues` directly, enabled by adding `crypto` to the `keyring` and `delegator` vats' globals. Removes the prior caller-supplied `entropy` escape hatch and the counter-based salt fallback. ## Disclaimer diff --git a/packages/evm-wallet-experiment/docs/setup-guide.md b/packages/evm-wallet-experiment/docs/setup-guide.md index 0c59ffc974..8d73a25558 100644 --- a/packages/evm-wallet-experiment/docs/setup-guide.md +++ b/packages/evm-wallet-experiment/docs/setup-guide.md @@ -463,21 +463,21 @@ yarn ocap daemon exec launchSubcluster '{ "services": ["ocapURLIssuerService", "ocapURLRedemptionService"], "vats": { "coordinator": { - "bundleSpec": "packages/evm-wallet-experiment/src/vats/coordinator-vat.bundle", + "bundleSpec": "packages/evm-wallet-experiment/src/vats/home-coordinator.bundle", "globals": ["TextEncoder", "TextDecoder", "Date", "setTimeout"] }, "keyring": { "bundleSpec": "packages/evm-wallet-experiment/src/vats/keyring-vat.bundle", - "globals": ["TextEncoder", "TextDecoder"] + "globals": ["TextEncoder", "TextDecoder", "crypto"] }, "provider": { "bundleSpec": "packages/evm-wallet-experiment/src/vats/provider-vat.bundle", "globals": ["TextEncoder", "TextDecoder", "fetch", "Request", "Headers", "Response"], "network": { "allowedHosts": [".infura.io", "api.pimlico.io", "swap.api.cx.metamask.io"] } }, - "delegation": { - "bundleSpec": "packages/evm-wallet-experiment/src/vats/delegation-vat.bundle", - "globals": ["TextEncoder", "TextDecoder"], + "delegator": { + "bundleSpec": "packages/evm-wallet-experiment/src/vats/delegator-vat.bundle", + "globals": ["TextEncoder", "TextDecoder", "crypto"], "parameters": { "delegationManagerAddress": "0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3" } } } @@ -553,19 +553,43 @@ yarn ocap daemon exec registerLocationHints '{"peerId": "HOME_PEER_ID", "hints": ### 3d. Launch the wallet subcluster -Same as the home device (see section 2c), but with the VPS's allowed hosts: +The away role uses `away-coordinator.bundle` and pairs it with a `redeemer` vat (not `delegator`); the home role is the one that signs delegations, so the away kernel only needs the redeeming half. Set `allowedHosts` to the chain's RPC endpoints this VPS is permitted to reach. ```bash -yarn ocap daemon exec launchSubcluster '{"config": { ... }}' +yarn ocap daemon exec launchSubcluster '{ + "config": { + "bootstrap": "coordinator", + "forceReset": true, + "services": ["ocapURLIssuerService", "ocapURLRedemptionService"], + "vats": { + "coordinator": { + "bundleSpec": "packages/evm-wallet-experiment/src/vats/away-coordinator.bundle", + "globals": ["TextEncoder", "TextDecoder", "Date", "setTimeout"] + }, + "keyring": { + "bundleSpec": "packages/evm-wallet-experiment/src/vats/keyring-vat.bundle", + "globals": ["TextEncoder", "TextDecoder", "crypto"] + }, + "provider": { + "bundleSpec": "packages/evm-wallet-experiment/src/vats/provider-vat.bundle", + "globals": ["TextEncoder", "TextDecoder", "fetch", "Request", "Headers", "Response"], + "network": { "allowedHosts": [".infura.io", "api.pimlico.io", "swap.api.cx.metamask.io"] } + }, + "redeemer": { + "bundleSpec": "packages/evm-wallet-experiment/src/vats/redeemer-vat.bundle", + "globals": ["TextEncoder", "TextDecoder"] + } + } + } +}' ``` ### 3e. Initialize with a throwaway key -The away wallet gets a throwaway key (for signing UserOps within delegations). Under SES lockdown, `crypto.getRandomValues` is unavailable in vat compartments, so you must generate entropy externally: +The away wallet gets a throwaway key (for signing UserOps within delegations): ```bash -ENTROPY="0x$(node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))")" -yarn ocap daemon queueMessage ko4 initializeKeyring "[{\"type\": \"throwaway\", \"entropy\": \"$ENTROPY\"}]" +yarn ocap daemon queueMessage ko4 initializeKeyring '[{"type":"throwaway"}]' ``` ### 3f. Connect to the home wallet diff --git a/packages/evm-wallet-experiment/scripts/setup-away.sh b/packages/evm-wallet-experiment/scripts/setup-away.sh index c3cf4c5f8a..559199e2ce 100755 --- a/packages/evm-wallet-experiment/scripts/setup-away.sh +++ b/packages/evm-wallet-experiment/scripts/setup-away.sh @@ -38,7 +38,6 @@ LISTEN_ADDRS="" RELAY_ADDR="" SKIP_BUILD=false QUIC_PORT=4002 -DELEGATION_MANAGER="0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3" CUSTOM_RPC_URL="" NON_INTERACTIVE=false @@ -215,7 +214,7 @@ if [[ "$SKIP_BUILD" == false ]]; then ok "Build complete" else info "Skipping build (--no-build)" - if [[ ! -f "$BUNDLE_DIR/coordinator-vat.bundle" ]]; then + if [[ ! -f "$BUNDLE_DIR/away-coordinator.bundle" ]]; then fail "Bundle files not found in $BUNDLE_DIR. Remove --no-build to build first." fi fi @@ -355,9 +354,8 @@ elif [[ -n "$INFURA_KEY" ]]; then ") fi -CONFIG=$(BUNDLE_DIR="$BUNDLE_DIR" DM="$DELEGATION_MANAGER" RPC_HOST="$AWAY_RPC_HOST" node -e " +CONFIG=$(BUNDLE_DIR="$BUNDLE_DIR" RPC_HOST="$AWAY_RPC_HOST" node -e " const bd = process.env.BUNDLE_DIR; - const dm = process.env.DM; const rpcHost = process.env.RPC_HOST; const extra = (process.env.EXTRA_ALLOWED_HOSTS || '').split(',').filter(Boolean); const hosts = [rpcHost, 'api.pimlico.io', 'swap.api.cx.metamask.io', ...extra].filter(Boolean); @@ -368,22 +366,21 @@ CONFIG=$(BUNDLE_DIR="$BUNDLE_DIR" DM="$DELEGATION_MANAGER" RPC_HOST="$AWAY_RPC_H services: ['ocapURLIssuerService', 'ocapURLRedemptionService'], vats: { coordinator: { - bundleSpec: bd + '/coordinator-vat.bundle', + bundleSpec: bd + '/away-coordinator.bundle', globals: ['TextEncoder', 'TextDecoder', 'Date', 'setTimeout'] }, keyring: { bundleSpec: bd + '/keyring-vat.bundle', - globals: ['TextEncoder', 'TextDecoder'] + globals: ['TextEncoder', 'TextDecoder', 'crypto'] }, provider: { bundleSpec: bd + '/provider-vat.bundle', - globals: ['TextEncoder', 'TextDecoder'], - platformConfig: { fetch: { allowedHosts: hosts } } + globals: ['TextEncoder', 'TextDecoder', 'fetch', 'Request', 'Headers', 'Response'], + network: { allowedHosts: hosts } }, - delegation: { - bundleSpec: bd + '/delegation-vat.bundle', - globals: ['TextEncoder', 'TextDecoder'], - parameters: { delegationManagerAddress: dm } + redeemer: { + bundleSpec: bd + '/redeemer-vat.bundle', + globals: ['TextEncoder', 'TextDecoder'] } } } @@ -407,14 +404,7 @@ ok "Subcluster launched — coordinator: $ROOT_KREF" # --------------------------------------------------------------------------- info "Initializing throwaway keyring..." -# Generate 32 bytes of entropy outside the SES compartment (crypto.getRandomValues -# is unavailable inside vats). The entropy is passed to the keyring vat which uses -# it as the private key for the throwaway account. -ENTROPY="0x$(node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))")" -INIT_ARGS=$(ENTROPY="$ENTROPY" node -e " - process.stdout.write(JSON.stringify([{ type: 'throwaway', entropy: process.env.ENTROPY }])); -") -daemon_qm --quiet "$ROOT_KREF" initializeKeyring "$INIT_ARGS" >/dev/null +daemon_qm --quiet "$ROOT_KREF" initializeKeyring '[{"type":"throwaway"}]' >/dev/null ok "Throwaway keyring initialized" info "Verifying accounts..." diff --git a/packages/evm-wallet-experiment/scripts/setup-home.sh b/packages/evm-wallet-experiment/scripts/setup-home.sh index e220747817..dabb4dc14d 100755 --- a/packages/evm-wallet-experiment/scripts/setup-home.sh +++ b/packages/evm-wallet-experiment/scripts/setup-home.sh @@ -231,7 +231,7 @@ if [[ "$SKIP_BUILD" == false ]]; then ok "Build complete" else info "Skipping build (--no-build)" - if [[ ! -f "$BUNDLE_DIR/coordinator-vat.bundle" ]]; then + if [[ ! -f "$BUNDLE_DIR/home-coordinator.bundle" ]]; then fail "Bundle files not found in $BUNDLE_DIR. Remove --no-build to build first." fi fi @@ -333,21 +333,21 @@ CONFIG=$(BUNDLE_DIR="$BUNDLE_DIR" DM="$DELEGATION_MANAGER" RPC_HOST="$RPC_HOST" services: ['ocapURLIssuerService', 'ocapURLRedemptionService'], vats: { coordinator: { - bundleSpec: bd + '/coordinator-vat.bundle', + bundleSpec: bd + '/home-coordinator.bundle', globals: ['TextEncoder', 'TextDecoder', 'Date', 'setTimeout'] }, keyring: { bundleSpec: bd + '/keyring-vat.bundle', - globals: ['TextEncoder', 'TextDecoder'] + globals: ['TextEncoder', 'TextDecoder', 'crypto'] }, provider: { bundleSpec: bd + '/provider-vat.bundle', - globals: ['TextEncoder', 'TextDecoder'], - platformConfig: { fetch: { allowedHosts: hosts } } + globals: ['TextEncoder', 'TextDecoder', 'fetch', 'Request', 'Headers', 'Response'], + network: { allowedHosts: hosts } }, - delegation: { - bundleSpec: bd + '/delegation-vat.bundle', - globals: ['TextEncoder', 'TextDecoder'], + delegator: { + bundleSpec: bd + '/delegator-vat.bundle', + globals: ['TextEncoder', 'TextDecoder', 'crypto'], parameters: { delegationManagerAddress: dm } } } diff --git a/packages/evm-wallet-experiment/src/cluster-config.test.ts b/packages/evm-wallet-experiment/src/cluster-config.test.ts index 5f01389750..2027080555 100644 --- a/packages/evm-wallet-experiment/src/cluster-config.test.ts +++ b/packages/evm-wallet-experiment/src/cluster-config.test.ts @@ -101,10 +101,19 @@ describe('cluster-config', () => { bundleBaseUrl: BUNDLE_BASE_URL, }); - const baseGlobals = ['TextEncoder', 'TextDecoder']; - for (const vatName of ['keyring', 'provider', 'delegator']) { + const providerConfig = config.vats.provider as { globals?: string[] }; + expect(providerConfig.globals).toStrictEqual([ + 'TextEncoder', + 'TextDecoder', + ]); + + for (const vatName of ['keyring', 'delegator']) { const vatConfig = config.vats[vatName] as { globals?: string[] }; - expect(vatConfig.globals).toStrictEqual(baseGlobals); + expect(vatConfig.globals).toStrictEqual([ + 'TextEncoder', + 'TextDecoder', + 'crypto', + ]); } const coordConfig = config.vats.coordinator as { globals?: string[] }; diff --git a/packages/evm-wallet-experiment/src/cluster-config.ts b/packages/evm-wallet-experiment/src/cluster-config.ts index 4486464cbf..9ae6465c9a 100644 --- a/packages/evm-wallet-experiment/src/cluster-config.ts +++ b/packages/evm-wallet-experiment/src/cluster-config.ts @@ -37,7 +37,7 @@ export function makeWalletClusterConfig( ? { delegator: { bundleSpec: `${bundleBaseUrl}/delegator-vat.bundle`, - globals: ['TextEncoder', 'TextDecoder'], + globals: ['TextEncoder', 'TextDecoder', 'crypto'], }, } : { @@ -58,7 +58,7 @@ export function makeWalletClusterConfig( }, keyring: { bundleSpec: `${bundleBaseUrl}/keyring-vat.bundle`, - globals: ['TextEncoder', 'TextDecoder'], + globals: ['TextEncoder', 'TextDecoder', 'crypto'], }, provider: { bundleSpec: `${bundleBaseUrl}/provider-vat.bundle`, diff --git a/packages/evm-wallet-experiment/src/index.ts b/packages/evm-wallet-experiment/src/index.ts index bf5f27a6ca..c71e9883c0 100644 --- a/packages/evm-wallet-experiment/src/index.ts +++ b/packages/evm-wallet-experiment/src/index.ts @@ -110,9 +110,7 @@ export { finalizeDelegation, computeDelegationId, generateSalt, - makeSaltGenerator, } from './lib/delegation.ts'; -export type { SaltGenerator } from './lib/delegation.ts'; // UserOperation utilities export { diff --git a/packages/evm-wallet-experiment/src/lib/bundler-client.ts b/packages/evm-wallet-experiment/src/lib/bundler-client.ts index ade7e74b99..b7fa7a30c9 100644 --- a/packages/evm-wallet-experiment/src/lib/bundler-client.ts +++ b/packages/evm-wallet-experiment/src/lib/bundler-client.ts @@ -1,9 +1,6 @@ /** * Bundler client using raw fetch for ERC-4337 interactions. * - * Avoids viem's createClient/http which use Math.random() (blocked under - * SES lockdown). All methods are simple JSON-RPC calls over fetch. - * * @module lib/bundler-client */ @@ -188,9 +185,6 @@ async function bundlerRpcOnce( /** * Create a bundler client for ERC-4337 operations. * - * Uses raw fetch instead of viem's createClient to avoid Math.random() - * usage that is blocked under SES lockdown. - * * @param config - Bundler configuration. * @returns A bundler client with ERC-4337 actions. */ @@ -292,7 +286,7 @@ export function makeBundlerClient( if (!hasTimers) { throw new Error( 'waitForUserOperationReceipt requires timer support ' + - '(not available in SES compartments)', + "(add 'setTimeout' and 'Date' to this vat's globals in cluster-config.ts)", ); } const { pollingInterval = 2000, timeout = 60000 } = options; diff --git a/packages/evm-wallet-experiment/src/lib/bundler.ts b/packages/evm-wallet-experiment/src/lib/bundler.ts index bfe332baa9..ceddf6b767 100644 --- a/packages/evm-wallet-experiment/src/lib/bundler.ts +++ b/packages/evm-wallet-experiment/src/lib/bundler.ts @@ -155,7 +155,7 @@ export async function waitForUserOp( ): Promise { if (typeof globalThis.setTimeout !== 'function') { throw new Error( - 'waitForUserOp requires timer support (not available in SES compartments)', + "waitForUserOp requires timer support (add 'setTimeout' to this vat's globals in cluster-config.ts)", ); } const { pollIntervalMs = 2000, timeoutMs = 60000 } = options; diff --git a/packages/evm-wallet-experiment/src/lib/delegation.test.ts b/packages/evm-wallet-experiment/src/lib/delegation.test.ts index c540b55729..4049ec3d8f 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation.test.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { encodeAllowedTargets, @@ -12,7 +12,6 @@ import { import { computeDelegationId, makeDelegation, - makeSaltGenerator, prepareDelegationTypedData, delegationMatchesAction, explainDelegationMatch, @@ -34,39 +33,19 @@ describe('lib/delegation', () => { expect(salt).toMatch(/^0x[\da-f]{64}$/iu); }); - it('generates unique salts', () => { - const salt1 = generateSalt(); - const salt2 = generateSalt(); - expect(salt1).not.toBe(salt2); - }); - }); - - describe('makeSaltGenerator', () => { - it('returns a function that generates 32-byte hex salts', () => { - const generate = makeSaltGenerator(); - expect(generate()).toMatch(/^0x[\da-f]{64}$/iu); - }); - - it('generates unique salts across sequential calls', () => { - const generate = makeSaltGenerator(); - expect(generate()).not.toBe(generate()); - }); - - it('two generators produce independent sequences', () => { - const gen1 = makeSaltGenerator(); - const gen2 = makeSaltGenerator(); - // Each generator's counter is independent — advance gen1 several times - // without touching gen2 and verify gen2 still produces valid salts. - gen1(); - gen1(); - gen1(); - expect(gen2()).toMatch(/^0x[\da-f]{64}$/iu); + it('generates unique salts on each call', () => { + expect(generateSalt()).not.toBe(generateSalt()); }); - it('accepts entropy without throwing', () => { - const entropy = '0xdeadbeef' as `0x${string}`; - const generate = makeSaltGenerator(entropy); - expect(generate()).toMatch(/^0x[\da-f]{64}$/iu); + it('throws an actionable error when crypto endowment is missing', () => { + vi.stubGlobal('crypto', undefined); + try { + expect(() => generateSalt()).toThrow( + /add 'crypto' to this vat's globals/u, + ); + } finally { + vi.unstubAllGlobals(); + } }); }); diff --git a/packages/evm-wallet-experiment/src/lib/delegation.ts b/packages/evm-wallet-experiment/src/lib/delegation.ts index b407164d82..0a5104edae 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation.ts @@ -49,63 +49,27 @@ export function computeDelegationId(delegation: { } /** - * A function that generates a unique delegation salt on each call. - */ -export type SaltGenerator = () => Hex; - -/** - * Create a salt generator for delegation uniqueness. + * Generate a random 32-byte hex salt for delegation uniqueness. * - * Prefers crypto.getRandomValues when available. In SES compartments - * where crypto is not endowed, falls back to a closure-local counter - * hashed with optional caller-supplied entropy. Each call to - * makeSaltGenerator produces an independent counter, so two vat instances - * each get their own sequence rather than sharing module-level state. + * Requires the `crypto` global; in vats, add `'crypto'` to the vat's + * `globals` list in `cluster-config.ts`. * - * @param entropy - Optional caller-supplied entropy hex string. When provided - * and crypto is unavailable, mixed into the counter hash so that separate - * vat instances produce distinct salts even though both start at counter 1. - * @returns A salt generator function. + * @returns A hex-encoded random salt. */ -export function makeSaltGenerator(entropy?: Hex): SaltGenerator { +export function generateSalt(): Hex { // eslint-disable-next-line n/no-unsupported-features/node-builtins - if (globalThis.crypto?.getRandomValues) { - return () => { - const bytes = new Uint8Array(32); - // eslint-disable-next-line n/no-unsupported-features/node-builtins - globalThis.crypto.getRandomValues(bytes); - return toHex(bytes); - }; + if (!globalThis.crypto?.getRandomValues) { + throw new Error( + 'generateSalt requires the "crypto" global endowment; ' + + "add 'crypto' to this vat's globals in cluster-config.ts", + ); } - - // SES fallback: unique per generator lifetime but not cryptographically random. - // The salt only needs uniqueness, not unpredictability. - let counter = 0; - if (entropy !== undefined) { - return () => { - counter += 1; - return keccak256( - encodePacked(['bytes', 'uint256'], [entropy, BigInt(counter)]), - ); - }; - } - return () => { - counter += 1; - return keccak256(encodePacked(['uint256'], [BigInt(counter)])); - }; + const bytes = new Uint8Array(32); + // eslint-disable-next-line n/no-unsupported-features/node-builtins + globalThis.crypto.getRandomValues(bytes); + return toHex(bytes); } -/** - * Generate a random salt for delegation uniqueness. - * - * Uses a module-level counter as the SES fallback. Prefer - * {@link makeSaltGenerator} when creating delegations in a vat, since it - * gives each vat instance an independent counter. - * - * @returns A hex-encoded random salt. - */ -export const generateSalt: SaltGenerator = makeSaltGenerator(); - /** * Create a new unsigned delegation struct. * @@ -114,9 +78,7 @@ export const generateSalt: SaltGenerator = makeSaltGenerator(); * @param options.delegate - The account receiving the delegation. * @param options.caveats - The caveats restricting the delegation. * @param options.chainId - The chain ID. - * @param options.salt - Optional salt (generated if omitted). - * @param options.saltGenerator - Optional salt generator to use when no - * explicit salt is provided. Defaults to {@link generateSalt}. + * @param options.salt - Optional salt (generated via {@link generateSalt} if omitted). * @param options.authority - Optional parent delegation hash (root if omitted). * @returns The unsigned Delegation struct. */ @@ -126,10 +88,9 @@ export function makeDelegation(options: { caveats: Caveat[]; chainId: number; salt?: Hex; - saltGenerator?: SaltGenerator; authority?: Hex; }): Delegation { - const salt = options.salt ?? (options.saltGenerator ?? generateSalt)(); + const salt = options.salt ?? generateSalt(); const authority = options.authority ?? ROOT_AUTHORITY; const id = computeDelegationId({ @@ -286,7 +247,7 @@ export function explainDelegationMatch( matches: false, failedCaveat: 'timestamp', reason: - 'Cannot evaluate timestamp caveat: Date.now() is not available (SES compartment) and no currentTime was provided', + 'Cannot evaluate timestamp caveat: Date is not endowed to this vat and no currentTime was provided', }; } const now = BigInt(Math.floor((currentTime ?? Date.now()) / 1000)); diff --git a/packages/evm-wallet-experiment/src/lib/keyring.test.ts b/packages/evm-wallet-experiment/src/lib/keyring.test.ts index 13a3b37053..bf4f3ef51f 100644 --- a/packages/evm-wallet-experiment/src/lib/keyring.test.ts +++ b/packages/evm-wallet-experiment/src/lib/keyring.test.ts @@ -111,45 +111,16 @@ describe('lib/keyring', () => { expect(keyring.getMnemonic()).toBeUndefined(); }); - it('requires secure randomness when creating throwaway keys without entropy', () => { + it('throws an actionable error when crypto endowment is missing', () => { vi.stubGlobal('crypto', undefined); try { expect(() => makeKeyring({ type: 'throwaway' })).toThrow( - 'Throwaway keyring requires crypto.getRandomValues or caller-provided entropy', + /add 'crypto' to this vat's globals/u, ); } finally { vi.unstubAllGlobals(); } }); - - it('accepts caller-provided entropy for throwaway keys', () => { - vi.stubGlobal('crypto', undefined); - try { - const entropy = - '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; - const keyring = makeKeyring({ - type: 'throwaway', - entropy: entropy as `0x${string}`, - }); - const accounts = keyring.getAccounts(); - expect(accounts).toHaveLength(1); - expect(accounts[0]).toMatch(/^0x[\da-f]{40}$/u); - } finally { - vi.unstubAllGlobals(); - } - }); - - it.each(['0xshort', '0x', 'not-hex', `0x${'ff'.repeat(33)}`])( - 'rejects invalid entropy: %s', - (badEntropy) => { - expect(() => - makeKeyring({ - type: 'throwaway', - entropy: badEntropy as `0x${string}`, - }), - ).toThrow('Invalid entropy'); - }, - ); }); }); diff --git a/packages/evm-wallet-experiment/src/lib/keyring.ts b/packages/evm-wallet-experiment/src/lib/keyring.ts index e8138e35a1..bf70d98100 100644 --- a/packages/evm-wallet-experiment/src/lib/keyring.ts +++ b/packages/evm-wallet-experiment/src/lib/keyring.ts @@ -8,16 +8,21 @@ import { import type { HDAccount, LocalAccount } from 'viem/accounts'; import type { EncryptedMnemonicData } from './mnemonic-crypto.ts'; -import type { Address, Hex } from '../types.ts'; +import type { Address } from '../types.ts'; const harden = globalThis.harden ?? ((value: T): T => value); /** * Options for initializing a keyring. + * + * Throwaway keyrings are intentionally ephemeral: each call to `makeKeyring` + * generates a fresh private key, so the key does not survive a vat restart. + * Baggage only persists `{ type: 'throwaway' }`; callers that need key + * stability across restarts must use `type: 'srp'`. */ export type KeyringInitOptions = | { type: 'srp'; mnemonic: string; addressIndex?: number } - | { type: 'throwaway'; entropy?: Hex }; + | { type: 'throwaway' }; /** * Encrypted keyring init data stored in baggage when a password is used. @@ -59,27 +64,14 @@ export function makeKeyring(options: KeyringInitOptions): Keyring { const startIndex = options.addressIndex ?? 0; deriveAccountInternal(startIndex); } else { - let privateKey: Hex; - if (options.entropy) { - // Caller-provided entropy (for SES compartments without crypto global). - // The caller is responsible for providing cryptographically secure bytes. - if (!/^0x[\da-f]{64}$/iu.test(options.entropy)) { - throw new Error( - 'Invalid entropy: expected a 0x-prefixed 32-byte hex string' + - ` (got ${String(options.entropy).length} chars)`, - ); - } - privateKey = options.entropy; - } else { - // eslint-disable-next-line n/no-unsupported-features/node-builtins - if (!globalThis.crypto?.getRandomValues) { - throw new Error( - 'Throwaway keyring requires crypto.getRandomValues or caller-provided entropy', - ); - } - privateKey = generatePrivateKey(); + // eslint-disable-next-line n/no-unsupported-features/node-builtins + if (!globalThis.crypto?.getRandomValues) { + throw new Error( + 'Throwaway keyring requires the "crypto" global endowment; ' + + "add 'crypto' to this vat's globals in cluster-config.ts", + ); } - const account = privateKeyToAccount(privateKey); + const account = privateKeyToAccount(generatePrivateKey()); accounts.set(account.address.toLowerCase() as Address, account); } diff --git a/packages/evm-wallet-experiment/src/lib/provider.ts b/packages/evm-wallet-experiment/src/lib/provider.ts index 264bdc1b43..7e62b5e0c9 100644 --- a/packages/evm-wallet-experiment/src/lib/provider.ts +++ b/packages/evm-wallet-experiment/src/lib/provider.ts @@ -123,9 +123,6 @@ async function jsonRpcOnce( /** * Create a JSON-RPC provider for the given chain. * - * Uses raw fetch instead of viem's createPublicClient to avoid - * Math.random() usage that is blocked under SES lockdown. - * * @param config - The chain configuration. * @returns The provider instance. */ diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index c97547280a..23f6fc9192 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -986,7 +986,7 @@ export function buildRootObject( if (typeof globalThis.setTimeout !== 'function') { throw new Error( 'EIP-7702 confirmation polling requires setTimeout ' + - '(not available in SES compartments without timer endowments)', + "(add the missing globals to this vat's globals in cluster-config.ts)", ); } const maxAttempts = 45; @@ -1129,12 +1129,11 @@ export function buildRootObject( // ------------------------------------------------------------------ /** - * Initialize the keyring vat with a seed phrase or throwaway entropy. + * Initialize the keyring vat with a seed phrase or a throwaway key. * * @param options - Keyring initialization options. * @param options.type - 'srp' for a seed phrase, 'throwaway' for ephemeral key. * @param options.mnemonic - BIP-39 mnemonic (srp only). - * @param options.entropy - Random entropy hex (throwaway only). * @param options.password - Encryption password (srp only). * @param options.salt - Encryption salt (srp only). * @param options.addressIndex - HD derivation index (srp only). @@ -1142,7 +1141,6 @@ export function buildRootObject( async initializeKeyring(options: { type: 'srp' | 'throwaway'; mnemonic?: string; - entropy?: Hex; password?: string; salt?: string; addressIndex?: number; @@ -1150,24 +1148,30 @@ export function buildRootObject( if (!keyringVat) { throw new Error('Keyring vat not available'); } - let initOptions: - | { type: 'srp'; mnemonic: string; addressIndex?: number } - | { type: 'throwaway'; entropy?: Hex }; if (options.type === 'throwaway') { - initOptions = { type: 'throwaway', entropy: options.entropy }; - } else { - initOptions = - options.addressIndex === undefined - ? { type: 'srp', mnemonic: options.mnemonic ?? '' } - : { - type: 'srp', - mnemonic: options.mnemonic ?? '', - addressIndex: options.addressIndex, - }; - } - - const password = options.type === 'srp' ? options.password : undefined; - await E(keyringVat).initialize(initOptions, password, options.salt); + await E(keyringVat).initialize( + { type: 'throwaway' }, + undefined, + options.salt, + ); + return; + } + const initOptions: { + type: 'srp'; + mnemonic: string; + addressIndex?: number; + } = { + type: 'srp', + mnemonic: options.mnemonic ?? '', + }; + if (options.addressIndex !== undefined) { + initOptions.addressIndex = options.addressIndex; + } + await E(keyringVat).initialize( + initOptions, + options.password, + options.salt, + ); }, /** @@ -1622,7 +1626,7 @@ export function buildRootObject( ) { throw new Error( 'waitForUserOpReceipt requires Date.now and setTimeout ' + - '(not available in SES compartments without timer endowments)', + "(add the missing globals to this vat's globals in cluster-config.ts)", ); } diff --git a/packages/evm-wallet-experiment/src/vats/delegator-vat.ts b/packages/evm-wallet-experiment/src/vats/delegator-vat.ts index 10bbb598e6..a2387f7b7a 100644 --- a/packages/evm-wallet-experiment/src/vats/delegator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/delegator-vat.ts @@ -17,7 +17,7 @@ import { encodeErc20TransferAmount, encodeAllowedCalldata, } from '../lib/caveats.ts'; -import { makeDelegation, makeSaltGenerator } from '../lib/delegation.ts'; +import { makeDelegation } from '../lib/delegation.ts'; import { ERC20_TRANSFER_SELECTOR, FIRST_ARG_OFFSET } from '../lib/erc20.ts'; import type { Address, @@ -61,8 +61,6 @@ export function buildRootObject( ) : new Map(); - const saltGenerator = makeSaltGenerator(); - /** * Persist grants map to baggage (handles both init and update). */ @@ -123,7 +121,6 @@ export function buildRootObject( delegate, caveats, chainId, - saltGenerator, }); return harden({ @@ -185,7 +182,6 @@ export function buildRootObject( delegate, caveats, chainId, - saltGenerator, }); return harden({ diff --git a/packages/evm-wallet-experiment/src/vats/home-coordinator.ts b/packages/evm-wallet-experiment/src/vats/home-coordinator.ts index 0ce68d0201..80e742a7ab 100644 --- a/packages/evm-wallet-experiment/src/vats/home-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/home-coordinator.ts @@ -1015,7 +1015,7 @@ export function buildRootObject( if (typeof globalThis.setTimeout !== 'function') { throw new Error( 'EIP-7702 confirmation polling requires setTimeout ' + - '(not available in SES compartments without timer endowments)', + "(add the missing globals to this vat's globals in cluster-config.ts)", ); } const maxAttempts = 45; @@ -1223,7 +1223,6 @@ export function buildRootObject( async initializeKeyring(options: { type: 'srp' | 'throwaway'; mnemonic?: string; - entropy?: Hex; password?: string; salt?: string; addressIndex?: number; @@ -1231,24 +1230,30 @@ export function buildRootObject( if (!keyringVat) { throw new Error('Keyring vat not available'); } - let initOptions: - | { type: 'srp'; mnemonic: string; addressIndex?: number } - | { type: 'throwaway'; entropy?: Hex }; if (options.type === 'throwaway') { - initOptions = { type: 'throwaway', entropy: options.entropy }; - } else { - initOptions = - options.addressIndex === undefined - ? { type: 'srp', mnemonic: options.mnemonic ?? '' } - : { - type: 'srp', - mnemonic: options.mnemonic ?? '', - addressIndex: options.addressIndex, - }; - } - - const password = options.type === 'srp' ? options.password : undefined; - await E(keyringVat).initialize(initOptions, password, options.salt); + await E(keyringVat).initialize( + { type: 'throwaway' }, + undefined, + options.salt, + ); + return; + } + const initOptions: { + type: 'srp'; + mnemonic: string; + addressIndex?: number; + } = { + type: 'srp', + mnemonic: options.mnemonic ?? '', + }; + if (options.addressIndex !== undefined) { + initOptions.addressIndex = options.addressIndex; + } + await E(keyringVat).initialize( + initOptions, + options.password, + options.salt, + ); }, async unlockKeyring(password: string): Promise { @@ -2206,7 +2211,7 @@ export function buildRootObject( ) { throw new Error( 'waitForUserOpReceipt requires Date.now and setTimeout ' + - '(not available in SES compartments without timer endowments)', + "(add the missing globals to this vat's globals in cluster-config.ts)", ); } diff --git a/packages/evm-wallet-experiment/src/vats/keyring-vat.test.ts b/packages/evm-wallet-experiment/src/vats/keyring-vat.test.ts index 6f4c3e4a9c..d99da47be0 100644 --- a/packages/evm-wallet-experiment/src/vats/keyring-vat.test.ts +++ b/packages/evm-wallet-experiment/src/vats/keyring-vat.test.ts @@ -233,6 +233,25 @@ describe('keyring-vat', () => { const accountsAfter = await (restoredRoot as any).getAccounts(); expect(accountsAfter).toStrictEqual(accountsBefore); }); + + it('rebuilds throwaway keyrings with a new address after resuscitation', async () => { + // Throwaway keys are intentionally ephemeral — baggage only stores + // `{ type: 'throwaway' }`, so restart produces a fresh random key. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).initialize({ type: 'throwaway' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const accountsBefore = await (root as any).getAccounts(); + expect(accountsBefore).toHaveLength(1); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const restoredRoot = buildRootObject({}, undefined, baggage as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const accountsAfter = await (restoredRoot as any).getAccounts(); + + expect(accountsAfter).toHaveLength(1); + expect(accountsAfter[0]).toMatch(/^0x[\da-f]{40}$/iu); + expect(accountsAfter).not.toStrictEqual(accountsBefore); + }); }); describe('password encryption', () => { diff --git a/packages/evm-wallet-experiment/test/e2e/docker/helpers/wallet-setup.ts b/packages/evm-wallet-experiment/test/e2e/docker/helpers/wallet-setup.ts index 4b71b69251..4e8eee335e 100644 --- a/packages/evm-wallet-experiment/test/e2e/docker/helpers/wallet-setup.ts +++ b/packages/evm-wallet-experiment/test/e2e/docker/helpers/wallet-setup.ts @@ -6,7 +6,6 @@ * are made by the caller — these helpers never branch or self-discover. */ -import { randomBytes } from 'node:crypto'; import { setTimeout as delay } from 'node:timers/promises'; import { callVat, daemonExec, evmRpc, getServiceInfo } from './docker-exec.ts'; @@ -46,7 +45,7 @@ export function launchWalletSubcluster( ? { delegator: { bundleSpec: `${BUNDLE_BASE}/delegator-vat.bundle`, - globals: ['TextEncoder', 'TextDecoder'], + globals: ['TextEncoder', 'TextDecoder', 'crypto'], }, } : { @@ -67,7 +66,7 @@ export function launchWalletSubcluster( }, keyring: { bundleSpec: `${BUNDLE_BASE}/keyring-vat.bundle`, - globals: ['TextEncoder', 'TextDecoder'], + globals: ['TextEncoder', 'TextDecoder', 'crypto'], }, provider: { bundleSpec: `${BUNDLE_BASE}/provider-vat.bundle`, @@ -106,26 +105,7 @@ export function initKeyring( | { type: 'srp'; mnemonic: string; addressIndex?: number } | { type: 'throwaway' }, ): string { - let keyringOpts: - | { type: 'srp'; mnemonic: string; addressIndex?: number } - | { type: 'throwaway'; entropy: string }; - if (options.type === 'throwaway') { - keyringOpts = { - type: 'throwaway', - entropy: `0x${randomBytes(32).toString('hex')}`, - }; - } else { - keyringOpts = - options.addressIndex === undefined - ? { type: 'srp', mnemonic: options.mnemonic } - : { - type: 'srp', - mnemonic: options.mnemonic, - addressIndex: options.addressIndex, - }; - } - - callVat(service, kref, 'initializeKeyring', [keyringOpts]); + callVat(service, kref, 'initializeKeyring', [options]); const accounts = callVat(service, kref, 'getAccounts') as string[]; return accounts[0] as string; diff --git a/packages/evm-wallet-experiment/test/e2e/run-peer-e2e.mjs b/packages/evm-wallet-experiment/test/e2e/run-peer-e2e.mjs index 2f055c167d..6a5e1923e8 100644 --- a/packages/evm-wallet-experiment/test/e2e/run-peer-e2e.mjs +++ b/packages/evm-wallet-experiment/test/e2e/run-peer-e2e.mjs @@ -86,7 +86,6 @@ import { NodejsPlatformServices } from '@metamask/kernel-node-runtime'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import { waitUntilQuiescent } from '@metamask/kernel-utils'; import { Kernel, kunser } from '@metamask/ocap-kernel'; -import { randomBytes } from 'node:crypto'; import { makeWalletClusterConfig } from '../../src/cluster-config.ts'; import { getDelegationManagerAddress } from '../../src/lib/sdk.ts'; @@ -397,10 +396,7 @@ async function main() { // ===================================================================== console.log('\n--- Initialize throwaway keyring (kernel2) ---'); - const throwawayEntropy = `0x${randomBytes(32).toString('hex')}`; - await call(kernel2, coord2, 'initializeKeyring', [ - { type: 'throwaway', entropy: throwawayEntropy }, - ]); + await call(kernel2, coord2, 'initializeKeyring', [{ type: 'throwaway' }]); // getAccounts returns only peer (home) accounts — throwaway is hidden const awayAccounts = await call(kernel2, coord2, 'getAccounts'); diff --git a/packages/evm-wallet-experiment/test/integration/openclaw-plugin.integration.test.ts b/packages/evm-wallet-experiment/test/integration/openclaw-plugin.integration.test.ts index 3130dc0885..1b22de4b86 100644 --- a/packages/evm-wallet-experiment/test/integration/openclaw-plugin.integration.test.ts +++ b/packages/evm-wallet-experiment/test/integration/openclaw-plugin.integration.test.ts @@ -1,5 +1,4 @@ import { array, assert, object, string } from '@metamask/superstruct'; -import { randomBytes } from 'node:crypto'; import { access, chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; @@ -253,10 +252,7 @@ exec "${process.execPath}" "${ocapCliEntrypoint}" "$@" } rootKref = launchResponse.rootKref; - const entropy = `0x${randomBytes(32).toString('hex')}`; - await callVat(rootKref, 'initializeKeyring', [ - { type: 'throwaway', entropy }, - ]); + await callVat(rootKref, 'initializeKeyring', [{ type: 'throwaway' }]); await callVat(rootKref, 'configureProvider', [ { chainId: 31337, rpcUrl }, ]); diff --git a/packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts b/packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts index 97108bf429..5c87372f9e 100644 --- a/packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts +++ b/packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts @@ -359,10 +359,8 @@ describe.sequential('Peer wallet integration', () => { const homeAddr = homeAccounts[0] as Address; // Away initializes a throwaway keyring so it has a delegate address. - const { randomBytes } = await import('node:crypto'); - const entropy = `0x${randomBytes(32).toString('hex')}`; await callVatMethod(kernel2, coordinatorKref2, 'initializeKeyring', [ - { type: 'throwaway', entropy }, + { type: 'throwaway' }, ]); const awayAccounts = (await callVatMethod( kernel2, diff --git a/packages/evm-wallet-experiment/test/integration/run-peer-wallet.mjs b/packages/evm-wallet-experiment/test/integration/run-peer-wallet.mjs index b81bd664ea..8060af389c 100644 --- a/packages/evm-wallet-experiment/test/integration/run-peer-wallet.mjs +++ b/packages/evm-wallet-experiment/test/integration/run-peer-wallet.mjs @@ -15,7 +15,6 @@ import { NodejsPlatformServices } from '@metamask/kernel-node-runtime'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import { waitUntilQuiescent } from '@metamask/kernel-utils'; import { Kernel, kunser } from '@metamask/ocap-kernel'; -import { randomBytes } from 'node:crypto'; import { makeWalletClusterConfig } from '../../src/cluster-config.ts'; @@ -280,10 +279,7 @@ async function main() { // -- Away wallet with throwaway key + peer -- console.log('\n--- Away wallet with throwaway key + peer ---'); - const entropy = `0x${randomBytes(32).toString('hex')}`; - await call(kernel2, coord2, 'initializeKeyring', [ - { type: 'throwaway', entropy }, - ]); + await call(kernel2, coord2, 'initializeKeyring', [{ type: 'throwaway' }]); const caps2Full = await call(kernel2, coord2, 'getCapabilities'); assert(caps2Full.hasLocalKeys === true, 'away wallet: now has local keys'); assert(caps2Full.hasPeerWallet === true, 'away wallet: still has peer'); diff --git a/packages/evm-wallet-experiment/test/integration/run-wallet.mjs b/packages/evm-wallet-experiment/test/integration/run-wallet.mjs index b714573dc1..6e8fbf129a 100644 --- a/packages/evm-wallet-experiment/test/integration/run-wallet.mjs +++ b/packages/evm-wallet-experiment/test/integration/run-wallet.mjs @@ -17,7 +17,6 @@ import { NodejsPlatformServices } from '@metamask/kernel-node-runtime'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import { waitUntilQuiescent } from '@metamask/kernel-utils'; import { Kernel, kunser } from '@metamask/ocap-kernel'; -import { randomBytes } from 'node:crypto'; import { makeWalletClusterConfig } from '../../src/cluster-config.ts'; @@ -264,9 +263,8 @@ async function main() { // -- Throwaway key -- console.log('\n--- Throwaway key ---'); - const entropy = `0x${randomBytes(32).toString('hex')}`; await call(kernel, coordinator2, 'initializeKeyring', [ - { type: 'throwaway', entropy }, + { type: 'throwaway' }, ]); const throwawayAccounts = await call(kernel, coordinator2, 'getAccounts'); assert(throwawayAccounts.length === 1, 'throwaway key produces one account'); diff --git a/packages/evm-wallet-experiment/test/setup-scripts.test.ts b/packages/evm-wallet-experiment/test/setup-scripts.test.ts new file mode 100644 index 0000000000..88cbbf2147 --- /dev/null +++ b/packages/evm-wallet-experiment/test/setup-scripts.test.ts @@ -0,0 +1,123 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect } from 'vitest'; + +import { makeWalletClusterConfig } from '../src/cluster-config.ts'; + +const HOME_SCRIPT = fileURLToPath( + new URL('../scripts/setup-home.sh', import.meta.url), +); +const AWAY_SCRIPT = fileURLToPath( + new URL('../scripts/setup-away.sh', import.meta.url), +); + +/** + * Extract the set of vat keys (and the bundle filename each uses) from the + * embedded Node config blob inside a setup shell script. The scripts build + * their cluster config via `CONFIG=$(node -e "...")`; the regex matches the + * `: { bundleSpec: bd + '/.bundle'` pattern inside that blob. + * + * @param scriptText - The raw contents of a setup shell script. + * @returns A mapping from vat key to bundle filename. + */ +function extractVatBundles(scriptText: string): Record { + const result: Record = {}; + const pattern = + /([a-zA-Z][a-zA-Z0-9]*): \{\s*bundleSpec: bd \+ '\/([a-zA-Z-]+\.bundle)'/gu; + for (const match of scriptText.matchAll(pattern)) { + const [, vatKey, bundleName] = match; + if (vatKey && bundleName) { + result[vatKey] = bundleName; + } + } + return result; +} + +/** + * Expected vat-key-to-bundle mapping for a given role, derived from the + * canonical `makeWalletClusterConfig` so the scripts cannot drift silently. + * + * @param role - The subcluster role. + * @returns A mapping from vat key to bundle filename. + */ +function canonicalVatBundles(role: 'home' | 'away'): Record { + const config = makeWalletClusterConfig({ + bundleBaseUrl: 'file:///stub', + role, + }); + const result: Record = {}; + for (const [vatKey, vatConfig] of Object.entries(config.vats)) { + const spec = (vatConfig as { bundleSpec: string }).bundleSpec; + const bundleName = spec.split('/').pop(); + if (bundleName) { + result[vatKey] = bundleName; + } + } + return result; +} + +describe('setup scripts match canonical cluster config', () => { + it.each([ + { role: 'home' as const, scriptPath: HOME_SCRIPT }, + { role: 'away' as const, scriptPath: AWAY_SCRIPT }, + ])( + '$role script wires the same vat keys and bundles as makeWalletClusterConfig', + async ({ role, scriptPath }) => { + const scriptText = await readFile(scriptPath, 'utf-8'); + expect(extractVatBundles(scriptText)).toStrictEqual( + canonicalVatBundles(role), + ); + }, + ); + + it('neither script references the pre-rename delegation-vat bundle', async () => { + for (const scriptPath of [HOME_SCRIPT, AWAY_SCRIPT]) { + const scriptText = await readFile(scriptPath, 'utf-8'); + expect(scriptText).not.toContain('delegation-vat.bundle'); + expect(scriptText).not.toMatch(/delegation: \{/u); + } + }); + + it.each([ + { vat: 'keyring', scriptPath: HOME_SCRIPT, role: 'home' }, + { vat: 'keyring', scriptPath: AWAY_SCRIPT, role: 'away' }, + { vat: 'delegator', scriptPath: HOME_SCRIPT, role: 'home' }, + ])( + "$role script endows 'crypto' on the $vat vat", + async ({ vat, scriptPath }) => { + const scriptText = await readFile(scriptPath, 'utf-8'); + const block = new RegExp(`${vat}: \\{[\\s\\S]*?\\}`, 'u').exec( + scriptText, + ); + expect(block).not.toBeNull(); + expect(block![0]).toContain("'crypto'"); + }, + ); + + it("away script does not endow 'crypto' on the redeemer vat", async () => { + const scriptText = await readFile(AWAY_SCRIPT, 'utf-8'); + const redeemerBlock = /redeemer: \{[\s\S]*?\}/u.exec(scriptText); + expect(redeemerBlock).not.toBeNull(); + expect(redeemerBlock![0]).not.toContain("'crypto'"); + }); + + it.each([ + { role: 'home', scriptPath: HOME_SCRIPT }, + { role: 'away', scriptPath: AWAY_SCRIPT }, + ])( + '$role script wires the provider vat for network fetch', + async ({ scriptPath }) => { + const scriptText = await readFile(scriptPath, 'utf-8'); + const providerBlock = /provider: \{[\s\S]*?^\s{8}\},/mu.exec(scriptText); + expect(providerBlock).not.toBeNull(); + // `fetch` globals must be endowed, matching cluster-config.ts + for (const globalName of ['fetch', 'Request', 'Headers', 'Response']) { + expect(providerBlock![0]).toContain(`'${globalName}'`); + } + // `network.allowedHosts` is the canonical field; `platformConfig.fetch` + // is not a valid kernel config shape (kernel-platforms only accepts `fs`). + expect(providerBlock![0]).toMatch(/network: \{ allowedHosts:/u); + expect(providerBlock![0]).not.toContain('platformConfig'); + }, + ); +});