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
16 changes: 12 additions & 4 deletions src/connectors/uniswap/clmm-routes/poolInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,18 @@ export async function getPoolInfo(fastify: FastifyInstance, network: string, poo
// Get the price of base token in terms of quote token
const price = isBaseToken0 ? parseFloat(price0) : parseFloat(price1);

// Get token reserves in the pool
const liquidity = pool.liquidity;
const token0Amount = formatTokenAmount(liquidity.toString(), token0.decimals);
const token1Amount = formatTokenAmount(liquidity.toString(), token1.decimals);
// Read the pool contract's actual ERC20 balances. Uniswap V3's `pool.liquidity` is
// the active virtual liquidity in sqrt-price space, not a token amount, so it cannot
// be used here.
const ethereum = await Ethereum.getInstance(network);
const token0Contract = ethereum.getContract(token0.address, ethereum.provider);
const token1Contract = ethereum.getContract(token1.address, ethereum.provider);
const [token0Balance, token1Balance] = await Promise.all([
ethereum.getERC20BalanceByAddress(token0Contract, poolAddress, token0.decimals),
ethereum.getERC20BalanceByAddress(token1Contract, poolAddress, token1.decimals),
]);
const token0Amount = formatTokenAmount(token0Balance.value.toString(), token0.decimals);
const token1Amount = formatTokenAmount(token1Balance.value.toString(), token1.decimals);

// Map to base and quote amounts
const baseTokenAmount = isBaseToken0 ? token0Amount : token1Amount;
Expand Down
207 changes: 207 additions & 0 deletions test/connectors/uniswap/clmm-routes/pool-info.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { BigNumber } from 'ethers';

import { Ethereum } from '../../../../src/chains/ethereum/ethereum';
import { fastifyWithTypeProvider } from '../../../utils/testUtils';

jest.mock('../../../../src/chains/ethereum/ethereum');
jest.mock('../../../../src/connectors/uniswap/uniswap');
jest.mock('../../../../src/connectors/uniswap/uniswap.utils');

const buildApp = async () => {
const server = fastifyWithTypeProvider();
await server.register(require('@fastify/sensible'));
const { poolInfoRoute } = await import('../../../../src/connectors/uniswap/clmm-routes/poolInfo');
await server.register(poolInfoRoute);
return server;
};

// Real USDM1/USDC pool: 0x6f161ad0e297ecb9d1b33c048272ccc964cb4b6a
// Real on-chain balances at time of writing: ~167.6K USDM1 and ~182.1K USDC.
// Regression test: previously the route returned pool.liquidity / 10^decimals for both
// tokens (a meaningless quantity), so this test pins the route to ERC20 balanceOf().
const POOL_ADDRESS = '0x6f161ad0e297ecb9d1b33c048272ccc964cb4b6a';
const USDM1 = {
address: '0x90a1717E0dABE37693f79aFe43AE236dc3b65957',
symbol: 'USDM1',
decimals: 18,
};
const USDC = {
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
symbol: 'USDC',
decimals: 6,
};

// Real ERC20 balances of the pool contract — what balanceOf() should return.
const USDM1_RAW_BALANCE = BigNumber.from('167600000000000000000000'); // 167,600 * 1e18
const USDC_RAW_BALANCE = BigNumber.from('182100000000'); // 182,100 * 1e6

// Bogus value the route used to return for both tokens (V3 virtual liquidity).
const POOL_LIQUIDITY = BigNumber.from('11034936417288527');

describe('GET /pool-info (Uniswap CLMM)', () => {
let server: any;

beforeAll(async () => {
server = await buildApp();
});

afterAll(async () => {
await server.close();
});

beforeEach(() => {
jest.clearAllMocks();
});

it("returns the pool contract's actual ERC20 balances, not pool.liquidity", async () => {
const { Uniswap } = await import('../../../../src/connectors/uniswap/uniswap');
const { getUniswapPoolInfo, formatTokenAmount } = await import('../../../../src/connectors/uniswap/uniswap.utils');

(getUniswapPoolInfo as jest.Mock).mockResolvedValue({
baseTokenAddress: USDM1.address,
quoteTokenAddress: USDC.address,
poolType: 'clmm',
});

// Make formatTokenAmount work like the real implementation so we can pin numeric outputs.
(formatTokenAmount as jest.Mock).mockImplementation(
(amount: string, decimals: number) => Number(amount) / Math.pow(10, decimals),
);

// Mock the V3 pool object. `liquidity` is set to the meaningless virtual-liquidity
// value to prove the route is NOT using it anymore.
const mockPool = {
token0: { address: USDM1.address, decimals: USDM1.decimals },
token1: { address: USDC.address, decimals: USDC.decimals },
liquidity: POOL_LIQUIDITY,
sqrtRatioX96: BigNumber.from('79228162514264337593543950336'),
token0Price: { toSignificant: () => '1.01146' }, // USDM1 priced in USDC
token1Price: { toSignificant: () => '0.98867' },
fee: 100, // 0.01% in Uniswap V3 hundredths-of-bips
tickSpacing: 1,
tickCurrent: -276211,
};

(Uniswap.getInstance as jest.Mock).mockResolvedValue({
getToken: jest.fn().mockImplementation((addr: string) => {
if (addr.toLowerCase() === USDM1.address.toLowerCase()) return USDM1;
if (addr.toLowerCase() === USDC.address.toLowerCase()) return USDC;
return null;
}),
getV3Pool: jest.fn().mockResolvedValue(mockPool),
});

// Mock ERC20 balanceOf calls by mint address.
const mockUsdm1Contract = { address: USDM1.address };
const mockUsdcContract = { address: USDC.address };
(Ethereum.getInstance as jest.Mock).mockResolvedValue({
provider: { _isProvider: true },
getContract: jest.fn().mockImplementation((tokenAddress: string) => {
if (tokenAddress.toLowerCase() === USDM1.address.toLowerCase()) return mockUsdm1Contract;
if (tokenAddress.toLowerCase() === USDC.address.toLowerCase()) return mockUsdcContract;
throw new Error(`unexpected contract address ${tokenAddress}`);
}),
getERC20BalanceByAddress: jest.fn().mockImplementation((contract: any, address: string, decimals: number) => {
expect(address).toBe(POOL_ADDRESS); // route must query the pool contract
if (contract.address === USDM1.address) {
return Promise.resolve({ value: USDM1_RAW_BALANCE, decimals });
}
if (contract.address === USDC.address) {
return Promise.resolve({ value: USDC_RAW_BALANCE, decimals });
}
return Promise.reject(new Error('unexpected token'));
}),
});

const response = await server.inject({
method: 'GET',
url: '/pool-info',
query: { network: 'mainnet', poolAddress: POOL_ADDRESS },
});

expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);

// Pool metadata still surfaced from the V3 pool object.
expect(body.address).toBe(POOL_ADDRESS);
expect(body.baseTokenAddress).toBe(USDM1.address);
expect(body.quoteTokenAddress).toBe(USDC.address);
expect(body.feePct).toBeCloseTo(0.01, 6);
expect(body.binStep).toBe(1);
expect(body.activeBinId).toBe(-276211);
expect(body.price).toBeCloseTo(1.01146, 4);

// The actual fix: token amounts come from ERC20 balanceOf, not pool.liquidity.
expect(body.baseTokenAmount).toBeCloseTo(167600, 0);
expect(body.quoteTokenAmount).toBeCloseTo(182100, 0);

// Regression guard: the legacy bug returned pool.liquidity / 10^decimals.
// Make sure neither side resembles those values.
const buggyBase = Number(POOL_LIQUIDITY.toString()) / 1e18;
const buggyQuote = Number(POOL_LIQUIDITY.toString()) / 1e6;
expect(body.baseTokenAmount).not.toBeCloseTo(buggyBase, 2);
expect(body.quoteTokenAmount).not.toBeCloseTo(buggyQuote, 2);
});

it('flips base/quote correctly when base is token1', async () => {
const { Uniswap } = await import('../../../../src/connectors/uniswap/uniswap');
const { getUniswapPoolInfo, formatTokenAmount } = await import('../../../../src/connectors/uniswap/uniswap.utils');

// Same pool, but caller treats USDC as the base.
(getUniswapPoolInfo as jest.Mock).mockResolvedValue({
baseTokenAddress: USDC.address,
quoteTokenAddress: USDM1.address,
poolType: 'clmm',
});
(formatTokenAmount as jest.Mock).mockImplementation(
(amount: string, decimals: number) => Number(amount) / Math.pow(10, decimals),
);

const mockPool = {
token0: { address: USDM1.address, decimals: USDM1.decimals },
token1: { address: USDC.address, decimals: USDC.decimals },
liquidity: POOL_LIQUIDITY,
sqrtRatioX96: BigNumber.from('79228162514264337593543950336'),
token0Price: { toSignificant: () => '1.01146' },
token1Price: { toSignificant: () => '0.98867' },
fee: 100,
tickSpacing: 1,
tickCurrent: -276211,
};

(Uniswap.getInstance as jest.Mock).mockResolvedValue({
getToken: jest.fn().mockImplementation((addr: string) => {
if (addr.toLowerCase() === USDM1.address.toLowerCase()) return USDM1;
if (addr.toLowerCase() === USDC.address.toLowerCase()) return USDC;
return null;
}),
getV3Pool: jest.fn().mockResolvedValue(mockPool),
});

(Ethereum.getInstance as jest.Mock).mockResolvedValue({
provider: { _isProvider: true },
getContract: jest.fn().mockImplementation((tokenAddress: string) => ({ address: tokenAddress })),
getERC20BalanceByAddress: jest.fn().mockImplementation((contract: any, _address: string, decimals: number) => {
if (contract.address === USDM1.address) {
return Promise.resolve({ value: USDM1_RAW_BALANCE, decimals });
}
return Promise.resolve({ value: USDC_RAW_BALANCE, decimals });
}),
});

const response = await server.inject({
method: 'GET',
url: '/pool-info',
query: { network: 'mainnet', poolAddress: POOL_ADDRESS },
});

expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);

// Base is USDC now → base amount should be the USDC balance.
expect(body.baseTokenAmount).toBeCloseTo(182100, 0);
expect(body.quoteTokenAmount).toBeCloseTo(167600, 0);
// Price flips correspondingly (USDC per USDM1 was 1.01146; USDM1 per USDC ≈ 0.98867).
expect(body.price).toBeCloseTo(0.98867, 4);
});
});
Loading