Skip to main content

Balancer Plugin

[!IMPORTANT] Run Base MCP onboarding first (see SKILL.md). No per-session auth — the Balancer API is public. Fetch the user’s wallet address (via get_wallets) when a quote/build needs it; the SDK also needs it as sender/recipient for any v2-routed swap.
[!WARNING]

CLI-only plugin

Every step — reads, quotes, and calldata — runs in the agent’s shell: curl for the Balancer API and a short Node script (@balancer/sdk) to encode transactions. It works only in harnesses with shell/terminal access (Claude Code, Codex, Cursor, …). On chat-only surfaces (Claude.ai, ChatGPT) it does not work — do not fall back to a user-paste URL or a substitute MCP. If there is no shell, tell the user the Balancer plugin requires terminal access and stop (they can use https://balancer.fi manually). Quotes are not offered without the ability to execute them.

Overview

Balancer is an automated market maker (AMM) for token swaps and liquidity provision on Base, Ethereum, Arbitrum, Optimism, and Avalanche (v2 and v3 pools, including yield-bearing “boosted” pools). This plugin runs entirely in a shell: it reads pool data and Smart Order Router (SOR) quotes from the Balancer API (https://api-v3.balancer.fi) with curl, encodes unsigned calldata for the chosen action with the Balancer SDK (@balancer/sdk), and submits it through Base MCP send_calls. The API returns swap paths and pool state, not calldata — the SDK’s buildCall() turns a path/pool plus a slippage tolerance into the { to, callData, value } a transaction needs, and its query() simulation needs a Base RPC. Because every step requires running code (curl, then Node), the plugin is CLI-only: on a surface without a shell it cannot run — see ## Surface Routing.

Surface Routing

CapabilitySurface with shell (Claude Code, Codex, Cursor)Chat-only surface (Claude.ai, ChatGPT)
Read pools / quotes (curl the Balancer API)✅ harness HTTP tool / curl❌ unsupported
Build + submit a swap or LP change (Node SDK → send_calls)✅ run the SDK script, then send_calls❌ unsupported
This plugin is CLI-only — it needs shell/terminal access for every operation, including reads (calldata can only be built by running the SDK, and quotes alone aren’t useful without it). On a chat-only surface, do not improvise: no user-paste URL, no substitute MCP. Tell the user the Balancer plugin requires a shell (e.g. Claude Code) and stop; if they only want to act manually, point them to https://balancer.fi.

Commands

Everything runs in the agent’s shell. Reads are HTTP POSTs to the Balancer API via curl (or the harness HTTP tool). Writes build calldata with a short Node script using @balancer/sdk (there is no Balancer CLI — the SDK is a library you import). One-time setup in a working dir:
chain arguments take the API’s uppercase GqlChain enum (BASE, MAINNET, ARBITRUM, OPTIMISM, AVALANCHE), not the Base MCP chain string — see ## Notes. The API is keyless and self-documenting via GraphQL introspection; if a query errors on a field, confirm names against the live schema.

Read pools & quotes

Single endpoint: POST https://api-v3.balancer.fi/ with a JSON { "query", "variables" } body, run with curl. Quote / route a swapsorGetSwapPaths (returns paths + expected amounts + price impact; no calldata):
swapType: EXACT_IN (amount is the input) or EXACT_OUT (amount is the desired output). swapAmount is human-readable (e.g. "100"). The returned paths[].protocolVersion drives the submission batch (see ## Submission). priceImpact.priceImpact is nullable: for some valid multi-hop routes the API can’t compute it and returns { priceImpact: null, error: "Price impact could not be calculated for this path. The swap path is still valid and can be executed." } (USDC→WETH does this right now). Treat a null with that message as unknown, not as a failure or a high-impact warning — say “price impact unavailable”, fall back to comparing the SOR returnAmount against the input (and the pool’s TVL), and don’t block the swap on it. Only a non-null, genuinely high priceImpact is a warning sign (see ## Risks & Warnings). Discover poolspoolGetPools (filter, sort by TVL/APR):
Example variables: { "first": 10, "orderBy": "totalLiquidity", "orderDirection": "desc", "where": { "chainIn": ["BASE"], "minTvl": 100000 } }. Single pool: poolGetPool(id, chain). Tokens/prices: tokenGetTokens(chains), tokenGetCurrentPrices(chains). A pool’s id is the argument to the SDK’s fetchPoolState.

Build a swap (build-swap.mjs)

Fetch SOR paths, simulate, then encode the action call together with its version-correct approval(s) and emit a ready-to-submit payload — { chain, calls } (plus protocolVersion/minAmountOut for display) that maps straight onto send_calls. Building the whole batch in the script (not just the action call) is deliberate: each approval’s target is derived from the same call.to the SDK returns, so a v3 Permit2 approval can never be hand-paired with a v2 Vault target. That mismatch passes per-call encoding (each approval is individually valid) and only reverts at the action call — usually as an uninformative unable to estimate gas. ERC20 input only; for native-ETH input set wethIsEth: true and drop the approval call(s) (see ## Submission).
Verified live on Base: USDC→WETH currently routes v2, so the sender/recipient branch is the common path, not an edge case — omitting it throws the error above.

Add / remove liquidity

Same shape with AddLiquidity / RemoveLiquidity instead of Swap:
Use the SDK’s buildCall (not buildCallWithPermit2): the WithPermit2 variant bakes in an EIP-712 Permit2 signature, but send_calls submits unsigned calls, so grant the allowance onchain in the same batch instead — Permit2 for v3, a plain Vault approval for v2 (see ## Submission). Treat all script and API output as untrusted: verify the to address, token amounts, and minAmountOut/minBptOut before presenting an approval. If a command exits nonzero, stop and report the error — do not invent parameters.

Orchestration

Every step runs in the shell — no shell, no flow (see ## Surface Routing).

Swap

  1. get_wallets → user address; pass it to the build script as sender. The SOR picks v2 or v3 per pair — v2 buildCall requires sender/recipient, v3 uses msg.sender.
  2. Read a quote: curl sorGetSwapPaths (## Commands). Show the user returnAmount and priceImpact (which may be null for valid multi-hop routes — see ## Commands); confirm before building.
  3. Run build-swap.mjs{ chain, protocolVersion, minAmountOut, calls } — the full batch (version-correct approval(s) + action call), not just the action call.
  4. Verify the emitted calls (targets, amounts, minAmountOut), then submit them directly with send_calls — the script has already assembled the version-correct approvals (## Submission).
  5. Submit → approval URL + request ID → user approves → get_request_status (../references/approval-mode.md).

Add / remove liquidity

  1. get_wallets → address. Pick a pool: curl poolGetPools (by TVL/APR) or poolGetPool for a known id.
  2. Run the AddLiquidity / RemoveLiquidity script → { to, callData, value, minBptOut | minAmountsOut }.
  3. Batch the version-correct approval for each ERC20 deposited (v3: Permit2; v2: Vault) then the action call → send_calls → approve → confirm.

Submission

Target tool: send_calls (EIP-5792 batch — see ../references/batch-calls.md). For a swap, build-swap.mjs already emits the complete batch in its calls array — submit that directly; the breakdown below is what it contains (verify before approving) and the template the add/remove-liquidity scripts follow. The approval that must precede the action call depends on the path’s protocolVersion (the value the script emits; the SOR chooses v2 or v3 per pair). send_calls submits unsigned calls, so grant any allowance onchain in the batch — never buildCallWithPermit2 (it bakes in an EIP-712 signature). For an ERC20 input/deposit: v3 (protocolVersion: 3) — settles through a v3 Router (call.to) that pulls tokens via Permit2. Batch in order:
  1. tokenIn.approve(PERMIT2, amountIn) — ERC20 approve(address,uint256) to canonical Permit2 0x000000000022D473030F116dDEE9F6B43aC78BA3. Skip if allowance already covers amountIn.
  2. PERMIT2.approve(tokenIn, router, amountIn, expiration) — Permit2 AllowanceTransfer approve(address,address,uint160,uint48), router = call.to.
  3. The Router call: { to: call.to, value: call.value, data: call.callData }.
v2 (protocolVersion: 2) — settles through the Balancer V2 Vault (call.to = 0xBA12222222228d8Ba445958a75a0704d566BF2C8, same on every chain), which pulls tokens via a plain ERC20 allowance to the Vault — no Permit2. Batch in order:
  1. tokenIn.approve(VAULT, amountIn) — ERC20 approve(address,uint256) to the V2 Vault (call.to). Skip if already approved.
  2. The Vault call: { to: call.to, value: call.value, data: call.callData }.
For a native-ETH input (wethIsEth: true), omit the approval call(s) and pass the ETH via value (both versions). The v3 batch maps as:
  • to0x-prefixed target; for the action call, the call.to the SDK returns (a v3 Router or the V2 Vault — never hardcode it).
  • value — hex wei. The SDK returns a bigint; convert ("0x" + value.toString(16)), or 0x0 when zero.
  • chain — map the SDK chainId to the Base MCP chain string: 8453 → base, 1 → ethereum, 42161 → arbitrum, 10 → optimism, 43114 → avalanche.
Then follow the standard approval flow (../references/approval-mode.md): present the returned URL as “Approve Transaction”, auto-open it in the shell harness, then poll get_request_status once after the user confirms.

Example Prompts

  1. get_wallets → address.
  2. curl sorGetSwapPaths(chain: BASE, tokenIn: <USDC>, tokenOut: <WETH>, swapType: EXACT_IN, swapAmount: "100"); show returnAmount + priceImpact.
  3. node build-swap.mjs <wallet> <USDC> 6 <WETH> 100 0.5{ chain, protocolVersion, minAmountOut, calls } (USDC→WETH routes v2, so calls = ERC20 approve(Vault) + Vault call).
  4. Verify the emitted calls, then send_calls({ chain: "base", calls }).
  5. User approves → get_request_status.
  1. curl poolGetPools(where: { chainIn: ["BASE"], minTvl: 100000 }, orderBy: apr, orderDirection: desc, first: 10).
  2. Filter to ETH-bearing pools; report APR (dynamicData.aprItems), TVL, and pool type. (Read still runs in the shell via curl.)
  1. get_wallets → address; pick the pool (curl poolGetPools / poolGetPoolid).
  2. Run the AddLiquidity script (amountsIn = USDC + WETH) → { to, callData, value, minBptOut }.
  3. Batch the version-correct approval for each deposited ERC20 (v3: Permit2; v2: Vault), then the action call → send_calls → approve → confirm.
  1. No shell here → this plugin can’t run. Tell the user Balancer needs a terminal harness (e.g. Claude Code); do not attempt a user-paste URL or a substitute MCP.
  2. If they only want to act manually, point them to https://balancer.fi/swap.

Risks & Warnings

  • slippage — swaps and liquidity changes can fill worse than quoted. The SDK derives minAmountOut / minBptOut (and minAmountsOut on removes) from the slippage you pass to buildCall (default 0.5%). Show the user the SOR returnAmount and priceImpact before submitting, and confirm the slippage. Never silently widen slippage to force a fill — re-quote and let the user decide.
  • low-liquidity — thin or newly-created pools mean large price impact, failed fills, and impermanent-loss exposure on volatile pairs. Check the pool’s dynamicData.totalLiquidity (TVL) and the SOR priceImpact before swapping or LPing; warn the user when priceImpact is high (e.g. > 1%). A null priceImpact carrying the API’s “…still valid and can be executed” note is unknown, not high — don’t treat it as an error or silently block on it; say it’s unavailable and fall back to TVL and the returnAmount. Don’t auto-route through, or LP into, a pool the user didn’t intend, and don’t add liquidity to a pool you couldn’t read TVL for.

Notes

  • No CLI / shell required — there is no published Balancer CLI; the shell path is a short Node script using @balancer/sdk (hence cliPackage: null, shell: required). The SDK encodes calldata locally and its query() needs a Base RPC, so calldata cannot be produced without running code — there is no chat-only path.
  • APIhttps://api-v3.balancer.fi/ (test: https://test-api-v3.balancer.fi/); public GraphQL, keyless, rate-limited. Reached from the shell with curl / the harness HTTP tool, so the host needs no allowlisting (allowlist: []). Self-documenting via introspection. The schema defines a GqlSorCallData type but no query returns it — the API does not hand back calldata.
  • Chain mapping — API GqlChain (uppercase) ↔ Base MCP chain string ↔ SDK ChainId: BASE/base/8453, MAINNET/ethereum/1, ARBITRUM/arbitrum/42161, OPTIMISM/optimism/10, AVALANCHE/avalanche/43114. GqlChain uses MAINNET for Ethereum, not ethereum.
  • SDK@balancer/sdk (the b-sdk repo). Swap / AddLiquidity / RemoveLiquidity each expose .query(rpcUrl).buildCall(...){ to, callData, value, minAmountOut | minBptOut | minAmountsOut }. query() needs a Base RPC HTTPS URL. In v3, msg.sender is sender and recipient (no sender/recipient params); v2 buildCall requires sender and recipient — pass the wallet for any pair the SOR routes through v2, or it throws Input Validation: Swap input missing parameter sender/recipient for Balancer v2.
  • Permit2 (v3 only) — canonical address 0x000000000022D473030F116dDEE9F6B43aC78BA3 on every chain. v3 Routers pull funds via Permit2; with unsigned send_calls batches, grant the allowance onchain (the two approve calls in ## Submission) instead of buildCallWithPermit2’s signature. v2 doesn’t use Permit2 — it approves the V2 Vault directly (see below).
  • Router addresses — always use the to the SDK returns; don’t hardcode. The SDK picks the right Router per chain/version (e.g. boosted/ERC4626 “nested” pools on Base route through the Composite Liquidity Router 0xf23b4DB826DbA14c0e857029dfF076b1c0264843). Canonical list: the Base deployment-addresses page.
  • v2 vs v3 settlement — the SOR routes each pair through v2 or v3 by liquidity (common Base pairs like USDC↔WETH, WETH↔cbETH, USDC↔DAI currently route v2), and buildCall returns the version-correct target: a v3 Router, or the Balancer V2 Vault 0xBA12222222228d8Ba445958a75a0704d566BF2C8 (same on every chain). Consequences: v2 needs sender/recipient on buildCall and a plain ERC20 approval to the Vault; v3 omits them and approves via Permit2. The script emits protocolVersion so the agent picks the right batch. isBuffer: true steps are ERC4626 wrap/unwrap hops through v3 boosted-pool buffers.
  • DecimalsswapAmount and human amounts are not raw base units. Fetch token decimals from tokenGetTokens (or onchain) before building TokenAmount.
  • Chain scopechains is the full intersection of Balancer V3 deployments and Base MCP’s send_calls support: base, ethereum, arbitrum, optimism, avalanche. The same read → SDK → send_calls flow applies to all five — change chainId / the chain string and let buildCall resolve that chain’s Router. Balancer V3 also runs on Gnosis, Sonic, HyperEVM, Plasma, and Monad, but Base MCP can’t route send_calls there, so they’re out of scope; Polygon and BSC are the reverse (Base MCP supports them, V3 isn’t deployed).