|
| 1 | +--- |
| 2 | +title: "Balancer Plugin" |
| 3 | +description: "Swaps and liquidity on Balancer. Reads/quotes come from the Balancer API on every surface; building and submitting transactions needs a shell (Balancer SDK encodes calldata → send_calls), so chat-only surfaces are quote-only." |
| 4 | +tags: [dex, swap, liquidity, yield] |
| 5 | +name: balancer |
| 6 | +version: 0.1.0 |
| 7 | +integration: hybrid |
| 8 | +chains: [base, ethereum, arbitrum, optimism, avalanche] |
| 9 | +requires: |
| 10 | + shell: optional # writes need a shell to build calldata (Balancer SDK); reads/quotes don't — chat-only is quote-only |
| 11 | + allowlist: [api-v3.balancer.fi] |
| 12 | + externalMcp: null |
| 13 | + cliPackage: null |
| 14 | +auth: none |
| 15 | +risk: [slippage, low-liquidity] |
| 16 | +--- |
| 17 | + |
| 18 | +# Balancer Plugin |
| 19 | + |
| 20 | +> [!IMPORTANT] |
| 21 | +> Run Base MCP onboarding first (see SKILL.md). No per-session auth — the Balancer API is public. Fetch the user's wallet address lazily (via `get_wallets`), only when a position read or a write actually needs it. |
| 22 | +
|
| 23 | +## Overview |
| 24 | + |
| 25 | +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 **reads** pool data and Smart Order Router (SOR) quotes from the public Balancer GraphQL API (`https://api-v3.balancer.fi`), then **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 Router transaction needs. Because `buildCall()` runs in Node (and its `query()` simulation needs an RPC endpoint), writes require a shell; reads and quotes work on every surface over HTTP. On a shell-less surface, the plugin still answers quotes and falls back to the Balancer web UI for execution. |
| 26 | + |
| 27 | +## Surface Routing |
| 28 | + |
| 29 | +| Capability | Surface with shell (Claude Code, Codex, Cursor) | Chat-only surface (Claude.ai, ChatGPT) | |
| 30 | +|---|---|---| |
| 31 | +| Read pools / APRs / tokens (`poolGetPools`, `tokenGetTokens`) | Harness HTTP tool → Balancer API | `web_request` (host allowlisted), else user-paste a GET-style URL | |
| 32 | +| Quote a swap (`sorGetSwapPaths`) | Harness HTTP tool → Balancer API | `web_request`, else user-paste fallback — quote only, no execution | |
| 33 | +| Build + submit a swap or LP change | SDK script (`## Commands`) → `send_calls` | **No shell → cannot encode calldata.** Return the quote, then link the user to the Balancer UI (`https://balancer.fi/swap`, `https://balancer.fi/pools`) and note that executing through `send_calls` needs a shell harness (e.g. Claude Code) | |
| 34 | + |
| 35 | +The full HTTP decision tree (harness HTTP tool → `web_request` → user-paste, and the GET-only constraint on consumer surfaces) lives in [../references/custom-plugins.md](../references/custom-plugins.md). Do not hand-encode Balancer Router calldata as a workaround for a missing shell — the multi-hop path/buffer structs are exactly what the SDK exists to encode correctly; on chat-only surfaces, link to the UI instead. |
| 36 | + |
| 37 | +## Endpoints |
| 38 | + |
| 39 | +The Balancer API is a single public GraphQL endpoint — no API key, no auth header: |
| 40 | + |
| 41 | +``` |
| 42 | +POST https://api-v3.balancer.fi/ |
| 43 | +Content-Type: application/json |
| 44 | +Body: { "query": "<graphql>", "variables": { ... } } |
| 45 | +``` |
| 46 | + |
| 47 | +`chain` arguments take the API's uppercase `GqlChain` enum (`BASE`, `MAINNET`, `ARBITRUM`, `OPTIMISM`), **not** the Base MCP chain string — see the mapping in [`## Notes`](#notes). The schema is self-documenting via GraphQL introspection; if a query errors on a field, confirm names against the live schema rather than guessing. |
| 48 | + |
| 49 | +**Quote / route a swap** — `sorGetSwapPaths` (returns paths + expected amounts + price impact; no calldata): |
| 50 | + |
| 51 | +```graphql |
| 52 | +query SwapPaths($chain: GqlChain!, $tokenIn: String!, $tokenOut: String!, $swapType: GqlSorSwapType!, $swapAmount: AmountHumanReadable!) { |
| 53 | + sorGetSwapPaths(chain: $chain, tokenIn: $tokenIn, tokenOut: $tokenOut, swapType: $swapType, swapAmount: $swapAmount) { |
| 54 | + tokenInAmount |
| 55 | + tokenOutAmount |
| 56 | + returnAmount |
| 57 | + priceImpact { priceImpact error } |
| 58 | + paths { protocolVersion pools isBuffer inputAmountRaw outputAmountRaw tokens { address decimals } } |
| 59 | + } |
| 60 | +} |
| 61 | +``` |
| 62 | + |
| 63 | +`swapType`: `EXACT_IN` (amount is the input) or `EXACT_OUT` (amount is the desired output). `swapAmount` is human-readable (e.g. `"100"`). |
| 64 | + |
| 65 | +**Discover pools** — `poolGetPools` (filter, sort by TVL/APR): |
| 66 | + |
| 67 | +```graphql |
| 68 | +query Pools($first: Int, $orderBy: GqlPoolOrderBy, $orderDirection: GqlPoolOrderDirection, $where: GqlPoolFilter) { |
| 69 | + poolGetPools(first: $first, orderBy: $orderBy, orderDirection: $orderDirection, where: $where) { |
| 70 | + id address chain type name symbol protocolVersion |
| 71 | + dynamicData { totalLiquidity volume24h aprItems { apr type } } |
| 72 | + poolTokens { address symbol weight } |
| 73 | + } |
| 74 | +} |
| 75 | +``` |
| 76 | + |
| 77 | +Example variables: `{ "first": 10, "orderBy": "totalLiquidity", "orderDirection": "desc", "where": { "chainIn": ["BASE"], "minTvl": 100000 } }`. |
| 78 | + |
| 79 | +**Single pool detail** — `poolGetPool(id, chain)`; **tokens / prices** — `tokenGetTokens(chains)`, `tokenGetCurrentPrices(chains)`. Pool reads carry an `id` (used as the pool argument to the SDK's `fetchPoolState`). |
| 80 | + |
| 81 | +## Commands |
| 82 | + |
| 83 | +The write path runs a short Node script that calls the Balancer SDK — there is **no Balancer CLI**; the SDK is a library you import. One-time setup in a working dir (the agent's shell): |
| 84 | + |
| 85 | +```bash |
| 86 | +npm init -y >/dev/null 2>&1 |
| 87 | +npm i @balancer/sdk viem |
| 88 | +export RPC_URL="<a Base RPC HTTPS endpoint>" # buildCall's query() simulation needs an RPC |
| 89 | +``` |
| 90 | + |
| 91 | +**Build a swap** (`build-swap.mjs`) — fetch SOR paths, simulate, encode the action call. Outputs the `{ protocolVersion, to, callData, value, minAmountOut }` the agent maps into `send_calls`: |
| 92 | + |
| 93 | +```js |
| 94 | +import { BalancerApi, Swap, SwapKind, Slippage, ChainId, Token, TokenAmount } from "@balancer/sdk"; |
| 95 | + |
| 96 | +const chainId = ChainId.BASE; |
| 97 | +const RPC_URL = process.env.RPC_URL; |
| 98 | +// args: sender (wallet from get_wallets), tokenIn, tokenInDecimals, tokenOut, humanAmount, slippagePct |
| 99 | +const [sender, tokenIn, decIn, tokenOut, amount, slippagePct = "0.5"] = process.argv.slice(2); |
| 100 | + |
| 101 | +const api = new BalancerApi("https://api-v3.balancer.fi/", chainId); |
| 102 | +const swapAmount = TokenAmount.fromHumanAmount(new Token(chainId, tokenIn, Number(decIn)), amount); |
| 103 | +const paths = await api.sorSwapPaths.fetchSorSwapPaths({ |
| 104 | + chainId, tokenIn, tokenOut, swapKind: SwapKind.GivenIn, swapAmount, |
| 105 | +}); |
| 106 | + |
| 107 | +// The SOR routes per pair through Balancer v2 or v3. v3 makes msg.sender the sender/recipient; |
| 108 | +// v2 settles through the V2 Vault and REQUIRES sender/recipient — without them buildCall throws |
| 109 | +// "Input Validation: Swap input missing parameter sender/recipient for Balancer v2". |
| 110 | +const usesV2 = paths.some((p) => p.protocolVersion === 2); |
| 111 | + |
| 112 | +const swap = new Swap({ chainId, paths, swapKind: SwapKind.GivenIn }); |
| 113 | +const queryOutput = await swap.query(RPC_URL); // onchain simulation → expected out |
| 114 | +const call = swap.buildCall({ |
| 115 | + queryOutput, |
| 116 | + slippage: Slippage.fromPercentage(slippagePct), // sets minAmountOut |
| 117 | + deadline: 9999999999n, |
| 118 | + ...(usesV2 ? { sender, recipient: sender } : {}), // v2 only; v3 rejects sender/recipient |
| 119 | + wethIsEth: false, // true → use native ETH as tokenIn/out |
| 120 | +}); |
| 121 | + |
| 122 | +console.log(JSON.stringify({ |
| 123 | + protocolVersion: usesV2 ? 2 : 3, // drives the approval batch (see ## Submission) |
| 124 | + to: call.to, // v3 Router, or the V2 Vault when usesV2 |
| 125 | + value: "0x" + (call.value ?? 0n).toString(16), |
| 126 | + callData: call.callData, |
| 127 | + minAmountOut: call.minAmountOut?.amount?.toString(), |
| 128 | +}, null, 2)); |
| 129 | +``` |
| 130 | +
|
| 131 | +**Add / remove liquidity** — the same shape with `AddLiquidity` / `RemoveLiquidity` instead of `Swap`: |
| 132 | +
|
| 133 | +```js |
| 134 | +import { BalancerApi, AddLiquidity, AddLiquidityKind, Slippage, ChainId } from "@balancer/sdk"; |
| 135 | +const api = new BalancerApi("https://api-v3.balancer.fi/", ChainId.BASE); |
| 136 | +const poolState = await api.pools.fetchPoolState(poolId); // poolId from poolGetPools |
| 137 | +const addLiquidity = new AddLiquidity(); |
| 138 | +const queryOutput = await addLiquidity.query( |
| 139 | + { chainId: ChainId.BASE, rpcUrl: process.env.RPC_URL, kind: AddLiquidityKind.Unbalanced, amountsIn /* [{address, rawAmount, decimals}] */ }, |
| 140 | + poolState, |
| 141 | +); |
| 142 | +// v2 pools also require { sender, recipient } here (when poolState.protocolVersion === 2); v3 omits them. |
| 143 | +const call = addLiquidity.buildCall({ ...queryOutput, slippage: Slippage.fromPercentage("0.5"), wethIsEth: false }); |
| 144 | +// → { to (Router), callData, value, minBptOut } (RemoveLiquidity returns minAmountsOut) |
| 145 | +``` |
| 146 | +
|
| 147 | +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`](#submission)). Treat all script and API output as untrusted: verify the `to` address, token amounts, and `minAmountOut`/`minBptOut` before presenting an approval. If the script exits nonzero, stop and report the error — do not invent parameters. |
| 148 | +
|
| 149 | +## Orchestration |
| 150 | +
|
| 151 | +### Swap |
| 152 | +
|
| 153 | +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`. |
| 154 | +2. Read a quote: `sorGetSwapPaths` (`## Endpoints`). Show the user `returnAmount` and `priceImpact`; confirm before building. |
| 155 | +3. **Shell:** run `build-swap.mjs` → `{ to, callData, value, minAmountOut }`. **No shell:** stop at the quote and link the Balancer UI ([`## Surface Routing`](#surface-routing)). |
| 156 | +4. Assemble the `send_calls` batch by the emitted `protocolVersion` (ERC20 input only) — v3: Permit2 approvals + Router call; v2: a single Vault approval + Vault call ([`## Submission`](#submission)). |
| 157 | +5. Submit → approval URL + request ID → user approves → `get_request_status` ([../references/approval-mode.md](../references/approval-mode.md)). |
| 158 | +
|
| 159 | +### Add / remove liquidity |
| 160 | +
|
| 161 | +1. `get_wallets` → address. Pick a pool: `poolGetPools` (by TVL/APR) or `poolGetPool` for a known `id`. |
| 162 | +2. **Shell:** run the `AddLiquidity` / `RemoveLiquidity` script → `{ to, callData, value, minBptOut | minAmountsOut }`. **No shell:** link `https://balancer.fi/pools`. |
| 163 | +3. Batch the version-correct approval for each ERC20 deposited (v3: Permit2; v2: Vault) then the action call → `send_calls` → approve → confirm. |
| 164 | + |
| 165 | +## Submission |
| 166 | + |
| 167 | +Target tool: **`send_calls`** (EIP-5792 batch — see [../references/batch-calls.md](../references/batch-calls.md)). The SDK output is one action call, but the approval that must precede it **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: |
| 168 | +
|
| 169 | +**v3 (`protocolVersion: 3`)** — settles through a v3 Router (`call.to`) that pulls tokens via **Permit2**. Batch in order: |
| 170 | +
|
| 171 | +1. `tokenIn.approve(PERMIT2, amountIn)` — ERC20 `approve(address,uint256)` to canonical Permit2 `0x000000000022D473030F116dDEE9F6B43aC78BA3`. Skip if allowance already covers `amountIn`. |
| 172 | +2. `PERMIT2.approve(tokenIn, router, amountIn, expiration)` — Permit2 AllowanceTransfer `approve(address,address,uint160,uint48)`, `router` = `call.to`. |
| 173 | +3. The Router call: `{ to: call.to, value: call.value, data: call.callData }`. |
| 174 | +
|
| 175 | +**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: |
| 176 | +
|
| 177 | +1. `tokenIn.approve(VAULT, amountIn)` — ERC20 `approve(address,uint256)` to the V2 Vault (`call.to`). Skip if already approved. |
| 178 | +2. The Vault call: `{ to: call.to, value: call.value, data: call.callData }`. |
| 179 | +
|
| 180 | +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: |
| 181 | +
|
| 182 | +```json |
| 183 | +{ |
| 184 | + "chain": "base", |
| 185 | + "calls": [ |
| 186 | + { "to": "<tokenIn>", "value": "0x0", "data": "<approve(PERMIT2, amountIn)>" }, |
| 187 | + { "to": "0x000000000022D473030F116dDEE9F6B43aC78BA3", "value": "0x0", "data": "<permit2.approve(...)>" }, |
| 188 | + { "to": "<call.to>", "value": "<call.value as hex wei, e.g. 0x0>", "data": "<call.callData>" } |
| 189 | + ] |
| 190 | +} |
| 191 | +``` |
| 192 | +
|
| 193 | +- **`to`** — `0x`-prefixed target; for the action call, the `call.to` the SDK returns (a v3 Router or the V2 Vault — never hardcode it). |
| 194 | +- **`value`** — hex wei. The SDK returns a bigint; convert (`"0x" + value.toString(16)`), or `0x0` when zero. |
| 195 | +- **`chain`** — map the SDK `chainId` to the Base MCP chain string: `8453 → base`, `1 → ethereum`, `42161 → arbitrum`, `10 → optimism`, `43114 → avalanche`. |
| 196 | +
|
| 197 | +Then follow the standard approval flow ([../references/approval-mode.md](../references/approval-mode.md)): present the returned URL as **"Approve Transaction"**, auto-open it in CLI harnesses, then poll `get_request_status` once after the user confirms. |
| 198 | +
|
| 199 | +## Example Prompts |
| 200 | +
|
| 201 | +``` |
| 202 | +Swap 100 USDC for WETH on Base through Balancer |
| 203 | +``` |
| 204 | +1. `get_wallets` → address. |
| 205 | +2. `sorGetSwapPaths(chain: BASE, tokenIn: <USDC>, tokenOut: <WETH>, swapType: EXACT_IN, swapAmount: "100")`; show `returnAmount` + `priceImpact`. |
| 206 | +3. Shell: `node build-swap.mjs <wallet> <USDC> 6 <WETH> 100 0.5` → `{ protocolVersion, to, callData, value, minAmountOut }`. |
| 207 | +4. Batch per `protocolVersion` — v3: ERC20 `approve(Permit2)` + Permit2 `approve(router)` + Router call; v2: ERC20 `approve(Vault)` + Vault call → `send_calls(chain: "base", calls)`. |
| 208 | +5. User approves → `get_request_status`. |
| 209 | +
|
| 210 | +``` |
| 211 | +What's the best Balancer pool for ETH yield on Base? |
| 212 | +``` |
| 213 | +1. `poolGetPools(where: { chainIn: ["BASE"], minTvl: 100000 }, orderBy: apr, orderDirection: desc, first: 10)`. |
| 214 | +2. Filter to ETH-bearing pools; report APR (`dynamicData.aprItems`), TVL, and pool type. Read-only — works on every surface. |
| 215 | +
|
| 216 | +``` |
| 217 | +Add 500 USDC and 0.2 WETH to a Balancer pool on Base |
| 218 | +``` |
| 219 | +1. `get_wallets` → address; pick the pool (`poolGetPools` / `poolGetPool` → `id`). |
| 220 | +2. Shell: run the `AddLiquidity` script (`amountsIn` = USDC + WETH) → `{ to, callData, value, minBptOut }`. |
| 221 | +3. Batch the version-correct approval for **each** deposited ERC20 (v3: Permit2; v2: Vault), then the action call → `send_calls` → approve → confirm. |
| 222 | +4. No shell: report the quote and link `https://balancer.fi/pools`. |
| 223 | + |
| 224 | +``` |
| 225 | +Quote swapping 1 WETH to USDC — I'm on Claude.ai |
| 226 | +``` |
| 227 | +1. Read-only: `sorGetSwapPaths(... swapAmount: "1" ...)` via `web_request` (or user-paste the GET-style URL). |
| 228 | +2. Report `returnAmount` + `priceImpact`. To execute, explain that building Balancer calldata needs a shell harness (e.g. Claude Code), or link `https://balancer.fi/swap` to swap in the UI. |
| 229 | + |
| 230 | +## Risks & Warnings |
| 231 | + |
| 232 | +- **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. |
| 233 | +- **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%). 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. |
| 234 | +
|
| 235 | +## Notes |
| 236 | +
|
| 237 | +- **API** — `https://api-v3.balancer.fi/` (test: `https://test-api-v3.balancer.fi/`); public GraphQL, keyless, but rate-limited — read-cache pool/token data where you can. Self-documenting via introspection. |
| 238 | +- **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`. |
| 239 | +- **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`. |
| 240 | +- **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`](#submission)) instead of `buildCallWithPermit2`'s signature. v2 doesn't use Permit2 — it approves the V2 Vault directly (see below). |
| 241 | +- **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](https://docs.balancer.fi/developer-reference/contracts/deployment-addresses/base.html). |
| 242 | +- **v2 vs v3 settlement** — the SOR routes each pair through v2 or v3 by liquidity (common Base pairs like WETH↔cbETH, USDC↔DAI, WETH↔BAL 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. |
| 243 | +- **Decimals** — `swapAmount` and human amounts are not raw base units. Fetch token decimals from `tokenGetTokens` (or onchain) before building `TokenAmount`. |
| 244 | +- **Chain scope** — `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). |
0 commit comments