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 (viaget_wallets) when a quote/build needs it; the SDK also needs it assender/recipientfor any v2-routed swap.
[!WARNING]CLI-only plugin
Every step — reads, quotes, and calldata — runs in the agent’s shell:curlfor 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 usehttps://balancer.fimanually). 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
| Capability | Surface 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 |
https://balancer.fi.
Commands
Everything runs in the agent’s shell. Reads are HTTP POSTs to the Balancer API viacurl (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 swap — sorGetSwapPaths (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 pools — poolGetPools (filter, sort by TVL/APR):
{ "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 thesender/recipientbranch is the common path, not an edge case — omitting it throws the error above.
Add / remove liquidity
Same shape withAddLiquidity / RemoveLiquidity instead of Swap:
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
get_wallets→ user address; pass it to the build script assender. The SOR picks v2 or v3 per pair — v2buildCallrequiressender/recipient, v3 usesmsg.sender.- Read a quote:
curlsorGetSwapPaths(## Commands). Show the userreturnAmountandpriceImpact(which may be null for valid multi-hop routes — see## Commands); confirm before building. - Run
build-swap.mjs→{ chain, protocolVersion, minAmountOut, calls }— the full batch (version-correct approval(s) + action call), not just the action call. - Verify the emitted
calls(targets, amounts,minAmountOut), then submit them directly withsend_calls— the script has already assembled the version-correct approvals (## Submission). - Submit → approval URL + request ID → user approves →
get_request_status(../references/approval-mode.md).
Add / remove liquidity
get_wallets→ address. Pick a pool:curlpoolGetPools(by TVL/APR) orpoolGetPoolfor a knownid.- Run the
AddLiquidity/RemoveLiquidityscript →{ to, callData, value, minBptOut | minAmountsOut }. - 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:
tokenIn.approve(PERMIT2, amountIn)— ERC20approve(address,uint256)to canonical Permit20x000000000022D473030F116dDEE9F6B43aC78BA3. Skip if allowance already coversamountIn.PERMIT2.approve(tokenIn, router, amountIn, expiration)— Permit2 AllowanceTransferapprove(address,address,uint160,uint48),router=call.to.- The Router call:
{ to: call.to, value: call.value, data: call.callData }.
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:
tokenIn.approve(VAULT, amountIn)— ERC20approve(address,uint256)to the V2 Vault (call.to). Skip if already approved.- The Vault call:
{ to: call.to, value: call.value, data: call.callData }.
wethIsEth: true), omit the approval call(s) and pass the ETH via value (both versions). The v3 batch maps as:
to—0x-prefixed target; for the action call, thecall.tothe 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)), or0x0when zero.chain— map the SDKchainIdto the Base MCP chain string:8453 → base,1 → ethereum,42161 → arbitrum,10 → optimism,43114 → avalanche.
get_request_status once after the user confirms.
Example Prompts
get_wallets→ address.curlsorGetSwapPaths(chain: BASE, tokenIn: <USDC>, tokenOut: <WETH>, swapType: EXACT_IN, swapAmount: "100"); showreturnAmount+priceImpact.node build-swap.mjs <wallet> <USDC> 6 <WETH> 100 0.5→{ chain, protocolVersion, minAmountOut, calls }(USDC→WETH routes v2, socalls= ERC20approve(Vault)+ Vault call).- Verify the emitted
calls, thensend_calls({ chain: "base", calls }). - User approves →
get_request_status.
curlpoolGetPools(where: { chainIn: ["BASE"], minTvl: 100000 }, orderBy: apr, orderDirection: desc, first: 10).- Filter to ETH-bearing pools; report APR (
dynamicData.aprItems), TVL, and pool type. (Read still runs in the shell viacurl.)
get_wallets→ address; pick the pool (curlpoolGetPools/poolGetPool→id).- Run the
AddLiquidityscript (amountsIn= USDC + WETH) →{ to, callData, value, minBptOut }. - Batch the version-correct approval for each deposited ERC20 (v3: Permit2; v2: Vault), then the action call →
send_calls→ approve → confirm.
- 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.
- 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(andminAmountsOuton removes) from the slippage you pass tobuildCall(default 0.5%). Show the user the SORreturnAmountandpriceImpactbefore 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 SORpriceImpactbefore swapping or LPing; warn the user whenpriceImpactis high (e.g. > 1%). A nullpriceImpactcarrying 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 thereturnAmount. 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(hencecliPackage: null,shell: required). The SDK encodes calldata locally and itsquery()needs a Base RPC, so calldata cannot be produced without running code — there is no chat-only path. - API —
https://api-v3.balancer.fi/(test:https://test-api-v3.balancer.fi/); public GraphQL, keyless, rate-limited. Reached from the shell withcurl/ the harness HTTP tool, so the host needs no allowlisting (allowlist: []). Self-documenting via introspection. The schema defines aGqlSorCallDatatype but no query returns it — the API does not hand back calldata. - Chain mapping — API
GqlChain(uppercase) ↔ Base MCP chain string ↔ SDKChainId:BASE/base/8453,MAINNET/ethereum/1,ARBITRUM/arbitrum/42161,OPTIMISM/optimism/10,AVALANCHE/avalanche/43114.GqlChainusesMAINNETfor Ethereum, notethereum. - SDK —
@balancer/sdk(theb-sdkrepo).Swap/AddLiquidity/RemoveLiquidityeach expose.query(rpcUrl)→.buildCall(...)→{ to, callData, value, minAmountOut | minBptOut | minAmountsOut }.query()needs a Base RPC HTTPS URL. In v3,msg.senderis sender and recipient (nosender/recipientparams); v2buildCallrequiressenderandrecipient— pass the wallet for any pair the SOR routes through v2, or it throwsInput Validation: Swap input missing parameter sender/recipient for Balancer v2. - Permit2 (v3 only) — canonical address
0x000000000022D473030F116dDEE9F6B43aC78BA3on every chain. v3 Routers pull funds via Permit2; with unsignedsend_callsbatches, grant the allowance onchain (the two approve calls in## Submission) instead ofbuildCallWithPermit2’s signature. v2 doesn’t use Permit2 — it approves the V2 Vault directly (see below). - Router addresses — always use the
tothe 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 Router0xf23b4DB826DbA14c0e857029dfF076b1c0264843). 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
buildCallreturns the version-correct target: a v3 Router, or the Balancer V2 Vault0xBA12222222228d8Ba445958a75a0704d566BF2C8(same on every chain). Consequences: v2 needssender/recipientonbuildCalland a plain ERC20 approval to the Vault; v3 omits them and approves via Permit2. The script emitsprotocolVersionso the agent picks the right batch.isBuffer: truesteps are ERC4626 wrap/unwrap hops through v3 boosted-pool buffers. - Decimals —
swapAmountand human amounts are not raw base units. Fetch token decimals fromtokenGetTokens(or onchain) before buildingTokenAmount. - Chain scope —
chainsis the full intersection of Balancer V3 deployments and Base MCP’ssend_callssupport: base, ethereum, arbitrum, optimism, avalanche. The same read → SDK →send_callsflow applies to all five — changechainId/ thechainstring and letbuildCallresolve that chain’s Router. Balancer V3 also runs on Gnosis, Sonic, HyperEVM, Plasma, and Monad, but Base MCP can’t routesend_callsthere, so they’re out of scope; Polygon and BSC are the reverse (Base MCP supports them, V3 isn’t deployed).