Skip to content

Commit e2bd322

Browse files
committed
feat(example): wire EIP-712 signing flow + tool-use loop for Day 5 milestone
Round 4.5 white-spot work from the bundle plan. Closes the 10 critical gaps that would have blocked the Loom demo recording: - /api/agent: full Anthropic tool-use loop. Parses tool_use content blocks, validates args via zod, calls buildPendleEnvelope, signs the EIP-712 digest via signEnvelope, attaches the signature into outer executeEnvelope call data, and returns both reply text + signed envelope. - src/agent/signing.ts: signEnvelope() helper using viem signTypedData with the canonical AgentPolicyGate domain (name=AgentPolicyGate, version=1, chainId, verifyingContract) + ExecuteEnvelope type. - envelope-builder.ts: canonical envelopeHash now includes per-call nonce + encodeAbiParameters for replay-resistance across Loom retakes. Added attachAgentSignature() to re-encode outer call data after signing. - /api/decode: merges decoder-data/agent-policy-gate.json + mock-pendle-router.json with BUILTIN_REGISTRY. resolveDescriptors() patches placeholder addresses with real ones from contracts/deployed.json once deploy lands. - src/agent/system-prompt.ts: known testnet token addresses (USDC, WETH, PT placeholders) + decimal-to-raw conversion rules. - env.ts + .env.example: AGENT_SIGNER_PRIVATE_KEY for server-side signing. - deployed.ts + deployed.json: getMockPendleRouterAddress() helper + PENDING placeholder entry mirroring the AgentPolicyGate pattern. - PendleAgentChat.tsx: renders EnvelopePreview after tool-use response, auto-fetches decoded inner call via /api/decode, passes connected wagmi account as receiverAddress. Blocked on Mike: forge deploy of AgentPolicyGate (Arb Sepolia + Robinhood testnet) + MockPendleRouter (Arb Sepolia, contract not yet written) and the cast send setAllowedRecipient follow-up so executeEnvelope passes the allow-list check.
1 parent 3a45f44 commit e2bd322

11 files changed

Lines changed: 652 additions & 82 deletions

File tree

examples/arbitrum-london/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ ROBINHOOD_TESTNET_RPC_URL=https://testnet.rpc.chain.robinhood.com
1515
# -- Deployer + agent signer (REQUIRED for forge scripts in contracts/) --
1616
DEPLOYER_PRIVATE_KEY=0x...
1717
AGENT_SIGNER_ADDRESS=0x...
18+
# Server-side EIP-712 signing key for /api/agent envelope binding.
19+
# Pair must match AGENT_SIGNER_ADDRESS above. TESTNET ONLY - rotate before mainnet.
20+
AGENT_SIGNER_PRIVATE_KEY=0x...
1821

1922
# -- x402 facilitator (REQUIRED for /api/x402/* - lands Phase 2) --
2023
X402_FACILITATOR_PRIVATE_KEY=0x...
Lines changed: 169 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,77 @@
11
import { NextResponse, type NextRequest } from 'next/server'
2+
import type { Hex } from 'viem'
23

4+
import { ARBITRUM_SEPOLIA_CHAIN_ID } from '@/src/chains'
5+
import {
6+
attachAgentSignature,
7+
buildPendleEnvelope,
8+
type DemoEnvelope,
9+
} from '@/src/agent/envelope-builder'
10+
import { signEnvelope } from '@/src/agent/signing'
11+
import {
12+
PENDLE_SYSTEM_PROMPT,
13+
RWA_SYSTEM_PROMPT,
14+
} from '@/src/agent/system-prompt'
15+
import {
16+
PENDLE_TOOL_DEFINITION,
17+
RWA_TOOL_DEFINITION,
18+
preparePendleYieldSwapArgs,
19+
} from '@/src/agent/tools'
20+
import { getAgentPolicyGateAddress } from '@/src/config/deployed'
321
import { getEnv } from '@/src/config/env'
422

523

624
export const runtime = 'nodejs'
725

