Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SOR - Add StablePool for Balancer v2 #1492

Merged
merged 4 commits into from
Jan 15, 2025
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
5 changes: 5 additions & 0 deletions .changeset/unlucky-yaks-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'backend': patch
---

SOR - Add StablePool for Balancer v2
1 change: 0 additions & 1 deletion modules/sor/balancer-sor.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { ANVIL_NETWORKS, startFork, stopAnvilForks } from '../../test/anvil/anvi
import {
prismaPoolDynamicDataFactory,
prismaPoolFactory,
prismaPoolTokenDynamicDataFactory,
prismaPoolTokenFactory,
hookFactory,
} from '../../test/factories';
Expand Down
8 changes: 4 additions & 4 deletions modules/sor/sor-debug.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { sorService } from './sor.service';
describe('sor debugging', () => {
it('sor v2', async () => {
const useProtocolVersion = 2;
const chain = Chain.ARBITRUM;
const chain = Chain.MAINNET;

const chainId = Object.keys(chainIdToChain).find((key) => chainIdToChain[key] === chain) as string;
initRequestScopedContext();
Expand All @@ -22,10 +22,10 @@ describe('sor debugging', () => {

const swaps = await sorService.getSorSwapPaths({
chain,
tokenIn: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', // weth
tokenOut: '0x5979d7b546e38e414f7e9822514be443a4800529', // wsteth
tokenIn: '0x865377367054516e17014ccded1e7d814edc9ce4', // dola
tokenOut: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // usdc
swapType: 'EXACT_IN',
swapAmount: '250',
swapAmount: '100',
useProtocolVersion,
// callDataInput: {
// receiver: '0xb5e6b895734409Df411a052195eb4EE7e40d8696',
Expand Down
1 change: 1 addition & 0 deletions modules/sor/sorV2/lib/poolsV2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export * from './gyro2/gyro2Pool';
export * from './gyro3/gyro3Pool';
export * from './gyroE/gyroEPool';
export * from './metastable/metastablePool';
export * from './stable/stablePool';
export * from './weighted/weightedPool';
139 changes: 139 additions & 0 deletions modules/sor/sorV2/lib/poolsV2/stable/stablePool.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// yarn vitest poolsV2/stable/stablePool.integration.test.ts

/**
* Test Data:
*
* In order to properly compare SOR quotes vs SDK queries, we need to setup test data from a specific blockNumber.
* Although the API does not provide that functionality, we can use subgraph to achieve it.
* These tests run against the 12th testnet deployment and these are their respective subgraphs:
* - data common to all pools: [balancer subgraph](https://api.studio.thegraph.com/query/75376/balancer-v3-sepolia/version/latest/graphql)
* - tokens (address, balance, decimals)
* - totalShares
* - swapFee
* - data specific to each pool type: [pools subgraph](https://api.studio.thegraph.com/query/75376/balancer-pools-v3-sepolia/version/latest/graphql)
* - weight
* - amp
* The only item missing from subgraph is priceRate, which can be fetched from a Tenderly simulation (getPoolTokenRates)
* against the VaultExplorer contract (0xEB15EBBF9C1a4D7D243d57dE447Df0b97C40c324).
*
* TODO: improve test data setup by creating a script that fetches all necessary data automatically for a given blockNumber.
*/

import { ExactInQueryOutput, Swap, SwapKind, Token, Address, Path } from '@balancer/sdk';

import { createTestClient, Hex, http, TestClient } from 'viem';
import { mainnet } from 'viem/chains';

import { PathWithAmount } from '../../path';
import { sorGetPathsWithPools } from '../../static';
import { getOutputAmount } from '../../utils/helpers';
import { chainToChainId } from '../../../../../network/chain-id-to-chain';
import { ANVIL_NETWORKS, startFork } from '../../../../../../test/anvil/anvil-global-setup';
import {
prismaPoolDynamicDataFactory,
prismaPoolFactory,
prismaPoolTokenFactory,
} from '../../../../../../test/factories';
import { Chain } from '@prisma/client';

const protocolVersion = 2;

describe('Balancer SOR Integration Tests', () => {
let rpcUrl: string;
let paths: PathWithAmount[];
let sdkSwap: Swap;
let snapshot: Hex;
let client: TestClient;

beforeAll(async () => {
// start fork to run queries against
({ rpcUrl } = await startFork(ANVIL_NETWORKS.MAINNET));
client = createTestClient({
mode: 'anvil',
chain: mainnet,
transport: http(rpcUrl),
});
snapshot = await client.snapshot();
});

beforeEach(async () => {
await client.revert({
id: snapshot,
});
snapshot = await client.snapshot();
});

describe('Stable Pool Path', () => {
beforeAll(async () => {
// setup mock pool data for a stable pool
const poolAddress = '0xff4ce5aaab5a627bf82f4a571ab1ce94aa365ea6';
const DOLA = prismaPoolTokenFactory.build({
address: '0x865377367054516e17014ccded1e7d814edc9ce4',
balance: '2767570.699080547814532726',
priceRate: '1',
});
const USDC = prismaPoolTokenFactory.build({
address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
token: { decimals: 6 },
balance: '1207675.308342',
priceRate: '1',
});
const prismaStablePool = prismaPoolFactory.stable('200').build({
chain: Chain.MAINNET,
id: '0xff4ce5aaab5a627bf82f4a571ab1ce94aa365ea6000200000000000000000426',
address: poolAddress,
tokens: [DOLA, USDC],
dynamicData: prismaPoolDynamicDataFactory.build({
totalShares: '3950733.111397308216047101',
swapFee: '0.0004',
}),
});

// get SOR paths
const tIn = new Token(
parseFloat(chainToChainId[DOLA.token.chain]),
DOLA.address as Address,
DOLA.token.decimals,
);
const tOut = new Token(
parseFloat(chainToChainId[USDC.token.chain]),
USDC.address as Address,
USDC.token.decimals,
);
const amountIn = BigInt(100e18);
paths = (await sorGetPathsWithPools(
tIn,
tOut,
SwapKind.GivenIn,
amountIn,
[prismaStablePool],
protocolVersion,
)) as PathWithAmount[];

const swapPaths: Path[] = paths.map((path) => ({
protocolVersion,
inputAmountRaw: path.inputAmount.amount,
outputAmountRaw: path.outputAmount.amount,
tokens: path.tokens.map((token) => ({
address: token.address,
decimals: token.decimals,
})),
pools: path.pools.map((pool) => pool.id),
}));

// build SDK swap from SOR paths
sdkSwap = new Swap({
chainId: parseFloat(chainToChainId['MAINNET']),
paths: swapPaths,
swapKind: SwapKind.GivenIn,
});
});

test('SOR quote should match swap query', async () => {
const returnAmountSOR = getOutputAmount(paths);
const queryOutput = await sdkSwap.query(rpcUrl);
const returnAmountQuery = (queryOutput as ExactInQueryOutput).expectedAmountOut;
expect(returnAmountQuery.amount).toEqual(returnAmountSOR.amount);
});
});
});
197 changes: 197 additions & 0 deletions modules/sor/sorV2/lib/poolsV2/stable/stablePool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { Chain } from '@prisma/client';
import { Address, Hex, parseEther, parseUnits } from 'viem';
import { PrismaPoolAndHookWithDynamic } from '../../../../../../prisma/prisma-types';
import { _calcInGivenOut, _calcOutGivenIn, _calculateInvariant } from '../composableStable/stableMath';
import { MathSol, WAD } from '../../utils/math';
import { PoolType, SwapKind, Token, TokenAmount } from '@balancer/sdk';
import { chainToChainId as chainToIdMap } from '../../../../../network/chain-id-to-chain';
import { StableData } from '../../../../../pool/subgraph-mapper';
import { TokenPairData } from '../../../../../pool/lib/pool-on-chain-tokenpair-data';
import { BasePool } from '../basePool';
import { BasePoolToken } from '../basePoolToken';

export class StablePool implements BasePool {
public readonly chain: Chain;
public readonly id: Hex;
public readonly address: string;
public readonly poolType: PoolType = PoolType.Stable;
public readonly amp: bigint;
public readonly swapFee: bigint;
public readonly tokens: BasePoolToken[];
public readonly tokenPairs: TokenPairData[];

private readonly tokenMap: Map<string, BasePoolToken>;

static fromPrismaPool(pool: PrismaPoolAndHookWithDynamic): StablePool {
const poolTokens: BasePoolToken[] = [];

if (!pool.dynamicData) throw new Error('Stable pool has no dynamic data');

for (const poolToken of pool.tokens) {
const token = new Token(
parseFloat(chainToIdMap[pool.chain]),
poolToken.address as Address,
poolToken.token.decimals,
poolToken.token.symbol,
poolToken.token.name,
);
const scale18 = parseEther(poolToken.balance);
const tokenAmount = TokenAmount.fromScale18Amount(token, scale18);

poolTokens.push(new BasePoolToken(token, tokenAmount.amount, poolToken.index));
}

const amp = parseUnits((pool.typeData as StableData).amp, 3);

return new StablePool(
pool.id as Hex,
pool.address,
pool.chain,
amp,
parseEther(pool.dynamicData.swapFee),
poolTokens,
pool.dynamicData.tokenPairsData as TokenPairData[],
);
}

constructor(
id: Hex,
address: string,
chain: Chain,
amp: bigint,
swapFee: bigint,
tokens: BasePoolToken[],
tokenPairs: TokenPairData[],
) {
this.id = id;
this.address = address;
this.chain = chain;
this.amp = amp;
this.swapFee = swapFee;

this.tokens = tokens.sort((a, b) => a.index - b.index);
this.tokenMap = new Map(this.tokens.map((token) => [token.token.address, token]));
this.tokenPairs = tokenPairs;
}

public getNormalizedLiquidity(tokenIn: Token, tokenOut: Token): bigint {
const { tIn, tOut } = this.getPoolTokens(tokenIn, tokenOut);

const tokenPair = this.tokenPairs.find(
(tokenPair) => tokenPair.tokenA === tIn.token.address && tokenPair.tokenB === tOut.token.address,
);

if (tokenPair) {
return BigInt(tokenPair.normalizedLiquidity);
}
return 0n;
}

public swapGivenIn(
tokenIn: Token,
tokenOut: Token,
swapAmount: TokenAmount,
mutateBalances?: boolean,
): TokenAmount {
const { tIn, tOut } = this.getPoolTokens(tokenIn, tokenOut);

if (swapAmount.amount > tIn.amount) {
throw new Error('Swap amount exceeds the pool limit');
}

const amountInWithFee = this.subtractSwapFeeAmount(swapAmount);
const balances = this.tokens.map((t) => t.scale18);

const invariant = _calculateInvariant(this.amp, [...balances], true);

const tokenOutScale18 = _calcOutGivenIn(
this.amp,
[...balances],
tIn.index,
tOut.index,
amountInWithFee.scale18,
invariant,
);

const amountOut = TokenAmount.fromScale18Amount(tokenOut, tokenOutScale18);

if (amountOut.amount < 0n) throw new Error('Swap output negative');

if (mutateBalances) {
tIn.increase(swapAmount.amount);
tOut.decrease(amountOut.amount);
}

return amountOut;
}

public swapGivenOut(
tokenIn: Token,
tokenOut: Token,
swapAmount: TokenAmount,
mutateBalances?: boolean,
): TokenAmount {
const { tIn, tOut } = this.getPoolTokens(tokenIn, tokenOut);

if (swapAmount.amount > tOut.amount) {
throw new Error('Swap amount exceeds the pool limit');
}

const balances = this.tokens.map((t) => t.scale18);

const invariant = _calculateInvariant(this.amp, balances, true);

const tokenInScale18 = _calcInGivenOut(
this.amp,
[...balances],
tIn.index,
tOut.index,
swapAmount.scale18,
invariant,
);

const amountIn = TokenAmount.fromScale18Amount(tokenIn, tokenInScale18, true);
const amountInWithFee = this.addSwapFeeAmount(amountIn);

if (amountInWithFee.amount < 0n) throw new Error('Swap output negative');

if (mutateBalances) {
tIn.increase(amountInWithFee.amount);
tOut.decrease(swapAmount.amount);
}

return amountInWithFee;
}

public subtractSwapFeeAmount(amount: TokenAmount): TokenAmount {
const feeAmount = amount.mulUpFixed(this.swapFee);
return amount.sub(feeAmount);
}

public addSwapFeeAmount(amount: TokenAmount): TokenAmount {
return amount.divUpFixed(MathSol.complementFixed(this.swapFee));
}

public getLimitAmountSwap(tokenIn: Token, tokenOut: Token, swapKind: SwapKind): bigint {
const { tIn, tOut } = this.getPoolTokens(tokenIn, tokenOut);

if (swapKind === SwapKind.GivenIn) {
// Return max valid amount of tokenIn
// As an approx - use almost the total balance of token out as we can add any amount of tokenIn and expect some back
return tIn.amount;
}
// Return max amount of tokenOut - approx is almost all balance
return tOut.amount;
}

public getPoolTokens(tokenIn: Token, tokenOut: Token): { tIn: BasePoolToken; tOut: BasePoolToken } {
const tIn = this.tokenMap.get(tokenIn.wrapped);
const tOut = this.tokenMap.get(tokenOut.wrapped);

if (!tIn || !tOut) {
throw new Error('Pool does not contain the tokens provided');
}

return { tIn, tOut };
}
}
Loading
Loading