diff --git a/examples/arbitrum-london/.env.example b/examples/arbitrum-london/.env.example index 5f808f3c..5b4ea0db 100644 --- a/examples/arbitrum-london/.env.example +++ b/examples/arbitrum-london/.env.example @@ -2,8 +2,15 @@ # Copy to .env.local and fill in. Server-only secrets (no NEXT_PUBLIC_ prefix) # never reach the client bundle. -# -- Claude Agent SDK (REQUIRED for /api/agent) -- +# -- LLM for /api/agent (set ONE of the two) -- +# Option 1: Anthropic Claude - preferred when set. ANTHROPIC_API_KEY=sk-ant-... +# Option 2: Groq - free tier, no credit card (https://console.groq.com/keys). +# Used automatically when ANTHROPIC_API_KEY is empty. OpenAI-compatible. +GROQ_API_KEY= +# Optional Groq model override (default: llama-3.3-70b-versatile). If the free +# tier rejects 70b, drop to a smaller free model: llama-3.1-8b-instant +GROQ_MODEL= # -- Arbitrum Sepolia (Scenario A) -- ARB_SEPOLIA_RPC_URL=https://sepolia-rollup.arbitrum.io/rpc diff --git a/examples/arbitrum-london/README.md b/examples/arbitrum-london/README.md index 409ef83b..a8954987 100644 --- a/examples/arbitrum-london/README.md +++ b/examples/arbitrum-london/README.md @@ -19,17 +19,21 @@ the tables below are the judge-facing copy, filled in after deploy (see [`DEPLOY | 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__) | +| AgentPolicyGate | [`0x8C696D9f12e83c9E36E9d64e973C064DF1ECe784`](https://sepolia.arbiscan.io/address/0x8C696D9f12e83c9E36E9d64e973C064DF1ECe784) | +| MockPendleRouter | [`0x92d8a5C349DF76aF764e91d6cb92101D2d8623C5`](https://sepolia.arbiscan.io/address/0x92d8a5C349DF76aF764e91d6cb92101D2d8623C5) | +| Agent-executed tx (`executeEnvelope`) | [`0x97ca441d66a1a934f94de1cde628cde145359f45e114270184894f35cadb9c3a`](https://sepolia.arbiscan.io/tx/0x97ca441d66a1a934f94de1cde628cde145359f45e114270184894f35cadb9c3a) | + +Both contracts were deployed 2026-06-05 (gate [deploy tx](https://sepolia.arbiscan.io/tx/0xfddcde74bc449d37035820faf1ddd42ffe2d953c04e3a96641df3048fc319f1d), router [deploy tx](https://sepolia.arbiscan.io/tx/0x003637ee7cc10942bd51455256d6f07e2731c20c0f8e6b043d31bf6312a90bb1)); the agent signer is `0xEC6613578be203e23e360A3985EA1601435D5907` and the router is allow-listed on the gate. The `executeEnvelope` tx above is a real `SmokeExecuteEnvelope` run (see [`DEPLOY.md`](./DEPLOY.md)) - same gate, same EIP-712 envelope the dApp signs; the `/flow-a` UI lands the same shape. ### 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__) | +| AgentPolicyGate | [`0x03008A57b9f1FA575D891a26b70608381D1Ab19E`](https://explorer.testnet.chain.robinhood.com/address/0x03008A57b9f1FA575D891a26b70608381D1Ab19E) | +| MockPendleRouter | [`0xDCF04578bD2C379dc6BaD97bD21A37aC65F53D51`](https://explorer.testnet.chain.robinhood.com/address/0xDCF04578bD2C379dc6BaD97bD21A37aC65F53D51) | +| Agent-executed tx (`executeEnvelope`) | [`0xfe39ae63191998fb6af0c4fc9868aa7ad7b4c17a506f2f9a22ffee02f17fefcc`](https://explorer.testnet.chain.robinhood.com/tx/0xfe39ae63191998fb6af0c4fc9868aa7ad7b4c17a506f2f9a22ffee02f17fefcc) | + +Both contracts were deployed 2026-06-05 (gate [deploy tx](https://explorer.testnet.chain.robinhood.com/tx/0x9dce4f1e054b1178d941b552b3d519781a043ae4d7f0636c538f40f63c2ead95), router [deploy tx](https://explorer.testnet.chain.robinhood.com/tx/0x78eab8464937b8dc29b5682179de1f20531b442428f2679f83967d35b6a54f61)); the agent signer is `0xEC6613578be203e23e360A3985EA1601435D5907` and the router is [allow-listed](https://explorer.testnet.chain.robinhood.com/tx/0x26f3b1015bdd2250cc86995dee807f534105a49ca940a35660c647341f438d6f) on the gate. The `executeEnvelope` tx above is a real `SmokeExecuteEnvelope` run - same gate, same EIP-712 envelope the dApp signs, full five-check path on-chain. Robinhood Chain (Arbitrum Orbit) runs the cancun/PUSH0 bytecode as-is; forge's EIP-3855 "might not work properly" warning is only a stale chain-id allowlist, not a runtime limit. ## What this proves diff --git a/examples/arbitrum-london/app/api/agent/route.ts b/examples/arbitrum-london/app/api/agent/route.ts index 44ce7420..b96bd118 100644 --- a/examples/arbitrum-london/app/api/agent/route.ts +++ b/examples/arbitrum-london/app/api/agent/route.ts @@ -7,6 +7,7 @@ import { buildPendleEnvelope, type DemoEnvelope, } from '@/src/agent/envelope-builder' +import { runAgentTurn, type AgentProvider, type AgentTurn } from '@/src/agent/llm' import { signEnvelope } from '@/src/agent/signing' import { PENDLE_SYSTEM_PROMPT, @@ -35,18 +36,19 @@ export const runtime = 'nodejs' * -> { reply, envelope?, scenario, milestone } * * Pendle flow: - * 1. Claude reads PENDLE_SYSTEM_PROMPT + receives PENDLE_TOOL_DEFINITION. - * 2. If args are clear: Claude calls prepare_pendle_yield_swap. + * 1. The model reads PENDLE_SYSTEM_PROMPT + receives PENDLE_TOOL_DEFINITION. + * 2. If args are clear: it calls prepare_pendle_yield_swap. * 3. We validate via zod, buildPendleEnvelope, sign EIP-712 via signEnvelope, * attach signature, return both the assistant's text reply (if any) and * the signed envelope. - * 4. If Claude asks for clarification (text only): return reply, no envelope. + * 4. If the model asks for clarification (text only): return reply, no envelope. * * RWA flow lands Phase 2 Day 10. * - * The SDK is dynamically imported inside the handler so the route module - * stays loadable even when ANTHROPIC_API_KEY is unset (better DX for the - * first contributor cloning the repo). + * The model call is delegated to runAgentTurn (src/agent/llm.ts), which picks + * Anthropic Claude when ANTHROPIC_API_KEY is set and otherwise the free, + * OpenAI-compatible Groq endpoint when GROQ_API_KEY is set. Everything below the + * call is provider-agnostic. */ const DEFAULT_RECEIVER = '0x000000000000000000000000000000000000dEaD' as const @@ -56,28 +58,6 @@ type AgentRequestBody = { receiverAddress?: `0x${string}`, } -type ToolUseBlock = { - type: 'tool_use', - id: string, - name: string, - input: Record, -} - -type TextBlock = { - type: 'text', - text: string, -} - -type ResponseContentBlock = ToolUseBlock | TextBlock | { type: string } - -const checkIsToolUseBlock = (block: ResponseContentBlock): block is ToolUseBlock => { - return block.type === 'tool_use' -} - -const checkIsTextBlock = (block: ResponseContentBlock): block is TextBlock => { - return block.type === 'text' -} - export const POST = async (request: NextRequest) => { let body: AgentRequestBody try { @@ -123,62 +103,57 @@ export const POST = async (request: NextRequest) => { ) } - const { ANTHROPIC_API_KEY, AGENT_SIGNER_PRIVATE_KEY } = env - - if (ANTHROPIC_API_KEY === undefined) { - return NextResponse.json( - { - error: 'ANTHROPIC_API_KEY not set', - hint: 'Add ANTHROPIC_API_KEY to .env.local - see .env.example', - }, - { status: 503 }, - ) + const { ANTHROPIC_API_KEY, GROQ_API_KEY, GROQ_MODEL, AGENT_SIGNER_PRIVATE_KEY } = env + + let provider: AgentProvider | null = null + let apiKey: string | undefined + let model = '' + if (ANTHROPIC_API_KEY !== undefined) { + provider = 'anthropic' + apiKey = ANTHROPIC_API_KEY + model = 'claude-haiku-4-5' + } else if (GROQ_API_KEY !== undefined) { + provider = 'groq' + apiKey = GROQ_API_KEY + model = GROQ_MODEL } - const { default: Anthropic } = await import('@anthropic-ai/sdk').catch(() => ({ default: null })) - if (Anthropic === null) { + if (provider === null || apiKey === undefined) { return NextResponse.json( { - error: '@anthropic-ai/sdk not installed', - hint: 'Run `pnpm install` in the workspace root to pull workspace deps', + error: 'No LLM API key set', + hint: + 'Set ANTHROPIC_API_KEY, or GROQ_API_KEY for the free Groq tier ' + + '(console.groq.com) - see .env.example.', }, { status: 503 }, ) } - const anthropic = new Anthropic({ apiKey: ANTHROPIC_API_KEY }) - const systemPrompt = scenario === 'rwa' ? RWA_SYSTEM_PROMPT : PENDLE_SYSTEM_PROMPT const toolDefinition = scenario === 'rwa' ? RWA_TOOL_DEFINITION : PENDLE_TOOL_DEFINITION - let completion + let turn: AgentTurn try { - completion = await anthropic.messages.create({ - model: 'claude-haiku-4-5', - max_tokens: 1024, - system: systemPrompt, - tools: [ toolDefinition ], - messages: messages.map((message) => ({ - role: message.role, - content: message.content, - })), + turn = await runAgentTurn({ + provider, + apiKey, + model, + systemPrompt, + tool: toolDefinition, + messages, }) } catch (modelError) { return NextResponse.json( - { error: 'Anthropic call failed', detail: String(modelError) }, + { error: 'Model call failed', detail: String(modelError) }, { status: 502 }, ) } - const contentBlocks = completion.content as ResponseContentBlock[] - const textReply = contentBlocks - .filter(checkIsTextBlock) - .map((block) => block.text) - .join('\n') - const toolUseBlock = contentBlocks.find(checkIsToolUseBlock) + const { textReply, toolName, toolInput } = turn - // No tool call - Claude is asking for clarification or refusing. - if (toolUseBlock === undefined) { + // No tool call - the model is asking for clarification or refusing. + if (toolName === undefined) { return NextResponse.json({ reply: textReply, scenario, @@ -195,8 +170,6 @@ export const POST = async (request: NextRequest) => { ) } - const { name: toolName, input: toolInput } = toolUseBlock - if (toolName !== 'prepare_pendle_yield_swap') { return NextResponse.json( { @@ -207,7 +180,7 @@ export const POST = async (request: NextRequest) => { ) } - const argsParse = preparePendleYieldSwapArgs.safeParse(toolInput) + const argsParse = preparePendleYieldSwapArgs.safeParse(toolInput ?? {}) if (!argsParse.success) { return NextResponse.json( { diff --git a/examples/arbitrum-london/app/api/decode/route.ts b/examples/arbitrum-london/app/api/decode/route.ts index b3ad610a..98da1514 100644 --- a/examples/arbitrum-london/app/api/decode/route.ts +++ b/examples/arbitrum-london/app/api/decode/route.ts @@ -117,7 +117,7 @@ export const POST = async (request: NextRequest) => { try { const decoded = await decodeCall( - { chain, call: { to: call.to, data: call.data } }, + { chain, call: { to: call.to, data: call.data, operation: 'call' } }, { registry: mergedRegistry }, ) diff --git a/examples/arbitrum-london/app/flow-a/AgentReasoning/AgentReasoning.tsx b/examples/arbitrum-london/app/flow-a/AgentReasoning/AgentReasoning.tsx new file mode 100644 index 00000000..9ebfcaa4 --- /dev/null +++ b/examples/arbitrum-london/app/flow-a/AgentReasoning/AgentReasoning.tsx @@ -0,0 +1,77 @@ +import { Icon } from '@/src/ui/Icon' + + +type AgentReasoningProps = { + reasoningLines: string[], + isPreparing: boolean, + isPrepared: boolean, +} + +const resolveStatus = (isPreparing: boolean, isPrepared: boolean, hasLines: boolean): string => { + if (isPreparing) { + return 'Preparing transaction...' + } + + if (isPrepared) { + return 'Transaction prepared' + } + + if (hasLines) { + return 'Awaiting your confirmation' + } + + return 'Ready' +} + +/** + * The visible "agent thinking" indicator for the Pendle flow. While the agent + * call is in flight it pulses and shows typing dots; once a reply lands its real + * text (sentence-split upstream) renders as staggered reasoning lines. The lines + * are never fabricated - they are the agent's actual reply, so the card is an + * honest recap of why the envelope was prepared. + */ +export const AgentReasoning = (props: AgentReasoningProps) => { + const { reasoningLines, isPreparing, isPrepared } = props + const hasLines = reasoningLines.length > 0 + const status = resolveStatus(isPreparing, isPrepared, hasLines) + const iconAnimationClass = isPreparing ? 'tx-anim-pulse' : '' + + const linesNode = hasLines ? ( +
+ {reasoningLines.map((line, index) => ( +
+ +

{line}

+
+ ))} +
+ ) : null + + const typingNode = isPreparing ? ( + + ) : null + + return ( +
+
+ + + +
+

Agent reasoning

+

{status}

+
+
+ {linesNode} + {typingNode} +
+ ) +} diff --git a/examples/arbitrum-london/app/flow-a/PendleAgentChat.tsx b/examples/arbitrum-london/app/flow-a/PendleAgentChat.tsx index 748e38cc..c6de3408 100644 --- a/examples/arbitrum-london/app/flow-a/PendleAgentChat.tsx +++ b/examples/arbitrum-london/app/flow-a/PendleAgentChat.tsx @@ -10,9 +10,11 @@ import { ChatMessage } from '@/src/ui/ChatMessage' import { EnvelopePreview } from '@/src/ui/EnvelopePreview' import { SequencerFeeRow } from '@/src/ui/SequencerFeeRow' +import { AgentReasoning } from './AgentReasoning/AgentReasoning' +import { PolicyChecklist } from './PolicyChecklist/PolicyChecklist' import { SignEnvelopeActions } from './SignEnvelopeActions' import { fetchDecoded, type DecodedCall } from './utils/fetchDecoded' -import { formatChainLabel, formatExplorerBase, resolveReplyText } from './utils/formatters' +import { formatChainLabel, formatExplorerBase, resolveReplyText, splitReasoningLines } from './utils/formatters' type Message = { id: string, role: 'user' | 'assistant', content: string } @@ -151,16 +153,28 @@ export const PendleAgentChat = () => { ) + const latestAssistant = [ ...messages ].reverse().find((message) => message.role === 'assistant') + const isPrepared = envelope !== null + const reasoningLines = isPrepared && latestAssistant !== undefined + ? splitReasoningLines(latestAssistant.content) + : [] + const transcriptMessages = messages.filter((message) => { + const isHoistedIntoReasoning = isPrepared && message.id === latestAssistant?.id + return !isHoistedIntoReasoning + }) + const messagesNode = messages.length === 0 ? emptyStateNode - : messages.map((message) => ( + : transcriptMessages.map((message) => ( )) - const loadingNode = isLoading ? ( -
Agent thinking…
+ const reasoningNode = isLoading || isPrepared ? ( + ) : null + const checklistNode = isPrepared ? : null + const errorNode = errorMessage !== null ? (
{errorMessage} @@ -208,11 +222,12 @@ export const PendleAgentChat = () => {
{messagesNode} - {loadingNode}
+ {reasoningNode} {errorNode} {previewNode} + {checklistNode} {actionsNode}
diff --git a/examples/arbitrum-london/app/flow-a/PolicyChecklist/PolicyChecklist.tsx b/examples/arbitrum-london/app/flow-a/PolicyChecklist/PolicyChecklist.tsx new file mode 100644 index 00000000..e2ce307d --- /dev/null +++ b/examples/arbitrum-london/app/flow-a/PolicyChecklist/PolicyChecklist.tsx @@ -0,0 +1,65 @@ +import { Icon } from '@/src/ui/Icon' + + +type PolicyCheckItem = { + id: string, + label: string, +} + +/** + * The five invariants AgentPolicyGate.executeEnvelope enforces on-chain. They + * are real properties of a prepared, agent-signed envelope - knowable before + * submission - so clearing them as a pre-flight checklist is honest rather than + * a simulated network call. Labels mirror the Solidity checks 1:1. + */ +const POLICY_CHECKS: ReadonlyArray = [ + { id: 'value', label: 'Forwarded value matches declared value' }, + { id: 'replay', label: 'Not a replay (fresh envelope)' }, + { id: 'allowlist', label: 'Recipient is on the allow-list' }, + { id: 'spend', label: 'Within the spend cap' }, + { id: 'signature', label: 'Agent signature valid (EIP-712)' }, +] + +/** + * Pre-flight view of the policy-gate checks for a prepared envelope. Each check + * pops in green, staggered, as a reveal of constraints the envelope already + * satisfies - not a live verification with fake latency. + */ +export const PolicyChecklist = () => { + return ( +
+
+ + + +
+

Policy gate

+

Enforced on-chain by AgentPolicyGate

+
+
+ +
    + {POLICY_CHECKS.map((check, index) => ( +
  • + + + +

    {check.label}

    +
  • + ))} +
+ +
+

This envelope satisfies all policy-gate checks

+
+
+ ) +} diff --git a/examples/arbitrum-london/app/flow-a/SignEnvelopeActions.tsx b/examples/arbitrum-london/app/flow-a/SignEnvelopeActions.tsx index 7cd5d84e..7ff499f5 100644 --- a/examples/arbitrum-london/app/flow-a/SignEnvelopeActions.tsx +++ b/examples/arbitrum-london/app/flow-a/SignEnvelopeActions.tsx @@ -1,3 +1,5 @@ +import { Icon } from '@/src/ui/Icon' + import { formatTxExplorerUrl, resolveExplorerLabel } from './utils/formatters' @@ -60,19 +62,49 @@ export const SignEnvelopeActions = (props: SignEnvelopeActionsProps) => { ) : null const explorerLabel = resolveExplorerLabel(envelopeChainId) - const txLinkNode = txHash !== undefined && envelopeChainId !== null ? ( + const explorerUrl = txHash !== undefined && envelopeChainId !== null + ? formatTxExplorerUrl(envelopeChainId, txHash) + : null + + const pendingLinkNode = txHash !== undefined && explorerUrl !== null && !isConfirmed ? ( - {isConfirmed ? `Confirmed on ${explorerLabel}` : `View pending tx on ${explorerLabel}`}: {txHash} + View pending tx on {explorerLabel}: {txHash} ) : null + const successNode = txHash !== undefined && explorerUrl !== null && isConfirmed ? ( +
+
+ + + +
+

Executed on-chain

+

Transaction confirmed

+
+
+

Transaction hash

+ + {txHash} + + +

View on {explorerLabel}

+
+ ) : null + return ( -
+
+ {successNode}
{notConnectedNode} {sendErrorNode} - {txLinkNode} + {pendingLinkNode}
) } diff --git a/examples/arbitrum-london/app/flow-a/utils/formatters.ts b/examples/arbitrum-london/app/flow-a/utils/formatters.ts index ad3df289..f6878c5a 100644 --- a/examples/arbitrum-london/app/flow-a/utils/formatters.ts +++ b/examples/arbitrum-london/app/flow-a/utils/formatters.ts @@ -65,3 +65,16 @@ export const resolveReplyText = (reply: string | undefined, hasEnvelope: boolean return '(empty reply)' } + +/** + * Splits the agent's real reply into sentence-sized lines for the reasoning + * card. Pure text transform - it never invents content, it only breaks the + * actual reply on sentence boundaries so a one-or-two sentence reply reads as a + * short, honest reasoning list. + */ +export const splitReasoningLines = (text: string): string[] => { + return text + .split(/(?<=[.!?])\s+/) + .map((part) => part.trim()) + .filter((part) => part.length > 0) +} diff --git a/examples/arbitrum-london/app/globals.css b/examples/arbitrum-london/app/globals.css index f1a7bfb2..1df81395 100644 --- a/examples/arbitrum-london/app/globals.css +++ b/examples/arbitrum-london/app/globals.css @@ -50,3 +50,81 @@ body { cursor: pointer; } } + +/* + * Mask plumbing for the component (src/ui/Icon.tsx). The icon shape is + * supplied per instance via `mask-image`; everything tints with `bg-current`, + * so an icon picks up whatever `text-*` color sits on it. + */ +.tx-icon-mask { + display: inline-block; + flex-shrink: 0; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; +} + +/* + * flow-a hero animations - the agent-reasoning, policy-gate and execution + * moments of the Pendle demo. Pure CSS keyframes (no Framer Motion); stagger is + * applied per element with an inline `animation-delay`. Every entrance keyframe + * uses `both` so an element holds its hidden start state until its delay fires + * and its visible end state afterwards. + */ +@keyframes tx-pulse-soft { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.06); } +} + +@keyframes tx-enter-x { + from { opacity: 0; transform: translateX(-8px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes tx-enter-y { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes tx-pop { + 0% { opacity: 0; transform: scale(0.4) rotate(-90deg); } + 60% { opacity: 1; transform: scale(1.1) rotate(0deg); } + 100% { opacity: 1; transform: scale(1) rotate(0deg); } +} + +@keyframes tx-spin { + to { transform: rotate(360deg); } +} + +@keyframes tx-typing-dot { + 0%, 100% { transform: scale(1); opacity: 0.4; } + 50% { transform: scale(1.6); opacity: 1; } +} + +@keyframes tx-card-in { + from { opacity: 0; transform: scale(0.97); } + to { opacity: 1; transform: scale(1); } +} + +.tx-anim-pulse { animation: tx-pulse-soft 2s ease-in-out infinite; } +.tx-anim-enter-x { animation: tx-enter-x 0.35s ease-out both; } +.tx-anim-enter-y { animation: tx-enter-y 0.35s ease-out both; } +.tx-anim-pop { animation: tx-pop 0.45s ease-out both; } +.tx-anim-spin { animation: tx-spin 1s linear infinite; } +.tx-anim-typing { animation: tx-typing-dot 1s ease-in-out infinite; } +.tx-anim-card-in { animation: tx-card-in 0.4s ease-out both; } + +@media (prefers-reduced-motion: reduce) { + .tx-anim-pulse, + .tx-anim-enter-x, + .tx-anim-enter-y, + .tx-anim-pop, + .tx-anim-spin, + .tx-anim-typing, + .tx-anim-card-in { + animation: none; + } +} diff --git a/examples/arbitrum-london/contracts/deployed.json b/examples/arbitrum-london/contracts/deployed.json index b3b76741..c0fabfaa 100644 --- a/examples/arbitrum-london/contracts/deployed.json +++ b/examples/arbitrum-london/contracts/deployed.json @@ -1,24 +1,30 @@ { "AgentPolicyGate": { "421614": { - "address": "0x__PENDING__", - "deployedAt": null, - "blockExplorer": "https://sepolia.arbiscan.io/address/0x__PENDING__", - "note": "Pending Mike deploy via forge script DeployArbSepolia.s.sol --broadcast --verify" + "address": "0x8C696D9f12e83c9E36E9d64e973C064DF1ECe784", + "deployedAt": "2026-06-05", + "blockExplorer": "https://sepolia.arbiscan.io/address/0x8C696D9f12e83c9E36E9d64e973C064DF1ECe784", + "note": "Deployed to Arbitrum Sepolia 2026-06-05. Tx: 0xfddcde74bc449d37035820faf1ddd42ffe2d953c04e3a96641df3048fc319f1d. Owner + agent signer: 0xEC6613578be203e23e360A3985EA1601435D5907" }, "46630": { - "address": "0x__PENDING__", - "deployedAt": null, - "blockExplorer": "https://explorer.testnet.chain.robinhood.com/address/0x__PENDING__", - "note": "Pending Mike deploy via forge script DeployRobinhoodTestnet.s.sol --broadcast" + "address": "0x03008A57b9f1FA575D891a26b70608381D1Ab19E", + "deployedAt": "2026-06-05", + "blockExplorer": "https://explorer.testnet.chain.robinhood.com/address/0x03008A57b9f1FA575D891a26b70608381D1Ab19E", + "note": "Deployed to Robinhood Chain testnet 2026-06-05. Tx: 0x9dce4f1e054b1178d941b552b3d519781a043ae4d7f0636c538f40f63c2ead95. Owner + agent signer: 0xEC6613578be203e23e360A3985EA1601435D5907. Robinhood (Arbitrum Orbit) executes PUSH0/cancun bytecode - the forge EIP-3855 warning is a stale chain-id allowlist, not a runtime limit. executeEnvelope verified non-reverting via SmokeExecuteEnvelope tx 0xfe39ae63191998fb6af0c4fc9868aa7ad7b4c17a506f2f9a22ffee02f17fefcc" } }, "MockPendleRouter": { "421614": { - "address": "0x__PENDING__", - "deployedAt": null, - "blockExplorer": "https://sepolia.arbiscan.io/address/0x__PENDING__", - "note": "Pending Mike deploy via forge script DeployMockPendleRouter.s.sol --broadcast. Allow-list required: cast send setAllowedRecipient(address,bool) true" + "address": "0x92d8a5C349DF76aF764e91d6cb92101D2d8623C5", + "deployedAt": "2026-06-05", + "blockExplorer": "https://sepolia.arbiscan.io/address/0x92d8a5C349DF76aF764e91d6cb92101D2d8623C5", + "note": "Deployed to Arbitrum Sepolia 2026-06-05. Tx: 0x003637ee7cc10942bd51455256d6f07e2731c20c0f8e6b043d31bf6312a90bb1. Allow-list via setAllowedRecipient on gate 0x8C696D9f12e83c9E36E9d64e973C064DF1ECe784" + }, + "46630": { + "address": "0xDCF04578bD2C379dc6BaD97bD21A37aC65F53D51", + "deployedAt": "2026-06-05", + "blockExplorer": "https://explorer.testnet.chain.robinhood.com/address/0xDCF04578bD2C379dc6BaD97bD21A37aC65F53D51", + "note": "Deployed to Robinhood Chain testnet 2026-06-05. Tx: 0x78eab8464937b8dc29b5682179de1f20531b442428f2679f83967d35b6a54f61. Allow-listed on gate 0x03008A57b9f1FA575D891a26b70608381D1Ab19E via setAllowedRecipient (tx 0x26f3b1015bdd2250cc86995dee807f534105a49ca940a35660c647341f438d6f)" } } } diff --git a/examples/arbitrum-london/contracts/foundry.toml b/examples/arbitrum-london/contracts/foundry.toml index 13bc8e65..47cc4a68 100644 --- a/examples/arbitrum-london/contracts/foundry.toml +++ b/examples/arbitrum-london/contracts/foundry.toml @@ -33,5 +33,7 @@ arbitrum_sepolia = '${ARB_SEPOLIA_RPC_URL}' robinhood_testnet = '${ROBINHOOD_TESTNET_RPC_URL}' [etherscan] -arbitrum_sepolia = { key = '${ARBISCAN_API_KEY}', url = 'https://api-sepolia.arbiscan.io/api' } +# Etherscan API V2 unified endpoint (one key across chains, chainid resolved from --chain). +# The legacy per-chain V1 url (api-sepolia.arbiscan.io/api) was deprecated in the 2025 V2 migration. +arbitrum_sepolia = { key = '${ARBISCAN_API_KEY}' } # Robinhood Chain testnet explorer verification config to be added when API published diff --git a/examples/arbitrum-london/decoder-data/agent-policy-gate.json b/examples/arbitrum-london/decoder-data/agent-policy-gate.json index d042b3b5..6295bc52 100644 --- a/examples/arbitrum-london/decoder-data/agent-policy-gate.json +++ b/examples/arbitrum-london/decoder-data/agent-policy-gate.json @@ -1,8 +1,8 @@ [ { "chain": "eip155:421614", - "address": "0x0000000000000000000000000000000000000000", - "label": "AgentPolicyGate (Buildathon, Arbitrum Sepolia - PENDING DEPLOY)", + "address": "0x8C696D9f12e83c9E36E9d64e973C064DF1ECe784", + "label": "AgentPolicyGate (Buildathon, Arbitrum Sepolia)", "abi": [ { "type": "function", @@ -70,8 +70,8 @@ }, { "chain": "eip155:46630", - "address": "0x0000000000000000000000000000000000000000", - "label": "AgentPolicyGate (Buildathon, Robinhood Chain testnet - PENDING DEPLOY)", + "address": "0x03008A57b9f1FA575D891a26b70608381D1Ab19E", + "label": "AgentPolicyGate (Buildathon, Robinhood Chain testnet)", "abi": [ { "type": "function", diff --git a/examples/arbitrum-london/decoder-data/mock-pendle-router.json b/examples/arbitrum-london/decoder-data/mock-pendle-router.json index f6279cdc..7578338a 100644 --- a/examples/arbitrum-london/decoder-data/mock-pendle-router.json +++ b/examples/arbitrum-london/decoder-data/mock-pendle-router.json @@ -1,8 +1,49 @@ [ { "chain": "eip155:421614", - "address": "0x0000000000000000000000000000000000000000", - "label": "MockPendleRouter (Buildathon, Arbitrum Sepolia - PENDING DEPLOY)", + "address": "0x92d8a5C349DF76aF764e91d6cb92101D2d8623C5", + "label": "MockPendleRouter (Buildathon, Arbitrum Sepolia)", + "abi": [ + { + "type": "function", + "name": "swapExactTokenForPt", + "stateMutability": "nonpayable", + "inputs": [ + { "name": "receiver", "type": "address" }, + { "name": "ptOut", "type": "address" }, + { "name": "amountIn", "type": "uint256" }, + { "name": "minPtOut", "type": "uint256" } + ], + "outputs": [{ "name": "ptOutReturned", "type": "uint256" }] + }, + { + "type": "event", + "name": "SwapExactTokenForPt", + "inputs": [ + { "indexed": true, "name": "receiver", "type": "address" }, + { "indexed": true, "name": "caller", "type": "address" }, + { "indexed": true, "name": "ptOut", "type": "address" }, + { "indexed": false, "name": "amountIn", "type": "uint256" }, + { "indexed": false, "name": "ptOutReturned", "type": "uint256" } + ] + } + ], + "clearSigning": { + "swapExactTokenForPt": { + "title": "Pendle yield swap (mock router)", + "fields": { + "receiver": "Recipient of PT tokens", + "ptOut": "Pendle PT token to receive", + "amountIn": "Input amount (raw token units, 1:0.995 deterministic rate)", + "minPtOut": "Min PT out after declared slippage" + } + } + } + }, + { + "chain": "eip155:46630", + "address": "0xDCF04578bD2C379dc6BaD97bD21A37aC65F53D51", + "label": "MockPendleRouter (Buildathon, Robinhood Chain testnet)", "abi": [ { "type": "function", diff --git a/examples/arbitrum-london/docs/flow-a-polish-plan.md b/examples/arbitrum-london/docs/flow-a-polish-plan.md new file mode 100644 index 00000000..5ce70503 --- /dev/null +++ b/examples/arbitrum-london/docs/flow-a-polish-plan.md @@ -0,0 +1,88 @@ +# flow-a demo polish - implementation plan (from Figma design) + +> Written before a context compaction so the design analysis survives. Execute from this file. + +**Branch:** `feat/arbitrum-pendle-real-tx` (off main). PR #26 open. +**Goal:** adapt the approved Figma design into the real `app/flow-a` Pendle flow - the 3 hero moments for the 90-second buildathon demo video, on the deployed Arbitrum Sepolia contracts. + +## Design source + +- Zip: `~/Downloads/TransactionSafetyFlowDesign.zip` (extracted earlier to `/tmp/txsfd`, may not survive compaction - re-unzip if needed). +- It is a standalone Vite+React+Tailwind+shadcn prototype with SIMULATED timers. We take its UX/structure, NOT its code (wrong stack), and wire it to real flow-a data. +- Key design files: `src/app/App.tsx` (flow state machine), `src/app/components/{agent-reasoning,policy-verification,transaction-preview,execution-success}.tsx`. + +## Stack translation (the design does NOT drop in) + +The design uses `motion/react` (Framer Motion), `lucide-react` (inline SVG), shadcn `Card/Badge/Button`. txKit forbids all three. Translate: +- **Framer Motion -> CSS keyframe animations.** Use the example's Tailwind + the `--tx-duration-*` / `--tx-ease-*` tokens. Respect `prefers-reduced-motion` (one block at end of each CSS file). +- **lucide inline SVG -> CSS-mask icons.** Rule: `assets/icons/.svg` + a `` with CSS mask. Icons needed: `brain`, `shield`, `check-circle`, `external-link`, `copy`. Check `app/flow-a` / `src/ui` for existing mask-icon pattern first (CopyableValue may already have copy/external-link). +- **shadcn Card/Badge/Button -> plain divs with the example's Tailwind classes** (the example already styles cards as `rounded-lg border border-border bg-card p-6`). + +## Token mapping (design token -> example Tailwind class) + +The example uses Tailwind classes backed by `--tx-*` via `@theme inline` (NOT raw `--tx-` vars). Verify the exact class set at build time: `grep -rE '@theme|--color-' app/globals.css` (or wherever the theme block is) + reuse the classes already in `PendleAgentChat.tsx` / `EnvelopePreview.tsx`. + +| Design raw token | Meaning | Example Tailwind class | +|---|---|---| +| `--tx-color-primary` | brand indigo | `text-accent` / `bg-accent` | +| `--tx-color-primary-light` | indigo tint bg | `bg-accent/10` (color-mix) or `bg-card-sunken` | +| `--tx-color-success` | green | `text-success` | +| `--tx-color-success-light` | green tint bg | `bg-success-bg` (verify name) | +| `--tx-color-mono` | main/code text | `text-foreground` | +| `--tx-color-mono-light` | code block bg | `bg-card-sunken` | +| `--tx-border-default` | card border | `border-border` | +| `--tx-border-strong` | emphasized border | `border-border-hover` (or `border-accent`) | +| `--tx-color-danger(-light)` | error | `text-error` / `bg-error-bg` | +| `--tx-color-warning(-light)` | amber | `text-warning` / `bg-warning-bg` | + +## The 4 components (design intent -> txKit build) + +### 1. AgentReasoning (NEW) - HERO 1 +Card, indigo-tinted bg, `border-border`. Header: a pulsing round icon (brain, success/accent), title "Agent Reasoning" (`text-accent`), subtitle "Preparing transaction..." -> "Transaction prepared" (driven by state). Body: reasoning lines stagger-fade in (each: a small accent dot + the line); while in-progress show a 3-dot typing animation. +- **Animation (CSS):** icon pulse (scale 1 -> 1.05, ~2s loop, ease-in-out); line entry (opacity 0 + translateX(-10px) -> 1/0, staggered); typing dots (scale 1 -> 1.5, 3 dots, staggered delays 0/0.2/0.4s). +- **HONESTY - reasoning source:** do NOT hardcode "Reading your yield position...". Use the agent's REAL `reply` text (from `/api/agent`) as the reasoning line(s), plus real flow-state status lines ("Preparing envelope", "Signing"). If the reply is one sentence, that is the single reasoning line - honest beats fabricated. + +### 2. PolicyChecklist (NEW) - HERO 2 +Card, `border-border-hover`. Header: shield icon (success), title "Policy-Gate Verification" (`text-success`), subtitle "Running on-chain safety checks..." -> "All checks passed". Body: the 5 checks, each with state pending (faint circle, opacity 40) / checking (spinning circle, accent) / passed (green check, springs in). Label opacity tracks state. Footer when complete: "Transaction meets all policy requirements". +- **The 5 checks (exact labels):** (1) "Forwarded value matches declared value", (2) "Not a replay (fresh envelope)", (3) "Recipient is on the allow-list", (4) "Within the spend cap", (5) "Agent signature valid (EIP-712)". +- **Animation (CSS):** check entry (opacity 0 + translateY(5px) -> 1/0, staggered); passed check pops (scale 0 + rotate -180 -> 1/0, spring-ish); checking spinner (rotate 360, 1s linear loop). +- **HONESTY:** these 5 are real properties of the prepared+signed envelope, knowable before submission - so a pre-sign "checklist clearing" is honest. Frame as "the checks AgentPolicyGate.executeEnvelope enforces on-chain", shown pre-flight. Drive the pass sequence off envelope readiness (after `/api/agent` returns a signed envelope), not a blind timer. Replaces the single `PolicyStatusBadge`. + +### 3. EnvelopePreview upgrade +Current `src/ui/EnvelopePreview.tsx` already shows chain, target, decoded args, hash, validity, fee slot. Adopt the design's richer formatting: +- Chain as a pill/badge (`bg-accent/10 text-accent`, mono). +- Decoded function call block: function name in `text-accent`, args indented as `name: value` rows in a `bg-card-sunken` block. +- A 3-column grid footer: Valid Until / Gas Estimate / Sequencer Fee (the existing `SequencerFeeRow` provides the sequencer fee; wire gas estimate if available, else show the sequencer fee row as-is). +- Keep existing copy buttons + focus-visible rings. + +### 4. ExecutionSuccess (upgrade of existing tx-link) - HERO 3 +`SignEnvelopeActions` already renders the post-tx explorer link. Upgrade the success state to a card: green border, a check icon that springs in, "Executed On-Chain" + "Transaction confirmed", the tx hash in a `bg-card-sunken` mono row + an external-link to Arbiscan ("View on Arbiscan"). Reuse `resolveExplorerLabel` / `formatTxExplorerUrl` (already multi-chain). + +## flow-a wiring (state -> component) + +`PendleAgentChat.tsx` already has: messages, isLoading, envelope, decodedInner, txHash, isSigning/isConfirming/isConfirmed. Map: +- isLoading (agent call in flight) -> AgentReasoning in "preparing" state (subtitle "Preparing transaction...", typing dots). +- envelope returned -> AgentReasoning "complete" + EnvelopePreview + PolicyChecklist (run the pass sequence on envelope arrival). +- envelope present + not signing -> Sign / Reject buttons (exist in SignEnvelopeActions). +- isSigning -> "Waiting for wallet signature..." pill. +- isConfirmed + txHash -> ExecutionSuccess card. +- Keep the existing `DeployPendingBanner` (now clears - contracts deployed). + +## Files + +- Create: `app/flow-a/AgentReasoning/AgentReasoning.tsx` (+ `.css`), `app/flow-a/PolicyChecklist/PolicyChecklist.tsx` (+ `.css`). Folder-per-component (project rule). Scoped helpers in `utils/` if needed. +- Modify: `src/ui/EnvelopePreview.tsx` (+ its css) - function-call formatting + 3-col grid. +- Modify: `app/flow-a/SignEnvelopeActions.tsx` - success card. +- Modify: `app/flow-a/PendleAgentChat.tsx` - render the new components per state; pass real reply as reasoning. +- Add icons: `assets/icons/{brain,shield,check-circle}.svg` (+ external-link/copy if not already present). MD5-dedup check before adding. + +## Code rules (apply throughout) + +Arrow functions; `const Component: React.FC` for internal, `forwardRef` + displayName + data-testid for public; blank line before `return (`; destructure when 3+ accesses; no double ternaries (split into `const xNode = cond ? : null` + `||` chain); no single-line if; no inline SVG (CSS mask); `||` over `??`; no em-dash (hyphen only); WCAG AA (focus-visible rings, `role`/`aria-live` on status, `prefers-reduced-motion` block per CSS file, 44px touch targets); English in code. + +## Verification + +- `pnpm exec tsc --noEmit` · `pnpm exec eslint app src` · `pnpm exec next build` · em-dash scan (`git diff main...HEAD | grep -nP '[\x{2014}\x{2013}]'`). +- Visual: `pnpm dev` -> `/flow-a` - the 3 hero moments render; reduced-motion respected. (Full agent run needs ANTHROPIC_API_KEY + Mike; verify the static/preview render + build.) +- Do NOT touch the other-session uncommitted changes in the working tree (Robinhood deploy sync in deployed.json/decoder-data/README - intentional, leave them; `git add` only flow-a polish files). +- Commit per component; PR #26 stays open (not merge). diff --git a/examples/arbitrum-london/public/icons/brain.svg b/examples/arbitrum-london/public/icons/brain.svg new file mode 100644 index 00000000..b72be7b6 --- /dev/null +++ b/examples/arbitrum-london/public/icons/brain.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/examples/arbitrum-london/public/icons/check-circle.svg b/examples/arbitrum-london/public/icons/check-circle.svg new file mode 100644 index 00000000..ae0f3139 --- /dev/null +++ b/examples/arbitrum-london/public/icons/check-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/examples/arbitrum-london/public/icons/shield.svg b/examples/arbitrum-london/public/icons/shield.svg new file mode 100644 index 00000000..a17b7593 --- /dev/null +++ b/examples/arbitrum-london/public/icons/shield.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/arbitrum-london/src/agent/llm.ts b/examples/arbitrum-london/src/agent/llm.ts new file mode 100644 index 00000000..022a6d9d --- /dev/null +++ b/examples/arbitrum-london/src/agent/llm.ts @@ -0,0 +1,187 @@ +import type Anthropic from '@anthropic-ai/sdk' + + +/** + * Provider-agnostic single agent turn. Everything downstream of the model call + * (zod validation, envelope build, EIP-712 signing) is identical regardless of + * who produced the tool call, so this module is the only place that knows about + * a specific LLM vendor. + * + * Two providers: + * - anthropic: Claude via @anthropic-ai/sdk (dynamically imported so the route + * stays loadable without the key/SDK). + * - groq: the free, OpenAI-compatible Groq endpoint (no extra dependency - a + * plain fetch). Any OpenAI-compatible host (Gemini, OpenRouter) would slot in + * the same way by swapping the base URL. + * + * The tool is described once in Anthropic shape (tools.ts) and translated to the + * OpenAI function shape for Groq, so there is a single source of truth. + */ + +export type AgentProvider = 'anthropic' | 'groq' + +export type AgentTurn = { + textReply: string, + toolName: string | undefined, + toolInput: Record | undefined, +} + +type ChatMessage = { + role: 'user' | 'assistant', + content: string, +} + +type RunAgentTurnParams = { + provider: AgentProvider, + apiKey: string, + model: string, + systemPrompt: string, + tool: Anthropic.Tool, + messages: ChatMessage[], +} + +const GROQ_BASE_URL = 'https://api.groq.com/openai/v1' +const MAX_TOKENS = 1024 + +// ----- Anthropic (Claude) ----- + +type AnthropicToolUseBlock = { + type: 'tool_use', + id: string, + name: string, + input: Record, +} + +type AnthropicTextBlock = { + type: 'text', + text: string, +} + +type AnthropicBlock = AnthropicToolUseBlock | AnthropicTextBlock | { type: string } + +const checkIsAnthropicToolUse = (block: AnthropicBlock): block is AnthropicToolUseBlock => { + return block.type === 'tool_use' +} + +const checkIsAnthropicText = (block: AnthropicBlock): block is AnthropicTextBlock => { + return block.type === 'text' +} + +const runAnthropicTurn = async (params: RunAgentTurnParams): Promise => { + const { apiKey, model, systemPrompt, tool, messages } = params + + const imported = await import('@anthropic-ai/sdk').catch(() => null) + if (imported === null) { + throw new Error('@anthropic-ai/sdk not installed - run pnpm install in the workspace root') + } + + const AnthropicSdk = imported.default + const anthropic = new AnthropicSdk({ apiKey }) + const completion = await anthropic.messages.create({ + model, + max_tokens: MAX_TOKENS, + temperature: 0, + system: systemPrompt, + tools: [ tool ], + messages: messages.map((message) => ({ role: message.role, content: message.content })), + }) + + const blocks = completion.content as AnthropicBlock[] + const textReply = blocks + .filter(checkIsAnthropicText) + .map((block) => block.text) + .join('\n') + const toolUse = blocks.find(checkIsAnthropicToolUse) + + return { + textReply, + toolName: toolUse?.name, + toolInput: toolUse?.input, + } +} + +// ----- Groq (OpenAI-compatible) ----- + +type GroqToolCall = { + function: { + name: string, + arguments: string, + }, +} + +type GroqChoice = { + message: { + content: string | null, + tool_calls?: GroqToolCall[], + }, +} + +type GroqResponse = { + choices?: GroqChoice[], + error?: { message?: string }, +} + +const parseToolArguments = (raw: string): Record => { + try { + return JSON.parse(raw) as Record + } catch { + return {} + } +} + +const runGroqTurn = async (params: RunAgentTurnParams): Promise => { + const { apiKey, model, systemPrompt, tool, messages } = params + + const openAiMessages = [ + { role: 'system', content: systemPrompt }, + ...messages.map((message) => ({ role: message.role, content: message.content })), + ] + const openAiTool = { + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.input_schema, + }, + } + + const response = await fetch(`${GROQ_BASE_URL}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + max_tokens: MAX_TOKENS, + temperature: 0, + messages: openAiMessages, + tools: [ openAiTool ], + tool_choice: 'auto', + }), + }) + + const json = (await response.json()) as GroqResponse + if (!response.ok) { + const detail = json.error?.message ?? `HTTP ${response.status}` + throw new Error(`Groq call failed: ${detail}`) + } + + const choice = json.choices?.[0] + const toolCall = choice?.message.tool_calls?.[0] + const toolInput = toolCall !== undefined ? parseToolArguments(toolCall.function.arguments) : undefined + + return { + textReply: choice?.message.content ?? '', + toolName: toolCall?.function.name, + toolInput, + } +} + +export const runAgentTurn = async (params: RunAgentTurnParams): Promise => { + if (params.provider === 'groq') { + return runGroqTurn(params) + } + + return runAnthropicTurn(params) +} diff --git a/examples/arbitrum-london/src/config/env.ts b/examples/arbitrum-london/src/config/env.ts index 3ea644b2..ab870934 100644 --- a/examples/arbitrum-london/src/config/env.ts +++ b/examples/arbitrum-london/src/config/env.ts @@ -10,8 +10,12 @@ import { z } from 'zod' * through the server. */ const envSchema = z.object({ - // Anthropic Claude Agent SDK - REQUIRED for /api/agent + // LLM for /api/agent - one of ANTHROPIC_API_KEY or GROQ_API_KEY is required. + // Anthropic Claude Agent SDK (preferred when set). ANTHROPIC_API_KEY: z.string().min(1).optional(), + // Groq - free, OpenAI-compatible fallback used when no Anthropic key is set. + GROQ_API_KEY: z.string().min(1).optional(), + GROQ_MODEL: z.string().min(1).default('llama-3.3-70b-versatile'), // Arbitrum Sepolia ARB_SEPOLIA_RPC_URL: z diff --git a/examples/arbitrum-london/src/ui/EnvelopePreview.tsx b/examples/arbitrum-london/src/ui/EnvelopePreview.tsx index 6a783831..fdb75b87 100644 --- a/examples/arbitrum-london/src/ui/EnvelopePreview.tsx +++ b/examples/arbitrum-london/src/ui/EnvelopePreview.tsx @@ -108,26 +108,33 @@ export const EnvelopePreview = (props: EnvelopePreviewProps) => { const title = decoded?.clearSigning?.title ?? decoded?.functionName ?? 'Unknown action' const fields = decoded?.clearSigning?.fields ?? {} const argList = decoded?.args ?? [] + const functionName = decoded?.functionName const badgeNode = policyStatus !== undefined ? : null - const argsNode = argList.length > 0 + const callBlockNode = functionName !== undefined ? ( -
- - Decoded arguments ({argList.length}) - -
- {argList.map((arg) => ( -
-
{fields[arg.name] ?? arg.name}
-
{stringifyValue(arg.value)}
-
- ))} -
-
+
+

Decoded function call

+
+
+ {functionName} + ( +
+
+ {argList.map((arg, index) => ( +
+ {fields[arg.name] ?? arg.name}:{' '} + {stringifyValue(arg.value)} + {index < argList.length - 1 ? , : null} +
+ ))} +
+
)
+
+
) : null @@ -135,10 +142,6 @@ export const EnvelopePreview = (props: EnvelopePreviewProps) => { ?
{feeSlot}
: null - const argsContainerNode = argsNode !== null - ?
{argsNode}
- : null - return (
@@ -151,10 +154,14 @@ export const EnvelopePreview = (props: EnvelopePreviewProps) => {

{innerLabel}

+ {callBlockNode} +
Chain - {chainLabel} + + {chainLabel} +
Policy gate @@ -179,8 +186,6 @@ export const EnvelopePreview = (props: EnvelopePreviewProps) => {
{feeNode} - - {argsContainerNode}
) } diff --git a/examples/arbitrum-london/src/ui/Icon.tsx b/examples/arbitrum-london/src/ui/Icon.tsx new file mode 100644 index 00000000..7e3c565b --- /dev/null +++ b/examples/arbitrum-london/src/ui/Icon.tsx @@ -0,0 +1,38 @@ +import type { CSSProperties } from 'react' + + +export type IconName = 'brain' | 'shield' | 'check-circle' + +type IconProps = { + name: IconName, + className?: string, +} + +const ICON_SOURCES: Record = { + 'brain': "url('/icons/brain.svg')", + 'shield': "url('/icons/shield.svg')", + 'check-circle': "url('/icons/check-circle.svg')", +} + +/** + * A monochrome icon rendered via CSS mask over the current text color, so it + * tints with any `text-*` utility (txKit rule: no inline SVG in JSX). The + * source files live in public/icons; the shared mask plumbing is the + * `.tx-icon-mask` class in globals.css. Decorative by default (aria-hidden) - + * the surrounding control carries the accessible label. + */ +export const Icon = (props: IconProps) => { + const { name, className } = props + const maskStyle: CSSProperties = { + maskImage: ICON_SOURCES[name], + WebkitMaskImage: ICON_SOURCES[name], + } + + return ( +