diff --git a/packages/evm-wallet-experiment/README.md b/packages/evm-wallet-experiment/README.md index 9a0144de5d..7538089d25 100644 --- a/packages/evm-wallet-experiment/README.md +++ b/packages/evm-wallet-experiment/README.md @@ -7,7 +7,7 @@ For a deeper explanation of the components and data flow, see [How It Works](./d ## 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()` 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. +- **`revokeDelegation()` and hybrid redemption require a bundler or peer relay.** Hybrid accounts submit on-chain `disableDelegation` / redemption via ERC-4337 UserOps; configure a bundler (and optional paymaster). **Stateless 7702** accounts use a direct EIP-1559 transaction instead; only the JSON-RPC provider must be configured. **Away wallets without a bundler** relay delegation redemptions to the home wallet via CapTP (requires the home wallet to be online). If the on-chain transaction fails, the local delegation status is not changed. - **Mnemonic encryption is optional.** The keyring vat can encrypt the mnemonic at rest using AES-256-GCM with a PBKDF2-derived key. Pass a `password` and `salt` to `initializeKeyring()` to enable encryption. Without a password, the mnemonic is stored in plaintext. When encrypted, the keyring starts in a locked state on daemon restart and must be unlocked with `unlockKeyring(password)` before signing operations work. - **Throwaway keyring needs secure entropy.** `initializeKeyring({ type: 'throwaway' })` requires either `crypto.getRandomValues` in the runtime or caller-provided entropy via `{ type: 'throwaway', entropy: '0x...' }`. Under SES lockdown (where `crypto` is unavailable inside vat compartments), the caller must generate 32 bytes of entropy externally and pass it in. @@ -143,13 +143,14 @@ await coordinator.connectToPeer(ocapUrl); // Alternatively, receive it manually: // await coordinator.receiveDelegation(delegation); -// 4. Configure the bundler +// 4. (Optional) Configure the bundler — without it, redemptions are relayed +// to the home wallet (requires home online). await coordinator.configureBundler({ bundlerUrl: 'https://bundler.example.com/rpc', chainId: 1, }); -// 5. Redeem the delegation via a UserOp +// 5. Redeem the delegation (via UserOp if bundler configured, or relayed to home) const userOpHash = await coordinator.redeemDelegation({ execution: { target: '0xContractAddress...' as Address, @@ -184,14 +185,18 @@ If the away kernel has no local keys and no matching delegation for a message or ### Offline Autonomy -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: +The away kernel caches the home kernel's accounts and signing mode during `connectToPeer()`. After the delegation is transferred: + +**With bundler configured** — 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 mode - `sendTransaction()` 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](./docs/how-it-works.md#offline-autonomy-vps-mode) for details. +**Without bundler (relay mode)** — delegation redemptions are relayed to the home wallet via CapTP, requiring the home to be online for sends. Message/typed-data signing still works locally with the throwaway key. + +The only operation that always 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](./docs/how-it-works.md#offline-autonomy-vps-mode) for details. ## Signing Strategy Resolution @@ -286,7 +291,11 @@ Delegations can be shared between kernels in three ways: ### Redeeming Delegations -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. +Redemption calls `DelegationManager.redeemDelegations` on-chain. The submission path depends on configuration: + +- **Bundler configured** — builds an ERC-4337 UserOp signed by the delegate and submitted to a bundler (routed through EntryPoint v0.7). +- **Stateless 7702** — broadcasts a direct EIP-1559 self-call transaction via the JSON-RPC provider. +- **Peer relay (away wallet, no bundler)** — relays the delegation + execution to the home wallet via CapTP; the home wallet submits on its own behalf using whichever path it supports. ```typescript const userOpHash = await coordinator.redeemDelegation({ diff --git a/packages/evm-wallet-experiment/docs/how-it-works.md b/packages/evm-wallet-experiment/docs/how-it-works.md index a882714bcb..27039af05a 100644 --- a/packages/evm-wallet-experiment/docs/how-it-works.md +++ b/packages/evm-wallet-experiment/docs/how-it-works.md @@ -81,8 +81,9 @@ Both devices create **DeleGator smart accounts** via MetaMask's Delegation Frame **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. +- **Hybrid (with bundler)** — 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. +- **Peer relay (away, no bundler)** — the away wallet relays the delegation + execution to the home wallet via CapTP. The home wallet submits the redemption using its own path (direct 7702 or bundler). This requires the home wallet to be online but eliminates the need for a Pimlico API key on the away device. All modes support: diff --git a/packages/evm-wallet-experiment/docs/setup-guide.md b/packages/evm-wallet-experiment/docs/setup-guide.md index af5ab4b0b2..f3a477607d 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. 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. +The `--pimlico-key` configures the Pimlico bundler for ERC-4337 UserOp submission with paymaster sponsorship. It is **optional** for the away device: with it, the away wallet submits its own UserOps autonomously (Hybrid smart account, offline-capable); without it, delegation redemptions are relayed to the home wallet via CapTP (requires the home wallet to be online). It is required 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/scripts/setup-away.sh b/packages/evm-wallet-experiment/scripts/setup-away.sh index 4e0700c5b3..ff74e9afbb 100755 --- a/packages/evm-wallet-experiment/scripts/setup-away.sh +++ b/packages/evm-wallet-experiment/scripts/setup-away.sh @@ -493,7 +493,7 @@ if [[ -n "$PIMLICO_KEY" ]]; then info "Smart account creation returned no address — redemption may not work" fi else - info "Skipping bundler config (no --pimlico-key). UserOp submission will not work." + info "Skipping bundler config (no --pimlico-key). Delegation redemptions will be relayed to the home wallet (requires home online)." fi # --------------------------------------------------------------------------- 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 648dd483f6..15d787e24d 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts +++ b/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts @@ -1430,6 +1430,60 @@ describe('coordinator-vat', () => { expect(caps.autonomy).toMatch(/^autonomous/u); expect(caps.chainId).toBe(11155111); }); + + it('reports autonomy via peer relay when no bundler and no 7702', async () => { + const peerAddress = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; + const mockPeerWallet = { + getAccounts: vi.fn().mockResolvedValue([peerAddress]), + getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), + handleSigningRequest: vi.fn(), + handleRedemptionRequest: vi.fn(), + registerAwayWallet: vi.fn().mockResolvedValue(undefined), + registerDelegateAddress: vi.fn().mockResolvedValue(undefined), + }; + + const freshBaggage = makeMockBaggage(); + freshBaggage.init('keyringVat', keyringVat); + freshBaggage.init('providerVat', providerVat); + freshBaggage.init('delegationVat', delegationVat); + freshBaggage.init('peerWallet', mockPeerWallet); + freshBaggage.init('cachedPeerAccounts', [peerAddress]); + + const coord = buildRootObject( + {}, + undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + freshBaggage as any, + ); + + await coord.bootstrap( + { + keyring: keyringVat, + provider: providerVat, + delegation: delegationVat, + }, + {}, + ); + + await coord.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + // Create delegation — no bundler, no 7702, but peer wallet is connected + await coord.createDelegation({ + delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, + caveats: [], + chainId: 1, + }); + + const caps = await coord.getCapabilities(); + expect(caps.hasBundlerConfig).toBe(false); + expect(caps.hasPeerWallet).toBe(true); + expect(caps.autonomy).toMatch(/^autonomous/u); + expect(caps.autonomy).toContain('relay, requires home online'); + }); }); describe('handleSigningRequest', () => { @@ -1853,6 +1907,199 @@ describe('coordinator-vat', () => { }); }); + describe('handleRedemptionRequest', () => { + it('executes single delegation redemption on behalf of peer', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + await coordinator.configureBundler({ + bundlerUrl: 'https://bundler.example.com', + chainId: 1, + }); + + const accounts = await coordinator.getAccounts(); + const delegator = accounts[0] as Address; + + const delegation = await coordinator.createDelegation({ + delegate: delegator, + caveats: [], + chainId: 1, + }); + + const result = await coordinator.handleRedemptionRequest({ + type: 'single', + delegations: [delegation], + execution: { + target: TARGET, + value: '0x0' as Hex, + callData: '0x' as Hex, + }, + maxFeePerGas: '0x3b9aca00' as Hex, + maxPriorityFeePerGas: '0x3b9aca00' as Hex, + }); + + expect(result).toBe('0xuserophash'); + }); + + it('executes batch delegation redemption on behalf of peer', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + await coordinator.configureBundler({ + bundlerUrl: 'https://bundler.example.com', + chainId: 1, + }); + + const accounts = await coordinator.getAccounts(); + const delegator = accounts[0] as Address; + + const delegation = await coordinator.createDelegation({ + delegate: delegator, + caveats: [], + chainId: 1, + }); + + const result = await coordinator.handleRedemptionRequest({ + type: 'batch', + delegations: [delegation], + executions: [ + { target: TARGET, value: '0x0' as Hex, callData: '0x' as Hex }, + { target: TARGET, value: '0x1' as Hex, callData: '0x' as Hex }, + ], + }); + + expect(result).toBe('0xuserophash'); + }); + + it('throws for empty delegations array', async () => { + await expect( + coordinator.handleRedemptionRequest({ + type: 'single', + delegations: [], + }), + ).rejects.toThrow('Missing or empty delegations in redemption request'); + }); + + it('throws for single request without execution', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + await coordinator.configureBundler({ + bundlerUrl: 'https://bundler.example.com', + chainId: 1, + }); + + const accounts = await coordinator.getAccounts(); + const delegator = accounts[0] as Address; + + const delegation = await coordinator.createDelegation({ + delegate: delegator, + caveats: [], + chainId: 1, + }); + + await expect( + coordinator.handleRedemptionRequest({ + type: 'single', + delegations: [delegation], + }), + ).rejects.toThrow('Missing execution in single redemption request'); + }); + + it('throws for batch request without executions', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + await coordinator.configureBundler({ + bundlerUrl: 'https://bundler.example.com', + chainId: 1, + }); + + const accounts = await coordinator.getAccounts(); + const delegator = accounts[0] as Address; + + const delegation = await coordinator.createDelegation({ + delegate: delegator, + caveats: [], + chainId: 1, + }); + + await expect( + coordinator.handleRedemptionRequest({ + type: 'batch', + delegations: [delegation], + }), + ).rejects.toThrow('Missing executions in batch redemption request'); + }); + + it('throws for unknown redemption request type', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + await coordinator.configureBundler({ + bundlerUrl: 'https://bundler.example.com', + chainId: 1, + }); + + const accounts = await coordinator.getAccounts(); + const delegator = accounts[0] as Address; + + const delegation = await coordinator.createDelegation({ + delegate: delegator, + caveats: [], + chainId: 1, + }); + + await expect( + coordinator.handleRedemptionRequest({ + type: 'unknown', + delegations: [delegation], + }), + ).rejects.toThrow('Unknown redemption request type: unknown'); + }); + + it('rejects relayed request when no bundler or 7702 configured', async () => { + // No bundler, no 7702 — cannot fulfill relayed requests + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + const accounts = await coordinator.getAccounts(); + const delegator = accounts[0] as Address; + + const delegation = await coordinator.createDelegation({ + delegate: delegator, + caveats: [], + chainId: 1, + }); + + await expect( + coordinator.handleRedemptionRequest({ + type: 'single', + delegations: [delegation], + execution: { + target: TARGET, + value: '0x0' as Hex, + callData: '0x' as Hex, + }, + }), + ).rejects.toThrow( + 'Cannot fulfill relayed redemption: no bundler or direct 7702 configured', + ); + }); + }); + describe('connectToPeer', () => { it('registers the coordinator as away wallet on the home device', async () => { const peerAddress = @@ -2638,7 +2885,7 @@ describe('coordinator-vat', () => { ).rejects.toThrow('No matching delegation found'); }); - it('throws when bundler is not configured', async () => { + it('throws when bundler is not configured and no peer wallet', async () => { await coordinator.initializeKeyring({ type: 'srp', mnemonic: TEST_MNEMONIC, @@ -2666,7 +2913,421 @@ describe('coordinator-vat', () => { maxFeePerGas: '0x3b9aca00' as Hex, maxPriorityFeePerGas: '0x3b9aca00' as Hex, }), - ).rejects.toThrow('Bundler not configured'); + ).rejects.toThrow( + 'Bundler not configured and no peer wallet available for relay', + ); + }); + + it('relays single delegation redemption to peer when no bundler', async () => { + const peerAddress = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; + const mockPeerWallet = { + getAccounts: vi.fn().mockResolvedValue([peerAddress]), + getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), + handleSigningRequest: vi.fn(), + handleRedemptionRequest: vi + .fn() + .mockResolvedValue('0xrelayedhash' as Hex), + registerAwayWallet: vi.fn().mockResolvedValue(undefined), + registerDelegateAddress: vi.fn().mockResolvedValue(undefined), + }; + + const freshBaggage = makeMockBaggage(); + freshBaggage.init('keyringVat', keyringVat); + freshBaggage.init('providerVat', providerVat); + freshBaggage.init('delegationVat', delegationVat); + freshBaggage.init('peerWallet', mockPeerWallet); + + const coord = buildRootObject( + {}, + undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + freshBaggage as any, + ); + + await coord.bootstrap( + { + keyring: keyringVat, + provider: providerVat, + delegation: delegationVat, + }, + {}, + ); + + await coord.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + const accounts = await coord.getAccounts(); + const delegator = accounts[0] as Address; + + const delegation = await coord.createDelegation({ + delegate: delegator, + caveats: [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([TARGET]), + }), + ], + chainId: 1, + }); + + // No configureBundler — forces relay path + const result = await coord.redeemDelegation({ + execution: { + target: TARGET, + value: '0x0' as Hex, + callData: '0x' as Hex, + }, + delegationId: delegation.id, + maxFeePerGas: '0x3b9aca00' as Hex, + maxPriorityFeePerGas: '0x3b9aca00' as Hex, + }); + + expect(result).toBe('0xrelayedhash'); + expect(mockPeerWallet.handleRedemptionRequest).toHaveBeenCalledWith({ + type: 'single', + delegations: [expect.objectContaining({ id: delegation.id })], + execution: { + target: TARGET, + value: '0x0', + callData: '0x', + }, + maxFeePerGas: '0x3b9aca00', + maxPriorityFeePerGas: '0x3b9aca00', + }); + }); + + it('relays batch delegation redemption to peer when no bundler', async () => { + const peerAddress = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; + const mockPeerWallet = { + getAccounts: vi.fn().mockResolvedValue([peerAddress]), + getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), + handleSigningRequest: vi.fn(), + handleRedemptionRequest: vi + .fn() + .mockResolvedValue('0xrelayedbatch' as Hex), + registerAwayWallet: vi.fn().mockResolvedValue(undefined), + registerDelegateAddress: vi.fn().mockResolvedValue(undefined), + }; + + const freshBaggage = makeMockBaggage(); + freshBaggage.init('keyringVat', keyringVat); + freshBaggage.init('providerVat', providerVat); + freshBaggage.init('delegationVat', delegationVat); + freshBaggage.init('peerWallet', mockPeerWallet); + + const coord = buildRootObject( + {}, + undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + freshBaggage as any, + ); + + await coord.bootstrap( + { + keyring: keyringVat, + provider: providerVat, + delegation: delegationVat, + }, + {}, + ); + + await coord.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + const accounts = await coord.getAccounts(); + const delegator = accounts[0] as Address; + + // Create delegation with allowedTargets for TARGET + const delegation = await coord.createDelegation({ + delegate: delegator, + caveats: [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([TARGET]), + }), + ], + chainId: 1, + }); + + // sendBatchTransaction routes through delegation batch path + const result = await coord.sendBatchTransaction([ + { from: delegator, to: TARGET, value: '0x1' as Hex }, + { from: delegator, to: TARGET, value: '0x2' as Hex }, + ]); + + expect(result).toBe('0xrelayedbatch'); + expect(mockPeerWallet.handleRedemptionRequest).toHaveBeenCalledWith({ + type: 'batch', + delegations: [expect.objectContaining({ id: delegation.id })], + executions: [ + { target: TARGET, value: '0x1', callData: '0x' }, + { target: TARGET, value: '0x2', callData: '0x' }, + ], + }); + }); + + it('relays sendTransaction via peer when delegation matches and no bundler', async () => { + const peerAddress = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; + const mockPeerWallet = { + getAccounts: vi.fn().mockResolvedValue([peerAddress]), + getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), + handleSigningRequest: vi.fn(), + handleRedemptionRequest: vi + .fn() + .mockResolvedValue('0xrelayedtx' as Hex), + registerAwayWallet: vi.fn().mockResolvedValue(undefined), + registerDelegateAddress: vi.fn().mockResolvedValue(undefined), + }; + + const freshBaggage = makeMockBaggage(); + freshBaggage.init('keyringVat', keyringVat); + freshBaggage.init('providerVat', providerVat); + freshBaggage.init('delegationVat', delegationVat); + freshBaggage.init('peerWallet', mockPeerWallet); + + const coord = buildRootObject( + {}, + undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + freshBaggage as any, + ); + + await coord.bootstrap( + { + keyring: keyringVat, + provider: providerVat, + delegation: delegationVat, + }, + {}, + ); + + await coord.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + const accounts = await coord.getAccounts(); + const delegator = accounts[0] as Address; + + await coord.createDelegation({ + delegate: delegator, + caveats: [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([TARGET]), + }), + ], + chainId: 1, + }); + + // sendTransaction matches delegation and relays via peer + const result = await coord.sendTransaction({ + from: delegator, + to: TARGET, + value: '0x1' as Hex, + }); + + expect(result).toBe('0xrelayedtx'); + expect(mockPeerWallet.handleRedemptionRequest).toHaveBeenCalledWith( + expect.objectContaining({ type: 'single' }), + ); + }); + + it('propagates peer relay errors to the caller', async () => { + const peerAddress = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; + const mockPeerWallet = { + getAccounts: vi.fn().mockResolvedValue([peerAddress]), + getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), + handleSigningRequest: vi.fn(), + handleRedemptionRequest: vi + .fn() + .mockRejectedValue(new Error('CapTP connection lost')), + registerAwayWallet: vi.fn().mockResolvedValue(undefined), + registerDelegateAddress: vi.fn().mockResolvedValue(undefined), + }; + + const freshBaggage = makeMockBaggage(); + freshBaggage.init('keyringVat', keyringVat); + freshBaggage.init('providerVat', providerVat); + freshBaggage.init('delegationVat', delegationVat); + freshBaggage.init('peerWallet', mockPeerWallet); + + const coord = buildRootObject( + {}, + undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + freshBaggage as any, + ); + + await coord.bootstrap( + { + keyring: keyringVat, + provider: providerVat, + delegation: delegationVat, + }, + {}, + ); + + await coord.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + const accounts = await coord.getAccounts(); + const delegator = accounts[0] as Address; + + const delegation = await coord.createDelegation({ + delegate: delegator, + caveats: [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([TARGET]), + }), + ], + chainId: 1, + }); + + await expect( + coord.redeemDelegation({ + execution: { + target: TARGET, + value: '0x0' as Hex, + callData: '0x' as Hex, + }, + delegationId: delegation.id, + }), + ).rejects.toThrow( + 'Failed to relay delegation redemption to home wallet: CapTP connection lost', + ); + }); + + it('propagates batch peer relay errors to the caller', async () => { + const peerAddress = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; + const mockPeerWallet = { + getAccounts: vi.fn().mockResolvedValue([peerAddress]), + getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), + handleSigningRequest: vi.fn(), + handleRedemptionRequest: vi + .fn() + .mockRejectedValue(new Error('peer disconnected')), + registerAwayWallet: vi.fn().mockResolvedValue(undefined), + registerDelegateAddress: vi.fn().mockResolvedValue(undefined), + }; + + const freshBaggage = makeMockBaggage(); + freshBaggage.init('keyringVat', keyringVat); + freshBaggage.init('providerVat', providerVat); + freshBaggage.init('delegationVat', delegationVat); + freshBaggage.init('peerWallet', mockPeerWallet); + + const coord = buildRootObject( + {}, + undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + freshBaggage as any, + ); + + await coord.bootstrap( + { + keyring: keyringVat, + provider: providerVat, + delegation: delegationVat, + }, + {}, + ); + + await coord.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + const accounts = await coord.getAccounts(); + const delegator = accounts[0] as Address; + + await coord.createDelegation({ + delegate: delegator, + caveats: [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([TARGET]), + }), + ], + chainId: 1, + }); + + await expect( + coord.sendBatchTransaction([ + { from: delegator, to: TARGET, value: '0x1' as Hex }, + { from: delegator, to: TARGET, value: '0x2' as Hex }, + ]), + ).rejects.toThrow( + 'Failed to relay batch delegation redemption to home wallet: peer disconnected', + ); + }); + + it('throws for non-delegation batch when peer is set but no bundler', async () => { + const peerAddress = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; + const mockPeerWallet = { + getAccounts: vi.fn().mockResolvedValue([peerAddress]), + getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), + handleSigningRequest: vi.fn(), + handleRedemptionRequest: vi.fn(), + registerAwayWallet: vi.fn().mockResolvedValue(undefined), + registerDelegateAddress: vi.fn().mockResolvedValue(undefined), + }; + + const freshBaggage = makeMockBaggage(); + freshBaggage.init('keyringVat', keyringVat); + freshBaggage.init('providerVat', providerVat); + freshBaggage.init('delegationVat', delegationVat); + freshBaggage.init('peerWallet', mockPeerWallet); + + const coord = buildRootObject( + {}, + undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + freshBaggage as any, + ); + + await coord.bootstrap( + { + keyring: keyringVat, + provider: providerVat, + delegation: delegationVat, + }, + {}, + ); + + await coord.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + const accounts = await coord.getAccounts(); + const sender = accounts[0] as Address; + + // Non-delegation target — no delegation covers this address + const otherTarget = + '0x9999999999999999999999999999999999999999' as Address; + + await expect( + coord.sendBatchTransaction([ + { from: sender, to: otherTarget, value: '0x1' as Hex }, + { from: sender, to: otherTarget, value: '0x2' as Hex }, + ]), + ).rejects.toThrow( + 'Non-delegation batch execution requires a bundler or direct 7702', + ); }); it('rejects delegations with non-signed status', async () => { diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts index 59b918483b..cd3d2bcd6f 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts @@ -298,6 +298,14 @@ type PeerWalletFacet = { }) => Promise; registerAwayWallet: (awayRef: unknown) => Promise; registerDelegateAddress: (address: string) => Promise; + handleRedemptionRequest: (request: { + type: 'single' | 'batch'; + delegations: Delegation[]; + execution?: Execution; + executions?: Execution[]; + maxFeePerGas?: Hex; + maxPriorityFeePerGas?: Hex; + }) => Promise; }; type ExternalSignerFacet = { @@ -1042,6 +1050,34 @@ export function buildRootObject( maxFeePerGas?: Hex | undefined; maxPriorityFeePerGas?: Hex | undefined; }): Promise { + // Check the relay path first — it forwards raw delegations/execution to + // the home wallet and does not need local chain ID or SDK calldata. + if (!bundlerConfig && !smartAccountConfig) { + if (peerWallet) { + try { + return await E(peerWallet).handleRedemptionRequest({ + type: 'single', + delegations: options.delegations, + execution: options.execution, + maxFeePerGas: options.maxFeePerGas, + maxPriorityFeePerGas: options.maxPriorityFeePerGas, + }); + } catch (relayError) { + const detail = + relayError instanceof Error + ? relayError.message + : String(relayError); + throw new Error( + `Failed to relay delegation redemption to home wallet: ${detail}`, + { cause: relayError }, + ); + } + } + throw new Error( + 'Bundler not configured and no peer wallet available for relay', + ); + } + const sender = smartAccountConfig?.address ?? options.delegations[0].delegate; @@ -1097,6 +1133,32 @@ export function buildRootObject( delegations: Delegation[]; executions: Execution[]; }): Promise { + // Check the relay path first — it forwards raw delegations/executions to + // the home wallet and does not need local chain ID or SDK calldata. + if (!bundlerConfig && !smartAccountConfig) { + if (peerWallet) { + try { + return await E(peerWallet).handleRedemptionRequest({ + type: 'batch', + delegations: options.delegations, + executions: options.executions, + }); + } catch (relayError) { + const detail = + relayError instanceof Error + ? relayError.message + : String(relayError); + throw new Error( + `Failed to relay batch delegation redemption to home wallet: ${detail}`, + { cause: relayError }, + ); + } + } + throw new Error( + 'Bundler not configured and no peer wallet available for relay', + ); + } + const sender = smartAccountConfig?.address ?? options.delegations[0]?.delegate; if (!sender) { @@ -1730,7 +1792,9 @@ export function buildRootObject( (await useDirect7702Tx(batchSender)); const useSmartAccountBatchPath = - bundlerConfig !== undefined || isDirect7702Batch; + bundlerConfig !== undefined || + isDirect7702Batch || + (peerWallet !== undefined && delegationVat !== undefined); // Smart account path: single UserOp or direct 7702 self-call if (useSmartAccountBatchPath) { @@ -1812,7 +1876,8 @@ export function buildRootObject( } if (!bundlerConfig) { throw new Error( - 'Bundler not configured (required for hybrid smart account batch execution)', + 'Non-delegation batch execution requires a bundler or direct 7702; ' + + 'peer relay is only available for delegation redemptions', ); } return buildAndSubmitUserOp({ sender, callData }); @@ -2708,6 +2773,63 @@ export function buildRootObject( } }, + // ------------------------------------------------------------------ + // Peer delegation redemption relay + // ------------------------------------------------------------------ + + async handleRedemptionRequest(request: { + type: 'single' | 'batch'; + delegations: Delegation[]; + execution?: Execution; + executions?: Execution[]; + maxFeePerGas?: Hex; + maxPriorityFeePerGas?: Hex; + }): Promise { + if (!request.delegations || request.delegations.length === 0) { + throw new Error('Missing or empty delegations in redemption request'); + } + + // Guard against infinite relay loops: if this wallet cannot fulfill + // the request locally, it must not relay it back to its own peer. + // Uses the same config-based check as the relay entry condition in + // submitDelegationUserOp/submitBatchDelegationUserOp — keep in sync. + const canFulfillLocally = + bundlerConfig !== undefined || + (smartAccountConfig?.implementation === 'stateless7702' && + providerVat !== undefined); + if (!canFulfillLocally) { + throw new Error( + 'Cannot fulfill relayed redemption: no bundler or direct 7702 configured', + ); + } + + if (request.type === 'single') { + if (!request.execution) { + throw new Error('Missing execution in single redemption request'); + } + return submitDelegationUserOp({ + delegations: request.delegations, + execution: request.execution, + maxFeePerGas: request.maxFeePerGas, + maxPriorityFeePerGas: request.maxPriorityFeePerGas, + }); + } + + if (request.type === 'batch') { + if (!request.executions || request.executions.length === 0) { + throw new Error('Missing executions in batch redemption request'); + } + return submitBatchDelegationUserOp({ + delegations: request.delegations, + executions: request.executions, + }); + } + + throw new Error( + `Unknown redemption request type: ${String(request.type)}`, + ); + }, + // ------------------------------------------------------------------ // Introspection // ------------------------------------------------------------------ @@ -2741,7 +2863,7 @@ export function buildRootObject( cachedPeerSigningMode = signingMode; persistBaggage('cachedPeerSigningMode', cachedPeerSigningMode); } catch (error) { - logger.debug('peer getCapabilities timed out, using cache', error); + logger.warn('peer getCapabilities failed, using cache', error); signingMode = cachedPeerSigningMode ?? 'peer:unknown'; } } else if (externalSigner) { @@ -2766,11 +2888,18 @@ export function buildRootObject( // 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. + // Peer relay can redeem but requires the home wallet to be online. let autonomy: string; + const canRedeemLocally = + bundlerConfig !== undefined || + (smartAccountConfig?.implementation === 'stateless7702' && + providerVat !== undefined); + // Relay requires no smartAccountConfig — mirrors the entry condition + // in submitDelegationUserOp/submitBatchDelegationUserOp. + const canRedeemViaRelay = + !canRedeemLocally && !smartAccountConfig && peerWallet !== undefined; const canRedeemDelegationsOnChain = - activeDelegations.length > 0 && - (bundlerConfig !== undefined || - smartAccountConfig?.implementation === 'stateless7702'); + activeDelegations.length > 0 && (canRedeemLocally || canRedeemViaRelay); if (canRedeemDelegationsOnChain) { const limits = activeDelegations .flatMap((del) => del.caveats) @@ -2780,8 +2909,12 @@ export function buildRootObject( limits.length > 0 ? `autonomous within limits: ${limits.join('; ')}` : 'autonomous (no spending limits)'; - autonomy = - cachedPeerAccounts.length > 0 ? `${base} (offline-capable)` : base; + if (canRedeemViaRelay) { + autonomy = `${base} (relay, requires home online)`; + } else { + autonomy = + cachedPeerAccounts.length > 0 ? `${base} (offline-capable)` : base; + } } else if (peerWallet) { autonomy = 'requires peer wallet approval for each action'; } else {