Per-agent-wallet Virtuals BondingV5 + USDC↔VIRTUAL trading helpers — ACP wallet edition.
A self-contained TypeScript library for programmatically trading on the Virtuals Protocol BondingV5 curve, from a Virtuals ACP smart wallet. Every transaction is submitted via the official acp CLI's wallet send-transaction command from a per-wallet isolated config directory — no private keys ever touch this codebase.
Adapted from a private fleet-based market maker running on Base mainnet.
- What this is
- What it is not
- Architecture in 90 seconds
- Quick start
- The five public entrypoints
- Critical gotchas (read these)
- Verified Base mainnet addresses
- Function selectors
- HTTP route reference
- Deep docs
- License
A small library (~1,000 LOC of TypeScript across three files) that does exactly one thing well: submits BondingV5 buy/sell transactions and USDC↔VIRTUAL Uniswap V3 swaps from one ACP smart wallet at a time.
Three primitives:
| Function | What it does |
|---|---|
balanceOf(holder, token) |
ERC20.balanceOf via eth_call. Pure read, no tx. |
usdcToVirtualSwap(walletId, opts) |
Multi-hop USDC → WETH(0.05%) → VIRTUAL(0.05%) via Uniswap V3 SwapRouter02, as two single-hop calls (more on this below). |
virtualToUsdcSwap(walletId, opts) |
Reverse of the above. |
bondingV5Trade(walletId, { side, preToken, amountInWei, ... }) |
Calls BondingV5.buy or .sell. Handles approval to FRouterV3 (not the V5 proxy — more on this below) and fee-on-transfer balance reconciliation. |
Each operation is structured as: prep calldata → idempotent approve (if needed) → trade. No retries, no concurrency control — that belongs in your strategy layer.
- ❌ A wallet generator or key custodian. The agent wallet signs internally.
- ❌ ACP wallet onboarding. You need
acp wallet create+ session signer setup separately. - ❌ Post-graduation UniV2 trading. Use
uniV2Router02directly or an aggregator. - ❌ A retry/backoff layer.
- ❌ A pricing oracle.
minOutWeidefaults to1nfor BondingV5 trades — production users should compute this from a fresh quote. - ❌ Multi-chain. Base only (chainId 8453).
- ❌ A KyberSwap aggregator wrapper. Our production system uses Kyber for USDC↔VIRTUAL — the UniV3 helpers in this repo are the lower-level fallback. See docs/ARCHITECTURE.md.
┌─────────────────────────────────────────────────────────────────────┐
│ Your strategy code │
│ └─ bondingV5Trade(walletId, { side, preToken, amountInWei }) │
│ │ │
│ ▼ │
│ bondingV5.ts │
│ ├─ getAcpWalletAddress(walletId) ─┐ │
│ ├─ balanceOf(addr, tokenIn) │ │
│ ├─ ensureApproval(...) │ all of these spawn │
│ └─ sendTx(walletId, to, data) │ `acp wallet ...` in a │
│ │ │ per-wallet ACP_CONFIG_DIR │
│ ▼ │ │
│ acpCli.ts ───────────────────────────┘ │
│ spawn("acp", ["wallet", "send-transaction", "--chain-id", ...]) │
│ │ │
│ ▼ │
│ Virtuals ACP smart wallet (one per walletId) │
│ Session signer in keystore → signs userOp → submits to bundler │
│ │ │
│ ▼ │
│ Base mainnet (chainId 8453) │
│ • Approve to FRouterV3 (0x02fe...) for BondingV5 trades │
│ • BondingV5 proxy (0x1a54...) buy()/sell() │
│ • UniV3 SwapRouter02 (0x2626...) USDC↔VIRTUAL via WETH │
└─────────────────────────────────────────────────────────────────────┘
The orchestrator never holds a private key. It identifies a wallet by integer walletId, which maps to a config dir ${ACP_CONFIG_ROOT}/mainnet/${walletId} containing that wallet's keystore + session signer. Different walletIds run in completely isolated environments — no shared state.
For the full architecture, including why we use ACP wallets, how fees-on-transfer break naive sizing, and how the dynamic agent-router picks BondingV5 vs UniV2, see docs/ARCHITECTURE.md.
- Node ≥ 20 +
pnpmornpm. acpCLI on PATH. Confirm withacp --version. (Get it from Virtuals.)- An onboarded ACP wallet. This library trades from a wallet — it doesn't create one. Onboarding is one command:
acp wallet createinside a freshACP_CONFIG_DIR. Seedocs/ACP_WALLETS.md. - Base RPC URL. Public works for testing; use QuickNode/Alchemy at scale.
git clone https://github.com/moomooed/bondv5-trader
cd bondv5-trader
pnpm install
cp .env.example .env # edit BASE_RPC_URL, ACP_CONFIG_ROOT
pnpm typecheck# Read balances on walletId=1 (must already be onboarded under ACP_CONFIG_ROOT/mainnet/1)
pnpm cli balances 1
# Same plus a preToken balance
pnpm cli balances 1 0xb2a99bc73c89b6bcbeb4650eedcd5f2776373c48
# Fund: swap 80-100% of USDC into VIRTUAL via UniV3
pnpm cli usdc-to-virtual 1
# Buy: spend 1 VIRTUAL on a preToken via BondingV5
pnpm cli buy 1 0xb2a99bc73c89b6bcbeb4650eedcd5f2776373c48 1000000000000000000
# Sell: dump 50 preTokens back to VIRTUAL via BondingV5
pnpm cli sell 1 0xb2a99bc73c89b6bcbeb4650eedcd5f2776373c48 50000000000000000000
# Close: VIRTUAL back to USDC
pnpm cli virtual-to-usdc 1The CLI is a thin wrapper around the library — see examples/cli.ts.
import {
balanceOf, // read ERC20 balance
ensureApproval, // idempotent approve to MAX_UINT256
usdcToVirtualSwap, // UniV3 USDC -> VIRTUAL (two-leg)
virtualToUsdcSwap, // UniV3 VIRTUAL -> USDC (two-leg)
bondingV5Trade, // BondingV5.buy or .sell
} from "bondv5-trader";Full signatures, args, returns, errors, examples → docs/API.md.
These are the five things that will silently waste your time if you don't internalize them:
BondingV5.buy()/sell() delegate to FRouterV3, which is the actual transferFrom caller. Approving the V5 proxy reverts with ERC20: insufficient allowance.
bondingV5 proxy 0x1a540088125d00dd3990f9da45ca0859af4d3b01 ← call target for buy/sell
fRouterV3 0x02fe8ec3d9bbf7318eb54590bcc39198a8b47ded ← ERC20 spender to approve
This library handles it correctly via ensureApproval(walletId, addr, tokenIn, ADDR.fRouterV3, amount). If you're hand-rolling an integration, don't get this wrong.
There's no USDC/VIRTUAL UniV3 pool with material liquidity on Base. The route is USDC → WETH → VIRTUAL, both 0.05% pools. The natural exactInput (multi-hop) reverts with InvalidFEOpcode on Base because of the callback boundary between the two 0.05% pools.
We work around this with two exactInputSingle calls, reading the actual WETH balance after leg 1 before sizing leg 2. Slightly more gas, but it works. See docs/CONTRACTS.md#univ3-quirk.
The trading address is the ACP smart wallet address returned by acp wallet address --json. The owner EOA controls it via session signers, but you never reference the EOA from this library. getAcpWalletAddress(walletId) resolves the smart wallet address; everything else uses it.
Different builds return the tx hash under different keys:
txHashtransactionHashhashuserOpHash(only — no tx hash yet)
The wrapper accepts all four. When it gets a tx hash, it waitForTransactionReceipt on Base (90 s timeout, ~45 blocks of headroom under congestion) and throws if the on-chain status is reverted. This is how the library distinguishes "tx submitted and reverted" from "submitted, still pending" — callers can rely on a successful return meaning the tx mined and didn't revert.
Once a token graduates off the bonding curve onto Uniswap V2, BondingV5.buy/sell will revert. Before trading, either:
- Track
tokenInfo(token).tradingOnUniswap(seedocs/CONTRACTS.md#bondingv5-tokeninfo), or - Check whether the token has a UniV2 pair on Base and route there instead.
This library deliberately does not include the UniV2 router (we keep this repo scoped to BondingV5). Selectors and FoT-safe variants are in abi.ts though — see docs/CONTRACTS.md.
A sixth gotcha worth flagging: fee-on-transfer balance reconciliation. VIRTUAL and all AgentTokenV4 preTokens are fee-on-transfer, meaning if a prior swap deposits N wei, the wallet ends up with slightly less. bondingV5Trade caps amountInWei to the actual on-chain balance minus a 0.5% safety floor when the requested amount exceeds available — and throws if the gap is > 50% (probably a real sizing bug, not just FoT). See docs/CONTRACTS.md#fee-on-transfer.
All independently verified on-chain in our production system (May 2026).
| Contract | Address | Notes |
|---|---|---|
| BondingV5 proxy | 0x1a540088125d00dd3990f9da45ca0859af4d3b01 |
call target for buy()/sell() |
| BondingV5 impl | 0x22aAAfa24266CB2FC3eAE8C151b16537e5841bbD |
verified-source on Basescan |
| FRouterV3 | 0x02fe8ec3d9bbf7318eb54590bcc39198a8b47ded |
ERC20 spender to approve |
| VIRTUAL | 0x0b3e328455c4059EEb9e3f84b5543F74E24e7E1b |
18 decimals |
| USDC | 0x833589fcd6edb6e08f4c7c32d4f71b54bda02913 |
6 decimals, native |
| WETH | 0x4200000000000000000000000000000000000006 |
18 decimals |
| UniV3 SwapRouter02 | 0x2626664c2603336E57B271c5C0b26F421741e481 |
for USDC↔VIRTUAL |
| UniV3 QuoterV2 | 0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a |
read-only quoter |
| UniV3 0.05% WETH/VIRTUAL | 0x9c087eb773291e50cf6c6a90ef0f4500e349b903 |
leg 2 pool |
| UniV2 Router02 | 0x4752ba5DBc23f44D87826276BF6Fd6b1C372aD24 |
post-graduation only |
| Function | Selector | Source |
|---|---|---|
balanceOf(address) |
0x70a08231 |
ERC20 |
allowance(address,address) |
0xdd62ed3e |
ERC20 |
approve(address,uint256) |
0x095ea7b3 |
ERC20 |
buy(uint256,address,uint256,uint256) |
0x706910ff |
BondingV5 |
sell(uint256,address,uint256,uint256) |
0xb233e056 |
BondingV5 |
tokenInfo(address) |
0xf5dab711 |
BondingV5 |
exactInputSingle((...)) |
0x04e45aaf |
UniV3 SwapRouter02 |
exactInput((...)) |
0xb858183f |
UniV3 SwapRouter02 |
quoteExactInput(bytes,uint256) |
0xcdca1753 |
UniV3 QuoterV2 |
quoteExactInputSingle((...)) |
0xc6a5026a |
UniV3 QuoterV2 |
swapExactTokensForTokens |
0x38ed1739 |
UniV2 Router02 |
swapExactTokensForTokensSupportingFeeOnTransferTokens |
0x5c11d795 |
UniV2 Router02 (FoT-safe) |
All selectors are bytes4(keccak256("signature(types)")). See src/abi.ts for the calldata encoders.
If you want to expose these helpers over HTTP, examples/http-routes.ts shows the exact Fastify route handlers we use in production:
| Method | Path | Purpose |
|---|---|---|
POST |
/mm/bondv5/buy |
spend VIRTUAL → receive preToken |
POST |
/mm/bondv5/sell |
spend preToken → receive VIRTUAL |
GET |
/mm/bondv5/balances |
USDC + VIRTUAL + optional preToken balances |
POST |
/mm/usdc-to-virtual |
UniV3 multi-hop swap |
POST |
/mm/virtual-to-usdc |
UniV3 multi-hop reverse swap |
All bodies, response shapes, and example curl commands are in docs/RECIPES.md.
The repo includes a full docs/ folder for everything that doesn't belong in the README:
| Doc | What's in it |
|---|---|
docs/ARCHITECTURE.md |
High-level system design, data flow, ACP wallet model, why this is shaped the way it is |
docs/CONTRACTS.md |
BondingV5 contract internals, FRouterV3, fee-on-transfer math, UniV3 path encoding, multi-hop quirk, QuoterV2, post-graduation UniV2 |
docs/API.md |
Function-by-function reference: signature, args, returns, errors, examples for every export |
docs/ACP_WALLETS.md |
Smart-wallet model, session signers, per-wallet config dir isolation, onboarding pointer, keystore safety |
docs/RECIPES.md |
Common workflows: fund a wallet, buy + sell + close in one shot, fleet-style parallelism, slippage tuning, integrating in your own service |
docs/TROUBLESHOOTING.md |
Error-message decoder. "Why does it say X?" → why and what to fix. |
docs/SECURITY.md |
Threat model, what NOT to do, what to log, what to keep out of logs |
MIT. Adapted from a private fleet-based market maker. Share at will — no warranty. You signed the txs, you own the outcome.