826
/**
9-
* Phase 1 Day 3 milestone: minimum-viable Claude SDK echo.
27+
* Phase 1 Day 5 milestone: tool-use loop + EIP-712 envelope signing.
1028
*
1129
* V1 contract:
12-
* POST /api/agent { messages: [{ role, content }] }
13-
* -> { reply: string, envelope?: DemoEnvelope }
30+
* POST /api/agent { messages, scenario?, receiverAddress? }
31+
* -> { reply, envelope?, scenario, milestone }
1432
*
15-
* For the Day 3 milestone we only echo the user message back through
16-
* Claude with no tools attached - confirms Node runtime + env loading +
17-
* Anthropic SDK plumbing works end to end. Tool calls + envelope return
18-
* land in Day 4 (Pendle) and Day 10 (RWA).
33+
* Pendle flow:
34+
* 1. Claude reads PENDLE_SYSTEM_PROMPT + receives PENDLE_TOOL_DEFINITION.
35+
* 2. If args are clear: Claude calls prepare_pendle_yield_swap.
36+
* 3. We validate via zod, buildPendleEnvelope, sign EIP-712 via signEnvelope,
37+
* attach signature, return both the assistant's text reply (if any) and
38+
* the signed envelope.
39+
* 4. If Claude asks for clarification (text only): return reply, no envelope.
40+
*
41+
* RWA flow lands Phase 2 Day 10.
1942
*
2043
* The SDK is dynamically imported inside the handler so the route module
21-
* stays importable even when ANTHROPIC_API_KEY is unset (better DX for
22-
* the first contributor cloning the repo).
44+
* stays loadable even when ANTHROPIC_API_KEY is unset (better DX for the
45+
* first contributor cloning the repo).
2346
*/
47+
const DEFAULT_RECEIVER = '0x000000000000000000000000000000000000dEaD' as const
48+
2449
type AgentRequestBody = {
2550
messages: Array<{ role: 'user' | 'assistant', content: string }>,
2651
scenario?: 'pendle' | 'rwa',
52+
receiverAddress?: `0x${string}`,
53+
}
54+
55+
type ToolUseBlock = {
56+
type: 'tool_use',
57+
id: string,
58+
name: string,
59+
input: Record<string, unknown>,
60+
}
61+
62+
type TextBlock = {
63+
type: 'text',
64+
text: string,
65+
}
66+
67+
type ResponseContentBlock = ToolUseBlock | TextBlock | { type: string }
68+
69+
const checkIsToolUseBlock = (block: ResponseContentBlock): block is ToolUseBlock => {
70+
return block.type === 'tool_use'
71+
}
72+
73+
const checkIsTextBlock = (block: ResponseContentBlock): block is TextBlock => {
74+
return block.type === 'text'
2775
}
2876

