Skip to content

Commit 9bf40e4

Browse files
authored
test: add test for rebalance preview (#149)
1 parent 2bde891 commit 9bf40e4

File tree

4 files changed

+299
-17
lines changed

4 files changed

+299
-17
lines changed

tests/integration/services/liquidityFlow.test.ts

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { RouteService } from '../../../src/services/routes/RouteService'
44
import { LiquidityService } from '../../../src/services/liquidity/LiquidityService'
55
import { Pool, PoolType } from '../../../src/core/types'
66
import { deadlineFromMinutes } from '../../../src/utils/deadline'
7+
import { ChainId } from '../../../src/core/constants/chainId'
8+
import { getDefaultRpcUrl } from '../../../src/utils/chainConfig'
79

810
/**
911
* Integration tests for the complete liquidity flow.
@@ -16,24 +18,57 @@ import { deadlineFromMinutes } from '../../../src/utils/deadline'
1618
* - Quote calculations
1719
* - Approval handling
1820
*
21+
* Tests are parameterised across all deployed chains to ensure
22+
* coverage is not lost when new chains are added.
23+
*
1924
* Requirements:
20-
* - Local node running at localhost:8545 (e.g., anvil fork) OR Celo RPC endpoint
25+
* - RPC endpoint accessible (via env vars or default public RPCs)
2126
* - DEV_ADDRESS environment variable set to a valid address with token balances
27+
* (only needed for approval-check tests)
2228
*
2329
* @group integration
2430
* @group local
2531
*/
26-
describe('Liquidity Flow Integration', () => {
27-
const RPC_URL = process.env.CELO_RPC_URL || 'http://localhost:8545'
28-
const CHAIN_ID = 42220
32+
33+
interface ChainTestConfig {
34+
name: string
35+
chainId: number
36+
rpcEnvVar: string
37+
}
38+
39+
const CHAIN_CONFIGS: ChainTestConfig[] = [
40+
{
41+
name: 'Celo Mainnet',
42+
chainId: ChainId.CELO,
43+
rpcEnvVar: 'CELO_RPC_URL',
44+
},
45+
{
46+
name: 'Celo Sepolia',
47+
chainId: ChainId.CELO_SEPOLIA,
48+
rpcEnvVar: 'CELO_SEPOLIA_RPC_URL',
49+
},
50+
{
51+
name: 'Monad Testnet',
52+
chainId: ChainId.MONAD_TESTNET,
53+
rpcEnvVar: 'MONAD_TESTNET_RPC_URL',
54+
},
55+
{
56+
name: 'Monad',
57+
chainId: ChainId.MONAD,
58+
rpcEnvVar: 'MONAD_RPC_URL',
59+
},
60+
]
61+
62+
describe.each(CHAIN_CONFIGS)('Liquidity Flow Integration - $name', ({ chainId, rpcEnvVar }) => {
63+
const RPC_URL = process.env[rpcEnvVar] || getDefaultRpcUrl(chainId)
2964

3065
const publicClient = createPublicClient({
3166
transport: http(RPC_URL),
3267
})
3368

34-
const poolService = new PoolService(publicClient, CHAIN_ID)
35-
const routeService = new RouteService(publicClient, CHAIN_ID, poolService)
36-
const liquidityService = new LiquidityService(publicClient, CHAIN_ID, poolService, routeService)
69+
const poolService = new PoolService(publicClient, chainId)
70+
const routeService = new RouteService(publicClient, chainId, poolService)
71+
const liquidityService = new LiquidityService(publicClient, chainId, poolService, routeService)
3772

3873
// Test fixtures populated from on-chain data
3974
let pools: Pool[]
@@ -195,10 +230,12 @@ describe('Liquidity Flow Integration', () => {
195230
expect(params).toHaveProperty('amountAMin')
196231
expect(params).toHaveProperty('amountBMin')
197232

198-
// Verify slippage applied correctly
199-
expect(params.amountAMin).toBeLessThanOrEqual(amountA)
200-
expect(params.amountBMin).toBeLessThanOrEqual(amountB)
201-
expect(params.amountAMin).toBeGreaterThan(amountA * 990n / 1000n) // At least 99% of desired
233+
// Verify slippage applied correctly (min <= desired for each token)
234+
expect(params.amountAMin).toBeLessThanOrEqual(params.amountADesired)
235+
expect(params.amountBMin).toBeLessThanOrEqual(params.amountBDesired)
236+
// Min should be positive when desired is positive
237+
expect(params.amountAMin).toBeGreaterThan(0n)
238+
expect(params.amountBMin).toBeGreaterThan(0n)
202239

203240
expect(params).toHaveProperty('estimatedMinLiquidity')
204241
expect(params.estimatedMinLiquidity).toBeGreaterThan(0n)

tests/integration/services/poolDetails.test.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { createPublicClient, http } from 'viem'
22
import { PoolService } from '../../../src/services/pools/PoolService'
33
import { Pool, PoolType, FPMMPoolDetails, VirtualPoolDetails } from '../../../src/core/types'
4+
import { ChainId } from '../../../src/core/constants/chainId'
5+
import { getDefaultRpcUrl } from '../../../src/utils/chainConfig'
46

57
/**
68
* Integration tests for PoolService.getPoolDetails()
@@ -9,20 +11,52 @@ import { Pool, PoolType, FPMMPoolDetails, VirtualPoolDetails } from '../../../sr
911
* - FPMM pools: pricing, fees, rebalancing state
1012
* - Virtual pools: reserves, spread
1113
*
14+
* Tests are parameterised across all deployed chains to ensure
15+
* coverage is not lost when new chains are added.
16+
*
1217
* Requirements:
13-
* - RPC endpoint accessible (default: Celo mainnet via Forno)
18+
* - RPC endpoint accessible (via env vars or default public RPCs)
1419
*
1520
* @group integration
1621
*/
17-
describe('PoolService.getPoolDetails() Integration', () => {
18-
const RPC_URL = process.env.CELO_RPC_URL || process.env.CELO_MAINNET_RPC_URL || 'https://forno.celo.org'
19-
const CHAIN_ID = 42220 // Celo mainnet
22+
23+
interface ChainTestConfig {
24+
name: string
25+
chainId: number
26+
rpcEnvVar: string
27+
}
28+
29+
const CHAIN_CONFIGS: ChainTestConfig[] = [
30+
{
31+
name: 'Celo Mainnet',
32+
chainId: ChainId.CELO,
33+
rpcEnvVar: 'CELO_RPC_URL',
34+
},
35+
{
36+
name: 'Celo Sepolia',
37+
chainId: ChainId.CELO_SEPOLIA,
38+
rpcEnvVar: 'CELO_SEPOLIA_RPC_URL',
39+
},
40+
{
41+
name: 'Monad Testnet',
42+
chainId: ChainId.MONAD_TESTNET,
43+
rpcEnvVar: 'MONAD_TESTNET_RPC_URL',
44+
},
45+
{
46+
name: 'Monad',
47+
chainId: ChainId.MONAD,
48+
rpcEnvVar: 'MONAD_RPC_URL',
49+
},
50+
]
51+
52+
describe.each(CHAIN_CONFIGS)('PoolService.getPoolDetails() Integration - $name', ({ chainId, rpcEnvVar }) => {
53+
const RPC_URL = process.env[rpcEnvVar] || getDefaultRpcUrl(chainId)
2054

2155
const publicClient = createPublicClient({
2256
transport: http(RPC_URL),
2357
})
2458

25-
const poolService = new PoolService(publicClient, CHAIN_ID)
59+
const poolService = new PoolService(publicClient, chainId)
2660

2761
let pools: Pool[]
2862
let fpmmPool: Pool | undefined
@@ -172,7 +206,7 @@ describe('PoolService.getPoolDetails() Integration', () => {
172206
describe('Virtual pool details', () => {
173207
it('should return enriched Virtual pool details if any exist', async () => {
174208
if (!virtualPool) {
175-
console.log('No Virtual pools found on chain - skipping Virtual pool detail tests')
209+
console.log('No Virtual pools found on this chain - skipping Virtual pool detail tests')
176210
return
177211
}
178212

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { createPublicClient, http } from 'viem'
2+
import { PoolService } from '../../../src/services/pools/PoolService'
3+
import { Pool, PoolType, FPMMPoolDetails, PoolRebalancePreview } from '../../../src/core/types'
4+
import { ChainId } from '../../../src/core/constants/chainId'
5+
import { getDefaultRpcUrl } from '../../../src/utils/chainConfig'
6+
7+
/**
8+
* Integration tests for PoolService.getPoolRebalancePreview()
9+
*
10+
* Verifies rebalance preview retrieval from on-chain OpenLiquidityStrategy contracts:
11+
* - Eligibility filtering (FPMM, out-of-band, strategy registered)
12+
* - Preview structure and internal consistency
13+
* - Batch operations
14+
*
15+
* Note: Results depend on live market state — pools may or may not be out-of-band.
16+
*
17+
* Tests are parameterised across all deployed chains to ensure
18+
* coverage is not lost when new chains are added.
19+
*
20+
* @group integration
21+
*/
22+
23+
interface ChainTestConfig {
24+
name: string
25+
chainId: number
26+
rpcEnvVar: string
27+
}
28+
29+
const CHAIN_CONFIGS: ChainTestConfig[] = [
30+
{
31+
name: 'Celo Mainnet',
32+
chainId: ChainId.CELO,
33+
rpcEnvVar: 'CELO_RPC_URL',
34+
},
35+
{
36+
name: 'Celo Sepolia',
37+
chainId: ChainId.CELO_SEPOLIA,
38+
rpcEnvVar: 'CELO_SEPOLIA_RPC_URL',
39+
},
40+
{
41+
name: 'Monad Testnet',
42+
chainId: ChainId.MONAD_TESTNET,
43+
rpcEnvVar: 'MONAD_TESTNET_RPC_URL',
44+
},
45+
{
46+
name: 'Monad',
47+
chainId: ChainId.MONAD,
48+
rpcEnvVar: 'MONAD_RPC_URL',
49+
},
50+
]
51+
52+
describe.each(CHAIN_CONFIGS)('PoolService.getPoolRebalancePreview() Integration - $name', ({ chainId, rpcEnvVar }) => {
53+
const RPC_URL = process.env[rpcEnvVar] || getDefaultRpcUrl(chainId)
54+
55+
const publicClient = createPublicClient({
56+
transport: http(RPC_URL),
57+
})
58+
59+
const poolService = new PoolService(publicClient, chainId)
60+
61+
let pools: Pool[]
62+
let fpmmPool: Pool | undefined
63+
let fpmmPoolWithStrategy: Pool | undefined
64+
let virtualPool: Pool | undefined
65+
66+
beforeAll(async () => {
67+
pools = await poolService.getPools()
68+
fpmmPool = pools.find((p) => p.poolType === PoolType.FPMM)
69+
virtualPool = pools.find((p) => p.poolType === PoolType.Virtual)
70+
71+
// Find an FPMM pool with a registered liquidity strategy
72+
for (const pool of pools.filter((p) => p.poolType === PoolType.FPMM)) {
73+
const details = (await poolService.getPoolDetails(pool.poolAddr)) as FPMMPoolDetails
74+
if (details.rebalancing.liquidityStrategy) {
75+
fpmmPoolWithStrategy = pool
76+
break
77+
}
78+
}
79+
})
80+
81+
describe('single pool preview', () => {
82+
it('should discover at least one FPMM pool', () => {
83+
expect(fpmmPool).toBeDefined()
84+
})
85+
86+
it('should return a preview or null for an FPMM pool with a strategy', async () => {
87+
if (!fpmmPoolWithStrategy) {
88+
console.log('No FPMM pool with a registered liquidity strategy found — skipping')
89+
return
90+
}
91+
92+
const preview = await poolService.getPoolRebalancePreview(fpmmPoolWithStrategy.poolAddr)
93+
94+
if (preview === null) {
95+
// Pool is in-band or FX market is closed — valid result
96+
console.log('Pool is currently in-band or market closed — preview is null')
97+
return
98+
}
99+
100+
// Validate structure
101+
expect(preview.poolAddress).toBe(fpmmPoolWithStrategy.poolAddr)
102+
expect(preview.strategyAddress).toMatch(/^0x[a-fA-F0-9]{40}$/)
103+
expect(['Expand', 'Contract']).toContain(preview.direction)
104+
105+
// Token assignments
106+
const poolTokens = [fpmmPoolWithStrategy.token0.toLowerCase(), fpmmPoolWithStrategy.token1.toLowerCase()]
107+
expect(poolTokens).toContain(preview.inputToken.toLowerCase())
108+
expect(poolTokens).toContain(preview.outputToken.toLowerCase())
109+
expect(preview.inputToken.toLowerCase()).not.toBe(preview.outputToken.toLowerCase())
110+
111+
// Amount fields reference correct tokens
112+
expect(preview.amountRequired.token.toLowerCase()).toBe(preview.inputToken.toLowerCase())
113+
expect(preview.amountTransferred.token.toLowerCase()).toBe(preview.outputToken.toLowerCase())
114+
expect(preview.protocolIncentive.token.toLowerCase()).toBe(preview.outputToken.toLowerCase())
115+
expect(preview.liquiditySourceIncentive.token.toLowerCase()).toBe(preview.outputToken.toLowerCase())
116+
117+
// Approval fields
118+
expect(preview.approvalToken.toLowerCase()).toBe(preview.inputToken.toLowerCase())
119+
expect(preview.approvalSpender).toBe(preview.strategyAddress)
120+
expect(preview.approvalAmount).toBe(preview.amountRequired.amount)
121+
122+
// Amount sanity
123+
expect(preview.amountTransferred.amount).toBeGreaterThan(0n)
124+
expect(preview.protocolIncentive.amount).toBeLessThanOrEqual(preview.amountTransferred.amount)
125+
expect(preview.liquiditySourceIncentive.amount).toBeLessThanOrEqual(preview.amountTransferred.amount)
126+
})
127+
128+
it('should return valid config and context when preview is available', async () => {
129+
if (!fpmmPoolWithStrategy) return
130+
131+
const preview = await poolService.getPoolRebalancePreview(fpmmPoolWithStrategy.poolAddr)
132+
if (!preview) return
133+
134+
// Config validation
135+
expect(preview.config.protocolFeeRecipient).toMatch(/^0x[a-fA-F0-9]{40}$/)
136+
expect(preview.config.rebalanceCooldown).toBeGreaterThanOrEqual(0)
137+
expect(preview.config.lastRebalance).toBeGreaterThanOrEqual(0)
138+
139+
// Context token addresses match pool
140+
expect(preview.context.token0.toLowerCase()).toBe(fpmmPoolWithStrategy.token0.toLowerCase())
141+
expect(preview.context.token1.toLowerCase()).toBe(fpmmPoolWithStrategy.token1.toLowerCase())
142+
expect(preview.context.pool.toLowerCase()).toBe(fpmmPoolWithStrategy.poolAddr.toLowerCase())
143+
144+
// Action direction matches preview direction
145+
expect(preview.action.dir).toBe(preview.direction)
146+
})
147+
148+
it('should return null for a Virtual pool', async () => {
149+
if (!virtualPool) {
150+
console.log('No Virtual pools found on this chain — skipping')
151+
return
152+
}
153+
154+
const preview = await poolService.getPoolRebalancePreview(virtualPool.poolAddr)
155+
expect(preview).toBeNull()
156+
})
157+
})
158+
159+
describe('batch preview', () => {
160+
it('should return array matching total pool count', async () => {
161+
const previews = await poolService.getPoolRebalancePreviewBatch()
162+
163+
expect(previews).toHaveLength(pools.length)
164+
for (const preview of previews) {
165+
expect(preview === null || typeof preview === 'object').toBe(true)
166+
}
167+
})
168+
169+
it('should return valid previews for non-null entries in batch', async () => {
170+
const previews = await poolService.getPoolRebalancePreviewBatch()
171+
const nonNullPreviews = previews.filter((p): p is PoolRebalancePreview => p !== null)
172+
173+
for (const preview of nonNullPreviews) {
174+
expect(preview.poolAddress).toMatch(/^0x[a-fA-F0-9]{40}$/)
175+
expect(preview.strategyAddress).toMatch(/^0x[a-fA-F0-9]{40}$/)
176+
expect(['Expand', 'Contract']).toContain(preview.direction)
177+
expect(preview.amountTransferred.amount).toBeGreaterThan(0n)
178+
}
179+
})
180+
})
181+
182+
describe('consistency', () => {
183+
it('single and batch results should agree for the same pool', async () => {
184+
if (!fpmmPoolWithStrategy) return
185+
186+
const single = await poolService.getPoolRebalancePreview(fpmmPoolWithStrategy.poolAddr)
187+
const [batch] = await poolService.getPoolRebalancePreviewBatch([fpmmPoolWithStrategy.poolAddr])
188+
189+
if (single === null) {
190+
expect(batch).toBeNull()
191+
} else {
192+
expect(batch).not.toBeNull()
193+
expect(batch!.poolAddress).toBe(single.poolAddress)
194+
expect(batch!.direction).toBe(single.direction)
195+
expect(batch!.inputToken).toBe(single.inputToken)
196+
expect(batch!.outputToken).toBe(single.outputToken)
197+
expect(batch!.strategyAddress).toBe(single.strategyAddress)
198+
}
199+
})
200+
})
201+
})

tests/integration/services/routerSwapFlow.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,21 @@ const CHAIN_CONFIGS: ChainTestConfig[] = [
4141
chainId: ChainId.CELO,
4242
rpcEnvVar: 'CELO_RPC_URL',
4343
},
44+
{
45+
name: 'Celo Sepolia',
46+
chainId: ChainId.CELO_SEPOLIA,
47+
rpcEnvVar: 'CELO_SEPOLIA_RPC_URL',
48+
},
4449
{
4550
name: 'Monad Testnet',
4651
chainId: ChainId.MONAD_TESTNET,
4752
rpcEnvVar: 'MONAD_TESTNET_RPC_URL',
4853
},
54+
{
55+
name: 'Monad',
56+
chainId: ChainId.MONAD,
57+
rpcEnvVar: 'MONAD_RPC_URL',
58+
},
4959
]
5060

5161
describe.each(CHAIN_CONFIGS)('Router Swap Flow Integration - $name', ({ chainId, rpcEnvVar }) => {

0 commit comments

Comments
 (0)