From d2028c9973df96f848a501ff914101506e058bbb Mon Sep 17 00:00:00 2001 From: johngrantuk Date: Tue, 30 Jun 2026 22:06:46 +0100 Subject: [PATCH 1/2] fix: reclamm getMaxSwapAmount return raw instead of scaled --- typescript/src/reClamm/reClammPool.ts | 42 +++++++-- typescript/src/reClammV2/reClammV2Pool.ts | 42 +++++++-- typescript/test/reClammPool.test.ts | 110 +++++++++++++++++++++- 3 files changed, 172 insertions(+), 22 deletions(-) diff --git a/typescript/src/reClamm/reClammPool.ts b/typescript/src/reClamm/reClammPool.ts index 9ba17e8..e32035a 100644 --- a/typescript/src/reClamm/reClammPool.ts +++ b/typescript/src/reClamm/reClammPool.ts @@ -1,9 +1,11 @@ +import { MathSol } from '../utils/math'; import { MaxSwapParams, type PoolBase, SwapKind, type SwapParams, } from '../vault/types'; +import { toRawUndoRateRoundDown } from '../vault/utils'; import { ReClammMutable } from './reClammData'; import { computeCurrentVirtualBalances, @@ -11,6 +13,10 @@ import { computeOutGivenIn, } from './reClammMath'; +// Apply a 99% safety margin to the token out balance, consistent with other pool types, to avoid +// edge-case reverts caused by rounding/curve constraints when swapping at the theoretical max. +const _MAX_TOKEN_OUT_RATIO = 990000000000000000n; + export class ReClamm implements PoolBase { public reClammState: ReClammMutable; constructor(reClammState: ReClammMutable) { @@ -35,8 +41,20 @@ export class ReClamm implements PoolBase { * @returns GivenIn: Returns the max amount in. GivenOut: Returns the max amount out. */ getMaxSwapAmount(maxSwapParams: MaxSwapParams): bigint { - const { balancesLiveScaled18, indexIn, indexOut, swapKind } = - maxSwapParams; + const { + balancesLiveScaled18, + indexIn, + indexOut, + tokenRates, + scalingFactors, + swapKind, + } = maxSwapParams; + + // 99% of token out balance + const maxAmountOutScaled18 = MathSol.mulDownFixed( + _MAX_TOKEN_OUT_RATIO, + balancesLiveScaled18[indexOut], + ); if (swapKind === SwapKind.GivenIn) { const computeResult = @@ -47,15 +65,21 @@ export class ReClamm implements PoolBase { computeResult.currentVirtualBalanceB, indexIn, indexOut, - balancesLiveScaled18[indexOut], + maxAmountOutScaled18, + ); + // Scale to token in (and remove rate) + return toRawUndoRateRoundDown( + maxAmountIn, + scalingFactors[indexIn], + tokenRates[indexIn], ); - const maxAmountInWithTolerance = maxAmountIn - 10n; // 10 is a tolerance for rounding - return maxAmountInWithTolerance < 0n - ? 0n - : maxAmountInWithTolerance; } - const maxAmountOutWithTolerance = balancesLiveScaled18[indexOut] - 10n; // 10 is a tolerance for rounding - return maxAmountOutWithTolerance < 0n ? 0n : maxAmountOutWithTolerance; + // Scale to token out (and remove rate) + return toRawUndoRateRoundDown( + maxAmountOutScaled18, + scalingFactors[indexOut], + tokenRates[indexOut], + ); } getMaxSingleTokenAddAmount(): bigint { diff --git a/typescript/src/reClammV2/reClammV2Pool.ts b/typescript/src/reClammV2/reClammV2Pool.ts index b9ad1de..0fa4b1d 100644 --- a/typescript/src/reClammV2/reClammV2Pool.ts +++ b/typescript/src/reClammV2/reClammV2Pool.ts @@ -1,9 +1,11 @@ +import { MathSol } from '../utils/math'; import { MaxSwapParams, type PoolBase, SwapKind, type SwapParams, } from '../vault/types'; +import { toRawUndoRateRoundDown } from '../vault/utils'; import { ReClammV2Mutable } from './reClammV2Data'; import { computeCurrentVirtualBalances, @@ -11,6 +13,10 @@ import { computeOutGivenIn, } from './reClammV2Math'; +// Apply a 99% safety margin to the token out balance, consistent with other pool types, to avoid +// edge-case reverts caused by rounding/curve constraints when swapping at the theoretical max. +const _MAX_TOKEN_OUT_RATIO = 990000000000000000n; + export class ReClammV2 implements PoolBase { public reClammState: ReClammV2Mutable; constructor(reClammState: ReClammV2Mutable) { @@ -35,8 +41,20 @@ export class ReClammV2 implements PoolBase { * @returns GivenIn: Returns the max amount in. GivenOut: Returns the max amount out. */ getMaxSwapAmount(maxSwapParams: MaxSwapParams): bigint { - const { balancesLiveScaled18, indexIn, indexOut, swapKind } = - maxSwapParams; + const { + balancesLiveScaled18, + indexIn, + indexOut, + tokenRates, + scalingFactors, + swapKind, + } = maxSwapParams; + + // 99% of token out balance + const maxAmountOutScaled18 = MathSol.mulDownFixed( + _MAX_TOKEN_OUT_RATIO, + balancesLiveScaled18[indexOut], + ); if (swapKind === SwapKind.GivenIn) { const computeResult = @@ -47,15 +65,21 @@ export class ReClammV2 implements PoolBase { computeResult.currentVirtualBalanceB, indexIn, indexOut, - balancesLiveScaled18[indexOut], + maxAmountOutScaled18, + ); + // Scale to token in (and remove rate) + return toRawUndoRateRoundDown( + maxAmountIn, + scalingFactors[indexIn], + tokenRates[indexIn], ); - const maxAmountInWithTolerance = maxAmountIn - 10n; // 10 is a tolerance for rounding - return maxAmountInWithTolerance < 0n - ? 0n - : maxAmountInWithTolerance; } - const maxAmountOutWithTolerance = balancesLiveScaled18[indexOut] - 10n; // 10 is a tolerance for rounding - return maxAmountOutWithTolerance < 0n ? 0n : maxAmountOutWithTolerance; + // Scale to token out (and remove rate) + return toRawUndoRateRoundDown( + maxAmountOutScaled18, + scalingFactors[indexOut], + tokenRates[indexOut], + ); } getMaxSingleTokenAddAmount(): bigint { diff --git a/typescript/test/reClammPool.test.ts b/typescript/test/reClammPool.test.ts index 1edd148..17a7857 100644 --- a/typescript/test/reClammPool.test.ts +++ b/typescript/test/reClammPool.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'; import { SwapKind } from '../src/index'; import { ReClamm } from '../src/reClamm'; +import { toScaled18ApplyRateRoundDown } from '../src/vault/utils'; describe('reClamm pool', () => { const pool = new ReClamm({ @@ -29,8 +30,15 @@ describe('reClamm pool', () => { indexIn: 0, indexOut: 1, }; + // getMaxSwapAmount returns a raw amount, so it must be scaled back to 18 decimals + // (applying the rate) before being used as `amountGivenScaled18` in onSwap. const maxSwapAmount = pool.getMaxSwapAmount(swapParams); - const sp = { ...swapParams, amountGivenScaled18: maxSwapAmount }; + const amountGivenScaled18 = toScaled18ApplyRateRoundDown( + maxSwapAmount, + swapParams.scalingFactors[swapParams.indexIn], + swapParams.tokenRates[swapParams.indexIn], + ); + const sp = { ...swapParams, amountGivenScaled18 }; expect(() => pool.onSwap(sp)).not.toThrow(); }); test('exact out, 6 decimals', () => { @@ -47,7 +55,12 @@ describe('reClamm pool', () => { indexOut: 1, }; const maxSwapAmount = pool.getMaxSwapAmount(swapParams); - const sp = { ...swapParams, amountGivenScaled18: maxSwapAmount }; + const amountGivenScaled18 = toScaled18ApplyRateRoundDown( + maxSwapAmount, + swapParams.scalingFactors[swapParams.indexOut], + swapParams.tokenRates[swapParams.indexOut], + ); + const sp = { ...swapParams, amountGivenScaled18 }; expect(() => pool.onSwap(sp)).not.toThrow(); }); test('exact in, 6 decimals', () => { @@ -64,7 +77,12 @@ describe('reClamm pool', () => { indexOut: 0, }; const maxSwapAmount = pool.getMaxSwapAmount(swapParams); - const sp = { ...swapParams, amountGivenScaled18: maxSwapAmount }; + const amountGivenScaled18 = toScaled18ApplyRateRoundDown( + maxSwapAmount, + swapParams.scalingFactors[swapParams.indexIn], + swapParams.tokenRates[swapParams.indexIn], + ); + const sp = { ...swapParams, amountGivenScaled18 }; expect(() => pool.onSwap(sp)).not.toThrow(); }); test('exact out, 18 decimals', () => { @@ -81,8 +99,92 @@ describe('reClamm pool', () => { indexOut: 0, }; const maxSwapAmount = pool.getMaxSwapAmount(swapParams); - const sp = { ...swapParams, amountGivenScaled18: maxSwapAmount }; + const amountGivenScaled18 = toScaled18ApplyRateRoundDown( + maxSwapAmount, + swapParams.scalingFactors[swapParams.indexOut], + swapParams.tokenRates[swapParams.indexOut], + ); + const sp = { ...swapParams, amountGivenScaled18 }; expect(() => pool.onSwap(sp)).not.toThrow(); }); + describe('with rate', () => { + test('exact in, returns raw amount (not scaled18)', () => { + const swapParams = { + swapKind: SwapKind.GivenIn, + amountGivenScaled18: 0n, + balancesLiveScaled18: [ + 14552907646299798n, + 174459788000000000000n, + ], + // indexOut token has a 1.5 rate, so balancesLiveScaled18 != raw balances + tokenRates: [1000000000000000000n, 1500000000000000000n], + scalingFactors: [1n, 1n], + indexIn: 0, + indexOut: 1, + }; + const maxSwapAmount = pool.getMaxSwapAmount(swapParams); + + // The result must be a raw amount: scaling it back up by the indexIn token's + // rate/scalingFactor should reproduce a valid scaled18 swap input. + const amountGivenScaled18 = toScaled18ApplyRateRoundDown( + maxSwapAmount, + swapParams.scalingFactors[swapParams.indexIn], + swapParams.tokenRates[swapParams.indexIn], + ); + const sp = { ...swapParams, amountGivenScaled18 }; + expect(() => pool.onSwap(sp)).not.toThrow(); + + // Sanity check: with a rate of 1 on indexIn, raw == scaled18 here, so the max + // amount in must be well below the full indexOut balance (since only 99% of it + // is targeted, and the rate on indexOut is > 1). + expect(maxSwapAmount).toBeGreaterThan(0n); + expect(maxSwapAmount).toBeLessThan( + swapParams.balancesLiveScaled18[swapParams.indexOut], + ); + }); + test('exact out, returns raw amount (not scaled18)', () => { + const swapParams = { + swapKind: SwapKind.GivenOut, + amountGivenScaled18: 0n, + balancesLiveScaled18: [ + 14552907646299798n, + 174459788000000000000n, + ], + // indexOut token has a 1.5 rate, so balancesLiveScaled18 != raw balance + tokenRates: [1000000000000000000n, 1500000000000000000n], + scalingFactors: [1n, 1n], + indexIn: 0, + indexOut: 1, + }; + const maxSwapAmount = pool.getMaxSwapAmount(swapParams); + + // Expected raw amount: 99% of balancesLiveScaled18[indexOut], converted back to + // raw by removing the indexOut token's rate. + const expectedMaxAmountOutScaled18 = + (990000000000000000n * + swapParams.balancesLiveScaled18[ + swapParams.indexOut + ]) / + 1000000000000000000n; + const expectedRaw = + (expectedMaxAmountOutScaled18 * 1000000000000000000n) / + swapParams.tokenRates[swapParams.indexOut]; + expect(maxSwapAmount).toEqual(expectedRaw); + + // It must be strictly less than balancesLiveScaled18[indexOut], proving the + // rate has been removed (previously this returned the scaled18 value directly). + expect(maxSwapAmount).toBeLessThan( + swapParams.balancesLiveScaled18[swapParams.indexOut], + ); + + const amountGivenScaled18 = toScaled18ApplyRateRoundDown( + maxSwapAmount, + swapParams.scalingFactors[swapParams.indexOut], + swapParams.tokenRates[swapParams.indexOut], + ); + const sp = { ...swapParams, amountGivenScaled18 }; + expect(() => pool.onSwap(sp)).not.toThrow(); + }); + }); }); }); From 74b5078c7623c6df36c29e866e4c8a9bc1cbbd46 Mon Sep 17 00:00:00 2001 From: johngrantuk Date: Tue, 30 Jun 2026 22:08:16 +0100 Subject: [PATCH 2/2] chore: Bump TS package version --- typescript/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typescript/package.json b/typescript/package.json index d09d6a1..248ad4f 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -9,7 +9,7 @@ "publishConfig": { "access": "public" }, - "version": "0.0.40", + "version": "0.0.41", "main": "dist/index.js", "module": "dist/index.mjs", "types": "dist/index.d.ts",