Autonomous cross-border treasury agent, built on Arc, Circle's stablecoin-native L1.
Arcurrent watches a company's payment obligations, decides when and how to settle them based on real signals (balance, due dates, FX rate movement), converts currency via StableFX when a payment isn't USDC-denominated, sources liquidity across chains via Circle Bridge Kit/Gateway when the Arc balance is short, and pays sub-cent nanopayment fees to the rate oracle it consults before every decision, all without a human in the loop, settling in USDC on Arc.
Built for the Build on Arc hackathon: DeFi + Agentic Economy tracks.
Live dashboard: https://arcurrent-web.vercel.app
| Layer | Choice | Why |
|---|---|---|
| Monorepo | npm workspaces | No extra tooling; pnpm hit a Windows/corepack permissions wall |
| Dashboard/API | Next.js (App Router) + Supabase | Mirrors Circle's own arc-fintech reference app |
| Agent | Node.js/TypeScript service (apps/agent) |
Runs independently of the dashboard: autonomy means it isn't human-triggered |
| Wallets | @circle-fin/developer-controlled-wallets |
Circle's real, self-serve wallet SDK |
| Cross-chain liquidity | @circle-fin/app-kit (Bridge Kit) via @circle-fin/adapter-circle-wallets |
kit.bridge() signs through the same Circle-custodied wallets the rest of the app uses, no private key held for either side of the bridge |
| Nanopayments | @circle-fin/x402-batching (x402 protocol) |
Real, self-serve, has a working Circle reference impl (arc-nanopayments) |
| FX conversion | StableFX (gated, see below) | Behind an adapter interface until access is granted |
| Contracts | Hardhat 3 + viem | Foundry's native Windows install path was too much friction for solo/4-week scope |
See docs/STACK.md for the full verification notes (chain ID, RPC, contract addresses, package versions, all independently confirmed against docs.arc.io, chainlist.org, and the npm registry on 2026-07-14).
apps/
web/ Next.js dashboard + API routes + Circle webhooks
agent/ Autonomous decision loop (reads obligations, decides, settles, logs)
oracle/ x402-protected FX rate oracle: the agent pays it a sub-cent
nanopayment via Circle Gateway before recording a convert_currency
decision (real payment, real rate; StableFX itself stays gated)
packages/
contracts/ Hardhat 3 project: ObligationEscrow.sol, a real deployed contract
on Arc Testnet that holds the treasury's USDC and executes
settlements on the agent's instruction (see Status below)
shared/ Shared types + Arc network config used by web and agent
Core spine is real end-to-end, no mock data anywhere in the path:
-
Dashboard (
apps/web) lets a real user add an obligation (vendor, amount, currency, due date, destination address) via a Server Action into a real Postgres table (Supabase), and displays the live Circle treasury balance, the obligations list, and the agent's decision log with links to Arc Testnet Explorer. A/faucetpage links straight to Circle's public Arc Testnet faucet, so trying the app doesn't require hunting for the right chain in Circle's own faucet UI first. -
The evaluation loop (
packages/shared/src/evaluate.ts, built on the unit-testeddecide.ts) reads pending obligations, decides against the escrow contract's real USDC balance, due date, and a configurable reserve floor, and, when it decides to pay, callsObligationEscrow.settle(obligationId, destination, amount)as a contract-execution transaction via Circle's Developer-Controlled Wallets API, so settlement is a verifiable on-chain program action (with its own event log) rather than a bare wallet-to-wallet transfer. Reasoning + tx hash get written back to the database either way, including on a failed evaluation (a per-obligation try/catch logs why and moves on, instead of one bad obligation aborting the whole pass). An atomic claim (conditionalpending -> scheduledupdate) stops two overlapping evaluation passes from both settling the same obligation. There's exactly one implementation of this loop, run from two places:apps/agent(standalone, for local/manual runs) and a Vercel Cron route (apps/web/src/app/api/cron/evaluate, for it to actually run autonomously once deployed, seeapps/web/vercel.json). The route fails closed on a missing/wrongCRON_SECRET, since a real hit here moves real USDC. -
ObligationEscrow.sol(packages/contracts, deployed on Arc Testnet, unit-tested with a mock USDC in an isolated local EVM) holds the treasury's deposited USDC and only lets its owner (the treasury wallet) settle out of it. Deposits and settlements both emit events, so the whole payment history is independently verifiable on-chain, not just in Postgres. -
A webhook route (
/api/circle/webhook) moves an obligation fromscheduledtosettled/failedonce Circle confirms the onchain transaction. Every request'sX-Circle-Signatureis verified (ECDSA-SHA256 over the raw body) before anything is trusted; unverified/malformed requests get a clean401, never a crash. -
When Circle/Supabase credentials aren't configured, the app fails loudly with a clear error rather than falling back to fake data, confirmed by running it with an empty
.env. The same applies to the reserve floor and pay-ahead window: a missing or invalid value throws instead of silently defaulting. -
Nanopayments: when an obligation is denominated in EURC, the agent pays
apps/oracle's/rateroute a real$0.001x402 payment (Circle Gateway, gas-free, settled off-chain as a signed authorization, batched on-chain later) and gets back a live EUR/USD rate from a real public rate provider (no mock data). The rate, source, and payment id are written into the decision's signals. This doesn't execute a conversion, that part is still blocked on StableFX (see below). It proves the agent-pays-for-a-service nanopayment flow end-to-end. -
Cross-chain liquidity top-up: when settling an obligation would drop the escrow balance below the reserve floor, the agent no longer just flags it.
packages/shared/src/liquidity.tsbridges exactly the shortfall from a second Circle-custodied wallet on Base Sepolia into the Arc treasury wallet via Bridge Kit'skit.bridge()(real CCTP, both legs signed through@circle-fin/adapter-circle-wallets; no private key held for either wallet), then deposits the bridged USDC intoObligationEscrow(approve + deposit, same two Circle contract-execution transactions the manual setup step below documents, waiting for the approve to actually confirm on-chain rather than a fixed sleep). The obligation itself settles on the next evaluation pass, once the balance reflects the top-up. Bridging plus a fast-mode CCTP attestation can run long enough that forcing it into the same pass isn't worth the complexity, the bridge itself is wrapped in a 45s timeout (tuned to fit inside Vercel Hobby's 60s function ceiling) so a stuck bridge fails loudly with a logged reason instead of hanging. RequiresLIQUIDITY_WALLET_ID/LIQUIDITY_WALLET_ADDRESSto be set (see Setup); without them,request_liquidityfalls back to the old flag-only behavior.
Known gaps, tracked rather than faked:
- StableFX is gated (RFQ access, no self-serve signup): non-USDC obligations are
correctly flagged
convert_currencyby the decision engine but not yet settled.
npm installat the repo root (also buildspackages/shared).- Copy
.env.exampleto.env. - Create a Supabase project at supabase.com, then run the
migration in
supabase/migrations/against it (npx supabase db pushafternpx supabase link), and fill in the Supabase values in.env. - Generate a Circle API key + entity secret at
console.circle.com and fill in
CIRCLE_API_KEY/CIRCLE_ENTITY_SECRET. npm run setup:wallet: creates the real treasury wallet on Arc Testnet and printsTREASURY_WALLET_ID/TREASURY_WALLET_ADDRESSto add to.env.- Fund that wallet from the Circle faucet (select Arc
Testnet), or once the app is running, the in-app
/faucetpage links to the same place. - Generate a throwaway deployer key (
generatePrivateKey()fromviem/accounts), fund it via the faucet, and setARC_TESTNET_DEPLOYER_PRIVATE_KEY, this pays gas to deploy contracts and is separate from the Circle-custodied treasury wallet above. npm run deploy:obligation-escrow -w packages/contracts: deploysObligationEscrowto Arc Testnet and prints its address; setOBLIGATION_ESCROW_ADDRESSin.env.- Deposit USDC from the treasury wallet into the escrow (
approvethendeposit, both as Circle contract-execution transactions) before the agent can settle anything. - Generate a second throwaway EOA (
AGENT_X402_PRIVATE_KEY) and an address-onlyORACLE_SELLER_ADDRESS(no key needed, it only receives payments). Fund the x402 key with native gas + USDC via the faucet, then runtsx scripts/deposit-gateway.ts <amount>once to fund its Circle Gateway balance. npm run setup:liquidity-wallet: creates the cross-chain liquidity wallet on Base Sepolia and printsLIQUIDITY_WALLET_ID/LIQUIDITY_WALLET_ADDRESSto add to.env. Fund it via the Circle faucet (select Base Sepolia). It needs both testnet ETH (gas for the CCTP burn call) and USDC (the amount to bridge). Confirmed the faucet does not reliably grant both from one request. Request each explicitly and check the wallet's native balance before relying on it, since a USDC-only balance makeskit.bridge()hang instead of failing (there's a 45s timeout guard around it inliquidity.ts, but that's a safety net, not a fix). Optional: without this,request_liquiditydecisions are flagged but not acted on.npm run dev:oracle: starts the rate oracle atlocalhost:4000.npm run dev:web: dashboard atlocalhost:3000.npm run dev:agent: runs one evaluation pass over pending obligations.
Deploy apps/web to Vercel (set its directory as the project root) with all the
.env values above as project env vars (including LIQUIDITY_WALLET_ID/
LIQUIDITY_WALLET_ADDRESS if you want autonomous liquidity top-ups, not just local
manual runs), plus a generated CRON_SECRET
(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))").
vercel.json schedules GET /api/cron/evaluate once daily (0 6 * * *). The
Hobby plan caps cron jobs at once per day, so this runs as often as that plan
allows. On a Pro plan or higher you can raise the frequency in vercel.json.
Without this, the agent only evaluates obligations when someone runs
npm run dev:agent/start manually, or when the dashboard's "Add obligation"
form triggers an immediate pass; with it, settlement genuinely runs with no
human in the loop, just once a day rather than continuously.
apps/oracle deploys as its own separate Vercel project (it has its own
vercel.json rewriting all routes to api/index). Set ORACLE_URL in
apps/web's project env vars to that deployment's public /rate URL.
Real incident, worth knowing: Vercel's per-deployment URLs
(<project>-<hash>-<team>.vercel.app) can sit behind Vercel's own
Deployment Protection (an SSO login wall) even when the project itself has
no protection configured for its stable domain. Pointing ORACLE_URL at a
deployment URL instead of the project's stable alias
(https://<project>.vercel.app) silently broke every convert_currency
evaluation for this project between initial deploy and 2026-08-07, every
attempt got redirected into the SSO wall and failed with a confusing 404,
not an auth error, and the per-obligation try/catch (see Status) meant it
failed quietly instead of loudly. Always use the stable alias, and confirm
with curl <url>/rate that it returns 402 Payment Required (the correct
x402 response), not a redirect, before trusting it in ORACLE_URL.