Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

synroute-agent

A natural-language AI trading agent for Synthra. Talk to it in plain English ("swap 10 USDC for SYN on Arc", "bridge 25 USDC from Sepolia to Arc") and it quotes, swaps, and bridges on-chain through the SynRoute API. The reasoning is backed by Claude or OpenAI, switchable with a single environment variable.

License: MIT Node LLM: Claude | OpenAI TypeScript

This repository is a reference implementation / starting point: a small, readable, tool-calling agent that turns natural language into SynRoute API calls and signs and submits the resulting transactions itself. It is deliberately dependency-light (one LLM SDK, ethers v5, dotenv) so it can be read end to end in a few minutes and forked into a custom agent.

⚠️ The agent holds a private key and executes transactions autonomously. The model decides which on-chain action to take from your message. Use a dedicated, low-balance test key on testnets, keep the safety limits tight, and read the code before pointing it at anything valuable.


Table of contents


What it is

A REPL chat agent with three layers:

  1. LLM layer (src/llm.ts) is a provider-agnostic tool-calling loop. The model is given a system prompt plus a set of tools and decides which to call. It works identically with Anthropic (Claude) and OpenAI (GPT); pick the backend with LLM_PROVIDER.
  2. Tool layer (src/tools.ts) is the six capabilities exposed to the model (list_tokens, get_balance, get_price, get_quote, swap, bridge). Tools enforce safety guardrails, resolve token symbols to addresses, call SynRoute, and sign/submit transactions.
  3. Execution layer (src/synroute/, src/chain/) is a thin SynRoute HTTP client plus an ethers wallet wrapper that signs Permit2 typed data and broadcasts transactions.

The model never touches the chain directly and never sees the private key. It only emits structured tool calls; the tool layer does the rest and feeds results back as text.

Replies are streamed token-by-token to the console (both providers), and a short · running <tool>… notice is printed while a tool executes, so you see the agent think and act in real time rather than waiting for the full answer.


Architecture

                          ┌─────────────────────────────────────────────┐
   you (stdin)  ──────────▶  REPL  (src/index.ts)                        │
                          │     │                                        │
                          │     ▼                                        │
                          │  AgentSession (src/llm.ts)                   │
                          │   - system prompt + injected context         │
                          │   - tool-calling loop (Claude OR OpenAI)     │
                          │     │           ▲                            │
                          │     │ tool_use  │ tool_result (JSON)         │
                          │     ▼           │                            │
                          │  Tools (src/tools.ts)                        │
                          │   - guardrails (chain/amount/slippage)       │
                          │   - symbol → address (src/synroute/tokens)   │
                          │     │                  │                     │
                          │     ▼                  ▼                     │
                          │  SynRoute client   Wallet (src/chain/wallet) │
                          │  (HTTP, x-api-key) (ethers v5, Permit2)      │
                          └─────┼──────────────────┼─────────────────────┘
                                ▼                  ▼
                        SynRoute REST API     EVM RPC (Arc / Sepolia)
                                                   │
                                                   ▼
                                            on-chain transaction

Layered dependency direction (no cycles): index → llm → tools → {synroute/client, synroute/tokens, chain/wallet} → config.


Repository map

Path Lines Responsibility Key exports
src/index.ts ~59 REPL entry point. Builds tools + context, opens a session, reads stdin, prints replies. main()
src/llm.ts ~147 Provider-agnostic tool-calling loop. System prompt, AnthropicSession, OpenAISession, MAX_STEPS. createSession, buildSystemPrompt, AgentSession
src/tools.ts ~365 The six tools, the swap/bridge execution logic, guardrails, and the runtime context injected into the prompt. buildTools, buildAgentContext, ToolSpec
src/synroute/client.ts ~52 Tiny typed fetch wrapper for the SynRoute REST API. Adds x-api-key, throws SynrouteError. synroute, SynrouteError, Json
src/synroute/tokens.ts ~85 Loads the public token list, resolves symbols/addresses, native-coin aliasing, raw↔human amount conversion. resolveToken, tokensForChain, toRawAmount, fromRawAmount, nativeToken
src/chain/wallet.ts ~92 ethers v5 wallet: provider cache, sendTx (with Arc gas headroom), ERC20 allowance/balance, Permit2 signing. sendTx, ensureErc20Allowance, erc20Balance, signPermit2, walletAddress
src/chain/abis.ts ~13 Minimal ABIs: the CCTP router swapAndBurn struct and a 5-method ERC20. CCTP_ROUTER_ABI, ERC20_ABI
src/config.ts ~50 Central env-driven config: LLM, API, wallet, and safety guardrails. Chain registry. CONFIG, CHAINS, rpcUrlForChain, chainLabel

