Subchapter 2.9
plugins/balancer.mdMarkdown23 KBView on GitHub
[!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 assender/recipientfor any v2-routed swap.
[!WARNING]
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.
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.
| 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 |
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.
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:
npm init -y >/dev/null 2>&1
npm i @balancer/sdk viem
export RPC_URL="<a Base RPC HTTPS endpoint>" # buildCall's query() simulation needs an RPCchain 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.
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):
query SwapPaths($chain: GqlChain!, $tokenIn: String!, $tokenOut: String!, $swapType: GqlSorSwapType!, $swapAmount: AmountHumanReadable!) {
sorGetSwapPaths(chain: $chain, tokenIn: $tokenIn, tokenOut: $tokenOut, swapType: $swapType, swapAmount: $swapAmount) {
returnAmount
priceImpact { priceImpact error }
paths { protocolVersion pools isBuffer inputAmountRaw outputAmountRaw tokens { address decimals } }
}
}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):
query Pools($first: Int, $orderBy: GqlPoolOrderBy, $orderDirection: GqlPoolOrderDirection, $where: GqlPoolFilter) {
poolGetPools(first: $first, orderBy: $orderBy, orderDirection: $orderDirection, where: $where) {
id address chain type name symbol protocolVersion
dynamicData { totalLiquidity volume24h aprItems { apr type } }
poolTokens { address symbol weight }
}
}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.
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).
import { BalancerApi, Swap, SwapKind, Slippage, ChainId, Token, TokenAmount } from "@balancer/sdk";
import { encodeFunctionData } from "viem";
const chainId = ChainId.BASE;
const RPC_URL = process.env.RPC_URL;
// args: sender (wallet from get_wallets), tokenIn, tokenInDecimals, tokenOut, humanAmount, slippagePct
const [sender, tokenIn, decIn, tokenOut, amount, slippagePct = "0.5"
Verified live on Base: USDC→WETH currently routes v2, so the
sender/recipientbranch is the common path, not an edge case — omitting it throws the error above.
Same shape with AddLiquidity / RemoveLiquidity instead of Swap:
import { BalancerApi, AddLiquidity, AddLiquidityKind, Slippage, ChainId } from "@balancer/sdk";
const api = new BalancerApi("https://api-v3.balancer.fi/", ChainId.BASE);
const poolState = await api.pools.fetchPoolState(poolId); // poolId from poolGetPools
const addLiquidity = new AddLiquidity();
const queryOutput = await addLiquidity.query(
{ chainId: ChainId.BASE, rpcUrl: process.env.RPC_URL, kind: AddLiquidityKind.Unbalanced, amountsIn /* [{address, rawAmount, decimals}] */ },
poolState,
);
// v2 pools also require { sender, recipient } here (when poolState.protocolVersion === 2); v3 omits them.
const call = addLiquidity.buildCall({ ...queryOutput, slippage: Slippage.fromPercentage("0.5"), wethIsEth: false });
// → { to (Router), callData, value, minBptOut } (RemoveLiquidity returns minAmountsOut)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.
Every step runs in the shell — no shell, no flow (see ## Surface Routing).
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.curl sorGetSwapPaths (## Commands). Show the user returnAmount and priceImpact (which may be null for valid multi-hop routes — see ## Commands); confirm before building.build-swap.mjs → { chain, protocolVersion, minAmountOut, calls } — the full batch (version-correct approval(s) + action call), not just the action call.calls (targets, amounts, minAmountOut), then submit them directly with send_calls — the script has already assembled the version-correct approvals (## Submission).get_request_status (../references/approval-mode.md).get_wallets → address. Pick a pool: curl poolGetPools (by TVL/APR) or poolGetPool for a known id.AddLiquidity / RemoveLiquidity script → { to, callData, value, minBptOut | minAmountsOut }.send_calls → approve → confirm.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) — ERC20 approve(address,uint256) to canonical Permit2 0x000000000022D473030F116dDEE9F6B43aC78BA3. Skip if allowance already covers amountIn.PERMIT2.approve(tokenIn, router, amountIn, expiration) — Permit2 AllowanceTransfer approve(address,address,uint160,uint48), router = call.to.{ 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:
tokenIn.approve(VAULT, amountIn) — ERC20 approve(address,uint256) to the V2 Vault (call.to). Skip if already approved.{ 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:
{
"chain": "base",
"calls": [
{ "to": "<tokenIn>", "value": "0x0", "data": "<approve(PERMIT2, amountIn)>" },
{ "to": "0x000000000022D473030F116dDEE9F6B43aC78BA3", "value": "0x0", "data": "<permit2.approve(...)>" },
{ "to": "<call.to>", "value": "<call.value as hex wei, e.g. 0x0>", "data": "<call.callData>" }
]
}to — 0x-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.
Swap 100 USDC for WETH on Base through Balancerget_wallets → address.curl sorGetSwapPaths(chain: BASE, tokenIn: <USDC>, tokenOut: <WETH>, swapType: EXACT_IN, swapAmount: "100"); show returnAmount + priceImpact.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).calls, then send_calls({ chain: "base", calls }).get_request_status.What's the best Balancer pool for ETH yield on Base?curl poolGetPools(where: { chainIn: ["BASE"], minTvl: 100000 }, orderBy: apr, orderDirection: desc, first: 10).dynamicData.aprItems), TVL, and pool type. (Read still runs in the shell via curl.)Add 500 USDC and 0.2 WETH to a Balancer pool on Baseget_wallets → address; pick the pool (curl poolGetPools / poolGetPool → id).AddLiquidity script (amountsIn = USDC + WETH) → { to, callData, value, minBptOut }.send_calls → approve → confirm.Swap 1 WETH to USDC on Balancer — I'm on Claude.aihttps://balancer.fi/swap.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.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.@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.https://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.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.@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.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).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 (opens in a new tab).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.swapAmount and human amounts are not raw base units. Fetch token decimals from tokenGetTokens (or onchain) before building TokenAmount.chains 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).