diff --git a/packages/evm-wallet-experiment/README.md b/packages/evm-wallet-experiment/README.md index e964f0d47..9a0144de5 100644 --- a/packages/evm-wallet-experiment/README.md +++ b/packages/evm-wallet-experiment/README.md @@ -1,13 +1,13 @@ # @ocap/evm-wallet-experiment -A capability-driven EVM wallet implemented as an OCAP kernel subcluster. It uses the [MetaMask Delegation Framework (Gator)](https://github.com/MetaMask/delegation-framework) 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. +A capability-driven EVM wallet implemented as an OCAP kernel subcluster. It uses the [MetaMask Delegation Framework (Gator)](https://github.com/MetaMask/delegation-framework) for delegated transaction authority. **Hybrid** smart accounts submit ERC-4337 UserOperations through a bundler; **stateless EIP-7702** home accounts (mnemonic path) redeem delegations with normal EIP-1559 transactions via your JSON-RPC provider (e.g. Infura), without a bundler. 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](./docs/how-it-works.md). For deploying the wallet on a home device + VPS with OpenClaw, see the [Setup Guide](./docs/setup-guide.md). ## Security model and known limitations - **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-chain `disableDelegation` UserOp to the DelegationManager contract. The bundler and (optionally) paymaster must be configured. If the UserOp fails, the local delegation status is not changed. +- **`revokeDelegation()` and hybrid redemption require a bundler.** 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. 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. @@ -748,6 +748,15 @@ PIMLICO_API_KEY=xxx SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/xxx \ Full 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. +### Sepolia E2E — 7702 direct (no Pimlico) + +```bash +SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/xxx TEST_MNEMONIC="..." \ + yarn workspace @ocap/evm-wallet-experiment test:node:sepolia-7702-direct +``` + +On-chain flow using `implementation: 'stateless7702'`: EIP-7702 upgrade tx, delegation creation, redemption via `eth_sendRawTransaction` through your RPC only (no bundler). Skips if `SEPOLIA_RPC_URL` is unset. + ### Peer wallet Sepolia E2E (41 assertions, requires API keys) ```bash diff --git a/packages/evm-wallet-experiment/docs/how-it-works.md b/packages/evm-wallet-experiment/docs/how-it-works.md index 5b9008637..a882714bc 100644 --- a/packages/evm-wallet-experiment/docs/how-it-works.md +++ b/packages/evm-wallet-experiment/docs/how-it-works.md @@ -75,19 +75,25 @@ The MetaMask SDK must connect **before** SES lockdown (which freezes built-in pr > **Note:** MetaMask Mobile requires `EIP712Domain` to be explicitly listed in the `types` field of `eth_signTypedData_v4` requests. Without it, MetaMask computes an empty domain separator, producing invalid signatures. The `makeProviderSigner` adapter handles this automatically. -### Smart Accounts (ERC-4337) +### Smart Accounts (ERC-4337 and EIP-7702) -Both devices create **DeleGator smart accounts** via MetaMask's Delegation Framework. In mnemonic mode, the home device uses **EIP-7702** to promote the EOA into a smart account (same address, no funding needed). In interactive mode, the home device uses a **Hybrid** smart account (different address, auto-funded from the EOA). The away device always uses a **Hybrid** counterfactual smart account (deploys on first UserOp). These are ERC-4337 smart contract wallets that support: +Both devices create **DeleGator smart accounts** via MetaMask's Delegation Framework. In mnemonic mode, the home device uses **EIP-7702** to promote the EOA into a smart account (same address, no separate contract deployment). In interactive mode, the home device uses a **Hybrid** smart account (different address, counterfactual until the first UserOp). The away device always uses **Hybrid** (deploys on first UserOp). + +**Submission path:** + +- **Hybrid** — redemption, batch execution, and revocation go through **ERC-4337 UserOperations** (bundler + optional paymaster). Gas can be sponsored so the agent does not need ETH. +- **Stateless 7702 (home / mnemonic)** — the same SDK-encoded `execute` calldata is sent as a **normal EIP-1559 transaction** (self-call on the upgraded EOA) via your configured JSON-RPC provider (e.g. Infura). No bundler is required for redemption or revocation; the EOA pays gas. + +All modes support: -- **UserOperations** — transactions submitted through a bundler instead of directly - **Delegations** — signed permission slips that authorize another account to act on behalf of the smart account - **Caveat enforcers** — on-chain contracts that restrict what a delegation can do -The Pimlico bundler handles UserOp submission, gas estimation, and optional paymaster sponsorship (so the agent doesn't need ETH for gas). +When a bundler is configured for Hybrid, the Pimlico client handles UserOp submission, gas estimation, and optional paymaster sponsorship. #### Batch execution -The coordinator supports `sendBatchTransaction`, which combines multiple transactions into a single UserOp using `DeleGatorCore.executeWithMode` with `BatchDefault` mode. This is used when an operation requires multiple on-chain steps — for example, a token swap that needs an ERC-20 approval followed by the swap trade. Instead of submitting two separate UserOps, batch execution packs both into one atomic operation: either both succeed or both revert. The single-delegation redemption path remains available for standalone transactions. +The coordinator supports `sendBatchTransaction`, which combines multiple transactions into a single atomic execution using `DeleGatorCore.executeWithMode` with `BatchDefault` mode — either as one UserOp (Hybrid + bundler) or one direct EIP-1559 tx (stateless 7702). This is used when an operation requires multiple on-chain steps — for example, a token swap that needs an ERC-20 approval followed by the swap trade. The single-delegation redemption path remains available for standalone transactions. ### Delegations and Caveats diff --git a/packages/evm-wallet-experiment/docs/setup-guide.md b/packages/evm-wallet-experiment/docs/setup-guide.md index 90f4bcd6c..4b71f72b7 100644 --- a/packages/evm-wallet-experiment/docs/setup-guide.md +++ b/packages/evm-wallet-experiment/docs/setup-guide.md @@ -290,7 +290,7 @@ During delegation setup, the home script prompts for two optional spending limit Both limits are enforced on-chain by caveat enforcers in the DeleGator framework. The agent cannot bypass them. Press Enter at either prompt to skip that limit. -The `--pimlico-key` configures the Pimlico bundler for ERC-4337 UserOp submission with paymaster sponsorship. Without it, smart account deployment and on-chain delegation redemption will not work. +The `--pimlico-key` configures the Pimlico bundler for ERC-4337 UserOp submission with paymaster sponsorship. It is **required** for the away device (Hybrid smart account) and for any **Hybrid** home setup. For a **mnemonic home wallet using stateless EIP-7702** (`implementation: 'stateless7702'`), delegation redemption uses your normal RPC only — Pimlico is optional on the home device in that configuration. All scripts also accept `--chain ` (e.g. `--chain base`, `--chain ethereum`) or `--chain-id ` (default: Sepolia 11155111), `--quic-port` (default: 4002), and `--no-build`. For chains not supported by Infura (e.g. BNB Smart Chain), pass `--rpc-url` instead of `--infura-key`. Run with `--help` for details. diff --git a/packages/evm-wallet-experiment/openclaw-plugin/skills/wallet/SKILL.md b/packages/evm-wallet-experiment/openclaw-plugin/skills/wallet/SKILL.md index b4508173c..53f20cfbc 100644 --- a/packages/evm-wallet-experiment/openclaw-plugin/skills/wallet/SKILL.md +++ b/packages/evm-wallet-experiment/openclaw-plugin/skills/wallet/SKILL.md @@ -12,7 +12,7 @@ Use the **wallet tools** for any Ethereum balance, send, or sign request. Do not - **wallet_accounts** — List wallet accounts. Returns cached home accounts if the home device is offline. - **wallet_balance** — Get ETH balance for an address. Use `wallet_accounts` first to find the right address. -- **wallet_send** — Send ETH to an address. Fully autonomous — uses delegation redemption via the bundler, no home device needed. +- **wallet_send** — Send ETH to an address. Fully autonomous — uses delegation redemption (bundler for Hybrid accounts, direct RPC for stateless EIP-7702 home accounts), no home device needed when the away wallet is configured. - **wallet_token_resolve** — Resolve a token symbol or name (e.g. "USDC") to its contract address on the current chain. Not available for testnets. - **wallet_token_balance** — Get ERC-20 token balance. Accepts a contract address or symbol (e.g. "USDC"). Returns human-readable amount with symbol. - **wallet_token_send** — Send ERC-20 tokens to an address. Accepts a contract address or symbol. Automatically converts decimal amounts using the token's decimals. diff --git a/packages/evm-wallet-experiment/package.json b/packages/evm-wallet-experiment/package.json index a2d73df7f..79d5ecd2c 100644 --- a/packages/evm-wallet-experiment/package.json +++ b/packages/evm-wallet-experiment/package.json @@ -53,6 +53,7 @@ "test:node:peer": "yarn build && node --conditions development test/integration/run-peer-wallet.mjs", "test:node:daemon": "yarn build && node --conditions development test/integration/run-daemon-wallet.mjs", "test:node:sepolia": "yarn build && node --conditions development test/e2e/run-sepolia-e2e.mjs", + "test:node:sepolia-7702-direct": "yarn build && node --conditions development test/e2e/run-sepolia-7702-direct-e2e.mjs", "test:node:peer-e2e": "yarn build && node --conditions development test/e2e/run-peer-e2e.mjs", "test:node:spending-limits": "yarn build && node --conditions development test/e2e/run-spending-limits-e2e.mjs" }, diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts index a8a0b7244..648dd483f 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts +++ b/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { afterEach, describe, it, expect, beforeEach, vi } from 'vitest'; import { buildRootObject as buildDelegationRoot } from './delegation-vat.ts'; import { buildRootObject as buildKeyringRoot } from './keyring-vat.ts'; @@ -507,6 +507,109 @@ describe('coordinator-vat', () => { expect(providerVat.broadcastTransaction).not.toHaveBeenCalled(); }); + it('redeems delegation via direct 7702 tx when bundler is absent', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + // Configure provider so resolveChainId returns 11155111 (matching + // the delegation's chainId) — without this, the delegation lookup + // would use chainId 1 from the getChainId mock and miss the match. + await coordinator.configureProvider({ + rpcUrl: 'https://sepolia.infura.io/v3/test', + chainId: 11155111, + }); + + // Set up 7702 smart account (already delegated) — no bundler configured + providerVat.request.mockResolvedValueOnce( + '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', + ); + await coordinator.createSmartAccount({ + chainId: 11155111, + implementation: 'stateless7702', + }); + + const accounts = await coordinator.getAccounts(); + const delegator = accounts[0] as Address; + + await coordinator.createDelegation({ + delegate: delegator, + caveats: [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([TARGET]), + }), + ], + chainId: 11155111, + }); + + const tx: TransactionRequest = { + from: delegator, + to: TARGET, + value: '0x0' as Hex, + data: '0xdeadbeef' as Hex, + }; + + const result = await coordinator.sendTransaction(tx); + expect(result).toBe('0xtxhash'); + // Must use direct 7702 broadcast (self-call), not bundler UserOp + expect(providerVat.broadcastTransaction).toHaveBeenCalled(); + expect(providerVat.submitUserOp).not.toHaveBeenCalled(); + }); + + it('rejects tx to disallowed target when delegationVat exists without bundler', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + await coordinator.configureProvider({ + rpcUrl: 'https://sepolia.infura.io/v3/test', + chainId: 11155111, + }); + + // Set up 7702 smart account — no bundler configured + providerVat.request.mockResolvedValueOnce( + '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', + ); + await coordinator.createSmartAccount({ + chainId: 11155111, + implementation: 'stateless7702', + }); + + const accounts = await coordinator.getAccounts(); + const delegator = accounts[0] as Address; + + // Delegation only allows TARGET + await coordinator.createDelegation({ + delegate: delegator, + caveats: [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([TARGET]), + }), + ], + chainId: 11155111, + }); + + const DISALLOWED_TARGET = + '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Address; + const tx: TransactionRequest = { + from: delegator, + to: DISALLOWED_TARGET, + value: '0x0' as Hex, + data: '0x' as Hex, + }; + + // Must NOT silently bypass delegation enforcement + await expect(coordinator.sendTransaction(tx)).rejects.toThrow( + 'No delegation covers this transaction', + ); + expect(providerVat.broadcastTransaction).not.toHaveBeenCalled(); + expect(providerVat.submitUserOp).not.toHaveBeenCalled(); + }); + it('falls back to broadcast when no matching delegation', async () => { await coordinator.initializeKeyring({ type: 'srp', @@ -995,7 +1098,94 @@ describe('coordinator-vat', () => { expect(providerVat.submitUserOp).toHaveBeenCalled(); }); - it('throws when bundler not configured', async () => { + it('revokes via direct 7702 tx and polls eth_getTransactionReceipt', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + // Set up 7702 smart account (already delegated) + providerVat.request.mockResolvedValueOnce( + '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', + ); + await coordinator.createSmartAccount({ + chainId: 11155111, + implementation: 'stateless7702', + }); + + const delegation = await coordinator.createDelegation({ + delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, + caveats: [], + chainId: 11155111, + }); + + // Mock: direct tx receipt found immediately + providerVat.request.mockImplementation(async (method: string) => { + if (method === 'eth_getTransactionReceipt') { + return { status: '0x1', transactionHash: '0xabc' }; + } + if (method === 'eth_estimateGas') { + return '0x5208' as Hex; + } + return undefined; + }); + + const hash = await coordinator.revokeDelegation(delegation.id); + expect(hash).toBe('0xtxhash'); + expect(providerVat.submitUserOp).not.toHaveBeenCalled(); + expect(providerVat.broadcastTransaction).toHaveBeenCalled(); + + // Verify local status is revoked + const delegations = await coordinator.listDelegations(); + const found = (delegations as Delegation[]).find( + (entry) => entry.id === delegation.id, + ); + expect(found?.status).toBe('revoked'); + }); + + it('throws when 7702 direct revocation reverts on-chain', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + providerVat.request.mockResolvedValueOnce( + '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', + ); + await coordinator.createSmartAccount({ + chainId: 11155111, + implementation: 'stateless7702', + }); + + const delegation = await coordinator.createDelegation({ + delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, + caveats: [], + chainId: 11155111, + }); + + providerVat.request.mockImplementation(async (method: string) => { + if (method === 'eth_getTransactionReceipt') { + return { status: '0x0' }; + } + if (method === 'eth_estimateGas') { + return '0x5208' as Hex; + } + return undefined; + }); + + await expect(coordinator.revokeDelegation(delegation.id)).rejects.toThrow( + 'On-chain revocation reverted', + ); + + // Local status must NOT be updated + const delegations = await coordinator.listDelegations(); + const found = (delegations as Delegation[]).find( + (entry) => entry.id === delegation.id, + ); + expect(found?.status).toBe('signed'); + }); + + it('throws when bundler not configured for hybrid account', async () => { await coordinator.initializeKeyring({ type: 'srp', mnemonic: TEST_MNEMONIC, @@ -1008,7 +1198,7 @@ describe('coordinator-vat', () => { }); await expect(coordinator.revokeDelegation(delegation.id)).rejects.toThrow( - 'Bundler not configured', + 'Failed to submit on-chain revocation', ); }); @@ -1198,7 +1388,7 @@ describe('coordinator-vat', () => { hasExternalSigner: false, hasBundlerConfig: false, smartAccountAddress: undefined, - chainId: undefined, + chainId: 1, signingMode: 'local', autonomy: 'no signing authority', peerAccountsCached: false, @@ -1206,6 +1396,40 @@ describe('coordinator-vat', () => { hasAwayWallet: false, }); }); + + it('reports autonomy for 7702 with delegations and no bundler', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + // Set up 7702 smart account (already delegated) — no bundler + providerVat.request.mockResolvedValueOnce( + '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', + ); + await coordinator.createSmartAccount({ + chainId: 11155111, + implementation: 'stateless7702', + }); + + // Create a delegation + await coordinator.createDelegation({ + delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, + caveats: [], + chainId: 11155111, + }); + + // Configure provider so cachedProviderChainId is set to 11155111 + await coordinator.configureProvider({ + rpcUrl: 'https://sepolia.infura.io/v3/test', + chainId: 11155111, + }); + + const caps = await coordinator.getCapabilities(); + expect(caps.hasBundlerConfig).toBe(false); + expect(caps.autonomy).toMatch(/^autonomous/u); + expect(caps.chainId).toBe(11155111); + }); }); describe('handleSigningRequest', () => { @@ -2837,6 +3061,127 @@ describe('coordinator-vat', () => { }); }); + describe('waitForTransactionReceipt', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns success when receipt is found', async () => { + providerVat.request.mockResolvedValueOnce({ + status: '0x1', + transactionHash: '0xabc', + }); + + const result = await coordinator.waitForTransactionReceipt({ + txHash: '0xdeadbeef' as Hex, + }); + expect(result).toStrictEqual({ success: true }); + expect(providerVat.request).toHaveBeenCalledWith( + 'eth_getTransactionReceipt', + ['0xdeadbeef'], + ); + }); + + it('polls until receipt appears', async () => { + vi.useFakeTimers(); + + providerVat.request + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ status: '0x1' }); + + const resultPromise = coordinator.waitForTransactionReceipt({ + txHash: '0xabc' as Hex, + pollIntervalMs: 100, + }); + + await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(100); + + const result = await resultPromise; + expect(result.success).toBe(true); + expect(providerVat.request).toHaveBeenCalledTimes(2); + }); + + it('returns success: false for reverted transaction', async () => { + providerVat.request.mockResolvedValueOnce({ + status: '0x0', + transactionHash: '0xabc', + }); + + const result = await coordinator.waitForTransactionReceipt({ + txHash: '0xdeadbeef' as Hex, + }); + expect(result).toStrictEqual({ success: false }); + }); + + it('normalizes numeric status from provider', async () => { + providerVat.request.mockResolvedValueOnce({ + status: 1, + transactionHash: '0xabc', + }); + + const result = await coordinator.waitForTransactionReceipt({ + txHash: '0xdeadbeef' as Hex, + }); + expect(result).toStrictEqual({ success: true }); + }); + + it('treats missing status as success', async () => { + providerVat.request.mockResolvedValueOnce({ + transactionHash: '0xabc', + }); + + const result = await coordinator.waitForTransactionReceipt({ + txHash: '0xdeadbeef' as Hex, + }); + expect(result).toStrictEqual({ success: true }); + }); + + it('retries on transient RPC errors during polling', async () => { + providerVat.request + .mockRejectedValueOnce(new Error('network error')) + .mockResolvedValueOnce({ status: '0x1' }); + + const result = await coordinator.waitForTransactionReceipt({ + txHash: '0xabc' as Hex, + pollIntervalMs: 10, + }); + expect(result.success).toBe(true); + expect(providerVat.request).toHaveBeenCalledTimes(2); + }); + + it('throws on timeout when receipt never appears', async () => { + vi.useFakeTimers(); + + providerVat.request.mockResolvedValue(null); + + const resultPromise = coordinator.waitForTransactionReceipt({ + txHash: '0xabc' as Hex, + pollIntervalMs: 50, + timeoutMs: 200, + }); + + // Advance past the timeout + await vi.advanceTimersByTimeAsync(300); + + await expect(resultPromise).rejects.toThrow('not mined after'); + }); + + it('throws when provider is not configured', async () => { + const freshBaggage = makeMockBaggage(); + const coord = buildRootObject( + {}, + undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + freshBaggage as any, + ); + + await expect( + coord.waitForTransactionReceipt({ txHash: '0xdeadbeef' as Hex }), + ).rejects.toThrow('Provider not configured'); + }); + }); + describe('getTransactionReceipt', () => { beforeEach(async () => { await coordinator.initializeKeyring({ @@ -3464,13 +3809,13 @@ describe('coordinator-vat', () => { }); }); - describe('submitDelegationUserOp (stateless7702 signing)', () => { + describe('redeemDelegation (stateless7702 direct tx)', () => { /** - * Set up a 7702 smart account (already-delegated path) and configure - * a bundler. + * Set up a 7702 smart account (already-delegated path). Optionally + * configure a bundler (unused for redemption on the direct 7702 path). * * @param options - Setup options. - * @param options.usePaymaster - Whether to enable paymaster sponsorship. + * @param options.usePaymaster - Whether to enable paymaster on bundler config. * @returns The EOA address. */ async function setup7702WithBundler(options?: { @@ -3502,7 +3847,7 @@ describe('coordinator-vat', () => { return eoaAddress; } - it('uses EIP-712 typed data with 7702 domain name', async () => { + it('broadcasts a self-call EIP-1559 tx instead of a UserOp', async () => { const eoaAddress = await setup7702WithBundler(); // Create a delegation @@ -3528,19 +3873,16 @@ describe('coordinator-vat', () => { maxPriorityFeePerGas: '0x3b9aca00' as Hex, }); - expect(result).toBe('0xuserophash'); - - // Verify UserOp was submitted with the EOA address as sender - const submitCall = providerVat.submitUserOp.mock.calls[0][0]; - expect(submitCall.userOp.sender).toBe(eoaAddress); - expect(submitCall.userOp.signature).toMatch(/^0x/u); - expect(submitCall.userOp.signature).not.toBe('0x'); - // No factory should be included for 7702 accounts - expect(submitCall.userOp.factory).toBeUndefined(); - expect(submitCall.userOp.factoryData).toBeUndefined(); + expect(result).toBe('0xtxhash'); + expect(providerVat.submitUserOp).not.toHaveBeenCalled(); + expect(providerVat.broadcastTransaction).toHaveBeenCalledOnce(); + const signedRaw = providerVat.broadcastTransaction.mock + .calls[0][0] as Hex; + expect(typeof signedRaw).toBe('string'); + expect(signedRaw.startsWith('0x')).toBe(true); }); - it('produces a different signature than hybrid (typed data) signing', async () => { + it('uses direct broadcast for 7702 and UserOp submission for hybrid', async () => { // --- 7702 path --- const eoa7702 = await setup7702WithBundler(); @@ -3561,8 +3903,8 @@ describe('coordinator-vat', () => { maxPriorityFeePerGas: '0x3b9aca00' as Hex, }); - const sig7702 = - providerVat.submitUserOp.mock.calls[0][0].userOp.signature; + expect(providerVat.broadcastTransaction).toHaveBeenCalled(); + expect(providerVat.submitUserOp).not.toHaveBeenCalled(); // --- Hybrid path (fresh coordinator) --- const hybridBaggage = makeMockBaggage(); @@ -3630,15 +3972,13 @@ describe('coordinator-vat', () => { maxPriorityFeePerGas: '0x3b9aca00' as Hex, }); + expect(hybridProvider.submitUserOp).toHaveBeenCalled(); const sigHybrid = hybridProvider.submitUserOp.mock.calls[0][0].userOp.signature; - - // The two signatures must differ: 7702 uses EIP-712 with domain - // name 'EIP7702StatelessDeleGator', hybrid uses 'HybridDeleGator'. - expect(sig7702).not.toBe(sigHybrid); + expect(sigHybrid).toMatch(/^0x/u); }); - it('works with paymaster sponsorship', async () => { + it('does not use paymaster or UserOps for stateless7702 redemption', async () => { const eoaAddress = await setup7702WithBundler({ usePaymaster: true }); const delegation = await coordinator.createDelegation({ @@ -3658,16 +3998,10 @@ describe('coordinator-vat', () => { maxPriorityFeePerGas: '0x3b9aca00' as Hex, }); - expect(result).toBe('0xuserophash'); - expect(providerVat.sponsorUserOp).toHaveBeenCalled(); - expect(providerVat.estimateUserOpGas).not.toHaveBeenCalled(); - - const submitCall = providerVat.submitUserOp.mock.calls[0][0]; - expect(submitCall.userOp.paymaster).toBe( - '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - ); - expect(submitCall.userOp.sender).toBe(eoaAddress); - expect(submitCall.userOp.factory).toBeUndefined(); + expect(result).toBe('0xtxhash'); + expect(providerVat.sponsorUserOp).not.toHaveBeenCalled(); + expect(providerVat.submitUserOp).not.toHaveBeenCalled(); + expect(providerVat.broadcastTransaction).toHaveBeenCalled(); }); }); @@ -3801,6 +4135,141 @@ describe('coordinator-vat', () => { expect(result).toBe('0xdirectbatch'); expect(providerVat.submitUserOp).toHaveBeenCalledOnce(); }); + + it('batches via delegation redemption using direct 7702 when no bundler', async () => { + // Set up 7702 smart account — no bundler + providerVat.request.mockResolvedValueOnce( + '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', + ); + await coordinator.createSmartAccount({ + chainId: 11155111, + implementation: 'stateless7702', + }); + + await coordinator.configureProvider({ + rpcUrl: 'https://sepolia.infura.io/v3/test', + chainId: 11155111, + }); + + const accounts = await coordinator.getAccounts(); + const delegator = accounts[0] as Address; + const target1 = '0x1111111111111111111111111111111111111111' as Address; + const target2 = '0x2222222222222222222222222222222222222222' as Address; + + await coordinator.createDelegation({ + delegate: delegator, + caveats: [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([target1, target2]), + }), + ], + chainId: 11155111, + }); + + providerVat.request.mockImplementation(async (method: string) => { + if (method === 'eth_estimateGas') { + return '0x5208' as Hex; + } + return undefined; + }); + + const result = await coordinator.sendBatchTransaction([ + { from: delegator, to: target1, value: '0x1' as Hex }, + { from: delegator, to: target2, value: '0x2' as Hex }, + ]); + + expect(result).toBe('0xtxhash'); + expect(providerVat.submitUserOp).not.toHaveBeenCalled(); + expect(providerVat.broadcastTransaction).toHaveBeenCalledOnce(); + }); + + it('rejects batch to disallowed targets when delegations exist', async () => { + // Set up 7702 smart account — no bundler + providerVat.request.mockResolvedValueOnce( + '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', + ); + await coordinator.createSmartAccount({ + chainId: 11155111, + implementation: 'stateless7702', + }); + + await coordinator.configureProvider({ + rpcUrl: 'https://sepolia.infura.io/v3/test', + chainId: 11155111, + }); + + const accounts = await coordinator.getAccounts(); + const delegator = accounts[0] as Address; + + // Delegation only allows TARGET + await coordinator.createDelegation({ + delegate: delegator, + caveats: [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([TARGET]), + }), + ], + chainId: 11155111, + }); + + // Batch to disallowed targets must NOT bypass caveat enforcement + await expect( + coordinator.sendBatchTransaction([ + { + from: delegator, + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Address, + value: '0x1' as Hex, + }, + { + from: delegator, + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Address, + value: '0x2' as Hex, + }, + ]), + ).rejects.toThrow('No single delegation covers all'); + expect(providerVat.broadcastTransaction).not.toHaveBeenCalled(); + expect(providerVat.submitUserOp).not.toHaveBeenCalled(); + }); + + it('batches via direct EIP-1559 when stateless7702 has no bundler', async () => { + providerVat.request.mockResolvedValueOnce( + '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', + ); + await coordinator.createSmartAccount({ + chainId: 11155111, + implementation: 'stateless7702', + }); + + providerVat.request.mockImplementation(async (method: string) => { + if (method === 'eth_getCode') { + return '0x'; + } + if (method === 'eth_estimateGas') { + return '0x5208' as Hex; + } + return undefined; + }); + + const accounts = await coordinator.getAccounts(); + const result = await coordinator.sendBatchTransaction([ + { + from: accounts[0] as Address, + to: '0x1111111111111111111111111111111111111111' as Address, + value: '0x1' as Hex, + }, + { + from: accounts[0] as Address, + to: '0x2222222222222222222222222222222222222222' as Address, + value: '0x2' as Hex, + }, + ]); + + expect(result).toBe('0xtxhash'); + expect(providerVat.submitUserOp).not.toHaveBeenCalled(); + expect(providerVat.broadcastTransaction).toHaveBeenCalledOnce(); + }); }); describe('getSwapQuote', () => { diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts index f1998b198..59b918483 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts @@ -413,6 +413,187 @@ export function buildRootObject( restoreFromBaggage('cachedPeerAccounts') ?? []; cachedPeerSigningMode = restoreFromBaggage('cachedPeerSigningMode'); + /** Chain ID from the last `configureProvider` call (avoids RPC on every send). */ + let cachedProviderChainId: number | undefined = restoreFromBaggage( + 'cachedProviderChainId', + ); + + /** + * Resolve the wallet chain ID for delegation matching, SDK addresses, and txs. + * + * Order: bundler config → cached provider config → `eth_chainId` RPC. + * + * @returns The resolved chain ID. + */ + async function resolveChainId(): Promise { + if (bundlerConfig?.chainId !== undefined) { + return bundlerConfig.chainId; + } + if (cachedProviderChainId !== undefined) { + return cachedProviderChainId; + } + if (!providerVat) { + throw new Error( + 'Provider not configured — call configureProvider() first', + ); + } + return E(providerVat).getChainId(); + } + + /** + * Whether smart-account operations for this sender should use Infura-style + * raw transactions (stateless 7702) instead of ERC-4337 UserOps. + * + * @param sender - Smart account address (same as EOA for stateless 7702). + * @returns True when direct EIP-1559 submission should be used. + */ + async function useDirect7702Tx(sender: Address): Promise { + if (smartAccountConfig?.implementation === 'stateless7702') { + if ( + smartAccountConfig.address !== undefined && + smartAccountConfig.address.toLowerCase() !== sender.toLowerCase() + ) { + // Config points at a different account — fall through to lazy check. + } else { + return true; + } + } + if (smartAccountConfig?.implementation === 'hybrid') { + return false; + } + if (!providerVat) { + throw new Error( + 'Cannot determine account type: provider not configured and ' + + 'smartAccountConfig is absent. Call configureProvider() first.', + ); + } + const code = (await E(providerVat).request('eth_getCode', [ + sender, + 'latest', + ])) as string; + const chainId = await resolveChainId(); + return isEip7702Delegated(code, chainId); + } + + /** + * Sign and broadcast a self-call tx with SDK-encoded DeleGator calldata + * (7702 EOA). Returns the transaction hash immediately after broadcast. + * + * @param options - Direct submission options. + * @param options.sender - Upgraded EOA / smart account address. + * @param options.callData - SDK-wrapped `execute` calldata. + * @param options.maxFeePerGas - Optional max fee per gas override. + * @param options.maxPriorityFeePerGas - Optional priority fee override. + * @returns The transaction hash from `eth_sendRawTransaction`. + */ + async function buildAndSubmitDirect7702Tx(options: { + sender: Address; + callData: Hex; + maxFeePerGas?: Hex; + maxPriorityFeePerGas?: Hex; + }): Promise { + if (!providerVat) { + throw new Error('Provider vat not available'); + } + const chainId = await resolveChainId(); + let { maxFeePerGas, maxPriorityFeePerGas } = options; + if (!maxFeePerGas || !maxPriorityFeePerGas) { + const fees = await E(providerVat).getGasFees(); + maxFeePerGas = maxFeePerGas ?? fees.maxFeePerGas; + maxPriorityFeePerGas = maxPriorityFeePerGas ?? fees.maxPriorityFeePerGas; + } + const nonce = await E(providerVat).getNonce(options.sender); + const estimatedGas = validateGasEstimate( + await E(providerVat).request('eth_estimateGas', [ + { + from: options.sender, + to: options.sender, + data: options.callData, + }, + ]), + ); + const gasLimit = applyGasBuffer(estimatedGas, 10); + const filledTx: TransactionRequest = { + from: options.sender, + to: options.sender, + chainId, + nonce, + maxFeePerGas, + maxPriorityFeePerGas, + gasLimit, + data: options.callData, + value: '0x0' as Hex, + }; + const signedTx = await resolveTransactionSigning(filledTx); + return E(providerVat).broadcastTransaction(signedTx); + } + + /** + * Poll until an EIP-1559 transaction is mined or timeout. + * + * @param options - Polling options. + * @param options.txHash - Transaction hash to wait for. + * @param options.pollIntervalMs - Delay between RPC polls in milliseconds. + * @param options.timeoutMs - Maximum time to wait in milliseconds. + * @returns Whether the mined transaction succeeded (`status` 0x1). + */ + async function pollTransactionReceipt(options: { + txHash: Hex; + pollIntervalMs?: number; + timeoutMs?: number; + }): Promise<{ success: boolean }> { + if (!providerVat) { + throw new Error('Provider not configured'); + } + if ( + typeof globalThis.Date?.now !== 'function' || + typeof globalThis.setTimeout !== 'function' + ) { + throw new Error( + 'Transaction receipt polling requires Date.now and setTimeout', + ); + } + const interval = options.pollIntervalMs ?? 2000; + const timeout = options.timeoutMs ?? 120_000; + const start = Date.now(); + while (Date.now() - start < timeout) { + let receipt: { status?: string | number } | null = null; + try { + receipt = (await E(providerVat).request('eth_getTransactionReceipt', [ + options.txHash, + ])) as { status?: string | number } | null; + } catch (error) { + // Transient RPC errors (network hiccups, rate limits) should not + // abort polling — the tx was already broadcast and may still mine. + logger.warn( + `RPC error polling receipt for ${options.txHash}, will retry`, + error, + ); + await new Promise((resolve) => setTimeout(resolve, interval)); + continue; + } + if (receipt) { + // Normalize: some providers return status as a number (1) rather + // than the standard hex string ('0x1'). EIP-1559 receipts must have + // a status field; a missing one likely indicates a malformed response. + const { status } = receipt; + if (status === undefined || status === null) { + logger.warn( + `Receipt for ${options.txHash} has no status field — assuming success`, + ); + return harden({ success: true }); + } + const normalizedStatus = + typeof status === 'number' ? `0x${status.toString(16)}` : status; + return harden({ success: normalizedStatus === '0x1' }); + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + throw new Error( + `Transaction ${options.txHash} not mined after ${String(timeout)}ms`, + ); + } + /** * Check if an address belongs to the cached peer (home) accounts. * @@ -861,19 +1042,31 @@ export function buildRootObject( maxFeePerGas?: Hex | undefined; maxPriorityFeePerGas?: Hex | undefined; }): Promise { - if (!bundlerConfig) { - throw new Error('Bundler not configured'); - } - const sender = smartAccountConfig?.address ?? options.delegations[0].delegate; + const chainId = await resolveChainId(); const sdkCallData = buildSdkRedeemCallData({ delegations: options.delegations, execution: options.execution, - chainId: bundlerConfig.chainId, + chainId, }); + if (await useDirect7702Tx(sender)) { + return buildAndSubmitDirect7702Tx({ + sender, + callData: sdkCallData, + maxFeePerGas: options.maxFeePerGas, + maxPriorityFeePerGas: options.maxPriorityFeePerGas, + }); + } + + if (!bundlerConfig) { + throw new Error( + 'Bundler not configured (required for hybrid smart account redemption)', + ); + } + const userOpOptions: { sender: Address; callData: Hex; @@ -904,54 +1097,74 @@ export function buildRootObject( delegations: Delegation[]; executions: Execution[]; }): Promise { - if (!bundlerConfig) { - throw new Error('Bundler not configured'); - } - const sender = smartAccountConfig?.address ?? options.delegations[0]?.delegate; if (!sender) { throw new Error('No sender address available for batch delegation'); } + const chainId = await resolveChainId(); const sdkCallData = buildSdkBatchRedeemCallData({ delegations: options.delegations, executions: options.executions, - chainId: bundlerConfig.chainId, + chainId, }); + if (await useDirect7702Tx(sender)) { + return buildAndSubmitDirect7702Tx({ sender, callData: sdkCallData }); + } + + if (!bundlerConfig) { + throw new Error( + 'Bundler not configured (required for hybrid smart account batch redemption)', + ); + } + return buildAndSubmitUserOp({ sender, callData: sdkCallData }); } /** - * Submit a UserOp that calls `DelegationManager.disableDelegation` to - * revoke a delegation on-chain. The UserOp is sent from the delegator's - * smart account. + * Submit a transaction that calls `DelegationManager.disableDelegation` to + * revoke a delegation on-chain — either via a direct EIP-1559 tx (7702) or + * an ERC-4337 UserOp (hybrid). * * @param delegation - The delegation to disable. - * @returns The UserOp hash from the bundler. + * @returns The hash and whether the direct 7702 path was used. */ - async function submitDisableUserOp(delegation: Delegation): Promise { - if (!bundlerConfig) { - throw new Error('Bundler not configured'); - } - + async function submitDisableUserOp( + delegation: Delegation, + ): Promise<{ hash: Hex; isDirect: boolean }> { const sender = smartAccountConfig?.address ?? delegation.delegator; + const chainId = await resolveChainId(); const disableCallData = buildSdkDisableCallData({ delegation, - chainId: bundlerConfig.chainId, + chainId, }); try { - return await buildAndSubmitUserOp({ + const isDirect = await useDirect7702Tx(sender); + if (isDirect) { + const hash = await buildAndSubmitDirect7702Tx({ + sender, + callData: disableCallData, + }); + return { hash, isDirect: true }; + } + if (!bundlerConfig) { + throw new Error( + 'Bundler not configured (required for hybrid on-chain revocation)', + ); + } + const hash = await buildAndSubmitUserOp({ sender, callData: disableCallData, }); + return { hash, isDirect: false }; } catch (error) { - const message = error instanceof Error ? error.message : String(error); throw new Error( - `Failed to submit on-chain revocation for delegator ${delegation.delegator}: ${message}`, + `Failed to submit on-chain revocation for delegator ${delegation.delegator}`, + { cause: error }, ); } } @@ -1227,6 +1440,9 @@ export function buildRootObject( } await E(providerVat).configure(chainConfig); + + cachedProviderChainId = chainConfig.chainId; + persistBaggage('cachedProviderChainId', cachedProviderChainId); }, // ------------------------------------------------------------------ @@ -1405,8 +1621,11 @@ export function buildRootObject( throw new Error('Provider not configured'); } - // Check if a delegation covers this action and bundler is configured - if (delegationVat && bundlerConfig) { + // Enforce delegations whenever the delegation vat exists (bundler optional + // for 7702). Delegations are a security boundary — if we cannot resolve the + // chain ID we must fail rather than silently bypassing caveat enforcement. + if (delegationVat) { + const walletChainId = await resolveChainId(); const action: Action = { to: tx.to, value: tx.value, @@ -1415,7 +1634,7 @@ export function buildRootObject( const now = Date.now(); const delegation = await E(delegationVat).findDelegationForAction( action, - bundlerConfig.chainId, + walletChainId, now, ); @@ -1441,7 +1660,7 @@ export function buildRootObject( // No delegation matched — explain why before falling through const explanations = await E(delegationVat).explainActionMatch( action, - bundlerConfig.chainId, + walletChainId, now, ); if (explanations.length > 0) { @@ -1493,21 +1712,36 @@ export function buildRootObject( } if (txs.length === 1) { - return coordinator.sendTransaction(txs[0] as TransactionRequest); + return coordinator.sendTransaction(txs[0]); } if (!providerVat) { throw new Error('Provider not configured'); } - // Smart account path: batch into a single UserOp - if (bundlerConfig) { + const batchSender = + smartAccountConfig?.address ?? (await coordinator.getAccounts())[0]; + + // Cache the predicate result — useDirect7702Tx is impure (eth_getCode) + // and must not be called twice for the same sender (see revokeDelegation). + const isDirect7702Batch = + batchSender !== undefined && + smartAccountConfig?.implementation === 'stateless7702' && + (await useDirect7702Tx(batchSender)); + + const useSmartAccountBatchPath = + bundlerConfig !== undefined || isDirect7702Batch; + + // Smart account path: single UserOp or direct 7702 self-call + if (useSmartAccountBatchPath) { const executions: Execution[] = txs.map((tx) => ({ target: tx.to, value: tx.value ?? ('0x0' as Hex), callData: tx.data ?? ('0x' as Hex), })); + const walletChainId = await resolveChainId(); + // Delegation path: batch via redeemDelegations with BatchDefault mode. // Validate that the delegation covers ALL actions in the batch, // not just the first one, to avoid on-chain reverts. @@ -1523,7 +1757,7 @@ export function buildRootObject( for (const action of actions) { const found = await E(delegationVat).findDelegationForAction( action, - bundlerConfig.chainId, + walletChainId, now, ); if (!found || found.status !== 'signed') { @@ -1544,16 +1778,43 @@ export function buildRootObject( executions, }); } + + // No single delegation covers all batch actions — check whether + // delegations exist that partially match. If so, block the batch + // (same enforcement as sendTransaction) to prevent bypassing caveats + // via the direct execute path. + for (const action of actions) { + const explanations = await E(delegationVat).explainActionMatch( + action, + walletChainId, + now, + ); + if (explanations.length > 0) { + throw new Error( + buildDelegationMismatchError( + explanations, + `No single delegation covers all ${String(actions.length)} batch actions`, + ), + ); + } + } } // Direct smart account batch (no delegation) - const sender = - smartAccountConfig?.address ?? (await coordinator.getAccounts())[0]; + const sender = batchSender; if (!sender) { throw new Error('No accounts available for batch'); } const callData = buildBatchExecuteCallData({ executions }); + if (isDirect7702Batch) { + return buildAndSubmitDirect7702Tx({ sender, callData }); + } + if (!bundlerConfig) { + throw new Error( + 'Bundler not configured (required for hybrid smart account batch execution)', + ); + } return buildAndSubmitUserOp({ sender, callData }); } @@ -1732,15 +1993,15 @@ export function buildRootObject( }, /** - * Revoke a delegation on-chain by submitting a UserOp that calls - * `DelegationManager.disableDelegation`. Blocks until the UserOp is - * confirmed on-chain, then updates the local delegation status. + * Revoke a delegation on-chain by calling `DelegationManager.disableDelegation` + * via a UserOp (hybrid) or a direct EIP-1559 transaction (stateless 7702). + * Blocks until the transaction is confirmed on-chain, then updates the local + * delegation status. * - * Requires the bundler to be configured. The delegator's smart account - * submits the UserOp (gas is covered by the paymaster if configured). + * Hybrid accounts require a configured bundler (paymaster optional). * * @param id - The delegation identifier. - * @returns The UserOp hash of the on-chain revocation transaction. + * @returns The UserOp hash or transaction hash of the on-chain revocation. */ async revokeDelegation(id: string): Promise { if (!delegationVat) { @@ -1773,23 +2034,49 @@ export function buildRootObject( ); } - // Submit on-chain disable - const userOpHash = await submitDisableUserOp(delegation); + // Submit on-chain disable — returns the hash and which path was used + // so we poll the right receipt endpoint without calling useDirect7702Tx + // a second time (the predicate is impure due to eth_getCode). + const { hash: submissionHash, isDirect } = + await submitDisableUserOp(delegation); - // Wait for on-chain confirmation and check success - const receipt = (await coordinator.waitForUserOpReceipt({ - userOpHash, - })) as { success?: boolean } | null; - if (receipt && receipt.success === false) { - throw new Error( - `On-chain revocation reverted for delegation ${id} (userOpHash: ${userOpHash})`, - ); + if (isDirect) { + const receipt = await pollTransactionReceipt({ + txHash: submissionHash, + }); + if (!receipt.success) { + throw new Error( + `On-chain revocation reverted for delegation ${id} (tx: ${submissionHash})`, + ); + } + } else { + // waitForUserOpReceipt either returns a non-null receipt or throws + // on timeout — validate the shape to catch unexpected bundler responses. + const rawReceipt = await coordinator.waitForUserOpReceipt({ + userOpHash: submissionHash, + }); + const receipt = rawReceipt as { success?: boolean } | undefined; + if ( + !receipt || + typeof receipt !== 'object' || + !('success' in receipt) + ) { + throw new Error( + `Unexpected UserOp receipt format for delegation ${id} ` + + `(userOpHash: ${submissionHash})`, + ); + } + if (!receipt.success) { + throw new Error( + `On-chain revocation reverted for delegation ${id} (userOpHash: ${submissionHash})`, + ); + } } // Update local status after on-chain confirmation await E(delegationVat).revokeDelegation(id); - return userOpHash; + return submissionHash; }, async listDelegations(): Promise { @@ -1827,16 +2114,18 @@ export function buildRootObject( ); delegations = [delegation]; } else if (options.action) { + // Only resolve chain ID when needed for delegation matching + const walletChainId = await resolveChainId(); const now = Date.now(); const delegation = await E(delegationVat).findDelegationForAction( options.action, - bundlerConfig?.chainId, + walletChainId, now, ); if (!delegation) { const explanations = await E(delegationVat).explainActionMatch( options.action, - bundlerConfig?.chainId, + walletChainId, now, ); throw new Error( @@ -2014,8 +2303,7 @@ export function buildRootObject( throw new Error('No accounts available'); } - const chainId = - bundlerConfig?.chainId ?? (await E(providerVat).getChainId()); + const chainId = await resolveChainId(); const rawAmount = BigInt(options.srcAmount).toString(); @@ -2231,6 +2519,24 @@ export function buildRootObject( ); }, + /** + * Poll until a regular EIP-1559 transaction is mined (e.g. stateless 7702 + * direct sends). Prefer `waitForUserOpReceipt` for ERC-4337 UserOp hashes. + * + * @param options - Polling options. + * @param options.txHash - Transaction hash to wait for. + * @param options.pollIntervalMs - Delay between RPC polls in milliseconds. + * @param options.timeoutMs - Maximum time to wait in milliseconds. + * @returns Whether the mined transaction succeeded (`status` 0x1). + */ + async waitForTransactionReceipt(options: { + txHash: Hex; + pollIntervalMs?: number; + timeoutMs?: number; + }): Promise<{ success: boolean }> { + return pollTransactionReceipt(options); + }, + // ------------------------------------------------------------------ // Peer wallet connectivity // ------------------------------------------------------------------ @@ -2459,8 +2765,13 @@ export function buildRootObject( // Determine the agent's autonomy level based on delegations. // When delegations exist, the agent can send ETH within the // delegation's limits without requiring further user approval. + // Stateless 7702 can redeem via direct RPC without a bundler. let autonomy: string; - if (activeDelegations.length > 0 && bundlerConfig) { + const canRedeemDelegationsOnChain = + activeDelegations.length > 0 && + (bundlerConfig !== undefined || + smartAccountConfig?.implementation === 'stateless7702'); + if (canRedeemDelegationsOnChain) { const limits = activeDelegations .flatMap((del) => del.caveats) .map(describeCaveat) @@ -2477,6 +2788,13 @@ export function buildRootObject( autonomy = 'no signing authority'; } + let capabilityChainId: number | undefined; + try { + capabilityChainId = await resolveChainId(); + } catch (error) { + logger.warn('Failed to resolve chain ID for capabilities', error); + } + return harden({ hasLocalKeys, localAccounts, @@ -2486,7 +2804,7 @@ export function buildRootObject( hasExternalSigner: externalSigner !== undefined, hasBundlerConfig: bundlerConfig !== undefined, smartAccountAddress: smartAccountConfig?.address, - chainId: bundlerConfig?.chainId, + chainId: capabilityChainId, signingMode, autonomy, peerAccountsCached: cachedPeerAccounts.length > 0, diff --git a/packages/evm-wallet-experiment/test/e2e/run-sepolia-7702-direct-e2e.mjs b/packages/evm-wallet-experiment/test/e2e/run-sepolia-7702-direct-e2e.mjs new file mode 100644 index 000000000..4ffd2f027 --- /dev/null +++ b/packages/evm-wallet-experiment/test/e2e/run-sepolia-7702-direct-e2e.mjs @@ -0,0 +1,188 @@ +/* eslint-disable n/no-process-exit, import-x/no-unresolved, n/no-process-env */ +/** + * Sepolia E2E: stateless7702 delegation redemption via direct EIP-1559 tx (no bundler). + * + * Requires: + * SEPOLIA_RPC_URL - Sepolia JSON-RPC (e.g. Infura) + * TEST_MNEMONIC - Funded mnemonic (defaults to same env as run-sepolia-e2e) + * + * Does not use Pimlico. Skips when SEPOLIA_RPC_URL is unset. + * + * Usage: + * SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/xxx TEST_MNEMONIC="..." \ + * yarn workspace @ocap/evm-wallet-experiment test:node:sepolia-7702-direct + */ + +import '@metamask/kernel-shims/endoify-node'; + +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 { makeWalletClusterConfig } from '../../src/cluster-config.ts'; +import { getDelegationManagerAddress } from '../../src/lib/sdk.ts'; + +const { SEPOLIA_RPC_URL } = process.env; + +if (!SEPOLIA_RPC_URL) { + console.log('\nSkipping 7702 direct E2E: set SEPOLIA_RPC_URL\n'); + process.exit(0); +} + +const SEPOLIA_CHAIN_ID = 11155111; +const TEST_MNEMONIC = + process.env.TEST_MNEMONIC || + 'describe vote fluid circle capable include endless leopard clarify copper industry address'; + +const BUNDLE_BASE_URL = new URL('../../src/vats', import.meta.url).toString(); +const TX_TIMEOUT_MS = 120_000; + +let passed = 0; +let failed = 0; + +function assert(condition, label) { + if (condition) { + passed += 1; + console.log(` ✓ ${label}`); + } else { + failed += 1; + console.error(` ✗ ${label}`); + } +} + +async function call( + kernel, + target, + method, + args = [], + timeout = TX_TIMEOUT_MS, +) { + const resultP = kernel.queueMessage(target, method, args); + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const winner = await Promise.race([ + resultP.then((capData) => ({ done: true, capData })), + new Promise((resolve) => setTimeout(() => resolve({ done: false }), 500)), + ]); + if (winner.done) { + await waitUntilQuiescent(); + return kunser(winner.capData); + } + await waitUntilQuiescent(); + } + throw new Error(`call(${method}) timed out after ${timeout}ms`); +} + +async function main() { + console.log('\n=== Sepolia E2E: 7702 direct (no bundler) ===\n'); + + const rpcHost = new URL(SEPOLIA_RPC_URL).hostname; + + const kernelDb = await makeSQLKernelDatabase({ dbFilename: ':memory:' }); + const kernel = await Kernel.make(new NodejsPlatformServices({}), kernelDb, { + resetStorage: true, + }); + + const delegationManagerAddress = + getDelegationManagerAddress(SEPOLIA_CHAIN_ID); + + const walletConfig = makeWalletClusterConfig({ + bundleBaseUrl: BUNDLE_BASE_URL, + allowedHosts: [rpcHost], + delegationManagerAddress, + }); + const { rootKref } = await kernel.launchSubcluster(walletConfig); + await waitUntilQuiescent(); + + await call(kernel, rootKref, 'initializeKeyring', [ + { type: 'srp', mnemonic: TEST_MNEMONIC }, + ]); + const accounts = await call(kernel, rootKref, 'getAccounts'); + assert(accounts.length > 0, `EOA: ${accounts[0]}`); + + await call(kernel, rootKref, 'configureProvider', [ + { chainId: SEPOLIA_CHAIN_ID, rpcUrl: SEPOLIA_RPC_URL }, + ]); + assert(true, 'provider configured'); + + // 7702 upgrade (broadcasts auth tx; waits for receipt inside vat) + console.log('\n--- Create stateless7702 smart account ---'); + const smartConfig = await call( + kernel, + rootKref, + 'createSmartAccount', + [{ chainId: SEPOLIA_CHAIN_ID, implementation: 'stateless7702' }], + 180_000, + ); + assert( + smartConfig.implementation === 'stateless7702', + 'implementation: stateless7702', + ); + assert(accounts[0] === smartConfig.address, 'same address as EOA'); + + const delegation = await call(kernel, rootKref, 'createDelegation', [ + { + delegate: smartConfig.address, + caveats: [], + chainId: SEPOLIA_CHAIN_ID, + }, + ]); + assert(delegation.status === 'signed', 'delegation signed'); + + console.log('\n--- Redeem via direct tx (no bundler) ---'); + const txHash = await call(kernel, rootKref, 'redeemDelegation', [ + { + execution: { + target: smartConfig.address, + value: '0x0', + callData: '0x', + }, + delegationId: delegation.id, + }, + ]); + assert( + typeof txHash === 'string' && txHash.match(/^0x[\da-f]{64}$/iu), + `tx hash: ${txHash}`, + ); + + console.log( + `\n--- Poll eth_getTransactionReceipt (${TX_TIMEOUT_MS / 1000}s max) ---`, + ); + const deadline = Date.now() + TX_TIMEOUT_MS; + let mined = null; + while (Date.now() < deadline) { + const resp = await fetch(SEPOLIA_RPC_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'eth_getTransactionReceipt', + params: [txHash], + }), + }); + const json = await resp.json(); + if (json.result) { + mined = json.result; + break; + } + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + assert(mined !== null && mined.status === '0x1', 'tx succeeded on-chain'); + + try { + await kernel.stop(); + } catch { + // ignore + } + kernelDb.close(); + + console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch((error) => { + console.error('FATAL:', error); + process.exit(1); +});