Control flow

1. The agent loop (LLM tool-calling)

src/llm.ts runs a bounded loop (MAX_STEPS = 8). On each turn the model may either return text (done) or request one or more tool calls; the agent executes each tool and appends the JSON result to the conversation, then loops.

send(userText):
  append user message
  repeat up to MAX_STEPS:
    response = model.create(system, tools, messages)
    append assistant message
    if no tool calls: return assistant text
    for each tool call:
      result = runTool(name, args)          # may hit SynRoute and/or the chain
      append tool_result(result as JSON)
  return "Stopped after too many tool steps."

Both providers implement the same AgentSession interface, so the loop body is identical apart from SDK-specific message shapes (tool_use/tool_result for Anthropic, tool_calls/role:"tool" for OpenAI). Each turn is streamed: send(userText, { onText, onTool }) invokes onText(delta) for every text token and onTool(name) right before a tool runs (the REPL wires these to stdout). Tool errors are caught and returned to the model as { error: "..." } so it can recover or explain, rather than crashing the process.

The system prompt (BASE_SYSTEM_PROMPT) encodes the operating rules: map words to exact symbols, amounts are human units, same-chain means swap while different chains mean bridge, default to Arc, the wallet is both sender and recipient, use high slippage for bridges, never fabricate values. At startup, buildAgentContext() appends the live wallet address, the configured limits, and the per-chain token catalog so the model rarely needs list_tokens.

2. Same-chain swap

executeSwap in src/tools.ts:

1. assertSwapPolicy(chainId, amount, slippageBps)     # guardrails
2. resolve tokenIn / tokenOut  →  addresses + decimals
3. amount(human) → amount(raw)  via toRawAmount
4. POST /v1/swap  (approvalMode = permit2 | erc20)
5. if ERC20 approval required:   sendTx(approval.tokenApproval.approveTransaction)
6. if Permit2 signature required:
      signature = signPermit2(approval.permit2.typedData)
      POST /v1/swap again with permit2Signature + amount/expiration/nonce/sigDeadline
7. sendTx(response.transaction, withGasHeadroom=true)
8. return { status:'submitted', txHash, amountIn, amountOut, route }

Native input (e.g. ETH on Sepolia) skips steps 5 and 6 and is sent as msg.value.

3. Cross-chain bridge (CCTP)

executeBridge in src/tools.ts orchestrates Circle's Cross-Chain Transfer Protocol end to end:

1. assertChainAllowed(source) + assertSwapPolicy(dest, amount, slippage)
2. resolve tokenIn@source, tokenOut@dest; amount → raw
3. POST /v1/cctp/route   → route.route.execution (ex) + bridgeLeg
4. POST /v1/cctp/quote   (map bridgeToken→sourceUsdc, carry fee/maxFee)
                         → signed.sourceQuote with quoteSignature + intentHash
5. if non-native: ensureErc20Allowance(source, tokenIn, ex.sourceRouter, amountIn)
6. encode swapAndBurn(struct) with the signed quote + SOURCE router commands
   sendTx(source, { to: ex.sourceRouter, data, value })     # the burn
7. poll POST /v1/cctp/claim with DESTINATION router params,
   every 5s up to 60x, until status in {success, already_settled, already_claimed}