2977
export const POST = async (request: NextRequest) => {
@@ -38,6 +86,9 @@ export const POST = async (request: NextRequest) => {
3886
return NextResponse.json({ error: 'messages must be a non-empty array' }, { status: 400 })
3987
}
4088

89+
const scenario = body.scenario ?? 'pendle'
90+
const receiverAddress = body.receiverAddress ?? DEFAULT_RECEIVER
91+
4192
let env
4293
try {
4394
env = getEnv()
@@ -52,16 +103,13 @@ export const POST = async (request: NextRequest) => {
52103
return NextResponse.json(
53104
{
54105
error: 'ANTHROPIC_API_KEY not set',
55-
hint: 'Add ANTHROPIC_API_KEY to .env.local - see .env.example for the full list',
106+
hint: 'Add ANTHROPIC_API_KEY to .env.local - see .env.example',
56107
},
57108
{ status: 503 },
58109
)
59110
}
60111

61-
// Dynamic import keeps the route module loadable without the SDK installed.
62-
// Day 4 will replace this with the full Agent SDK + tool-use loop.
63112
const { default: Anthropic } = await import('@anthropic-ai/sdk').catch(() => ({ default: null }))
64-
65113
if (Anthropic === null) {
66114
return NextResponse.json(
67115
{
@@ -74,32 +122,128 @@ export const POST = async (request: NextRequest) => {
74122

75123
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY })
76124

125+
const systemPrompt = scenario === 'rwa' ? RWA_SYSTEM_PROMPT : PENDLE_SYSTEM_PROMPT
126+
const toolDefinition = scenario === 'rwa' ? RWA_TOOL_DEFINITION : PENDLE_TOOL_DEFINITION
127+
128+
let completion
77129
try {
78-
const completion = await anthropic.messages.create({
130+
completion = await anthropic.messages.create({
79131
model: 'claude-opus-4-5',
80132
max_tokens: 1024,
81-
system:
82-
'You are a placeholder echo agent. Day 4 replaces you with the full Pendle tool-use loop. Reply briefly to confirm the pipeline works.',
133+
system: systemPrompt,
134+
tools: [ toolDefinition ],
83135
messages: body.messages.map((message) => ({
84136
role: message.role,
85137
content: message.content,
86138
})),
87139
})
140+
} catch (modelError) {
141+
return NextResponse.json(
142+
{ error: 'Anthropic call failed', detail: String(modelError) },
143+
{ status: 502 },
144+
)
145+
}
88146

89-
const reply = completion.content
90-
.filter((block: { type: string }) => block.type === 'text')
91-
.map((block: { type: string, text?: string }) => block.text ?? '')
92-
.join('\n')
147+
const contentBlocks = completion.content as ResponseContentBlock[]
148+
const textReply = contentBlocks
149+
.filter(checkIsTextBlock)
150+
.map((block) => block.text)
151+
.join('\n')
152+
const toolUseBlock = contentBlocks.find(checkIsToolUseBlock)
93153

154+
// No tool call - Claude is asking for clarification or refusing.
155+
if (toolUseBlock === undefined) {
94156
return NextResponse.json({
95-
reply,
96-
scenario: body.scenario ?? 'pendle',
97-
milestone: 'phase-1-day-3-echo',
157+
reply: textReply,
158+
scenario,
159+
milestone: 'phase-1-day-5-tool-use-text-only',
98160
})
99-
} catch (modelError) {
161+
}
162+
163+
if (scenario !== 'pendle') {
100164
return NextResponse.json(
101-
{ error: 'Anthropic call failed', detail: String(modelError) },
165+
{
166+
error: 'RWA scenario not implemented yet',
167+
detail: 'Phase 2 Day 10 wires the RWA envelope builder + Robinhood Chain deploy.',
168+
},
169+
{ status: 501 },
170+
)
171+
}
172+
173+
if (toolUseBlock.name !== 'prepare_pendle_yield_swap') {
174+
return NextResponse.json(
175+
{
176+
error: `Unexpected tool call: ${toolUseBlock.name}`,
177+
detail: 'Only prepare_pendle_yield_swap is wired in the Pendle scenario.',
178+
},
102179
{ status: 502 },
103180
)
104181
}
182+
183+
const argsParse = preparePendleYieldSwapArgs.safeParse(toolUseBlock.input)
184+
if (!argsParse.success) {
185+
return NextResponse.json(
186+
{
187+
error: 'Invalid tool args from Claude',
188+
detail: argsParse.error.flatten(),
189+
rawInput: toolUseBlock.input,
190+
},
191+
{ status: 422 },
192+
)
193+
}
194+
195+
let envelope: DemoEnvelope
196+
try {
197+
envelope = buildPendleEnvelope(argsParse.data, receiverAddress)
198+
} catch (buildError) {
199+
return NextResponse.json(
200+
{
201+
error: 'Envelope build failed',
202+
detail: String(buildError),
203+
hint:
204+
'Most often this means MockPendleRouter or AgentPolicyGate is not deployed yet. ' +
205+
'Run the forge scripts and update contracts/deployed.json.',
206+
},
207+
{ status: 503 },
208+
)
209+
}
210+
211+
if (env.AGENT_SIGNER_PRIVATE_KEY === undefined) {
212+
return NextResponse.json(
213+
{
214+
error: 'AGENT_SIGNER_PRIVATE_KEY not set',
215+
hint:
216+
'Required for EIP-712 envelope binding. Add a testnet key to .env.local ' +
217+
'matching AGENT_SIGNER_ADDRESS.',
218+
},
219+
{ status: 503 },
220+
)
221+
}
222+
223+
let signedEnvelope: DemoEnvelope
224+
try {
225+
const gateAddress = getAgentPolicyGateAddress(ARBITRUM_SEPOLIA_CHAIN_ID)
226+
const signature = await signEnvelope({
227+
envelopeHash: envelope.meta.envelopeHash,
228+
to: envelope.inner.to,
229+
data: envelope.inner.data,
230+
value: BigInt(envelope.inner.value),
231+
chainId: ARBITRUM_SEPOLIA_CHAIN_ID,
232+
gateAddress,
233+
signerPrivateKey: env.AGENT_SIGNER_PRIVATE_KEY as Hex,
234+
})
235+
signedEnvelope = attachAgentSignature(envelope, signature)
236+
} catch (signError) {
237+
return NextResponse.json(
238+
{ error: 'Signing failed', detail: String(signError) },
239+
{ status: 500 },
240+
)
241+
}
242+
243+
return NextResponse.json({
244+
reply: textReply,
245+
envelope: signedEnvelope,
246+
scenario,
247+
milestone: 'phase-1-day-5-tool-use-with-envelope',
248+
})
105249
}

examples/arbitrum-london/app/api/decode/route.ts

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1-
import { decodeCall, BUILTIN_REGISTRY } from '@txkit/tx-decoder'
1+
import {
2+
decodeCall,
3+
BUILTIN_REGISTRY,
4+
buildRegistry,
5+
type RegistryDescriptor,
6+
} from '@txkit/tx-decoder'
27
import { NextResponse, type NextRequest } from 'next/server'
38

9+
import deployedJson from '@/contracts/deployed.json'
10+
import agentPolicyGateData from '@/decoder-data/agent-policy-gate.json'
11+
import mockPendleRouterData from '@/decoder-data/mock-pendle-router.json'
12+
13+
414
export const runtime = 'nodejs'
515

616
/**
@@ -10,14 +20,15 @@ export const runtime = 'nodejs'
1020
* POST /api/decode { chain: "eip155:421614", call: { to, data, value? } }
1121
* -> DecodedCall (per @txkit/tx-decoder shape)
1222
*
13-
* Day 5 client renders this through <EnvelopePreview/>.
14-
*
1523
* Registry composition:
1624
* - BUILTIN_REGISTRY: ERC-20 / Permit2 / Uniswap V3 / Aave V3 / CoW Swap
1725
* (5 mainstream protocols, 20 descriptors)
18-
* - AgentPolicyGate registry data (Arbitrum Sepolia + Robinhood Chain
19-
* testnet) is loaded from examples/arbitrum-london/decoder-data/ until
20-
* Mike's deploy populates contracts/deployed.json - see TODO below.
26+
* - examples/arbitrum-london/decoder-data: AgentPolicyGate + MockPendleRouter
27+
* (Arbitrum Sepolia, Robinhood Chain testnet). Addresses are placeholder
28+
* 0x0000...0000 in JSON; the merge step below patches them with real
29+
* addresses from contracts/deployed.json once Mike deploys.
30+
*
31+
* Day 5 client renders this through <EnvelopePreview/>.
2132
*/
2233
type DecodeRequestBody = {
2334
chain: `eip155:${number}`,
@@ -28,6 +39,62 @@ type DecodeRequestBody = {
2839
},
2940
}
3041

42+
type DeployedEntry = { address: string }
43+
type DeployedMap = Record<string, Record<string, DeployedEntry>>
44+
45+
const deployedMap = deployedJson as DeployedMap
46+
47+
/**
48+
* Patch placeholder addresses (0x0000...0000) in the example decoder data
49+
* with real addresses from contracts/deployed.json once a forge deploy
50+
* script lands. Entries still in PENDING state stay as-is - the decoder
51+
* misses them until the real contract is live, which surfaces the deploy
52+
* gap rather than silently mis-decoding.
53+
*/
54+
const resolveDescriptors = (
55+
data: ReadonlyArray<RegistryDescriptor>,
56+
contractName: string,
57+
): ReadonlyArray<RegistryDescriptor> => {
58+
const section = deployedMap[contractName]
59+
if (section === undefined) {
60+
return data
61+
}
62+
return data.map((descriptor) => {
63+
const chainId = String(Number(descriptor.chain.split(':')[1]))
64+
const entry = section[chainId]
65+
if (entry === undefined) {
66+
return descriptor
67+
}
68+
const isReal =
69+
/^0x[a-fA-F0-9]{40}$/.test(entry.address) && !entry.address.includes('PENDING')
70+
if (!isReal) {
71+
return descriptor
72+
}
73+
return { ...descriptor, address: entry.address as `0x${string}` }
74+
})
75+
}
76+
77+
const exampleDescriptors: ReadonlyArray<RegistryDescriptor> = [
78+
...resolveDescriptors(
79+
agentPolicyGateData as unknown as ReadonlyArray<RegistryDescriptor>,
80+
'AgentPolicyGate',
81+
),
82+
...resolveDescriptors(
83+
mockPendleRouterData as unknown as ReadonlyArray<RegistryDescriptor>,
84+
'MockPendleRouter',
85+
),
86+
]
87+
88+
const exampleRegistry = buildRegistry(exampleDescriptors)
89+
90+
// Inferred shape matches @txkit/tx-decoder's Registry (Readonly<Record<string, RegistryDescriptor>>).
91+
// We avoid importing the Registry type because it is currently not exported
92+
// from the package barrel - shape-compatibility via spread is sufficient.
93+
const mergedRegistry = {
94+
...BUILTIN_REGISTRY,
95+
...exampleRegistry,
96+
}
97+
3198
export const POST = async (request: NextRequest) => {
3299
let body: DecodeRequestBody
33100
try {
@@ -47,14 +114,10 @@ export const POST = async (request: NextRequest) => {
47114
return NextResponse.json({ error: 'call.to and call.data are required' }, { status: 400 })
48115
}
49116

50-
// TODO Phase 1 Day 5+: extend registry with examples/arbitrum-london/decoder-data/
51-
// const localRegistry = await loadExamplesRegistry()
52-
// const registry = mergeRegistries(BUILTIN_REGISTRY, localRegistry)
53-
54117
try {
55118
const decoded = await decodeCall(
56119
{ chain: body.chain, call: { to: body.call.to, data: body.call.data } },
57-
{ registry: BUILTIN_REGISTRY },
120+
{ registry: mergedRegistry },
58121
)
59122

60123
// BigInt values (decoded args) do not serialise as JSON by default.

0 commit comments

Comments
 (0)