Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions ts/packages/defi-protocols/src/dex/balancer_v3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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,
Expand All @@ -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);
});
});
18 changes: 13 additions & 5 deletions ts/packages/defi-protocols/src/dex/balancer_v3.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { encodeFunctionData, parseAbi, zeroAddress } from "viem";
import { encodeFunctionData, parseAbi } from "viem";

import { DefiError } from "@hypurrquant/defi-core";
import type {
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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,
Expand Down