8. return { status, sourceTxHash, destinationTxHash, ... }

Two correctness details are baked in (see Design notes): the sourceQuote field is named bridgeToken but the signer expects sourceUsdc, and the claim must pass the destination router's commands/inputs/deadline explicitly, otherwise the backend recovers source-leg calldata and the bridge silently delivers plain USDC instead of tokenOut.


Tool catalog

The six tools the model can call (defined in buildTools()):

Tool Inputs (required) Effect On-chain?
list_tokens chainId Returns {symbol, name, address, decimals}[] for a chain. no
get_balance chainId, token Wallet balance of a token (human units). read
get_price chainId, base, quote Current price of base in quote. read
get_quote chainId, tokenIn, tokenOut, amount Preview a same-chain swap (amountOut, route). No execution. read
swap chainId, tokenIn, tokenOut, amount (+ slippageBps, default 50) Execute a same-chain swap; signs + submits. write
bridge sourceChainId, destChainId, tokenIn, tokenOut, amount (+ slippageBps, default 50) Cross-chain swap via CCTP; signs + submits + settles. write

All amounts are human units of the input token (e.g. "12.5"), never raw/wei; toRawAmount/fromRawAmount handle the conversion. Tokens may be symbols (USDC) or addresses; native coins use the alias ETH/NATIVE.


SynRoute API surface

The agent talks to these endpoints (src/synroute/client.ts). Auth is x-api-key; base URL is SYNTHRA_API_BASE.

Method Endpoint Used by Notes
GET /v1/price get_price ?chainId&base&quote[&amount]
GET /v1/pools (available) ?chainId&tokenA&tokenB&limit
POST /v1/quote get_quote tradeType: EXACT_INPUT; returns amountOutDecimals, routeString
POST /v1/swap swap Returns approval (tokenApproval and/or permit2) and transaction
POST /v1/cctp/route bridge Returns route.route.execution + bridgeLeg
POST /v1/cctp/quote bridge Signs the source quote (quoteSignature, intentHash)
POST /v1/cctp/claim bridge Idempotent settlement; poll until terminal status

Response fields the agent depends on:

  • /v1/swap returns approval.tokenApproval.{needsApproval, approveTransaction}, approval.permit2.{signatureRequired, typedData}, transaction.{to,data,value,gasLimit}, amountOut, routeString.
  • /v1/cctp/route returns route.state === 'Success', route.route.execution.{sourceRouter, destinationRouter, destinationUniversalRouter, sourceRouterCommands/Inputs/Deadline, destinationRouterCommands/Inputs/Deadline, sourceQuote}, route.route.bridgeLeg.{feeAmount, maxFee}.
  • /v1/cctp/quote returns sourceQuote.{quoteSignature, intentHash, tokenIn, amountIn, ...} (the full struct passed to swapAndBurn).
  • /v1/cctp/claim returns status, destinationTxHash.

For the full, authoritative API contract see the Synthra API docs / the synthra-api skill. This README documents only the subset the agent exercises.


Configuration

All configuration is environment-driven (.env, loaded by dotenv). Copy .env.example to .env.

Variable Default Purpose
LLM_PROVIDER anthropic anthropic (Claude) or openai (GPT).
ANTHROPIC_API_KEY (none) Required when LLM_PROVIDER=anthropic.
ANTHROPIC_MODEL claude-sonnet-4-6 Anthropic model id.
OPENAI_API_KEY (none) Required when LLM_PROVIDER=openai.
OPENAI_MODEL gpt-4o OpenAI model id.
SYNTHRA_API_BASE https://trading-api.synthra.org SynRoute REST base URL.
SYNTHRA_API_KEY (none) SynRoute API key (sent as x-api-key).
SYNTHRA_APPROVAL_MODE permit2 permit2 (Universal Router, recommended) or erc20 (SwapRouter02).
SYNTHRA_TOKENLIST_URL Synthra tokenlist Source for symbol↔address resolution.
PRIVATE_KEY (none) The agent's signing key. Dedicated test key only. Unset means read-only mode.
RPC_URL_5042002 (none) Arc RPC endpoint.
RPC_URL_11155111 (none) Sepolia RPC endpoint.
AGENT_MAX_INPUT_AMOUNT 100 Max input amount per swap (human units of input token).
AGENT_MAX_SLIPPAGE_BPS 100 Max slippage the agent may use, in basis points.
AGENT_ALLOWED_CHAINS 5042002,11155111 Comma-separated chain ids the agent may touch.

