Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 12 additions & 14 deletions packages/evm-wallet-experiment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
44 changes: 34 additions & 10 deletions packages/evm-wallet-experiment/docs/setup-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": ["<chain>.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" }
}
}
Expand Down Expand Up @@ -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": ["<chain>.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
Expand Down
30 changes: 10 additions & 20 deletions packages/evm-wallet-experiment/scripts/setup-away.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ LISTEN_ADDRS=""
RELAY_ADDR=""
SKIP_BUILD=false
QUIC_PORT=4002
DELEGATION_MANAGER="0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3"
CUSTOM_RPC_URL=""
NON_INTERACTIVE=false

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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']
}
}
}
Expand All @@ -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..."
Expand Down
16 changes: 8 additions & 8 deletions packages/evm-wallet-experiment/scripts/setup-home.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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']
Comment thread
cursor[bot] marked this conversation as resolved.
},
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 }
}
}
Expand Down
15 changes: 12 additions & 3 deletions packages/evm-wallet-experiment/src/cluster-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] };
Expand Down
4 changes: 2 additions & 2 deletions packages/evm-wallet-experiment/src/cluster-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function makeWalletClusterConfig(
? {
delegator: {
bundleSpec: `${bundleBaseUrl}/delegator-vat.bundle`,
globals: ['TextEncoder', 'TextDecoder'],
globals: ['TextEncoder', 'TextDecoder', 'crypto'],
},
}
: {
Expand All @@ -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`,
Expand Down
2 changes: 0 additions & 2 deletions packages/evm-wallet-experiment/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,7 @@ export {
finalizeDelegation,
computeDelegationId,
generateSalt,
makeSaltGenerator,
} from './lib/delegation.ts';
export type { SaltGenerator } from './lib/delegation.ts';

// UserOperation utilities
export {
Expand Down
Loading
Loading