diff --git a/examples/arbitrum-london/DEPLOY.md b/examples/arbitrum-london/DEPLOY.md index c7c1c500..b22d5cde 100644 --- a/examples/arbitrum-london/DEPLOY.md +++ b/examples/arbitrum-london/DEPLOY.md @@ -120,9 +120,11 @@ cast send $GATE_ARB "setSpendLimit(uint256)" 1000000000000000000 \ --rpc-url arbitrum_sepolia --private-key $DEPLOYER_PRIVATE_KEY ``` -## 3. Deploy to Robinhood Chain testnet +## 3. Deploy to Robinhood Chain testnet (sponsor bonus - real tx on a second chain) -Gate only, no `--verify` (explorer verifier API not published as of 2026-05-26). +Same shape as step 2 but on chainId 46630, no `--verify` (explorer verifier API not published as of 2026-05-26). Deploying the router here too lets `SmokeExecuteEnvelope` (step 7) land a real `executeEnvelope` tx on Robinhood - the only way to get a Robinhood tx hash while scenario C has no UI flow. + +### 3a. AgentPolicyGate ```bash forge script script/DeployRobinhoodTestnet.s.sol \ @@ -134,7 +136,25 @@ forge script script/DeployRobinhoodTestnet.s.sol \ export GATE_ROBINHOOD=0x... # paste from console ``` -No router and no allow-list needed on Robinhood until scenario C's envelope builder ships. +### 3b. MockPendleRouter + +```bash +forge script script/DeployMockPendleRouter.s.sol \ + --rpc-url robinhood_testnet \ + --broadcast +``` + +```bash +export ROUTER_ROBINHOOD=0x... # paste from console +``` + +### 3c. Allow-list the router on the gate (REQUIRED) + +```bash +cast send $GATE_ROBINHOOD "setAllowedRecipient(address,bool)" $ROUTER_ROBINHOOD true \ + --rpc-url robinhood_testnet \ + --private-key $DEPLOYER_PRIVATE_KEY +``` ## 4. Wire the addresses into the app @@ -218,6 +238,29 @@ Open `/flow-a`, ask the agent to prepare a Pendle yield swap. Expect: That is the recordable Loom path for scenario A. +## 7. Capture real tx hashes (required - AI Agentic category) + +The UI sign button already lands a real `executeEnvelope` tx (the recordable Loom moment). For a reproducible, no-wallet artifact - and the only way to land a tx on Robinhood Chain, where scenario C has no UI yet - run the smoke script. It reproduces exactly what the dApp does: the agent signs an envelope, then it executes through the gate. The full path (deploy -> allow-list -> smoke tx) was rehearsed on a local anvil before shipping. + +Arbitrum Sepolia: + +```bash +cd examples/arbitrum-london/contracts +GATE_ADDRESS=$GATE_ARB ROUTER_ADDRESS=$ROUTER_ARB \ +forge script script/SmokeExecuteEnvelope.s.sol --rpc-url arbitrum_sepolia --broadcast +``` + +Robinhood Chain testnet (bonus): + +```bash +GATE_ADDRESS=$GATE_ROBINHOOD ROUTER_ADDRESS=$ROUTER_ROBINHOOD \ +forge script script/SmokeExecuteEnvelope.s.sol --rpc-url robinhood_testnet --broadcast +``` + +The tx hash prints in the broadcast output and is saved to `broadcast/SmokeExecuteEnvelope.s.sol//run-latest.json` under `transactions[0].hash`. Paste both contract addresses and the tx hashes into the "Live on-chain" tables in `examples/arbitrum-london/README.md` - that is the verifiable proof judges check. + +The gate enforces the prerequisites on-chain: a signer-pair mismatch reverts `InvalidSignature`, a missing allow-list reverts `RecipientNotAllowed`. If the script succeeds, the demo path is sound. + ## Gotchas captured during scaffolding - `MockPendleRouter` did not exist before 2026-05-29; the app referenced it but the contract + deploy script were missing. They are now in `contracts/src/MockPendleRouter.sol` + `contracts/script/DeployMockPendleRouter.s.sol`. diff --git a/examples/arbitrum-london/README.md b/examples/arbitrum-london/README.md new file mode 100644 index 00000000..409ef83b --- /dev/null +++ b/examples/arbitrum-london/README.md @@ -0,0 +1,61 @@ +# txKit - Arbitrum London Buildathon demo + +**Verify before you sign**, for AI-agent-initiated transactions. A Claude agent turns a +plain-English intent into an [ERC-8265](https://github.com/ethereum/ERCs/pull/1753) Prepared +Transaction Envelope. The user reviews a typed, decoded, fee-previewed summary. An on-chain +`AgentPolicyGate` enforces the policy - recipient allow-list, spend cap, replay protection, and +EIP-712 agent-signer binding - before anything executes. + +- **Scenario A (live):** Pendle yield swap on Arbitrum Sepolia. +- **Scenario C (roadmap):** x402-paid RWA agent on Robinhood Chain testnet. + +## Live on-chain + +Real, verifiable artifacts - the AI Agentic category requires real tx hashes, not preview-only +flows. Machine-readable addresses live in [`contracts/deployed.json`](./contracts/deployed.json); +the tables below are the judge-facing copy, filled in after deploy (see [`DEPLOY.md`](./DEPLOY.md)). + +### Arbitrum Sepolia - chainId 421614 + +| What | Value | +|---|---| +| AgentPolicyGate | [`0x__PENDING__`](https://sepolia.arbiscan.io/address/0x__PENDING__) | +| MockPendleRouter | [`0x__PENDING__`](https://sepolia.arbiscan.io/address/0x__PENDING__) | +| Agent-executed tx (`executeEnvelope`) | [`0x__PENDING__`](https://sepolia.arbiscan.io/tx/0x__PENDING__) | + +### Robinhood Chain testnet - chainId 46630 (sponsor bonus) + +| What | Value | +|---|---| +| AgentPolicyGate | [`0x__PENDING__`](https://explorer.testnet.chain.robinhood.com/address/0x__PENDING__) | +| MockPendleRouter | [`0x__PENDING__`](https://explorer.testnet.chain.robinhood.com/address/0x__PENDING__) | +| Agent-executed tx (`executeEnvelope`) | [`0x__PENDING__`](https://explorer.testnet.chain.robinhood.com/tx/0x__PENDING__) | + +## What this proves + +1. An autonomous agent **prepares** a transaction from natural-language intent (Claude tool use + produces an ERC-8265 envelope). +2. The human **sees** a typed, decoded preview - function, arguments, sequencer-fee breakdown, + expiry, policy verdict - before signing, not blind calldata. +3. An on-chain gate **enforces** the rules, so a rogue or compromised agent cannot move value + outside policy. Five checks in `AgentPolicyGate.executeEnvelope`: forwarded value matches the + declared value, envelope not already used, recipient allow-listed, value within the spend cap, + and the EIP-712 signature recovers to the configured agent signer. + +The verification layer - not agent autonomy - is the point. It scales to any agent and any +transaction. + +## Run it + +- **Deploy + capture tx hashes:** [`DEPLOY.md`](./DEPLOY.md) - one funded testnet key, copy-paste. +- **Local dev:** `pnpm dev`, then open `/flow-a`. Until the contracts are deployed the flow runs + in preview mode (a banner explains, and the agent call is skipped so it spends nothing). +- **Contracts:** `cd contracts && forge test` (20 tests). + +## Honest scope + +- Scenario A is live end to end. The swap target is a deterministic `MockPendleRouter` (flat + 1:0.995, no token custody) so the gate path executes on a testnet without funding real input + tokens - real plumbing, mock payload. +- Scenario C's UI is a placeholder; the policy-gate execution is still proven on Robinhood Chain + via the `SmokeExecuteEnvelope` script (same gate, same envelope shape, real tx). diff --git a/examples/arbitrum-london/app/api/agent/route.ts b/examples/arbitrum-london/app/api/agent/route.ts index 92c9848f..44ce7420 100644 --- a/examples/arbitrum-london/app/api/agent/route.ts +++ b/examples/arbitrum-london/app/api/agent/route.ts @@ -17,7 +17,11 @@ import { RWA_TOOL_DEFINITION, preparePendleYieldSwapArgs, } from '@/src/agent/tools' -import { getAgentPolicyGateAddress } from '@/src/config/deployed' +import { + checkIsAgentPolicyGateDeployed, + checkIsMockPendleRouterDeployed, + getAgentPolicyGateAddress, +} from '@/src/config/deployed' import { getEnv } from '@/src/config/env' @@ -88,6 +92,27 @@ export const POST = async (request: NextRequest) => { return NextResponse.json({ error: 'messages must be a non-empty array' }, { status: 400 }) } + // Cost guard: skip the model call entirely when the scenario A contracts are + // not live yet. The envelope cannot be built without them, so calling Claude + // here would spend an API request for nothing. The deploy-pending banner + // already tells the user they are in preview mode. + if (scenario === 'pendle') { + const isGateDeployed = checkIsAgentPolicyGateDeployed(ARBITRUM_SEPOLIA_CHAIN_ID) + const isRouterDeployed = checkIsMockPendleRouterDeployed(ARBITRUM_SEPOLIA_CHAIN_ID) + const isScenarioReady = isGateDeployed && isRouterDeployed + if (!isScenarioReady) { + return NextResponse.json( + { + error: 'Contracts not deployed yet', + hint: + 'AgentPolicyGate / MockPendleRouter are still placeholder addresses on ' + + 'Arbitrum Sepolia. Deploy them (see DEPLOY.md) and update contracts/deployed.json.', + }, + { status: 503 }, + ) + } + } + let env try { env = getEnv() @@ -129,7 +154,7 @@ export const POST = async (request: NextRequest) => { let completion try { completion = await anthropic.messages.create({ - model: 'claude-opus-4-5', + model: 'claude-haiku-4-5', max_tokens: 1024, system: systemPrompt, tools: [ toolDefinition ], @@ -157,7 +182,6 @@ export const POST = async (request: NextRequest) => { return NextResponse.json({ reply: textReply, scenario, - milestone: 'phase-1-day-5-tool-use-text-only', }) } @@ -165,7 +189,7 @@ export const POST = async (request: NextRequest) => { return NextResponse.json( { error: 'RWA scenario not implemented yet', - detail: 'Phase 2 Day 10 wires the RWA envelope builder + Robinhood Chain deploy.', + detail: 'The RWA scenario is not implemented in this build.', }, { status: 501 }, ) @@ -249,6 +273,5 @@ export const POST = async (request: NextRequest) => { reply: textReply, envelope: signedEnvelope, scenario, - milestone: 'phase-1-day-5-tool-use-with-envelope', }) } diff --git a/examples/arbitrum-london/app/api/decode/route.ts b/examples/arbitrum-london/app/api/decode/route.ts index 345b8718..b3ad610a 100644 --- a/examples/arbitrum-london/app/api/decode/route.ts +++ b/examples/arbitrum-london/app/api/decode/route.ts @@ -87,9 +87,8 @@ const exampleDescriptors: ReadonlyArray = [ const exampleRegistry = buildRegistry(exampleDescriptors) -// Inferred shape matches @txkit/tx-decoder's Registry (Readonly>). -// We avoid importing the Registry type because it is currently not exported -// from the package barrel - shape-compatibility via spread is sufficient. +// The Registry type is not exported from the @txkit/tx-decoder barrel yet, so +// we merge by spread - the inferred shape is structurally compatible. const mergedRegistry = { ...BUILTIN_REGISTRY, ...exampleRegistry, diff --git a/examples/arbitrum-london/app/flow-a/PendleAgentChat.tsx b/examples/arbitrum-london/app/flow-a/PendleAgentChat.tsx index 63a908c9..748e38cc 100644 --- a/examples/arbitrum-london/app/flow-a/PendleAgentChat.tsx +++ b/examples/arbitrum-london/app/flow-a/PendleAgentChat.tsx @@ -10,18 +10,12 @@ import { ChatMessage } from '@/src/ui/ChatMessage' import { EnvelopePreview } from '@/src/ui/EnvelopePreview' import { SequencerFeeRow } from '@/src/ui/SequencerFeeRow' +import { SignEnvelopeActions } from './SignEnvelopeActions' +import { fetchDecoded, type DecodedCall } from './utils/fetchDecoded' +import { formatChainLabel, formatExplorerBase, resolveReplyText } from './utils/formatters' -type Message = { role: 'user' | 'assistant', content: string } -type DecodedArg = { name: string | null, type: string, value: unknown } - -type DecodedCall = { - selector?: string | null, - functionName?: string | null, - args?: ReadonlyArray, - source?: string, - clearSigning?: { title?: string, fields?: Record }, -} +type Message = { id: string, role: 'user' | 'assistant', content: string } type AgentResponse = { reply?: string, @@ -48,62 +42,12 @@ const INITIAL_STATE: ChatState = { decodedInner: null, } -const formatChainLabel = (chain: `eip155:${number}`): string => { - const chainId = Number(chain.split(':')[1]) - if (chainId === 421614) { - return 'Arbitrum Sepolia (421614)' - } - - if (chainId === 46630) { - return 'Robinhood Chain testnet (46630)' - } - - return `chain ${chainId}` -} - -const formatTxExplorerUrl = (chainId: number, txHash: `0x${string}`): string => { - if (chainId === 421614) { - return `https://sepolia.arbiscan.io/tx/${txHash}` - } - - if (chainId === 46630) { - return `https://explorer.testnet.chain.robinhood.com/tx/${txHash}` - } - - return `chain ${chainId} tx ${txHash}` -} - -const formatExplorerBase = (chainId: number | null): string | undefined => { - if (chainId === 421614) { - return 'https://sepolia.arbiscan.io' - } - - if (chainId === 46630) { - return 'https://explorer.testnet.chain.robinhood.com' - } - - return undefined -} - -const resolveReplyText = (reply: string | undefined, hasEnvelope: boolean): string => { - const hasReplyText = reply !== undefined && reply.length > 0 - if (hasReplyText) { - return reply - } - - if (hasEnvelope) { - return '(envelope prepared - review below)' - } - - return '(empty reply)' -} - /** * Scenario A client: Claude tool-use loop + one-click sign. * * Sends conversation history to /api/agent, which returns either a clarifying * reply (text only) or a signed envelope ready for review. wagmi - * useSendTransaction signs envelope.call in one click; the Arbiscan link + * useSendTransaction signs envelope.call in one click; the explorer link * appears once the tx hash is returned. */ export const PendleAgentChat = () => { @@ -124,28 +68,6 @@ export const PendleAgentChat = () => { } = useSendTransaction() const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({ hash: txHash }) - const fetchDecoded = async (env: DemoEnvelope): Promise => { - try { - const { chain, inner } = env - const response = await fetch('/api/decode', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chain, - call: { to: inner.to, data: inner.data, value: inner.value }, - }), - }) - const json = (await response.json()) as DecodedCall & { error?: string } - if (!response.ok || json.error !== undefined) { - return null - } - - return json - } catch { - return null - } - } - const handleSubmit = async (event: FormEvent) => { event.preventDefault() const trimmed = input.trim() @@ -153,7 +75,7 @@ export const PendleAgentChat = () => { return } - const userMessage: Message = { role: 'user', content: trimmed } + const userMessage: Message = { id: crypto.randomUUID(), role: 'user', content: trimmed } const next = [ ...messages, userMessage ] patchState({ messages: next, input: '', isLoading: true, errorMessage: null }) resetSendTx() @@ -168,13 +90,15 @@ export const PendleAgentChat = () => { const { reply, envelope: returnedEnvelope, error, hint } = json if (!response.ok) { - const detail = hint !== undefined ? `${error ?? 'Agent error'} - ${hint}` : error ?? 'Agent request failed' + const baseError = error ?? 'Agent error' + const detail = hint !== undefined ? `${baseError} - ${hint}` : baseError patchState({ errorMessage: detail }) return } const replyText = resolveReplyText(reply, returnedEnvelope !== undefined) - patchState({ messages: [ ...next, { role: 'assistant', content: replyText } ] }) + const assistantMessage: Message = { id: crypto.randomUUID(), role: 'assistant', content: replyText } + patchState({ messages: [ ...next, assistantMessage ] }) if (returnedEnvelope !== undefined) { const decoded = await fetchDecoded(returnedEnvelope) @@ -202,22 +126,6 @@ export const PendleAgentChat = () => { resetSendTx() } - const resolveTxButtonLabel = (): string => { - if (isSigning) { - return 'Sign in your wallet...' - } - - if (isConfirming) { - return 'Waiting for confirmation...' - } - - if (isConfirmed) { - return 'Confirmed - sign another?' - } - - return 'Sign tx in wallet' - } - const isBusySendingTx = isSigning || isConfirming const envelopeChainId = envelope !== null ? Number(envelope.chain.split(':')[1]) : null @@ -232,12 +140,12 @@ export const PendleAgentChat = () => { : undefined const emptyStateNode = ( -
-