RPC for chain N is read from RPC_URL_<N> (rpcUrlForChain).


Safety model

The agent is autonomous, so the guardrails are the contract:

  • assertChainAllowed rejects every action whose chain is not in AGENT_ALLOWED_CHAINS.
  • assertSwapPolicy enforces amount ≤ AGENT_MAX_INPUT_AMOUNT and slippageBps ≤ AGENT_MAX_SLIPPAGE_BPS before anything is signed. The model cannot talk its way past these; they run in code on every swap/bridge.
  • Read-only mode: with no PRIVATE_KEY, get_*/list_tokens still work; swap/bridge throw.
  • The key never reaches the model. The LLM only emits tool calls; signing happens in src/chain/wallet.ts.
  • Authorization model: by design, the user asking to swap/bridge is the authorization (no second confirmation). Add a confirmation tool if you need human-in-the-loop.

Operational guidance: dedicated low-balance key, testnets only, narrow AGENT_ALLOWED_CHAINS, conservative limits, and a SynRoute key scoped to test usage.


Install and run

Requirements: Node.js ≥ 18, an LLM API key (Anthropic or OpenAI), a SynRoute API key, a funded test wallet, and RPC endpoints for the chains you enable.

git clone <your-fork-url> synroute-agent
cd synroute-agent
npm install
cp .env.example .env      # fill in keys, PRIVATE_KEY, RPC URLs, limits

# Dev (no build step, via tsx):
npm run dev

# or build + run:
npm run build && npm start

# type-check only:
npm run typecheck
Script Command Purpose
dev tsx src/index.ts Run from source, fast iteration.
build tsc -p tsconfig.json Emit dist/.
start node dist/index.js Run the built agent.
typecheck tsc --noEmit Strict type check (no emit).

The build also exposes a synroute-agent bin (dist/index.js).


Example session

  SynRoute Agent
  provider : anthropic (claude-sonnet-4-6)
  wallet   : 0xA11ce…F00d
  chains   : 5042002, 11155111
  limits   : max 100/swap, slippage ≤ 100bps

you › quote 10 USDC to SYN on Arc
agent › 10 USDC ≈ 9,512.4 SYN  (route: USDC → SYN, 0.3% pool)

you › swap 5 USDC for SYN
agent ›
  · running swap…
  Done. Swapped 5 USDC for ~4,756 SYN on Arc.    (text streams in live)
  tx: https://testnet.arcscan.app/tx/0x… (submitted)

you › bridge 10 USDC from Sepolia to Arc
agent › Bridging 10 USDC Sepolia → Arc via CCTP…
        source burn: 0x… ; settled on Arc: 0x… (success)

Extending the agent

Add a tool. Append a ToolSpec in buildTools() (src/tools.ts): a name, a JSON-Schema parameters, and an async handler(args) returning a JSON-serializable result. It is automatically exposed to both providers. Keep handlers disciplined: validate inputs (str/num), enforce policy, return structured data.

Add a chain. Add it to CHAINS in src/config.ts (with cctpDomain if it is CCTP-capable), include its id in AGENT_ALLOWED_CHAINS, set RPC_URL_<id>, and add any native-coin entry to NATIVE_TOKENS in src/synroute/tokens.ts. The token list must include that chain's tokens.

Switch / add an LLM provider. Set LLM_PROVIDER. To add a third provider, implement the AgentSession interface in src/llm.ts (a send() that runs the same tool loop against the new SDK) and wire it into createSession.

