Skip to content

Commit 7a5f73e

Browse files
tiago-hansenclaude
andcommitted
Add Balancer plugin for Base MCP
New hybrid plugin (skills/base-mcp/plugins/balancer.md): read pools and SOR quotes from the public Balancer GraphQL API, encode swap/liquidity calldata with @balancer/sdk (version-aware for Balancer v2/v3 routing), and submit via send_calls. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 44b371b commit 7a5f73e

1 file changed

Lines changed: 244 additions & 0 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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, USDCDAI, WETHBAL 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

Comments
 (0)