From 904ea2035f7aeced5d12f3c048b4ff3eac1a8d48 Mon Sep 17 00:00:00 2001 From: Hui-Sang Kim <102507786+Hiksang@users.noreply.github.com> Date: Wed, 6 May 2026 23:28:13 +0900 Subject: [PATCH] fix(balancer-v3): require registered pool address, drop zeroAddress fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background: BalancerV3Adapter.buildSwap() shipped a `swapSingleTokenExactIn` call with `zeroAddress` as the pool argument, gated only by a `// TODO: resolve pool from registry` comment. The factory wires the adapter via `case "balancer_v3"`, but no chain in the registry declares `interface = "balancer_v3"`, so the broken path has been unreachable in practice — yet the unit test was happily decoding calldata that, if anyone wired it up, would have called the V3 Router with `pool = 0x0` and reverted (or worse, silently routed through some default). Fix: - Constructor reads `entry.contracts["pool"]` if present. - buildSwap throws DefiError.invalidParam with a clear remediation message ("register `pool = "0x..."` under [protocol.contracts]") when the pool is missing — same error class as the existing amount_out_min guard. - When the pool is registered, the calldata uses it verbatim; description string echoes the pool for log clarity. Test changes: - Added pool to the existing ENTRY fixture and a parallel ENTRY_NO_POOL fixture for the new failure case. - New test: "buildSwap refuses to ship without a registered pool address". - Hardened the existing happy-path test to also assert the pool address survives encoding (previously only checked minAmountOut). Out of scope: - Multi-pool routing / on-chain factory queries / quoter integration — Balancer V3's BatchRouter is the right surface for that and is intentionally not pursued here. - Removing the BalancerV3 export+factory case. Keeping it dormant so the next consumer can register `pool = "0x..."` and ship without re-adding the adapter. Verified: - pnpm -C ts -r build — clean. - pnpm -C ts -r lint — 3 packages clean. - pnpm -C ts -r test — 64/64 (defi-cli) + 39/39 (defi-protocols), balancer_v3.test.ts now 3 tests (was 2). --- .../src/dex/balancer_v3.test.ts | 31 ++++++++++++++++--- .../defi-protocols/src/dex/balancer_v3.ts | 18 ++++++++--- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/ts/packages/defi-protocols/src/dex/balancer_v3.test.ts b/ts/packages/defi-protocols/src/dex/balancer_v3.test.ts index 9eb05bd..b82bfbf 100644 --- a/ts/packages/defi-protocols/src/dex/balancer_v3.test.ts +++ b/ts/packages/defi-protocols/src/dex/balancer_v3.test.ts @@ -10,13 +10,19 @@ const LOW = ("0x" + "01".repeat(20)) as Address; const HIGH = ("0x" + "ff".repeat(20)) as Address; const RECIPIENT = ("0x" + "be".repeat(20)) as Address; +const ROUTER = ("0x" + "01".repeat(20)) as Address; +const POOL = ("0x" + "ab".repeat(20)) as Address; const ENTRY: ProtocolEntry = { name: "test_balancer_v3", slug: "test-balancer-v3", category: ProtocolCategory.Dex, interface: "balancer_v3", chain: "test", - contracts: { router: ("0x" + "01".repeat(20)) as Address }, + contracts: { router: ROUTER, pool: POOL }, +}; +const ENTRY_NO_POOL: ProtocolEntry = { + ...ENTRY, + contracts: { router: ROUTER }, }; const swapAbi = parseAbi([ @@ -38,7 +44,22 @@ describe("BalancerV3Adapter slippage protection (SSOT 7.3)", () => { ).rejects.toThrow(/amount_out_min/); }); - it("buildSwap forwards amount_out_min override verbatim", async () => { + it("buildSwap refuses to ship without a registered pool address", async () => { + const adapter = new BalancerV3Adapter(ENTRY_NO_POOL); + await expect( + adapter.buildSwap({ + protocol: ENTRY_NO_POOL.slug, + token_in: LOW, + token_out: HIGH, + amount_in: 1_000_000n, + slippage: { bps: 50 }, + recipient: RECIPIENT, + amount_out_min: 1n, + }), + ).rejects.toThrow(/pool address/); + }); + + it("buildSwap forwards amount_out_min override verbatim and uses registered pool", async () => { const adapter = new BalancerV3Adapter(ENTRY); const tx = await adapter.buildSwap({ protocol: ENTRY.slug, @@ -51,7 +72,9 @@ describe("BalancerV3Adapter slippage protection (SSOT 7.3)", () => { }); const decoded = decodeFunctionData({ abi: swapAbi, data: tx.data as Hex }); // args order: pool, tokenIn, tokenOut, exactAmountIn, minAmountOut, ... - expect((decoded.args as readonly unknown[])[4]).toBe(99_999n); - expect((decoded.args as readonly unknown[])[4]).not.toBe(0n); + const args = decoded.args as readonly unknown[]; + expect(String(args[0]).toLowerCase()).toBe(POOL.toLowerCase()); + expect(args[4]).toBe(99_999n); + expect(args[4]).not.toBe(0n); }); }); diff --git a/ts/packages/defi-protocols/src/dex/balancer_v3.ts b/ts/packages/defi-protocols/src/dex/balancer_v3.ts index 029b355..bb121ab 100644 --- a/ts/packages/defi-protocols/src/dex/balancer_v3.ts +++ b/ts/packages/defi-protocols/src/dex/balancer_v3.ts @@ -1,4 +1,4 @@ -import { encodeFunctionData, parseAbi, zeroAddress } from "viem"; +import { encodeFunctionData, parseAbi } from "viem"; import { DefiError } from "@hypurrquant/defi-core"; import type { @@ -19,6 +19,7 @@ const abi = parseAbi([ export class BalancerV3Adapter implements IDex { private readonly protocolName: string; private readonly router: `0x${string}`; + private readonly pool: `0x${string}` | undefined; constructor(entry: ProtocolEntry, _rpcUrl?: string) { this.protocolName = entry.name; @@ -27,6 +28,7 @@ export class BalancerV3Adapter implements IDex { throw new DefiError("CONTRACT_ERROR", "Missing 'router' contract"); } this.router = router; + this.pool = entry.contracts?.["pool"] as `0x${string}` | undefined; } name(): string { @@ -43,16 +45,22 @@ export class BalancerV3Adapter implements IDex { `floor off-chain (e.g. via the Vault's static-call simulation) and pass it explicitly.`, ); } + if (!this.pool) { + throw DefiError.invalidParam( + `[${this.protocolName}] buildSwap requires a pool address. Register the pool under ` + + `[protocol.contracts] as \`pool = "0x..."\` in the protocol's TOML config. ` + + `Multi-pool routing is intentionally not implemented in this adapter — for that, ` + + `quote against the Balancer V3 BatchRouter off-chain and route via a different surface.`, + ); + } const minAmountOut = params.amount_out_min; const deadline = BigInt(params.deadline ?? 18446744073709551615n); - // Balancer V3 requires a pool address. For now use a simplified single-pool swap. - // In production, the pool would be resolved from the registry or an on-chain query. const data = encodeFunctionData({ abi, functionName: "swapSingleTokenExactIn", args: [ - zeroAddress, // TODO: resolve pool from registry + this.pool, params.token_in, params.token_out, params.amount_in, @@ -64,7 +72,7 @@ export class BalancerV3Adapter implements IDex { }); return { - description: `[${this.protocolName}] Swap ${params.amount_in} via Balancer V3`, + description: `[${this.protocolName}] Swap ${params.amount_in} via Balancer V3 pool ${this.pool}`, to: this.router, data, value: 0n,