Change the swap execution path. Flip SYNTHRA_APPROVAL_MODE between permit2 and erc20. The tool layer handles both response shapes.

Tighten authorization. Add a confirm/simulate tool and instruct the model (system prompt) to call it before swap/bridge.


Design notes and gotchas

These are encoded in the code for a reason; preserve them when refactoring.

  • bridgeToken vs sourceUsdc. /v1/cctp/route's sourceQuote names the bridge asset bridgeToken, but /v1/cctp/quote (the signer) expects sourceUsdc. executeBridge maps it and carries feeAmount/maxFee fallbacks from bridgeLeg.
  • Destination router params on claim. The claim body must pass universalRouter, routerCommands, routerInputs, routerDeadline from the destination execution. Omit them and the backend recovers the source leg's calldata, which reverts on the destination chain and falls back to delivering plain USDC instead of tokenOut.
  • Arc gas headroom. Arc's native-USDC is an ERC20-facade precompile that gas estimation under-models. sendTx(..., withGasHeadroom=true) sends swaps with max(2× estimate, 4,000,000) gas (unused gas is refunded). Used for the swap and the CCTP burn.
  • Permit2 typed data. signPermit2 strips the EIP712Domain entry from types because ethers derives the domain itself; signing it twice errors.
  • Native input. When tokenIn is the native coin, there is no ERC20 approval / Permit2 step and the amount is sent as msg.value (must equal amountIn).
  • Idempotent settlement. already_settled/already_claimed are treated as success terminal states, so re-running a claim is safe.

Constants reference

Concept Value
Arc chain id 5042002 (CCTP domain 26)
Sepolia chain id 11155111 (CCTP domain 0)
Default chain (unspecified) first of AGENT_ALLOWED_CHAINS (Arc)
Agent loop cap MAX_STEPS = 8
Default swap slippage 50 bps (capped by AGENT_MAX_SLIPPAGE_BPS)
Claim polling every 5s, up to 60 attempts
Arc gas max(2× estimate, 4,000,000)
Native alias ETH / NATIVE (Sepolia native)

For AI coding agents

If you are an LLM ingesting this repo to modify it, the invariants below should hold across edits:

  • Single source of config: everything tunable lives in CONFIG (src/config.ts). Do not read process.env elsewhere except rpcUrlForChain.
  • The model never sees secrets. Never pass PRIVATE_KEY, *_API_KEY, or signing material into prompts, tool descriptions, or tool results.
  • Guardrails run in code, not in the prompt. Any new write action must call assertSwapPolicy (or assertChainAllowed) before signing.
  • Amounts: tool inputs are human units; convert with toRawAmount before hitting the API and fromRawAmount before returning to the model. Never surface raw/wei to the user.
  • Tool results must be JSON-serializable and small; the loop stringifies them back into the conversation.
  • Determinism of the loop: keep runTool returning { error } on failure rather than throwing out of the loop.
  • Where to edit: new capability means buildTools in src/tools.ts; new chain means CHAINS/NATIVE_TOKENS; new provider means AgentSession in src/llm.ts; API shape changes mean src/synroute/client.ts plus the relevant execute* function.
  • Type safety: tsconfig is strict with noUnusedLocals/noUnusedParameters; run npm run typecheck before considering a change done.

Limitations

  • Starting point, not production. No persistence, no retries/backoff beyond the CCTP claim poll, no nonce management for concurrent sends, minimal observability.
  • Single wallet, single key. One signer across all chains; no key rotation or HSM.
  • Trust in SynRoute responses. The agent submits calldata the API returns (except the CCTP source burn, which it encodes locally); it does not independently simulate every transaction.
  • No human-in-the-loop by default. Asking is authorizing. Add a confirmation tool if you need approval gating.
  • Testnet-shaped. Defaults target Arc and Sepolia; broaden CHAINS/AGENT_ALLOWED_CHAINS deliberately.

License

MIT, see LICENSE.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages