A capability-driven EVM wallet implemented as an OCAP kernel subcluster. It uses the MetaMask Delegation Framework (Gator) for delegated transaction authority via ERC-4337 UserOperations. The wallet subcluster isolates key management, Ethereum RPC communication, and delegation lifecycle into separate vats, enforcing the principle of least authority across the entire signing pipeline.
For a deeper explanation of the components and data flow, see How It Works. For deploying the wallet on a home device + VPS with OpenClaw, see the Setup Guide.
- 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()requires a bundler. Revocation submits an on-chaindisableDelegationUserOp to the DelegationManager contract. The bundler and (optionally) paymaster must be configured. If the UserOp 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
passwordandsalttoinitializeKeyring()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 withunlockKeyring(password)before signing operations work. - Throwaway keyring needs secure entropy.
initializeKeyring({ type: 'throwaway' })requires eithercrypto.getRandomValuesin the runtime or caller-provided entropy via{ type: 'throwaway', entropy: '0x...' }. Under SES lockdown (wherecryptois unavailable inside vat compartments), the caller must generate 32 bytes of entropy externally and pass it in.
The wallet is composed of four vats within a single kernel subcluster. The coordinator vat acts as the bootstrap vat and public API surface. It orchestrates signing strategy resolution, delegation redemption, and peer wallet communication by dispatching to the other three vats via E() (eventual send).
+-----------------------+
| Coordinator Vat |
| (bootstrap / API) |
| |
| - Signing strategy |
| resolution |
| - Delegation |
| redemption |
| - Peer wallet |
| communication |
+--+------+------+------+
| | |
E() | | | E()
+----------------+ | +------------------+
| | |
v v v
+-------------------+ +-------------------+ +------------------------+
| Keyring Vat | | Provider Vat | | Delegation Vat |
| | | | | |
| - HD key deriv. | | - Ethereum | | - Create / store |
| - Throwaway keys | | JSON-RPC | | delegations |
| - Transaction | | - Broadcast tx | | - EIP-712 typed data |
| signing | | - Bundler RPC | | preparation |
| - Message signing | | (UserOp submit, | | - Action matching |
| - Typed data | | gas estimate) | | - Revocation |
| signing | | | | |
+-------------------+ +-------------------+ +------------------------+
Keys NEVER leave Network I/O only Pure delegation logic
the keyring vat (no keys, no network)
Coordinator vat -- The bootstrap vat and sole public API surface. Resolves which signing strategy to use for each request (delegation, local key, external signer, or peer wallet). Builds and submits ERC-4337 UserOperations for delegation redemption. Manages peer wallet connectivity via OCAP URLs.
Keyring vat -- Isolates private key material. Supports two initialization modes: srp (BIP-39 mnemonic with BIP-44 HD derivation at m/44'/60'/0'/0/{index}) and throwaway (a single randomly generated private key). Signs transactions, personal messages, and EIP-712 typed data. Keys never leave this vat. Optionally encrypts the mnemonic at rest with AES-256-GCM (password-based, PBKDF2 key derivation).
Provider vat -- Handles all Ethereum JSON-RPC communication. Wraps a viem transport for standard RPC calls (eth_call, eth_getBalance, etc.), transaction broadcasting, and ERC-4337 bundler RPC (eth_sendUserOperation, eth_estimateUserOperationGas).
Delegation vat -- Manages the lifecycle of Gator delegations: creation, EIP-712 signing preparation, storage, action matching, and revocation. Accepts incoming signed delegations from peer wallets. Contains no key material and performs no network I/O.
The wallet supports a two-kernel topology where a home kernel (the user's device) delegates authority to an away kernel (a remote server or secondary device). The two kernels connect via OCAP URLs over QUIC or TCP.
The home kernel is the authority holder. It typically has MetaMask connected as an external signer (no local keyring needed). It creates delegations and shares them with the away kernel.
import {
makeWalletClusterConfig,
connectMetaMaskSigner,
} from '@ocap/evm-wallet-experiment';
// 1. Launch the wallet subcluster
const config = makeWalletClusterConfig({ bundleBaseUrl: '/bundles' });
const { rootKref } = await kernel.launchSubcluster(config);
// 2. Connect MetaMask as external signer
const signer = await connectMetaMaskSigner({
dappMetadata: { name: 'My dApp', url: 'https://example.com' },
});
await coordinator.connectExternalSigner(signer);
// 3. Configure the bundler for ERC-4337 UserOps
await coordinator.configureBundler({
bundlerUrl: 'https://bundler.example.com/rpc',
chainId: 1,
});
// 4. Issue an OCAP URL and share it with the away kernel
const ocapUrl = await coordinator.issueOcapUrl();
// Send ocapUrl to the away kernel via any out-of-band channel
// 5. Read the delegate address sent by the away kernel (automatic)
const delegateAddress = await coordinator.getDelegateAddress();
// Falls back to manual input if the away kernel hasn't connected yet
// 6. Create a delegation for the away kernel's address
const delegation = await coordinator.createDelegation({
delegate: delegateAddress ?? ('0xAwayAddress...' as Address),
caveats: [
makeCaveat({
type: 'allowedTargets',
terms: encodeAllowedTargets(['0xContractAddress...' as Address]),
}),
makeCaveat({
type: 'valueLte',
terms: encodeValueLte(1000000000000000000n), // 1 ETH max
}),
],
chainId: 1,
});
// 7. Push the delegation to the away kernel (automatic over QUIC/CapTP)
await coordinator.pushDelegationToAway(delegation);The away kernel receives delegated authority. It initializes a throwaway keyring and redeems delegations by building ERC-4337 UserOperations. In the provided home/away setup flow, the delegate address exchange and back-channel registration happen automatically, so no manual copy-paste is needed.
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 });
// 2. Connect to the home kernel via the OCAP URL
// This automatically:
// - Connects to the peer coordinator
// - Caches peer accounts for offline use
// Additional setup automation can then:
// - Register a back-channel (registerAwayWallet) for delegation push
// - Send the delegate address to the home device
await coordinator.connectToPeer(ocapUrl);
// 3. The home device pushes the delegation automatically via pushDelegationToAway().
// Alternatively, receive it manually:
// await coordinator.receiveDelegation(delegation);
// 4. Configure the bundler
await coordinator.configureBundler({
bundlerUrl: 'https://bundler.example.com/rpc',
chainId: 1,
});
// 5. Redeem the delegation via a UserOp
const userOpHash = await coordinator.redeemDelegation({
execution: {
target: '0xContractAddress...' as Address,
value: '0x0' as Hex,
callData: '0x...' as Hex,
},
delegationId: delegation.id,
maxFeePerGas: '0x3b9aca00' as Hex,
maxPriorityFeePerGas: '0x3b9aca00' as Hex,
});In the provided setup flow, the away kernel connects via connectToPeer(), then higher-level automation registers a back-channel with the home coordinator and sends the delegate address. This enables the home device to push delegations and read the delegate address without manual copy-paste.
If the away kernel has no local keys and no matching delegation for a message or typed-data signing request, it falls back to the peer wallet (the home kernel's coordinator). The signing request is forwarded over the OCAP URL connection, and the home kernel signs it using its own keyring or external signer. Transaction signing (signTransaction) does not have a peer fallback — the away kernel must use delegation redemption for sends.
Away Kernel Home Kernel
+-----------+ +-----------+
|Coordinator| -- signing request --> |Coordinator|
| | | |
| (no keys)| | Keyring |
| | <-- signature -------- | or |
| | | MetaMask |
+-----------+ +-----------+
^ ^
| OCAP URL over QUIC/TCP |
+--------------------------------------+
The away kernel caches the home kernel's accounts and signing mode during connectToPeer(). After the delegation is transferred, the home device can go offline — the away kernel operates fully autonomously:
getAccounts()returns cached peer accounts (with a 5-second timeout on live peer calls)getCapabilities()returns cached signing modesendTransaction()signs locally and submits via the bundler (no home needed)signMessage()/signTypedData()signs with the local throwaway key
The only operation that still requires the home online is signing as the home EOA address specifically (since that requires the home's private key). The away coordinator detects this and throws a clear error instead of signing with the wrong key. See How It Works — Offline Autonomy for details.
The coordinator resolves the appropriate signer for each request type using a priority chain.
Priority: local key → external signer → error
- Local key -- If the keyring owns the
fromaccount, it signs directly. - External signer -- If a MetaMask or other external signer is connected, it signs.
No delegation or peer wallet fallback is used for bare transaction signing.
Priority: delegation (UserOp) → local key sign + broadcast → external signer sign + broadcast → error
- Delegation -- If a stored delegation matches the transaction's action (target, value, data), the coordinator builds and submits a UserOp signed via the local keyring.
- Local key -- If the keyring owns the
fromaccount, it signs and broadcasts directly. - External signer -- If a MetaMask or other external signer is connected, it signs and broadcasts.
Delegations are checked first, but there is no peer wallet fallback for transactions.
Priority: peer account guard → keyring → external signer → peer wallet → error
- Peer account guard -- If
frommatches a cached peer (home) account, the request is routed to the peer wallet. If the peer is offline, a descriptive error is thrown instead of silently signing with the wrong key. - Keyring -- If the keyring has keys, it signs directly.
- External signer -- If a MetaMask or other external signer is connected, it signs.
- Peer wallet -- If a peer wallet is connected, the request is forwarded.
Delegations use the MetaMask Delegation Framework (Gator). A delegation grants a delegate address the authority to act on behalf of a delegator address, subject to caveats (on-chain enforced restrictions).
The delegator signs an EIP-712 typed data payload that binds the delegation parameters. The DelegationManager contract is the verifying contract.
import {
makeCaveat,
encodeAllowedTargets,
encodeAllowedMethods,
encodeValueLte,
encodeTimestamp,
encodeLimitedCalls,
encodeErc20TransferAmount,
} from '@ocap/evm-wallet-experiment';
const delegation = await coordinator.createDelegation({
delegate: '0xDelegateAddress...' as Address,
caveats: [
makeCaveat({
type: 'allowedTargets',
terms: encodeAllowedTargets(['0xContract...' as Address]),
}),
makeCaveat({
type: 'allowedMethods',
terms: encodeAllowedMethods(['0xa9059cbb' as Hex]), // transfer(address,uint256)
}),
makeCaveat({
type: 'valueLte',
terms: encodeValueLte(0n), // No ETH value allowed
}),
],
chainId: 1,
});| Caveat Type | Description | Encoder |
|---|---|---|
allowedTargets |
Restrict to specific contract addresses | encodeAllowedTargets |
allowedMethods |
Restrict to specific function selectors (4 bytes) | encodeAllowedMethods |
valueLte |
Cap the ETH value per call | encodeValueLte |
nativeTokenTransferAmount |
Cap the native token (ETH) transfer amount | encodeNativeTokenTransferAmount |
erc20TransferAmount |
Cap the ERC-20 token transfer amount | encodeErc20TransferAmount |
limitedCalls |
Limit total number of calls | encodeLimitedCalls |
timestamp |
Restrict usage to a time window (unix seconds) | encodeTimestamp |
Delegations can be shared between kernels in three ways:
- Automatic push (recommended) -- When the away device connects via
connectToPeer(), it registers a back-channel with the home coordinator. The home device can then push delegations directly viapushDelegationToAway(delegation). The setup scripts andupdate-limits.shuse this automatically. - Direct -- Call
coordinator.receiveDelegation(delegation)on the receiving kernel, passing the signed delegation object. - OCAP URL -- The home kernel issues an OCAP URL, and the away kernel redeems it to obtain a reference to the home coordinator, then receives delegations via method calls.
Redemption builds an ERC-4337 UserOperation that calls DelegationManager.redeemDelegations on-chain. The UserOp is signed by the delegate and submitted to a bundler, which routes it through the EntryPoint v0.7 contract.
const userOpHash = await coordinator.redeemDelegation({
execution: {
target: '0xTokenContract...' as Address,
value: '0x0' as Hex,
callData: '0xa9059cbb...' as Hex, // transfer(address,uint256) encoded
},
delegationId: delegation.id,
// OR: action: { to: '0x...', value: '0x0', data: '0x...' } for automatic matching
maxFeePerGas: '0x3b9aca00' as Hex,
maxPriorityFeePerGas: '0x3b9aca00' as Hex,
});| 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 |
|---|---|
signTransaction(tx) |
Sign a transaction using strategy resolution. |
sendTransaction(tx) |
Sign and broadcast a transaction via the provider. |
sendBatchTransaction(txs) |
Batch multiple transactions into a single UserOp for smart accounts. Falls back to sequential sendTransaction calls for EOA wallets without a smart account. |
signMessage(message, account?) |
Sign a personal message (EIP-191). |
signTypedData(data, from?) |
Sign EIP-712 typed data. |
request(method, params?) |
Forward a raw JSON-RPC request to the provider. |
| Method | Description |
|---|---|
createDelegation(opts) |
Create and sign a new delegation. Returns the signed Delegation. |
receiveDelegation(delegation) |
Store a signed delegation received from a peer. |
revokeDelegation(id) |
Revoke a delegation on-chain via DelegationManager.disableDelegation. Returns the UserOp hash. |
listDelegations() |
List all stored delegations. |
redeemDelegation(options) |
Build, sign, and submit a UserOp to redeem a delegation. |
| Method | Description |
|---|---|
issueOcapUrl() |
Issue an OCAP URL that grants a reference to this coordinator. Requires ocapURLIssuerService. |
connectToPeer(ocapUrl) |
Connect to a remote coordinator via an OCAP URL and cache peer accounts for offline use. |
refreshPeerAccounts() |
Re-fetch and cache peer accounts. Throws if no peer wallet is connected. |
registerAwayWallet(awayRef) |
Register an away wallet reference for delegation push. Called automatically by the away device during connectToPeer. |
pushDelegationToAway(delegation) |
Push a signed delegation to the registered away wallet over CapTP. Throws if no away wallet is registered. |
registerDelegateAddress(address) |
Store a delegate address received from the away device. Called automatically via sendDelegateAddressToPeer. |
getDelegateAddress() |
Return the pending delegate address sent by the away device, or undefined if none. |
sendDelegateAddressToPeer(address) |
Send this device's delegate address to the connected peer (home) device. Used by the higher-level setup flow. |
handleSigningRequest(request) |
Handle an incoming signing request from a peer wallet. |
| Method | Description |
|---|---|
getTokenBalance(options) |
Get ERC-20 balance. Accepts { token, owner }. Returns the raw balance as a decimal string. |
getTokenMetadata(options) |
Get token metadata. Accepts { token }. Returns { name, symbol, decimals }. Handles non-standard bytes32 returns (MKR). |
sendErc20Transfer(options) |
Send ERC-20 tokens. Accepts { token, to, amount, from? }. Routes through sendTransaction (delegation-aware). |
| Method | Description |
|---|---|
getSwapQuote(options) |
Get the best swap quote from MetaSwap (MetaMask's aggregator). Accepts { srcToken, destToken, srcAmount, slippage }. Returns a SwapQuote with trade data and approval details. The SwapQuote type has a corresponding runtime validation struct (SwapQuoteStruct) exported from the package. |
swapTokens(options) |
Execute a token swap. Fetches a fresh quote, handles ERC-20 approval if needed, then executes the swap. Returns a SwapResult with transaction hashes and details. When a smart account is configured and the swap requires an ERC-20 approval, the approve and swap are batched into a single UserOp and the result includes batched: true. |
| Method | Description |
|---|---|
getAccounts() |
List accounts. Returns cached peer accounts when the home device is offline (5s timeout). |
getCapabilities() |
Return a WalletCapabilities object describing the wallet state, including cached peer info. |
| Method | Description |
|---|---|
createSmartAccount(config) |
Create a smart account. Accepts { chainId, implementation?, deploySalt?, address? }. Default implementation is 'hybrid' (counterfactual, deploys on first UserOp). 'stateless7702' is also supported but requires RPC providers that expose EIP-7702 designator codes via eth_getCode (see note below). |
getSmartAccountAddress() |
Return the smart account address, if configured. |
Note on smart account implementations: The stateless7702 implementation uses EIP-7702 to promote the EOA into a DeleGator smart account — same address, no separate contract or funding needed. The home device uses this by default. The hybrid implementation creates a counterfactual smart account that deploys on-chain on the first UserOp via factory data. The away device uses this because its throwaway EOA has no ETH to pay for the on-chain EIP-7702 authorization tx.
The WalletCapabilities object contains:
type WalletCapabilities = {
hasLocalKeys: boolean;
localAccounts: Address[];
delegationCount: number;
delegations?: DelegationInfo[];
hasPeerWallet: boolean;
hasExternalSigner: boolean;
hasBundlerConfig: boolean;
smartAccountAddress?: Address;
chainId?: number;
signingMode?: string;
autonomy?: string;
peerAccountsCached?: boolean;
cachedPeerAccounts?: Address[];
hasAwayWallet?: boolean;
};The package exports standalone utility functions that can be used outside of the vat context.
import {
encodeAllowedTargets,
encodeAllowedMethods,
encodeValueLte,
encodeNativeTokenTransferAmount,
encodeErc20TransferAmount,
encodeLimitedCalls,
encodeTimestamp,
makeCaveat,
getEnforcerAddress,
} from '@ocap/evm-wallet-experiment';
// Build a complete Caveat struct from type + encoded terms
const caveat = makeCaveat({
type: 'allowedTargets',
terms: encodeAllowedTargets(['0x...' as Address]),
chainId: 1, // optional: looks up enforcer address by chain
});import {
encodeTransfer,
encodeApprove,
encodeAllowance,
encodeBalanceOf,
decodeTransferCalldata,
decodeAllowanceResult,
makeErc20TransferExecution,
isErc20TransferCalldata,
ERC20_TRANSFER_SELECTOR,
ERC20_APPROVE_SELECTOR,
ERC20_ALLOWANCE_SELECTOR,
} from '@ocap/evm-wallet-experiment';
// Encode a transfer(address,uint256) call
const callData = encodeTransfer('0xRecipient...' as Address, 1000000n);
// Build an Execution struct for delegation redemption
const execution = makeErc20TransferExecution({
token: '0xUSDC...' as Address,
to: '0xRecipient...' as Address,
amount: 1000000n, // 1 USDC (6 decimals)
});
// Decode transfer calldata
const { to, amount } = decodeTransferCalldata(callData);
// Check if calldata is a transfer
isErc20TransferCalldata(callData); // trueimport {
makeDelegation,
prepareDelegationTypedData,
delegationMatchesAction,
explainDelegationMatch,
finalizeDelegation,
computeDelegationId,
generateSalt,
} from '@ocap/evm-wallet-experiment';
// Create an unsigned delegation
const delegation = makeDelegation({
delegator: '0xDelegator...' as Address,
delegate: '0xDelegate...' as Address,
caveats: [],
chainId: 1,
});
// Prepare EIP-712 typed data for signing
const typedData = prepareDelegationTypedData({
delegation,
verifyingContract: '0xDelegationManager...' as Address,
});
// Check if a delegation covers an action (client-side, best-effort)
const matches = delegationMatchesAction(delegation, {
to: '0xTarget...' as Address,
value: '0x0' as Hex,
data: '0x...' as Hex,
});import {
buildDelegationUserOp,
buildRedeemCallData,
computeUserOpHash,
encodeDelegationChain,
encodeExecution,
ENTRY_POINT_V07,
} from '@ocap/evm-wallet-experiment';
// Build an unsigned UserOp for delegation redemption
const userOp = buildDelegationUserOp({
sender: '0xSmartAccount...' as Address,
nonce: '0x0' as Hex,
delegations: [signedDelegation],
execution: {
target: '0x...' as Address,
value: '0x0' as Hex,
callData: '0x...' as Hex,
},
maxFeePerGas: '0x3b9aca00' as Hex,
maxPriorityFeePerGas: '0x3b9aca00' as Hex,
});
// Compute the hash for signing (ERC-4337 v0.7 packing)
const hash = computeUserOpHash(userOp, ENTRY_POINT_V07, 1);Use makeBundlerClient for ERC-4337 bundler interactions (replaces the legacy submitUserOp / estimateUserOpGas helpers). The bundler client also exports a UserOpReceiptResult type for typed receipt results.
import {
makeBundlerClient,
ENTRY_POINT_V07,
type UserOpReceiptResult,
} from '@ocap/evm-wallet-experiment';
const client = makeBundlerClient({
bundlerUrl: 'https://api.pimlico.io/v2/sepolia/rpc',
chainId: 11155111,
apiKey: 'YOUR_PIMLICO_KEY',
});
// Estimate gas
const gas = await client.estimateUserOperationGas({
userOp: unsignedUserOp,
entryPointAddress: ENTRY_POINT_V07,
});
// Sponsor via paymaster (Pimlico)
const sponsorship = await client.sponsorUserOperation({
userOp: unsignedUserOp,
entryPointAddress: ENTRY_POINT_V07,
context: { sponsorshipPolicyId: 'sp_my_policy' },
});
// Submit
const userOpHash = await client.sendUserOperation({
userOp: signedUserOp,
entryPointAddress: ENTRY_POINT_V07,
});
// Wait for inclusion
const receipt = await client.waitForUserOperationReceipt({
hash: userOpHash,
pollingInterval: 2000,
timeout: 60000,
});Utilities for interacting with the MetaMask Delegation Framework SDK, including batch operations and smart account address computation.
import {
buildBatchExecuteCallData,
buildSdkBatchRedeemCallData,
buildSdkRedeemCallData,
buildSdkDisableCallData,
createSdkExecution,
computeSmartAccountAddress,
toSdkDelegation,
encodeSdkDelegations,
resolveEnvironment,
getDelegationManagerAddress,
getEnforcerAddresses,
Implementation,
ExecutionMode,
} from '@ocap/evm-wallet-experiment';
// Build callData for batch execution (multiple executions in one UserOp)
const callData = buildBatchExecuteCallData([execution1, execution2]);
// Build callData for batch delegation redemption (multiple delegations in one UserOp)
const redeemCallData = buildSdkBatchRedeemCallData({
delegations: [[sdkDelegation1], [sdkDelegation2]],
executions: [execution1, execution2],
modes: [ExecutionMode.CALL, ExecutionMode.CALL],
environment,
});import {
connectMetaMaskSigner,
makeProviderSigner,
} from '@ocap/evm-wallet-experiment';
// High-level: connect via MetaMask SDK
const signer = await connectMetaMaskSigner({
dappMetadata: { name: 'My dApp', url: 'https://example.com' },
infuraAPIKey: 'YOUR_KEY',
});
// Low-level: wrap any EIP-1193 provider
const signer = makeProviderSigner(window.ethereum, {
disconnect: () => {
/* cleanup */
},
});
const accounts = await signer.getAccounts();
const signature = await signer.signTypedData(typedData, accounts[0]);
signer.disconnect();The openclaw-plugin/ directory contains an OpenClaw plugin that exposes wallet operations as AI agent tools. The plugin communicates with the OCAP daemon via ocap daemon exec commands.
| File | Purpose |
|---|---|
index.ts |
Tool registration — defines all wallet_* tools |
daemon.ts |
Daemon communication layer — spawns CLI, decodes CapData responses |
token-resolver.ts |
Token symbol resolution via the MetaMask Token API |
| Tool | Description |
|---|---|
wallet_accounts |
List wallet accounts |
wallet_balance |
Get ETH balance for an address |
wallet_send |
Send ETH to an address |
wallet_token_resolve |
Resolve a token symbol (e.g. "USDC") to a contract address on the current chain |
wallet_token_balance |
Get ERC-20 token balance (accepts address or symbol) |
wallet_token_send |
Send ERC-20 tokens (accepts address or symbol) |
wallet_token_info |
Get token metadata (name, symbol, decimals) |
wallet_swap_quote |
Get a token swap quote (source/dest token, amount, slippage) |
wallet_swap |
Execute a token swap with automatic approval handling |
wallet_sign |
Sign a message |
wallet_capabilities |
Check wallet capabilities |
All token tools (wallet_token_balance, wallet_token_send, wallet_token_info) accept either a contract address (0x...) or a token symbol/name (e.g. "USDC", "DAI"). Symbol resolution uses the MetaMask Token API — a single search request returns contract addresses with metadata for the wallet's chain. Testnets are not indexed; provide contract addresses directly on test networks.
The dedicated wallet_token_resolve tool can be used to search for tokens by name or symbol explicitly.
Use makeWalletClusterConfig to generate the ClusterConfig for launching the subcluster.
import { makeWalletClusterConfig } from '@ocap/evm-wallet-experiment';
const config = makeWalletClusterConfig({
bundleBaseUrl: 'https://example.com/bundles',
delegationManagerAddress: '0x...', // optional, defaults to placeholder
services: ['ocapURLIssuerService', 'ocapURLRedemptionService'], // default
});
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.
All vat code runs under SES lockdown. Key considerations:
- Vat code uses
makeDefaultExofrom@metamask/kernel-utils/exoto create remotable objects, andE()from@endo/eventual-sendfor cross-vat communication. All vat root objects are exos. - Library utilities (in
src/lib/) use theharden()pattern for lockdown compatibility. Thehardenfunction is obtained fromglobalThis.hardenwith a passthrough fallback for environments without lockdown. - MetaMask SDK cannot run inside a SES compartment. It is connected as an external signer reference that lives outside the vat boundary. The coordinator stores a reference to it but never loads it into a locked-down compartment.
- viem is used within vat compartments for ABI encoding, hashing, and HD key derivation. It is compatible with the SES environment as configured.
# Build vat bundles
yarn workspace @ocap/evm-wallet-experiment build
# Lint
yarn workspace @ocap/evm-wallet-experiment lint:fixThe package has four tiers of tests, each exercising a progressively larger slice of the stack.
yarn workspace @ocap/evm-wallet-experiment test:dev:quietFast, in-process tests using vitest. All inter-vat E() calls are mocked. Covers every lib/ module and every vat's buildRootObject logic in isolation — keyring operations, signing, mnemonic encryption/decryption, delegation creation/matching, caveat encoding, UserOp building, bundler client, SDK adapter, MetaMask signer, and coordinator strategy resolution.
yarn workspace @ocap/evm-wallet-experiment test:nodePlain Node.js script that runs under real SES lockdown in a real kernel. Launches the wallet subcluster (4 vats), exercises the full coordinator API via kernel.queueMessage(), and verifies inter-vat E() communication actually works end-to-end. Covers: keyring init (SRP + throwaway), signing (message, transaction, EIP-712), delegation lifecycle (create, sign, list), capabilities introspection, and no-authority error handling.
yarn workspace @ocap/evm-wallet-experiment test:node:peerTwo separate kernel instances connected via QUIC direct transport. Tests the home/away wallet architecture: OCAP URL issuance and redemption, remote message signing forwarded over CapTP, remote EIP-712 signing, transaction signing rejection (no peer fallback), delegation creation on the home wallet and transfer to the away wallet, and combined throwaway-key + peer + delegation capabilities. Verifies that remote signatures are identical to local signatures (same signing key, same result).
yarn workspace @ocap/evm-wallet-experiment test:node:daemonExercises the wallet through the daemon JSON-RPC socket — the same interface an agent process uses in production. Boots a kernel with an RPC socket server, launches the wallet subcluster via launchSubcluster RPC, then calls wallet methods via queueMessage RPC over the Unix socket. Covers: daemon status, subcluster lifecycle, keyring init, signing, delegation creation, capabilities, error propagation through the RPC layer, and subcluster termination.
PIMLICO_API_KEY=xxx SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/xxx \
yarn workspace @ocap/evm-wallet-experiment test:node:sepoliaFull on-chain test on Sepolia testnet. Creates a Hybrid smart account (standalone single-device test), creates and signs a delegation, redeems it by submitting an ERC-4337 UserOp to the Pimlico bundler with paymaster gas sponsorship, and waits for on-chain inclusion. Skips automatically if PIMLICO_API_KEY and SEPOLIA_RPC_URL are not set.
PIMLICO_API_KEY=xxx SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/xxx \
MNEMONIC="your twelve word mnemonic" \
yarn workspace @ocap/evm-wallet-experiment test:node:peer-e2eThe most comprehensive test: two kernels connected via local QUIC, exercising the complete home/away flow against Sepolia. Covers OCAP URL peer connection, remote message signing forwarded via CapTP, transaction signing rejection (no peer fallback), provider RPC queries, cross-kernel delegation transfer and revocation, smart account creation, self-delegation redemption via UserOp, and on-chain inclusion. Skips if any of PIMLICO_API_KEY, SEPOLIA_RPC_URL, or MNEMONIC is missing. Takes ~30–90 s depending on network conditions.
yarn workspace @ocap/evm-wallet-experiment test:integrationVitest-based peer wallet tests in test/integration/peer-wallet.test.ts. Requires building bundles first (yarn build). Tests OCAP URL connection, remote message/transaction signing via CapTP, no-authority errors, and capabilities reporting across two kernels.
The wallet supports all chains where the MetaMask Delegation Framework is deployed:
| Chain ID | Network | Swap Support |
|---|---|---|
| 1 | Ethereum | Yes |
| 10 | Optimism | Yes |
| 56 | BNB Smart Chain | Yes |
| 137 | Polygon | Yes |
| 8453 | Base | Yes |
| 42161 | Arbitrum One | Yes |
| 59144 | Linea | Yes |
| 11155111 | Sepolia (testnet) | No |
All chains share the same DelegationManager and enforcer addresses (deterministic CREATE2 deployment, including Sepolia). Swap support (via MetaSwap API) is only available on mainnets.
Use SUPPORTED_CHAIN_IDS, CHAIN_NAMES, and getChainContracts(chainId) to query chain support programmatically. Use getPimlicoRpcUrl(chainId) for Pimlico bundler URLs.
The package exports chain contract addresses used by the Delegation Framework:
| Export | Description |
|---|---|
SUPPORTED_CHAIN_IDS |
Array of all supported chain IDs. |
CHAIN_NAMES |
Human-readable chain names keyed by chain ID. |
CHAIN_CONTRACTS |
Registry of contract addresses keyed by chain ID. |
getChainContracts(chainId) |
Get contracts for a chain, falling back to placeholders. |
getPimlicoRpcUrl(chainId) |
Get the Pimlico bundler RPC URL for a given chain. |
SEPOLIA_CHAIN_ID |
Sepolia testnet chain ID (11155111). |
PIMLICO_RPC_BASE_URL |
(deprecated) Base URL for the Pimlico bundler on Sepolia. |
ENTRY_POINT_V07 |
ERC-4337 EntryPoint v0.7 address (0x0000000071727de22e5e9d8baf0edac6f37da032). |
ROOT_AUTHORITY |
The root authority hash (no parent delegation): 0xfff...fff. |
DELEGATION_TYPES |
EIP-712 type definitions for the Delegation Framework. |
ETH_HD_PATH_PREFIX |
BIP-44 HD path for Ethereum: m/44'/60'/0'/0. |
- Batch execution --
sendBatchTransactionbatches multiple transactions into a single UserOp for smart accounts. Token swaps that require an ERC-20 approval now batch the approve + swap into one UserOp (indicated bybatched: truein theSwapResult). EOA wallets without a smart account fall back to sequential execution. - EIP-7702 authorization -- The authorization flow now checks for reverted transactions instead of silently timing out, providing faster and more informative error reporting.
- 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
raceWithTimeouthelper (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.
This package is experimental software developed with the assistance of AI code generation tools. While it includes extensive test coverage (unit, integration, and on-chain E2E tests), it has not been formally audited. It may contain bugs, security vulnerabilities, or unexpected behavior.
This software is provided "as is", without warranty of any kind. The authors are not responsible for any loss of funds, data, or other damages resulting from its use. Do not use this package in production or with real assets without conducting your own thorough review and testing.