From 837aed77ea7bbc2124c8bcd49adc78c7084a33a1 Mon Sep 17 00:00:00 2001 From: MikeDiam Date: Fri, 29 May 2026 16:29:09 +0300 Subject: [PATCH] feat(example): add MockPendleRouter contract + deploy runbook for scenario A The Buildathon demo app referenced MockPendleRouter (envelope-builder.ts imports getMockPendleRouterAddress, holds MOCK_PENDLE_ROUTER_ABI, points at contracts/src/MockPendleRouter.sol) but the contract and its deploy script did not exist - scenario A (Pendle yield swap) was undeployable. - contracts/src/MockPendleRouter.sol: deterministic 1:0.995 mock matching the ABI the app already expects. Token-agnostic (no real transfers), emits SwapExactTokenForPt, reverts InsufficientPtOut below minPtOut. Apache-2.0. - contracts/script/DeployMockPendleRouter.s.sol: Arbitrum Sepolia deploy script. - contracts/test/MockPendleRouter.t.sol: 5 tests incl. an end-to-end agent-signed envelope -> AgentPolicyGate -> router integration test. - DEPLOY.md: full copy-paste deploy runbook (both chains, allow-list step, signer-pair pre-check, deployed.json + decoder-data wiring, smoke test). - contracts/README.md: pointer to DEPLOY.md. Verification: forge test 20 passed (15 gate + 5 router), app tsc --noEmit clean. Contracts still need a funded-key deploy (Mike) - see DEPLOY.md. --- examples/arbitrum-london/DEPLOY.md | 226 ++++++++++++++++++ examples/arbitrum-london/contracts/README.md | 2 + .../script/DeployMockPendleRouter.s.sol | 46 ++++ .../contracts/src/MockPendleRouter.sol | 61 +++++ .../contracts/test/MockPendleRouter.t.sol | 105 ++++++++ 5 files changed, 440 insertions(+) create mode 100644 examples/arbitrum-london/DEPLOY.md create mode 100644 examples/arbitrum-london/contracts/script/DeployMockPendleRouter.s.sol create mode 100644 examples/arbitrum-london/contracts/src/MockPendleRouter.sol create mode 100644 examples/arbitrum-london/contracts/test/MockPendleRouter.t.sol diff --git a/examples/arbitrum-london/DEPLOY.md b/examples/arbitrum-london/DEPLOY.md new file mode 100644 index 0000000..c7c1c50 --- /dev/null +++ b/examples/arbitrum-london/DEPLOY.md @@ -0,0 +1,226 @@ +# Deploy runbook - txKit Arbitrum London Buildathon demo + +Copy-paste deploy of the demo's on-chain pieces to Arbitrum Sepolia (scenario A, Pendle yield swap) and Robinhood Chain testnet (scenario C, gate only for now). Every command below is meant to be pasted as-is after you fill the env vars and the captured addresses. + +Verified state at time of writing (2026-05-29): `forge build` + `forge test` green (20 tests: 15 AgentPolicyGate + 5 MockPendleRouter incl. an end-to-end gate->router integration test), app `tsc --noEmit` clean. The only thing standing between the repo and a live demo is this deploy, which needs a funded key you hold. + +## What gets deployed + +| Contract | Chain | Why | Verify on explorer | +|---|---|---|---| +| `AgentPolicyGate` | Arbitrum Sepolia (421614) | scenario A policy enforcement point | yes (Arbiscan) | +| `MockPendleRouter` | Arbitrum Sepolia (421614) | scenario A inner swap target | yes (Arbiscan) | +| `AgentPolicyGate` | Robinhood Chain testnet (46630) | multi-chain story; scenario C gate | no (explorer verifier API not published) | + +Scenario C (RWA on Robinhood) does NOT need a router yet: `buildRwaEnvelope` still throws "not implemented" (lands Phase 2 Day 10), and `buildPendleEnvelope` hardcodes the router on Arbitrum Sepolia only. Deploy the Robinhood gate now purely for the "deployed on both chains" claim. + +## 0. Prerequisites + +Tooling (already present on this machine): + +```bash +forge --version # foundry +cast --version +``` + +Contract deps (already vendored under contracts/lib): + +```bash +cd examples/arbitrum-london/contracts +ls lib # expect: forge-std openzeppelin-contracts +``` + +Environment. The forge/cast commands read these from the shell. Easiest path: create `contracts/.env` (foundry auto-loads `.env` from the dir you run `forge` in) or export them inline. Source the template at `examples/arbitrum-london/.env.example`: + +```bash +# required for both chains +export DEPLOYER_PRIVATE_KEY=0x... # funded on BOTH testnets +export AGENT_SIGNER_ADDRESS=0x... # address the gate will trust +export AGENT_SIGNER_PRIVATE_KEY=0x... # its private key (used by /api/agent) + +# RPC + verify +export ARB_SEPOLIA_RPC_URL=https://sepolia-rollup.arbitrum.io/rpc +export ROBINHOOD_TESTNET_RPC_URL=https://testnet.rpc.chain.robinhood.com +export ARBISCAN_API_KEY=... # only needed for --verify +``` + +Funds: + +- Arbitrum Sepolia ETH: any ArbSepolia faucet (e.g. the Alchemy or QuickNode faucet) to `DEPLOYER_PRIVATE_KEY`'s address. +- Robinhood Chain testnet ETH: https://faucet.testnet.chain.robinhood.com + +## 1. Pre-flight checks (do not skip) + +Confirm the signer pair matches. The gate stores `AGENT_SIGNER_ADDRESS` in its constructor and recovers ECDSA signatures from `/api/agent`, which signs with `AGENT_SIGNER_PRIVATE_KEY`. A mismatch means every `executeEnvelope` reverts `InvalidSignature` AFTER deploy - silent until the demo runs. + +```bash +# Must print exactly AGENT_SIGNER_ADDRESS +cast wallet address --private-key $AGENT_SIGNER_PRIVATE_KEY +``` + +Confirm tests green and balances funded: + +```bash +cd examples/arbitrum-london/contracts +forge build +forge test # expect: 20 passed + +DEPLOYER=$(cast wallet address --private-key $DEPLOYER_PRIVATE_KEY) +cast balance $DEPLOYER --rpc-url arbitrum_sepolia +cast balance $DEPLOYER --rpc-url robinhood_testnet +``` + +## 2. Deploy to Arbitrum Sepolia + +### 2a. AgentPolicyGate + +```bash +forge script script/DeployArbSepolia.s.sol \ + --rpc-url arbitrum_sepolia \ + --broadcast \ + --verify \ + --etherscan-api-key $ARBISCAN_API_KEY +``` + +Capture the printed `AgentPolicyGate deployed at:` address. + +```bash +export GATE_ARB=0x... # paste from console +``` + +### 2b. MockPendleRouter + +```bash +forge script script/DeployMockPendleRouter.s.sol \ + --rpc-url arbitrum_sepolia \ + --broadcast \ + --verify \ + --etherscan-api-key $ARBISCAN_API_KEY +``` + +```bash +export ROUTER_ARB=0x... # paste from console +``` + +### 2c. Allow-list the router on the gate (REQUIRED) + +`executeEnvelope` reverts `RecipientNotAllowed` if the inner target is not allow-listed. The inner target for scenario A is the router. + +```bash +cast send $GATE_ARB "setAllowedRecipient(address,bool)" $ROUTER_ARB true \ + --rpc-url arbitrum_sepolia \ + --private-key $DEPLOYER_PRIVATE_KEY +``` + +Spend limit: scenario A forwards `value = 0`, and `spendLimit` defaults to `0` (`value > spendLimit` is `0 > 0` = false, so it passes). You do NOT need `setSpendLimit` for the Pendle demo. Only set it if a future scenario forwards ETH: + +```bash +# OPTIONAL - only if forwarding ETH later +cast send $GATE_ARB "setSpendLimit(uint256)" 1000000000000000000 \ + --rpc-url arbitrum_sepolia --private-key $DEPLOYER_PRIVATE_KEY +``` + +## 3. Deploy to Robinhood Chain testnet + +Gate only, no `--verify` (explorer verifier API not published as of 2026-05-26). + +```bash +forge script script/DeployRobinhoodTestnet.s.sol \ + --rpc-url robinhood_testnet \ + --broadcast +``` + +```bash +export GATE_ROBINHOOD=0x... # paste from console +``` + +No router and no allow-list needed on Robinhood until scenario C's envelope builder ships. + +## 4. Wire the addresses into the app + +Three files carry `0x__PENDING__` / `0x000...0` placeholders. The app throws a clear "not deployed yet" error until they hold real addresses (the regex gate is `^0x[a-fA-F0-9]{40}$` with no "PENDING" substring). + +### 4a. `contracts/deployed.json` + +Replace all three entries: + +```json +{ + "AgentPolicyGate": { + "421614": { + "address": "", + "deployedAt": "2026-05-29T00:00:00Z", + "blockExplorer": "https://sepolia.arbiscan.io/address/" + }, + "46630": { + "address": "", + "deployedAt": "2026-05-29T00:00:00Z", + "blockExplorer": "https://explorer.testnet.chain.robinhood.com/address/" + } + }, + "MockPendleRouter": { + "421614": { + "address": "", + "deployedAt": "2026-05-29T00:00:00Z", + "blockExplorer": "https://sepolia.arbiscan.io/address/" + } + } +} +``` + +### 4b. `decoder-data/agent-policy-gate.json` + +Set the top-level `address` and drop "PENDING DEPLOY" from `label` on both entries (chain `eip155:421614` -> `GATE_ARB`, chain `eip155:46630` -> `GATE_ROBINHOOD`). + +### 4c. `decoder-data/mock-pendle-router.json` + +Set the single entry's `address` (chain `eip155:421614`) to `ROUTER_ARB` and drop "PENDING DEPLOY" from `label`. + +### 4d. App env + +Ensure `examples/arbitrum-london/.env.local` has: + +```bash +ANTHROPIC_API_KEY=sk-ant-... +AGENT_SIGNER_PRIVATE_KEY=0x... # SAME pair as the gate's agentSigner +ARB_SEPOLIA_RPC_URL=https://sepolia-rollup.arbitrum.io/rpc +ROBINHOOD_TESTNET_RPC_URL=https://testnet.rpc.chain.robinhood.com +``` + +## 5. Post-deploy verification + +```bash +# Gate trusts the right signer (must equal AGENT_SIGNER_ADDRESS) +cast call $GATE_ARB "agentSigner()(address)" --rpc-url arbitrum_sepolia + +# Router is allow-listed (must be true) +cast call $GATE_ARB "allowedRecipients(address)(bool)" $ROUTER_ARB --rpc-url arbitrum_sepolia + +# Router conversion sanity (1_000_000 -> 995_000) +cast call $ROUTER_ARB "swapExactTokenForPt(address,address,uint256,uint256)(uint256)" \ + $(cast wallet address --private-key $DEPLOYER_PRIVATE_KEY) \ + 0x0000000000000000000000000000000000000001 1000000 0 \ + --rpc-url arbitrum_sepolia +``` + +## 6. Smoke test the demo + +```bash +cd examples/arbitrum-london +pnpm dev # http://localhost:3000 +``` + +Open `/flow-a`, ask the agent to prepare a Pendle yield swap. Expect: + +- no "not deployed yet" error, +- the envelope preview shows the outer call to `GATE_ARB` and the inner call to `ROUTER_ARB`, +- the decoded inner action reads as `swapExactTokenForPt(...)` via `decoder-data/mock-pendle-router.json`. + +That is the recordable Loom path for scenario A. + +## 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`. +- The allow-list step (2c) is the single easiest thing to forget - the gate compiles and deploys fine without it, then reverts only at execute time. +- Signer-pair mismatch (step 1) fails the same way: silent until execute. Check it before you spend gas. +- Robinhood verify is intentionally skipped; do not pass `--verify` there or the broadcast errors out on a missing verifier. diff --git a/examples/arbitrum-london/contracts/README.md b/examples/arbitrum-london/contracts/README.md index 5cbf4f5..b0f7d00 100644 --- a/examples/arbitrum-london/contracts/README.md +++ b/examples/arbitrum-london/contracts/README.md @@ -14,6 +14,8 @@ forge test ## Deployment +> Full copy-paste runbook (both chains, MockPendleRouter, allow-list, app wiring, smoke test): [`../DEPLOY.md`](../DEPLOY.md). The section below is the contracts-only quick reference. + Required environment (set via `.env` or inline): - `DEPLOYER_PRIVATE_KEY` - owner key, signs deployment diff --git a/examples/arbitrum-london/contracts/script/DeployMockPendleRouter.s.sol b/examples/arbitrum-london/contracts/script/DeployMockPendleRouter.s.sol new file mode 100644 index 0000000..99677fa --- /dev/null +++ b/examples/arbitrum-london/contracts/script/DeployMockPendleRouter.s.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.28; + +import { Script } from "forge-std/Script.sol"; +import { console2 } from "forge-std/console2.sol"; + +import { MockPendleRouter } from "../src/MockPendleRouter.sol"; + +/** + * @notice Deploys MockPendleRouter to Arbitrum Sepolia (chainId 421614) for + * the Buildathon demo scenario A. The off-chain envelope builder + * hardcodes the router on Arbitrum Sepolia, so this is the only chain + * that needs the mock. + * + * Usage: + * forge script script/DeployMockPendleRouter.s.sol \ + * --rpc-url arbitrum_sepolia \ + * --broadcast \ + * --verify \ + * --etherscan-api-key $ARBISCAN_API_KEY + * + * Required environment: + * - DEPLOYER_PRIVATE_KEY (deployer key) + * - ARB_SEPOLIA_RPC_URL (RPC endpoint) + * - ARBISCAN_API_KEY (only required for --verify) + * + * After deployment: + * 1. Append the address to contracts/deployed.json under MockPendleRouter. + * 2. Sync the address into examples/arbitrum-london/decoder-data/mock-pendle-router.json. + * 3. Allow-list the router on the gate so executeEnvelope accepts it: + * cast send "setAllowedRecipient(address,bool)" true \ + * --rpc-url arbitrum_sepolia --private-key $DEPLOYER_PRIVATE_KEY + */ +contract DeployMockPendleRouter is Script { + function run() external returns (MockPendleRouter router) { + uint256 deployerKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + + vm.startBroadcast(deployerKey); + router = new MockPendleRouter(); + vm.stopBroadcast(); + + console2.log("MockPendleRouter deployed at:", address(router)); + console2.log("Chain:", block.chainid); + console2.log("Remember: allow-list this address on AgentPolicyGate via setAllowedRecipient"); + } +} diff --git a/examples/arbitrum-london/contracts/src/MockPendleRouter.sol b/examples/arbitrum-london/contracts/src/MockPendleRouter.sol new file mode 100644 index 0000000..865d6b2 --- /dev/null +++ b/examples/arbitrum-london/contracts/src/MockPendleRouter.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.28; + +/** + * @title MockPendleRouter + * @notice Deterministic stand-in for a Pendle router, used only by the txKit + * Arbitrum London Buildathon demo (scenario A). It does NOT move any + * ERC-20 tokens - it computes a fixed-rate PT amount, emits an event, + * and returns the amount, so the agent -> AgentPolicyGate -> router + * call path can execute end-to-end on a testnet without funding the + * caller with real input tokens. + * + * Conversion is a fixed 1:0.995 (a flat 50 bps haircut), matching the + * off-chain `buildPendleEnvelope` minPtOut math in + * examples/arbitrum-london/src/agent/envelope-builder.ts: any declared + * slippage of 50 bps or looser succeeds, anything tighter reverts with + * InsufficientPtOut - the same shape a real router would use. + * + * @dev Out of scope (demo only): real token custody, dynamic pricing, PT + * minting, maturity logic. None of these are needed to demonstrate the + * envelope review-and-sign flow, and adding them would obscure it. + */ +contract MockPendleRouter { + /// @notice Numerator of the deterministic input->PT conversion rate. + uint256 public constant RATE_NUMERATOR = 995; + /// @notice Denominator of the deterministic input->PT conversion rate. + uint256 public constant RATE_DENOMINATOR = 1000; + + error InsufficientPtOut(uint256 ptOut, uint256 minPtOut); + + event SwapExactTokenForPt( + address indexed receiver, + address indexed caller, + address indexed ptOut, + uint256 amountIn, + uint256 ptOutReturned + ); + + /** + * @notice Swap an exact input amount for a deterministic PT amount. + * @param receiver Address credited with the PT output (event only - no real transfer in the mock). + * @param ptOut The PT token the caller wants (event only - the mock is token-agnostic). + * @param amountIn Input amount in raw token units. + * @param minPtOut Minimum acceptable PT out; reverts if the fixed-rate output is below it. + * @return ptOutReturned The deterministic PT amount (amountIn * 995 / 1000). + */ + function swapExactTokenForPt( + address receiver, + address ptOut, + uint256 amountIn, + uint256 minPtOut + ) external returns (uint256 ptOutReturned) { + ptOutReturned = (amountIn * RATE_NUMERATOR) / RATE_DENOMINATOR; + if (ptOutReturned < minPtOut) { + revert InsufficientPtOut(ptOutReturned, minPtOut); + } + + emit SwapExactTokenForPt(receiver, msg.sender, ptOut, amountIn, ptOutReturned); + return ptOutReturned; + } +} diff --git a/examples/arbitrum-london/contracts/test/MockPendleRouter.t.sol b/examples/arbitrum-london/contracts/test/MockPendleRouter.t.sol new file mode 100644 index 0000000..35afa62 --- /dev/null +++ b/examples/arbitrum-london/contracts/test/MockPendleRouter.t.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.28; + +import { Test } from "forge-std/Test.sol"; + +import { AgentPolicyGate } from "../src/AgentPolicyGate.sol"; +import { MockPendleRouter } from "../src/MockPendleRouter.sol"; + +contract MockPendleRouterTest is Test { + MockPendleRouter internal router; + address internal receiver; + address internal ptToken; + + function setUp() public { + router = new MockPendleRouter(); + receiver = makeAddr("receiver"); + ptToken = makeAddr("ptToken"); + } + + function test_swap_returnsDeterministicRate() public { + uint256 amountIn = 1_000_000; + uint256 expected = (amountIn * 995) / 1000; + + uint256 ptOut = router.swapExactTokenForPt(receiver, ptToken, amountIn, 0); + + assertEq(ptOut, expected, "PT out should be 995/1000 of input"); + } + + function test_swap_emitsEvent() public { + uint256 amountIn = 2_000_000; + uint256 expected = (amountIn * 995) / 1000; + + vm.expectEmit(true, true, true, true); + emit MockPendleRouter.SwapExactTokenForPt(receiver, address(this), ptToken, amountIn, expected); + + router.swapExactTokenForPt(receiver, ptToken, amountIn, expected); + } + + function test_swap_succeedsAtExactly50BpsSlippage() public { + // Off-chain builder sets minPtOut = amountIn * (10000 - 50) / 10000 = 9950/10000. + // The mock returns 995/1000 = 9950/10000, so a 50 bps declaration is the tight boundary. + uint256 amountIn = 1_000_000; + uint256 minPtOut = (amountIn * (10_000 - 50)) / 10_000; + + uint256 ptOut = router.swapExactTokenForPt(receiver, ptToken, amountIn, minPtOut); + + assertEq(ptOut, minPtOut, "50 bps boundary should pass exactly"); + } + + function test_revert_whenSlippageTighterThan50Bps() public { + // 10 bps declaration => minPtOut = 9990/10000, above the mock's 9950/10000 output => revert. + uint256 amountIn = 1_000_000; + uint256 minPtOut = (amountIn * (10_000 - 10)) / 10_000; + uint256 mockOut = (amountIn * 995) / 1000; + + vm.expectRevert( + abi.encodeWithSelector(MockPendleRouter.InsufficientPtOut.selector, mockOut, minPtOut) + ); + router.swapExactTokenForPt(receiver, ptToken, amountIn, minPtOut); + } + + /** + * @notice End-to-end scenario A path: an agent-signed envelope routed + * through AgentPolicyGate into the mock router. Proves the + * demo's on-chain call path executes and emits both events. + */ + function test_integration_throughAgentPolicyGate() public { + (address agentSigner, uint256 agentSignerKey) = makeAddrAndKey("agentSigner"); + address owner = makeAddr("owner"); + + vm.prank(owner); + AgentPolicyGate gate = new AgentPolicyGate(owner, agentSigner); + + vm.prank(owner); + gate.setAllowedRecipient(address(router), true); + + uint256 amountIn = 1_000_000; + uint256 minPtOut = (amountIn * (10_000 - 50)) / 10_000; + bytes memory innerData = abi.encodeCall( + MockPendleRouter.swapExactTokenForPt, + (receiver, ptToken, amountIn, minPtOut) + ); + bytes32 envelopeHash = keccak256("pendle-envelope-integration"); + uint256 value = 0; + + bytes32 structHash = keccak256( + abi.encode( + gate.EXECUTE_ENVELOPE_TYPEHASH(), + envelopeHash, + address(router), + keccak256(innerData), + value + ) + ); + bytes32 digest = keccak256( + abi.encodePacked("\x19\x01", gate.DOMAIN_SEPARATOR(), structHash) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(agentSignerKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + gate.executeEnvelope(envelopeHash, signature, address(router), innerData, value); + + assertTrue(gate.usedEnvelopes(envelopeHash), "envelope should be consumed"); + } +}