+

+

Try{': '} - Swap 100 USDC for PT-stETH + Swap 100 USDC for PT-stETH

-

+

The agent calls prepare_pendle_yield_swap, you review the decoded envelope, then sign in your wallet.

@@ -245,91 +153,72 @@ export const PendleAgentChat = () => { const messagesNode = messages.length === 0 ? emptyStateNode - : messages.map((message, index) => ( - + : messages.map((message) => ( + )) + const loadingNode = isLoading ? ( +
Agent thinking…
+ ) : null + + const errorNode = errorMessage !== null ? ( +
+ {errorMessage} +
+ ) : null + + const previewNode = envelope !== null ? ( + + )} + /> + ) : null + + const actionsNode = envelope !== null ? ( + + ) : null + return (
{messagesNode} - {isLoading ? ( -
Agent thinking…
- ) : null} + {loadingNode}
- {errorMessage !== null ? ( -
- {errorMessage} -
- ) : null} - - {envelope !== null ? ( - - )} - /> - ) : null} - - {envelope !== null ? ( -
-
- - -
- {!isConnected ? ( -

Connect your wallet to sign

- ) : null} - {sendError !== null ? ( -
- {sendError.message} -
- ) : null} - {txHash !== undefined && envelopeChainId !== null ? ( - - {isConfirmed ? 'Confirmed on Arbiscan' : 'View pending tx on Arbiscan'}: {txHash} - - ) : null} -
- ) : null} + {errorNode} + {previewNode} + {actionsNode}
patchState({ input: event.target.value })} @@ -338,7 +227,7 @@ export const PendleAgentChat = () => { diff --git a/examples/arbitrum-london/app/flow-a/SignEnvelopeActions.tsx b/examples/arbitrum-london/app/flow-a/SignEnvelopeActions.tsx new file mode 100644 index 00000000..7cd5d84e --- /dev/null +++ b/examples/arbitrum-london/app/flow-a/SignEnvelopeActions.tsx @@ -0,0 +1,99 @@ +import { formatTxExplorerUrl, resolveExplorerLabel } from './utils/formatters' + + +type SignEnvelopeActionsProps = { + isConnected: boolean, + isSigning: boolean, + isConfirming: boolean, + isConfirmed: boolean, + isBusySendingTx: boolean, + txHash: `0x${string}` | undefined, + sendError: Error | null, + envelopeChainId: number | null, + onReject: () => void, + onSign: () => void, +} + +/** + * Review actions for a prepared envelope: reject, sign (one click via wagmi in + * the parent), and the explorer link once a tx hash is returned. Split out of + * PendleAgentChat so the chat component stays focused on conversation state. + */ +export const SignEnvelopeActions = (props: SignEnvelopeActionsProps) => { + const { + onSign, + onReject, + txHash, + sendError, + isSigning, + isConnected, + isConfirmed, + isConfirming, + isBusySendingTx, + envelopeChainId, + } = props + + const resolveTxButtonLabel = (): string => { + if (isSigning) { + return 'Sign in your wallet...' + } + + if (isConfirming) { + return 'Waiting for confirmation...' + } + + if (isConfirmed) { + return 'Confirmed - sign another?' + } + + return 'Sign tx in wallet' + } + + const notConnectedNode = !isConnected ? ( +

Connect your wallet to sign

+ ) : null + + const sendErrorNode = sendError !== null ? ( +
+ {sendError.message} +
+ ) : null + + const explorerLabel = resolveExplorerLabel(envelopeChainId) + const txLinkNode = txHash !== undefined && envelopeChainId !== null ? ( + + {isConfirmed ? `Confirmed on ${explorerLabel}` : `View pending tx on ${explorerLabel}`}: {txHash} + + ) : null + + return ( +
+
+ + +
+ {notConnectedNode} + {sendErrorNode} + {txLinkNode} +
+ ) +} diff --git a/examples/arbitrum-london/app/flow-a/page.tsx b/examples/arbitrum-london/app/flow-a/page.tsx index 8d683492..b6808211 100644 --- a/examples/arbitrum-london/app/flow-a/page.tsx +++ b/examples/arbitrum-london/app/flow-a/page.tsx @@ -1,5 +1,7 @@ import Link from 'next/link' +import { DeployPendingBanner } from '@/src/ui/DeployPendingBanner' + import { PendleAgentChat } from './PendleAgentChat' @@ -8,12 +10,13 @@ import { PendleAgentChat } from './PendleAgentChat' * Static lift-pitch + technical note + client-side chat below the fold. */ const FlowA = () => { + return (
← Back
-

+

Scenario A - Arbitrum Sepolia

Pendle yield swap

@@ -24,9 +27,11 @@ const FlowA = () => {

+ + -