Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion examples/arbitrum-london/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 10 additions & 6 deletions examples/arbitrum-london/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
105 changes: 39 additions & 66 deletions examples/arbitrum-london/app/api/agent/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand All @@ -56,28 +58,6 @@ type AgentRequestBody = {
receiverAddress?: `0x${string}`,
}

type ToolUseBlock = {
type: 'tool_use',
id: string,
name: string,
input: Record<string, unknown>,
}

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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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(
{
Expand All @@ -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(
{
Expand Down
2 changes: 1 addition & 1 deletion examples/arbitrum-london/app/api/decode/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
)

Expand Down
Original file line number Diff line number Diff line change
@@ -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 ? (
<div className="space-y-2">
{reasoningLines.map((line, index) => (
<div
key={`${index}-${line}`}
className="flex items-start gap-2 tx-anim-enter-x"
style={{ animationDelay: `${index * 0.12}s` }}
>
<span className="mt-2 size-1 shrink-0 rounded-full bg-accent" />
<p className="text-sm leading-relaxed text-foreground">{line}</p>
</div>
))}
</div>
) : null

const typingNode = isPreparing ? (
<div className="flex gap-1 pt-2" aria-hidden="true">
<span className="size-1 rounded-full bg-accent tx-anim-typing" style={{ animationDelay: '0s' }} />
<span className="size-1 rounded-full bg-accent tx-anim-typing" style={{ animationDelay: '0.2s' }} />
<span className="size-1 rounded-full bg-accent tx-anim-typing" style={{ animationDelay: '0.4s' }} />
</div>
) : null

return (
<div role="status" aria-live="polite" className="rounded-lg border border-border bg-accent/10 p-5">
<div className="mb-4 flex items-center gap-3">
<span className={`flex size-10 items-center justify-center rounded-full bg-accent ${iconAnimationClass}`}>
<Icon name="brain" className="size-5 text-accent-text" />
</span>
<div>
<h3 className="text-sm font-semibold text-accent">Agent reasoning</h3>
<p className="text-xs text-muted">{status}</p>
</div>
</div>
{linesNode}
{typingNode}
</div>
)
}
25 changes: 20 additions & 5 deletions examples/arbitrum-london/app/flow-a/PendleAgentChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -151,16 +153,28 @@ export const PendleAgentChat = () => {
</div>
)

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) => (
<ChatMessage key={message.id} role={message.role} content={message.content} />
))

const loadingNode = isLoading ? (
<div role="status" aria-live="polite" className="text-sm text-muted">Agent thinking&hellip;</div>
const reasoningNode = isLoading || isPrepared ? (
<AgentReasoning reasoningLines={reasoningLines} isPreparing={isLoading} isPrepared={isPrepared} />
) : null

const checklistNode = isPrepared ? <PolicyChecklist /> : null

const errorNode = errorMessage !== null ? (
<div role="alert" className="rounded-md border border-error bg-error-bg px-3 py-2 text-sm text-error">
{errorMessage}
Expand Down Expand Up @@ -208,11 +222,12 @@ export const PendleAgentChat = () => {
<section className="space-y-4">
<div className="space-y-3">
{messagesNode}
{loadingNode}
</div>

{reasoningNode}
{errorNode}
{previewNode}
{checklistNode}
{actionsNode}

<form onSubmit={handleSubmit} className="flex gap-2">
Expand Down
Loading
Loading