From 1fa990114870cb5ea9260cb3a577dff20884167b Mon Sep 17 00:00:00 2001 From: Mike Graham Date: Tue, 29 Jul 2025 16:02:38 -0700 Subject: [PATCH 01/16] test: additional scenarios for getUserPositions --- src/chaincode/dex/getUserPositions.spec.ts | 561 +++++++++++++++++++++ 1 file changed, 561 insertions(+) diff --git a/src/chaincode/dex/getUserPositions.spec.ts b/src/chaincode/dex/getUserPositions.spec.ts index f3500d6..728350a 100644 --- a/src/chaincode/dex/getUserPositions.spec.ts +++ b/src/chaincode/dex/getUserPositions.spec.ts @@ -198,4 +198,565 @@ describe("GetPosition", () => { // Then expect(response).toEqual(transactionErrorMessageContains("Invalid bookmark")); }); + + test("should handle user with more than 10 DexPositionOwner objects across multiple pools", async () => { + // Given + const positionOwners: DexPositionOwner[] = []; + const positions: DexPositionData[] = []; + const pools: Pool[] = []; + const ticks: TickData[] = []; + + // Create 12 different pools (more than 10 to test pagination) + for (let poolIndex = 0; poolIndex < 12; poolIndex++) { + const poolHash = `pool-hash-${poolIndex}`; + const testPool = plainToInstance(Pool, { + ...pool, + poolHash + }); + pools.push(testPool); + + // Create position owner for this pool + const owner = new DexPositionOwner(users.testUser1.identityKey, poolHash); + + // Add multiple positions per pool + for (let posIndex = 0; posIndex < 3; posIndex++) { + const positionId = `position-${poolIndex}-${posIndex}`; + const tickRange = `${posIndex * 10}:${(posIndex + 1) * 10}`; + owner.addPosition(tickRange, positionId); + + // Create corresponding position data + const position = plainToInstance(DexPositionData, { + ...positionData, + poolHash, + positionId, + tickLower: posIndex * 10, + tickUpper: (posIndex + 1) * 10 + }); + positions.push(position); + + // Create tick data for position bounds + const lowerTick = plainToInstance(TickData, { + poolHash, + tick: posIndex * 10, + liquidityGross: new BigNumber("100"), + initialised: true, + liquidityNet: new BigNumber("100"), + feeGrowthOutside0: new BigNumber("0"), + feeGrowthOutside1: new BigNumber("0") + }); + const upperTick = plainToInstance(TickData, { + ...lowerTick, + tick: (posIndex + 1) * 10 + }); + ticks.push(lowerTick, upperTick); + } + + positionOwners.push(owner); + } + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + dexClass, + ...pools, + ...positionOwners, + ...positions, + ...ticks + ); + + const getUserPositionsDto = new GetUserPositionsDto(users.testUser1.identityKey, undefined, 10); + + // When + const response = await contract.GetUserPositions(ctx, getUserPositionsDto); + + // Then + expect(response.Data?.positions).toHaveLength(10); + expect(response.Data?.nextBookMark).toBeDefined(); + expect(response.Data?.nextBookMark).not.toBe(""); + + // Verify positions are from different pools + const poolHashes = new Set(response.Data?.positions.map(p => p.poolHash)); + expect(poolHashes.size).toBeGreaterThan(1); + }); + + test("should handle pagination with bookmark when user has positions across multiple pools", async () => { + // Given + const positionOwners: DexPositionOwner[] = []; + const positions: DexPositionData[] = []; + const pools: Pool[] = []; + const ticks: TickData[] = []; + + // Create 5 pools with 4 positions each (20 total positions) + for (let poolIndex = 0; poolIndex < 5; poolIndex++) { + const poolHash = `pool-hash-${poolIndex}`; + const testPool = plainToInstance(Pool, { + ...pool, + poolHash + }); + pools.push(testPool); + + const owner = new DexPositionOwner(users.testUser1.identityKey, poolHash); + + for (let posIndex = 0; posIndex < 4; posIndex++) { + const positionId = `position-${poolIndex}-${posIndex}`; + const tickRange = `${posIndex * 10}:${(posIndex + 1) * 10}`; + owner.addPosition(tickRange, positionId); + + const position = plainToInstance(DexPositionData, { + ...positionData, + poolHash, + positionId, + tickLower: posIndex * 10, + tickUpper: (posIndex + 1) * 10 + }); + positions.push(position); + + const lowerTick = plainToInstance(TickData, { + poolHash, + tick: posIndex * 10, + liquidityGross: new BigNumber("100"), + initialised: true, + liquidityNet: new BigNumber("100"), + feeGrowthOutside0: new BigNumber("0"), + feeGrowthOutside1: new BigNumber("0") + }); + const upperTick = plainToInstance(TickData, { + ...lowerTick, + tick: (posIndex + 1) * 10 + }); + ticks.push(lowerTick, upperTick); + } + + positionOwners.push(owner); + } + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + dexClass, + ...pools, + ...positionOwners, + ...positions, + ...ticks + ); + + // First request - get first 8 positions + const firstRequest = new GetUserPositionsDto(users.testUser1.identityKey, undefined, 8); + const firstResponse = await contract.GetUserPositions(ctx, firstRequest); + + // Second request - get next 8 positions using bookmark + const secondRequest = new GetUserPositionsDto(users.testUser1.identityKey, firstResponse.Data?.nextBookMark, 8); + const secondResponse = await contract.GetUserPositions(ctx, secondRequest); + + // Third request - get remaining positions + const thirdRequest = new GetUserPositionsDto(users.testUser1.identityKey, secondResponse.Data?.nextBookMark, 8); + const thirdResponse = await contract.GetUserPositions(ctx, thirdRequest); + + // Then + expect(firstResponse.Data?.positions).toHaveLength(8); + expect(firstResponse.Data?.nextBookMark).toBeDefined(); + + expect(secondResponse.Data?.positions).toHaveLength(8); + expect(secondResponse.Data?.nextBookMark).toBeDefined(); + + expect(thirdResponse.Data?.positions).toHaveLength(4); + expect(thirdResponse.Data?.nextBookMark).toBe(""); + + // Verify no duplicate positions across requests + const allPositionIds = [ + ...firstResponse.Data!.positions.map(p => p.positionId), + ...secondResponse.Data!.positions.map(p => p.positionId), + ...thirdResponse.Data!.positions.map(p => p.positionId) + ]; + const uniquePositionIds = new Set(allPositionIds); + expect(uniquePositionIds.size).toBe(20); + }); + + test("should handle user with positions in different tick ranges within same pool", async () => { + // Given + const owner = new DexPositionOwner(users.testUser1.identityKey, pool.genPoolHash()); + const positions: DexPositionData[] = []; + const ticks: TickData[] = []; + + // Create positions in different tick ranges + const tickRanges = [ + { lower: -200, upper: -100 }, + { lower: -50, upper: 50 }, + { lower: 100, upper: 200 }, + { lower: 300, upper: 400 }, + { lower: 500, upper: 600 } + ]; + + for (let i = 0; i < tickRanges.length; i++) { + const { lower, upper } = tickRanges[i]; + const positionId = `position-range-${i}`; + const tickRange = `${lower}:${upper}`; + + owner.addPosition(tickRange, positionId); + + const position = plainToInstance(DexPositionData, { + ...positionData, + positionId, + tickLower: lower, + tickUpper: upper + }); + positions.push(position); + + // Create tick data + const lowerTick = plainToInstance(TickData, { + poolHash: pool.genPoolHash(), + tick: lower, + liquidityGross: new BigNumber("100"), + initialised: true, + liquidityNet: new BigNumber("100"), + feeGrowthOutside0: new BigNumber("0"), + feeGrowthOutside1: new BigNumber("0") + }); + const upperTick = plainToInstance(TickData, { + ...lowerTick, + tick: upper + }); + ticks.push(lowerTick, upperTick); + } + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1) + .savedState(pool, owner, ...positions, ...ticks, currencyClass, dexClass); + + const getUserPositionsDto = new GetUserPositionsDto(users.testUser1.identityKey); + + // When + const response = await contract.GetUserPositions(ctx, getUserPositionsDto); + + // Then + expect(response.Data?.positions).toHaveLength(5); + + // Verify all positions are from the same pool but different tick ranges + const poolHashes = new Set(response.Data?.positions.map(p => p.poolHash)); + expect(poolHashes.size).toBe(1); + + const tickRangeStrings = response.Data?.positions.map(p => `${p.tickLower}:${p.tickUpper}`); + expect(tickRangeStrings).toEqual(expect.arrayContaining([ + "-200:-100", "-50:50", "100:200", "300:400", "500:600" + ])); + }); + + test("should handle multiple positions within same tick range", async () => { + // Given + const owner = new DexPositionOwner(users.testUser1.identityKey, pool.genPoolHash()); + const positions: DexPositionData[] = []; + + // Add multiple positions to the same tick range + const tickRange = "0:100"; + const positionIds = ["pos-1", "pos-2", "pos-3"]; + + for (const positionId of positionIds) { + owner.addPosition(tickRange, positionId); + + const position = plainToInstance(DexPositionData, { + ...positionData, + positionId + }); + positions.push(position); + } + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1) + .savedState( + pool, + owner, + ...positions, + tickLowerData, + tickUpperData, + currencyClass, + dexClass + ); + + const getUserPositionsDto = new GetUserPositionsDto(users.testUser1.identityKey); + + // When + const response = await contract.GetUserPositions(ctx, getUserPositionsDto); + + // Then + expect(response.Data?.positions).toHaveLength(3); + + // All positions should have same tick range but different position IDs + const returnedPositionIds = response.Data?.positions.map(p => p.positionId); + expect(returnedPositionIds).toEqual(expect.arrayContaining(positionIds)); + + // All should have same tick bounds + response.Data?.positions.forEach(position => { + expect(position.tickLower).toBe(0); + expect(position.tickUpper).toBe(100); + }); + }); + + test("should return empty result when user has no positions", async () => { + // Given + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1) + .savedState(currencyClass, dexClass); + + const getUserPositionsDto = new GetUserPositionsDto(users.testUser1.identityKey); + + // When + const response = await contract.GetUserPositions(ctx, getUserPositionsDto); + + // Then + expect(response.Data?.positions).toHaveLength(0); + expect(response.Data?.nextBookMark).toBe(""); + }); + + test("should handle edge case with chainBookmark but no more data", async () => { + // Given + const owner = new DexPositionOwner(users.testUser1.identityKey, pool.genPoolHash()); + owner.addPosition("0:100", "single-position"); + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1) + .savedState( + pool, + positionData, + owner, + tickLowerData, + tickUpperData, + currencyClass, + dexClass + ); + + // Request with local bookmark pointing beyond available positions + const getUserPositionsDto = new GetUserPositionsDto(users.testUser1.identityKey, "@2", 5); + + // When + const response = await contract.GetUserPositions(ctx, getUserPositionsDto); + + // Then + expect(response).toEqual(transactionErrorMessageContains("Invalid bookmark")); + }); + + test("should handle user with exactly 10 different DexPositionOwner entries", async () => { + // Given + const positionOwners: DexPositionOwner[] = []; + const positions: DexPositionData[] = []; + const pools: Pool[] = []; + const ticks: TickData[] = []; + + // Create exactly 10 different pools + for (let poolIndex = 0; poolIndex < 10; poolIndex++) { + const poolHash = `pool-boundary-${poolIndex}`; + const testPool = plainToInstance(Pool, { + ...pool, + poolHash + }); + pools.push(testPool); + + // Create one DexPositionOwner per pool + const owner = new DexPositionOwner(users.testUser1.identityKey, poolHash); + + // Add 1-3 positions per pool (varying number) + const numPositions = (poolIndex % 3) + 1; // Will give 1, 2, or 3 positions + for (let posIndex = 0; posIndex < numPositions; posIndex++) { + const positionId = `boundary-pos-${poolIndex}-${posIndex}`; + const tickLower = posIndex * 50; + const tickUpper = (posIndex + 1) * 50; + const tickRange = `${tickLower}:${tickUpper}`; + + owner.addPosition(tickRange, positionId); + + // Create corresponding DexPositionData + const position = plainToInstance(DexPositionData, { + ...positionData, + poolHash, + positionId, + tickLower, + tickUpper + }); + positions.push(position); + + // Create tick data for position bounds + const lowerTick = plainToInstance(TickData, { + poolHash, + tick: tickLower, + liquidityGross: new BigNumber("100"), + initialised: true, + liquidityNet: new BigNumber("100"), + feeGrowthOutside0: new BigNumber("0"), + feeGrowthOutside1: new BigNumber("0") + }); + const upperTick = plainToInstance(TickData, { + ...lowerTick, + tick: tickUpper + }); + ticks.push(lowerTick, upperTick); + } + + positionOwners.push(owner); + } + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + dexClass, + ...pools, + ...positionOwners, + ...positions, + ...ticks + ); + + // Test with default limit (10) - should get all DexPositionOwner entries in one page + const getUserPositionsDto = new GetUserPositionsDto(users.testUser1.identityKey); + + // When + const response = await contract.GetUserPositions(ctx, getUserPositionsDto); + + // Then + // Calculate expected total positions: pools 0,3,6,9 have 1 pos, pools 1,4,7 have 2 pos, pools 2,5,8 have 3 pos + const expectedPositions = (4 * 1) + (3 * 2) + (3 * 3); // 4 + 6 + 9 = 19 total positions + expect(response.Data?.positions).toHaveLength(10); // Limited by default limit of 10 + expect(response.Data?.nextBookMark).toBeDefined(); + expect(response.Data?.nextBookMark).not.toBe(""); + + // Verify positions are from exactly 10 different pools + const poolHashes = new Set(response.Data?.positions.map(p => p.poolHash)); + expect(poolHashes.size).toBeGreaterThanOrEqual(1); + + // Verify all position IDs are unique + const positionIds = response.Data?.positions.map(p => p.positionId); + const uniquePositionIds = new Set(positionIds); + expect(uniquePositionIds.size).toBe(10); + + // Test second page to get remaining positions + const secondRequest = new GetUserPositionsDto( + users.testUser1.identityKey, + response.Data?.nextBookMark, + 10 + ); + const secondResponse = await contract.GetUserPositions(ctx, secondRequest); + + // Should get the remaining 9 positions + expect(secondResponse.Data?.positions).toHaveLength(9); + expect(secondResponse.Data?.nextBookMark).toBe(""); // No more data + + // Verify no duplicate positions between pages + const allPositionIds = [ + ...response.Data!.positions.map(p => p.positionId), + ...secondResponse.Data!.positions.map(p => p.positionId) + ]; + const allUniqueIds = new Set(allPositionIds); + expect(allUniqueIds.size).toBe(19); // Total unique positions + }); + + test("should handle empty DexPositionOwner entries and still retrieve positions from later pools", async () => { + // Given + const positionOwners: DexPositionOwner[] = []; + const positions: DexPositionData[] = []; + const pools: Pool[] = []; + const ticks: TickData[] = []; + + // Create 10 empty DexPositionOwner entries (pools a-00 through a-09) + for (let i = 0; i < 10; i++) { + const poolHash = `pool-a-${i.toString().padStart(2, '0')}`; // a-00, a-01, ..., a-09 + const testPool = plainToInstance(Pool, { + ...pool, + poolHash + }); + pools.push(testPool); + + // Create DexPositionOwner with empty tickRangeMap (simulating all positions removed) + const emptyOwner = new DexPositionOwner(users.testUser1.identityKey, poolHash); + // No positions added - tickRangeMap remains empty {} + positionOwners.push(emptyOwner); + } + + // Create 11th pool with actual positions (lexically after the first 10) + const activePoolHash = `pool-b-00`; // Will be lexically after all a-XX pools + const activePool = plainToInstance(Pool, { + ...pool, + poolHash: activePoolHash + }); + pools.push(activePool); + + // Create DexPositionOwner with actual positions + const activeOwner = new DexPositionOwner(users.testUser1.identityKey, activePoolHash); + + // Add 3 positions to the 11th pool + for (let posIndex = 0; posIndex < 3; posIndex++) { + const positionId = `active-position-${posIndex}`; + const tickLower = posIndex * 100; + const tickUpper = (posIndex + 1) * 100; + const tickRange = `${tickLower}:${tickUpper}`; + + activeOwner.addPosition(tickRange, positionId); + + // Create corresponding DexPositionData + const position = plainToInstance(DexPositionData, { + ...positionData, + poolHash: activePoolHash, + positionId, + tickLower, + tickUpper + }); + positions.push(position); + + // Create tick data + const lowerTick = plainToInstance(TickData, { + poolHash: activePoolHash, + tick: tickLower, + liquidityGross: new BigNumber("100"), + initialised: true, + liquidityNet: new BigNumber("100"), + feeGrowthOutside0: new BigNumber("0"), + feeGrowthOutside1: new BigNumber("0") + }); + const upperTick = plainToInstance(TickData, { + ...lowerTick, + tick: tickUpper + }); + ticks.push(lowerTick, upperTick); + } + + positionOwners.push(activeOwner); + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + dexClass, + ...pools, + ...positionOwners, + ...positions, + ...ticks + ); + + // Second request - should now get the positions from the 11th pool + const dto = new GetUserPositionsDto( + users.testUser1.identityKey, + undefined, + 10 + ); + + // When + const result = await contract.GetUserPositions(ctx, dto); + + // Then + + // Should successfully retrieve the 3 positions from the 11th pool + expect(result.Data?.positions).toHaveLength(3); + expect(result.Data?.nextBookMark).toBe(""); // No more data + + // Verify all positions are from the active pool + const poolHashes = result.Data?.positions.map(p => p.poolHash); + expect(poolHashes).toEqual(['pool-b-00', 'pool-b-00', 'pool-b-00']); + + // Verify position IDs + const positionIds = result.Data?.positions.map(p => p.positionId); + expect(positionIds).toEqual(expect.arrayContaining([ + 'active-position-0', + 'active-position-1', + 'active-position-2' + ])); + }); }); From 054aa8d053e4829b14afbf51fd26d3f64857ca50 Mon Sep 17 00:00:00 2001 From: Mike Graham Date: Tue, 29 Jul 2025 16:06:03 -0700 Subject: [PATCH 02/16] chore: lint --- src/chaincode/dex/getUserPositions.spec.ts | 176 ++++++++------------- 1 file changed, 67 insertions(+), 109 deletions(-) diff --git a/src/chaincode/dex/getUserPositions.spec.ts b/src/chaincode/dex/getUserPositions.spec.ts index 728350a..082312d 100644 --- a/src/chaincode/dex/getUserPositions.spec.ts +++ b/src/chaincode/dex/getUserPositions.spec.ts @@ -217,7 +217,7 @@ describe("GetPosition", () => { // Create position owner for this pool const owner = new DexPositionOwner(users.testUser1.identityKey, poolHash); - + // Add multiple positions per pool for (let posIndex = 0; posIndex < 3; posIndex++) { const positionId = `position-${poolIndex}-${posIndex}`; @@ -250,20 +250,13 @@ describe("GetPosition", () => { }); ticks.push(lowerTick, upperTick); } - + positionOwners.push(owner); } const { ctx, contract } = fixture(DexV3Contract) .registeredUsers(users.testUser1) - .savedState( - currencyClass, - dexClass, - ...pools, - ...positionOwners, - ...positions, - ...ticks - ); + .savedState(currencyClass, dexClass, ...pools, ...positionOwners, ...positions, ...ticks); const getUserPositionsDto = new GetUserPositionsDto(users.testUser1.identityKey, undefined, 10); @@ -274,9 +267,9 @@ describe("GetPosition", () => { expect(response.Data?.positions).toHaveLength(10); expect(response.Data?.nextBookMark).toBeDefined(); expect(response.Data?.nextBookMark).not.toBe(""); - + // Verify positions are from different pools - const poolHashes = new Set(response.Data?.positions.map(p => p.poolHash)); + const poolHashes = new Set(response.Data?.positions.map((p) => p.poolHash)); expect(poolHashes.size).toBeGreaterThan(1); }); @@ -297,7 +290,7 @@ describe("GetPosition", () => { pools.push(testPool); const owner = new DexPositionOwner(users.testUser1.identityKey, poolHash); - + for (let posIndex = 0; posIndex < 4; posIndex++) { const positionId = `position-${poolIndex}-${posIndex}`; const tickRange = `${posIndex * 10}:${(posIndex + 1) * 10}`; @@ -327,48 +320,49 @@ describe("GetPosition", () => { }); ticks.push(lowerTick, upperTick); } - + positionOwners.push(owner); } const { ctx, contract } = fixture(DexV3Contract) .registeredUsers(users.testUser1) - .savedState( - currencyClass, - dexClass, - ...pools, - ...positionOwners, - ...positions, - ...ticks - ); + .savedState(currencyClass, dexClass, ...pools, ...positionOwners, ...positions, ...ticks); // First request - get first 8 positions const firstRequest = new GetUserPositionsDto(users.testUser1.identityKey, undefined, 8); const firstResponse = await contract.GetUserPositions(ctx, firstRequest); // Second request - get next 8 positions using bookmark - const secondRequest = new GetUserPositionsDto(users.testUser1.identityKey, firstResponse.Data?.nextBookMark, 8); + const secondRequest = new GetUserPositionsDto( + users.testUser1.identityKey, + firstResponse.Data?.nextBookMark, + 8 + ); const secondResponse = await contract.GetUserPositions(ctx, secondRequest); // Third request - get remaining positions - const thirdRequest = new GetUserPositionsDto(users.testUser1.identityKey, secondResponse.Data?.nextBookMark, 8); + const thirdRequest = new GetUserPositionsDto( + users.testUser1.identityKey, + secondResponse.Data?.nextBookMark, + 8 + ); const thirdResponse = await contract.GetUserPositions(ctx, thirdRequest); // Then expect(firstResponse.Data?.positions).toHaveLength(8); expect(firstResponse.Data?.nextBookMark).toBeDefined(); - + expect(secondResponse.Data?.positions).toHaveLength(8); expect(secondResponse.Data?.nextBookMark).toBeDefined(); - + expect(thirdResponse.Data?.positions).toHaveLength(4); expect(thirdResponse.Data?.nextBookMark).toBe(""); // Verify no duplicate positions across requests const allPositionIds = [ - ...firstResponse.Data!.positions.map(p => p.positionId), - ...secondResponse.Data!.positions.map(p => p.positionId), - ...thirdResponse.Data!.positions.map(p => p.positionId) + ...firstResponse.Data!.positions.map((p) => p.positionId), + ...secondResponse.Data!.positions.map((p) => p.positionId), + ...thirdResponse.Data!.positions.map((p) => p.positionId) ]; const uniquePositionIds = new Set(allPositionIds); expect(uniquePositionIds.size).toBe(20); @@ -393,7 +387,7 @@ describe("GetPosition", () => { const { lower, upper } = tickRanges[i]; const positionId = `position-range-${i}`; const tickRange = `${lower}:${upper}`; - + owner.addPosition(tickRange, positionId); const position = plainToInstance(DexPositionData, { @@ -432,15 +426,15 @@ describe("GetPosition", () => { // Then expect(response.Data?.positions).toHaveLength(5); - + // Verify all positions are from the same pool but different tick ranges - const poolHashes = new Set(response.Data?.positions.map(p => p.poolHash)); + const poolHashes = new Set(response.Data?.positions.map((p) => p.poolHash)); expect(poolHashes.size).toBe(1); - - const tickRangeStrings = response.Data?.positions.map(p => `${p.tickLower}:${p.tickUpper}`); - expect(tickRangeStrings).toEqual(expect.arrayContaining([ - "-200:-100", "-50:50", "100:200", "300:400", "500:600" - ])); + + const tickRangeStrings = response.Data?.positions.map((p) => `${p.tickLower}:${p.tickUpper}`); + expect(tickRangeStrings).toEqual( + expect.arrayContaining(["-200:-100", "-50:50", "100:200", "300:400", "500:600"]) + ); }); test("should handle multiple positions within same tick range", async () => { @@ -451,10 +445,10 @@ describe("GetPosition", () => { // Add multiple positions to the same tick range const tickRange = "0:100"; const positionIds = ["pos-1", "pos-2", "pos-3"]; - + for (const positionId of positionIds) { owner.addPosition(tickRange, positionId); - + const position = plainToInstance(DexPositionData, { ...positionData, positionId @@ -464,15 +458,7 @@ describe("GetPosition", () => { const { ctx, contract } = fixture(DexV3Contract) .registeredUsers(users.testUser1) - .savedState( - pool, - owner, - ...positions, - tickLowerData, - tickUpperData, - currencyClass, - dexClass - ); + .savedState(pool, owner, ...positions, tickLowerData, tickUpperData, currencyClass, dexClass); const getUserPositionsDto = new GetUserPositionsDto(users.testUser1.identityKey); @@ -481,13 +467,13 @@ describe("GetPosition", () => { // Then expect(response.Data?.positions).toHaveLength(3); - + // All positions should have same tick range but different position IDs - const returnedPositionIds = response.Data?.positions.map(p => p.positionId); + const returnedPositionIds = response.Data?.positions.map((p) => p.positionId); expect(returnedPositionIds).toEqual(expect.arrayContaining(positionIds)); - + // All should have same tick bounds - response.Data?.positions.forEach(position => { + response.Data?.positions.forEach((position) => { expect(position.tickLower).toBe(0); expect(position.tickUpper).toBe(100); }); @@ -516,15 +502,7 @@ describe("GetPosition", () => { const { ctx, contract } = fixture(DexV3Contract) .registeredUsers(users.testUser1) - .savedState( - pool, - positionData, - owner, - tickLowerData, - tickUpperData, - currencyClass, - dexClass - ); + .savedState(pool, positionData, owner, tickLowerData, tickUpperData, currencyClass, dexClass); // Request with local bookmark pointing beyond available positions const getUserPositionsDto = new GetUserPositionsDto(users.testUser1.identityKey, "@2", 5); @@ -554,7 +532,7 @@ describe("GetPosition", () => { // Create one DexPositionOwner per pool const owner = new DexPositionOwner(users.testUser1.identityKey, poolHash); - + // Add 1-3 positions per pool (varying number) const numPositions = (poolIndex % 3) + 1; // Will give 1, 2, or 3 positions for (let posIndex = 0; posIndex < numPositions; posIndex++) { @@ -562,7 +540,7 @@ describe("GetPosition", () => { const tickLower = posIndex * 50; const tickUpper = (posIndex + 1) * 50; const tickRange = `${tickLower}:${tickUpper}`; - + owner.addPosition(tickRange, positionId); // Create corresponding DexPositionData @@ -591,20 +569,13 @@ describe("GetPosition", () => { }); ticks.push(lowerTick, upperTick); } - + positionOwners.push(owner); } const { ctx, contract } = fixture(DexV3Contract) .registeredUsers(users.testUser1) - .savedState( - currencyClass, - dexClass, - ...pools, - ...positionOwners, - ...positions, - ...ticks - ); + .savedState(currencyClass, dexClass, ...pools, ...positionOwners, ...positions, ...ticks); // Test with default limit (10) - should get all DexPositionOwner entries in one page const getUserPositionsDto = new GetUserPositionsDto(users.testUser1.identityKey); @@ -614,24 +585,24 @@ describe("GetPosition", () => { // Then // Calculate expected total positions: pools 0,3,6,9 have 1 pos, pools 1,4,7 have 2 pos, pools 2,5,8 have 3 pos - const expectedPositions = (4 * 1) + (3 * 2) + (3 * 3); // 4 + 6 + 9 = 19 total positions + const expectedPositions = 4 * 1 + 3 * 2 + 3 * 3; // 4 + 6 + 9 = 19 total positions expect(response.Data?.positions).toHaveLength(10); // Limited by default limit of 10 expect(response.Data?.nextBookMark).toBeDefined(); expect(response.Data?.nextBookMark).not.toBe(""); - + // Verify positions are from exactly 10 different pools - const poolHashes = new Set(response.Data?.positions.map(p => p.poolHash)); + const poolHashes = new Set(response.Data?.positions.map((p) => p.poolHash)); expect(poolHashes.size).toBeGreaterThanOrEqual(1); - + // Verify all position IDs are unique - const positionIds = response.Data?.positions.map(p => p.positionId); + const positionIds = response.Data?.positions.map((p) => p.positionId); const uniquePositionIds = new Set(positionIds); expect(uniquePositionIds.size).toBe(10); // Test second page to get remaining positions const secondRequest = new GetUserPositionsDto( - users.testUser1.identityKey, - response.Data?.nextBookMark, + users.testUser1.identityKey, + response.Data?.nextBookMark, 10 ); const secondResponse = await contract.GetUserPositions(ctx, secondRequest); @@ -642,8 +613,8 @@ describe("GetPosition", () => { // Verify no duplicate positions between pages const allPositionIds = [ - ...response.Data!.positions.map(p => p.positionId), - ...secondResponse.Data!.positions.map(p => p.positionId) + ...response.Data!.positions.map((p) => p.positionId), + ...secondResponse.Data!.positions.map((p) => p.positionId) ]; const allUniqueIds = new Set(allPositionIds); expect(allUniqueIds.size).toBe(19); // Total unique positions @@ -658,7 +629,7 @@ describe("GetPosition", () => { // Create 10 empty DexPositionOwner entries (pools a-00 through a-09) for (let i = 0; i < 10; i++) { - const poolHash = `pool-a-${i.toString().padStart(2, '0')}`; // a-00, a-01, ..., a-09 + const poolHash = `pool-a-${i.toString().padStart(2, "0")}`; // a-00, a-01, ..., a-09 const testPool = plainToInstance(Pool, { ...pool, poolHash @@ -681,14 +652,14 @@ describe("GetPosition", () => { // Create DexPositionOwner with actual positions const activeOwner = new DexPositionOwner(users.testUser1.identityKey, activePoolHash); - + // Add 3 positions to the 11th pool for (let posIndex = 0; posIndex < 3; posIndex++) { const positionId = `active-position-${posIndex}`; const tickLower = posIndex * 100; const tickUpper = (posIndex + 1) * 100; const tickRange = `${tickLower}:${tickUpper}`; - + activeOwner.addPosition(tickRange, positionId); // Create corresponding DexPositionData @@ -717,46 +688,33 @@ describe("GetPosition", () => { }); ticks.push(lowerTick, upperTick); } - + positionOwners.push(activeOwner); const { ctx, contract } = fixture(DexV3Contract) .registeredUsers(users.testUser1) - .savedState( - currencyClass, - dexClass, - ...pools, - ...positionOwners, - ...positions, - ...ticks - ); + .savedState(currencyClass, dexClass, ...pools, ...positionOwners, ...positions, ...ticks); // Second request - should now get the positions from the 11th pool - const dto = new GetUserPositionsDto( - users.testUser1.identityKey, - undefined, - 10 - ); + const dto = new GetUserPositionsDto(users.testUser1.identityKey, undefined, 10); // When const result = await contract.GetUserPositions(ctx, dto); // Then - + // Should successfully retrieve the 3 positions from the 11th pool expect(result.Data?.positions).toHaveLength(3); expect(result.Data?.nextBookMark).toBe(""); // No more data - + // Verify all positions are from the active pool - const poolHashes = result.Data?.positions.map(p => p.poolHash); - expect(poolHashes).toEqual(['pool-b-00', 'pool-b-00', 'pool-b-00']); - + const poolHashes = result.Data?.positions.map((p) => p.poolHash); + expect(poolHashes).toEqual(["pool-b-00", "pool-b-00", "pool-b-00"]); + // Verify position IDs - const positionIds = result.Data?.positions.map(p => p.positionId); - expect(positionIds).toEqual(expect.arrayContaining([ - 'active-position-0', - 'active-position-1', - 'active-position-2' - ])); + const positionIds = result.Data?.positions.map((p) => p.positionId); + expect(positionIds).toEqual( + expect.arrayContaining(["active-position-0", "active-position-1", "active-position-2"]) + ); }); }); From dbf322ad94e15d3c237353b68b2b62452ff2e8ff Mon Sep 17 00:00:00 2001 From: Mike Graham Date: Fri, 1 Aug 2025 15:24:20 -0700 Subject: [PATCH 03/16] test: basic happy path test for swap.ts --- src/chaincode/dex/swap.spec.ts | 177 +++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 src/chaincode/dex/swap.spec.ts diff --git a/src/chaincode/dex/swap.spec.ts b/src/chaincode/dex/swap.spec.ts new file mode 100644 index 0000000..7994fb9 --- /dev/null +++ b/src/chaincode/dex/swap.spec.ts @@ -0,0 +1,177 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { randomUniqueKey, TokenBalance, TokenClass, TokenClassKey, TokenInstance } from "@gala-chain/api"; +import { currency, fixture, transactionSuccess, users } from "@gala-chain/test"; +import BigNumber from "bignumber.js"; +import { plainToInstance } from "class-transformer"; + +import { + DexFeePercentageTypes, + DexPositionData, + Pool, + SwapDto, + SwapResDto, + TickData +} from "../../api"; +import { DexV3Contract } from "../DexV3Contract"; +import dex from "../test/dex"; +import { generateKeyFromClassKey } from "./dexUtils"; + +describe("swap", () => { + test("should execute a successful token swap in the happy path", async () => { + // Given + const currencyClass: TokenClass = currency.tokenClass(); + const currencyInstance: TokenInstance = currency.tokenInstance(); + const currencyClassKey: TokenClassKey = currency.tokenClassKey(); + const dexClass: TokenClass = dex.tokenClass(); + const dexInstance: TokenInstance = dex.tokenInstance(); + const dexClassKey: TokenClassKey = dex.tokenClassKey(); + + // Create normalized token keys for pool + const token0Key = generateKeyFromClassKey(dexClassKey); + const token1Key = generateKeyFromClassKey(currencyClassKey); + const fee = DexFeePercentageTypes.FEE_1_PERCENT; + + // Initialize pool with manual values + const pool = new Pool( + token0Key, + token1Key, + dexClassKey, + currencyClassKey, + fee, + new BigNumber("1"), // Initial sqrt price of 1 (price = 1:1) + 0.05 // 5% protocol fee + ); + + // Add initial liquidity to the pool + pool.liquidity = new BigNumber("1000000"); // 1M liquidity units + pool.sqrtPrice = new BigNumber("1"); + + // Create pool balances - pool needs tokens to pay out + const poolAlias = pool.getPoolAlias(); + const poolDexBalance = plainToInstance(TokenBalance, { + ...dex.tokenBalance(), + owner: poolAlias, + quantity: new BigNumber("500000") // Pool has 500k DEX tokens + }); + const poolCurrencyBalance = plainToInstance(TokenBalance, { + ...currency.tokenBalance(), + owner: poolAlias, + quantity: new BigNumber("500000") // Pool has 500k CURRENCY tokens + }); + + // Create user balances - user needs tokens to swap + const userDexBalance = plainToInstance(TokenBalance, { + ...dex.tokenBalance(), + owner: users.testUser1.identityKey, + quantity: new BigNumber("10000") // User has 10k DEX tokens + }); + const userCurrencyBalance = plainToInstance(TokenBalance, { + ...currency.tokenBalance(), + owner: users.testUser1.identityKey, + quantity: new BigNumber("10000") // User has 10k CURRENCY tokens + }); + + // Create tick data for the current price range + // For simplicity, create one tick that encompasses the current price + const tickLower = -100; + const tickUpper = 100; + + const tickLowerData = plainToInstance(TickData, { + poolHash: pool.genPoolHash(), + tick: tickLower, + liquidityGross: new BigNumber("1000000"), + liquidityNet: new BigNumber("1000000"), + feeGrowthOutside0: new BigNumber("0"), + feeGrowthOutside1: new BigNumber("0"), + initialised: true + }); + + const tickUpperData = plainToInstance(TickData, { + poolHash: pool.genPoolHash(), + tick: tickUpper, + liquidityGross: new BigNumber("1000000"), + liquidityNet: new BigNumber("-1000000"), + feeGrowthOutside0: new BigNumber("0"), + feeGrowthOutside1: new BigNumber("0"), + initialised: true + }); + + // Create position to represent the liquidity + const position = new DexPositionData( + pool.genPoolHash(), + "test-position", + tickUpper, + tickLower, + dexClassKey, + currencyClassKey, + fee + ); + position.liquidity = new BigNumber("1000000"); + + // Setup the fixture + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + currencyInstance, + dexClass, + dexInstance, + pool, + poolDexBalance, + poolCurrencyBalance, + userDexBalance, + userCurrencyBalance, + tickLowerData, + tickUpperData, + position + ); + + // Create swap DTO - swap 100 DEX for CURRENCY + const swapDto = new SwapDto( + dexClassKey, + currencyClassKey, + fee, + new BigNumber("100"), // Swap 100 tokens + true, // zeroForOne - swapping token0 (DEX) for token1 (CURRENCY) + new BigNumber("0.9"), // sqrtPriceLimit - allow up to 10% price impact + undefined, // No max input limit + undefined // No min output limit + ); + + swapDto.uniqueKey = randomUniqueKey(); + + const signedDto = swapDto.signed(users.testUser1.privateKey); + + // When + const response = await contract.Swap(ctx, signedDto); + + // Then + expect(response).toEqual(transactionSuccess()); + expect(response.Data).toBeDefined(); + + const swapResult = response.Data as SwapResDto; + expect(swapResult.token0).toBe(dexClass.symbol); + expect(swapResult.token1).toBe(currencyClass.symbol); + expect(swapResult.userAddress).toBe(users.testUser1.identityKey); + expect(swapResult.poolHash).toBe(pool.genPoolHash()); + expect(swapResult.poolAlias).toBe(poolAlias); + expect(swapResult.poolFee).toBe(fee); + + // Verify amounts - exact amounts will depend on swap math + expect(new BigNumber(swapResult.amount0).toNumber()).toBeGreaterThan(0); // User pays DEX + expect(new BigNumber(swapResult.amount1).toNumber()).toBeLessThan(0); // User receives CURRENCY + }); +}); \ No newline at end of file From 8979b8e097e95e2f9960abb379aa6b5758a626ef Mon Sep 17 00:00:00 2001 From: Mike Graham Date: Fri, 1 Aug 2025 17:00:25 -0700 Subject: [PATCH 04/16] test: additional swap parameters --- src/chaincode/dex/swap.spec.ts | 60 ++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/src/chaincode/dex/swap.spec.ts b/src/chaincode/dex/swap.spec.ts index 7994fb9..adc84bd 100644 --- a/src/chaincode/dex/swap.spec.ts +++ b/src/chaincode/dex/swap.spec.ts @@ -42,7 +42,7 @@ describe("swap", () => { // Create normalized token keys for pool const token0Key = generateKeyFromClassKey(dexClassKey); const token1Key = generateKeyFromClassKey(currencyClassKey); - const fee = DexFeePercentageTypes.FEE_1_PERCENT; + const fee = DexFeePercentageTypes.FEE_0_05_PERCENT; // Initialize pool with manual values const pool = new Pool( @@ -51,25 +51,35 @@ describe("swap", () => { dexClassKey, currencyClassKey, fee, - new BigNumber("1"), // Initial sqrt price of 1 (price = 1:1) - 0.05 // 5% protocol fee + new BigNumber("0.01664222241481084743"), + 0.1 ); + + const bitmap: Record = { + "-30":"75557863725914323419136", + "-31":"37778931862957161709568", + "-32":"40564819207303340847894502572032", + "-33":"26959946667150639794667015087019630673637144422540572481103610249216", + "-346":"5708990770823839524233143877797980545530986496", + "346":"20282409603651670423947251286016" + }; // Add initial liquidity to the pool - pool.liquidity = new BigNumber("1000000"); // 1M liquidity units - pool.sqrtPrice = new BigNumber("1"); - + pool.liquidity = new BigNumber("77789.999499306764803261"); + pool.grossPoolLiquidity = new BigNumber("348717210.55494320449679994"); + pool.sqrtPrice = new BigNumber("0.01664222241481084743"); + pool.bitmap = bitmap; // Create pool balances - pool needs tokens to pay out const poolAlias = pool.getPoolAlias(); const poolDexBalance = plainToInstance(TokenBalance, { ...dex.tokenBalance(), owner: poolAlias, - quantity: new BigNumber("500000") // Pool has 500k DEX tokens + quantity: new BigNumber("97.238975330345368866") }); const poolCurrencyBalance = plainToInstance(TokenBalance, { ...currency.tokenBalance(), owner: poolAlias, - quantity: new BigNumber("500000") // Pool has 500k CURRENCY tokens + quantity: new BigNumber("188809.790718") }); // Create user balances - user needs tokens to swap @@ -84,8 +94,6 @@ describe("swap", () => { quantity: new BigNumber("10000") // User has 10k CURRENCY tokens }); - // Create tick data for the current price range - // For simplicity, create one tick that encompasses the current price const tickLower = -100; const tickUpper = 100; @@ -134,32 +142,44 @@ describe("swap", () => { poolCurrencyBalance, userDexBalance, userCurrencyBalance, - tickLowerData, - tickUpperData, - position + // tickLowerData, + // tickUpperData, + // position ); - // Create swap DTO - swap 100 DEX for CURRENCY const swapDto = new SwapDto( dexClassKey, currencyClassKey, fee, - new BigNumber("100"), // Swap 100 tokens + new BigNumber("151.714011"), true, // zeroForOne - swapping token0 (DEX) for token1 (CURRENCY) - new BigNumber("0.9"), // sqrtPriceLimit - allow up to 10% price impact - undefined, // No max input limit - undefined // No min output limit + new BigNumber("0.000000000000000000094212147"), + new BigNumber("151.714011"), + new BigNumber("-75.8849266551571701291") ); swapDto.uniqueKey = randomUniqueKey(); const signedDto = swapDto.signed(users.testUser1.privateKey); + const expectedResponse = new SwapResDto( + dexClass.symbol, + "https://app.gala.games/test-image-placeholder-url.png", + currencyClass.symbol, + "https://app.gala.games/test-image-placeholder-url.png", + "151.7140110000", + "-79.8788701633", + "client|testUser1", + pool.genPoolHash(), + poolAlias, + DexFeePercentageTypes.FEE_0_05_PERCENT, + ctx.txUnixTime + ); // When const response = await contract.Swap(ctx, signedDto); // Then - expect(response).toEqual(transactionSuccess()); + expect(response).toEqual(transactionSuccess(expectedResponse)); expect(response.Data).toBeDefined(); const swapResult = response.Data as SwapResDto; @@ -172,6 +192,6 @@ describe("swap", () => { // Verify amounts - exact amounts will depend on swap math expect(new BigNumber(swapResult.amount0).toNumber()).toBeGreaterThan(0); // User pays DEX - expect(new BigNumber(swapResult.amount1).toNumber()).toBeLessThan(0); // User receives CURRENCY + expect(new BigNumber(swapResult.amount1).toNumber()).toBeLessThan(100); // User receives CURRENCY }); }); \ No newline at end of file From 508d2d4b9afd603c6459e29f0326032711f69e55 Mon Sep 17 00:00:00 2001 From: Mike Graham Date: Fri, 1 Aug 2025 19:57:56 -0700 Subject: [PATCH 05/16] test: additional helper functions used by swap --- src/api/utils/dex/computeSwapStep.spec.ts | 129 ++++++++++++++++ src/chaincode/dex/swap.helper.spec.ts | 158 ++++++++++++++++++++ src/chaincode/dex/tickData.helper.spec.ts | 171 ++++++++++++++++++++++ 3 files changed, 458 insertions(+) create mode 100644 src/api/utils/dex/computeSwapStep.spec.ts create mode 100644 src/chaincode/dex/swap.helper.spec.ts create mode 100644 src/chaincode/dex/tickData.helper.spec.ts diff --git a/src/api/utils/dex/computeSwapStep.spec.ts b/src/api/utils/dex/computeSwapStep.spec.ts new file mode 100644 index 0000000..72a0d64 --- /dev/null +++ b/src/api/utils/dex/computeSwapStep.spec.ts @@ -0,0 +1,129 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; + +import { computeSwapStep } from "./swapMath.helper"; + +describe("computeSwapStep", () => { + test("should compute swap step for exact input swap", () => { + // Given + const sqrtPriceCurrent = new BigNumber("1"); // Starting at 1:1 price + const sqrtPriceTarget = new BigNumber("0.9"); // Target price + const liquidity = new BigNumber("1000000"); // 1M liquidity + const amountRemaining = new BigNumber("100"); // 100 tokens remaining to swap + const fee = 3000; // 0.3% fee + + // When + const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( + sqrtPriceCurrent, + sqrtPriceTarget, + liquidity, + amountRemaining, + fee + ); + + // Then + expect(sqrtPriceNext).toBeDefined(); + expect(amountIn).toBeDefined(); + expect(amountOut).toBeDefined(); + expect(feeAmount).toBeDefined(); + + // Verify amounts are positive/correct sign + expect(amountIn.toNumber()).toBeGreaterThan(0); // Input amount should be positive + expect(amountOut.toNumber()).toBeGreaterThan(0); // Output amount should be positive + expect(feeAmount.toNumber()).toBeGreaterThan(0); // Fee should be positive + + // Verify fee calculation + expect(feeAmount.toNumber()).toBeCloseTo(amountIn.toNumber() * 0.003, 2); + + // Verify price moved in correct direction + expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceCurrent.toNumber()); + expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceTarget.toNumber()); + }); + + test("should compute swap step for exact output swap", () => { + // Given + const sqrtPriceCurrent = new BigNumber("1"); + const sqrtPriceTarget = new BigNumber("1.1"); + const liquidity = new BigNumber("1000000"); + const amountRemaining = new BigNumber("-50"); // Negative for exact output + const fee = 3000; + + // When + const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( + sqrtPriceCurrent, + sqrtPriceTarget, + liquidity, + amountRemaining, + fee + ); + + // Then + expect(amountIn.toNumber()).toBeGreaterThan(0); + expect(amountOut.toNumber()).toBeGreaterThan(0); + expect(feeAmount.toNumber()).toBeGreaterThan(0); + + // Verify price moved in correct direction for opposite swap + expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceCurrent.toNumber()); + expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceTarget.toNumber()); + }); + + test("should handle swap that reaches target price", () => { + // Given + const sqrtPriceCurrent = new BigNumber("1"); + const sqrtPriceTarget = new BigNumber("0.99"); // Small price movement + const liquidity = new BigNumber("10000000"); // Large liquidity + const amountRemaining = new BigNumber("1000"); // Large amount to swap + const fee = 3000; + + // When + const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( + sqrtPriceCurrent, + sqrtPriceTarget, + liquidity, + amountRemaining, + fee + ); + + // Then + // Should reach target price with large amount and liquidity + expect(sqrtPriceNext.toNumber()).toBe(sqrtPriceTarget.toNumber()); + }); + + test("should handle zero liquidity edge case", () => { + // Given + const sqrtPriceCurrent = new BigNumber("1"); + const sqrtPriceTarget = new BigNumber("0.9"); + const liquidity = new BigNumber("0"); // No liquidity + const amountRemaining = new BigNumber("100"); + const fee = 3000; + + // When + const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( + sqrtPriceCurrent, + sqrtPriceTarget, + liquidity, + amountRemaining, + fee + ); + + // Then + // With zero liquidity, should reach target price but no amounts + expect(sqrtPriceNext.toNumber()).toBe(sqrtPriceTarget.toNumber()); + expect(amountIn.toNumber()).toBe(0); + expect(amountOut.toNumber()).toBe(0); + expect(feeAmount.toNumber()).toBe(0); + }); +}); \ No newline at end of file diff --git a/src/chaincode/dex/swap.helper.spec.ts b/src/chaincode/dex/swap.helper.spec.ts new file mode 100644 index 0000000..97441b1 --- /dev/null +++ b/src/chaincode/dex/swap.helper.spec.ts @@ -0,0 +1,158 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { GalaChainContext } from "@gala-chain/chaincode"; +import { fixture } from "@gala-chain/test"; +import BigNumber from "bignumber.js"; +import { plainToInstance } from "class-transformer"; + +import { + DexFeePercentageTypes, + Pool, + SwapState, + TickData, + sqrtPriceToTick +} from "../../api"; +import { processSwapSteps } from "./swap.helper"; + +describe("swap.helper", () => { + describe("processSwapSteps", () => { + test("should process swap steps in happy path scenario", async () => { + // Given + const poolHash = "test-pool-hash"; + const fee = DexFeePercentageTypes.FEE_0_3_PERCENT; + + // Create a pool with initialized bitmap + const pool = plainToInstance(Pool, { + token0: "GALA:Unit:none:none", + token1: "TEST:Unit:none:none", + fee, + sqrtPrice: new BigNumber("1"), // 1:1 price + liquidity: new BigNumber("1000000"), // 1M liquidity + grossPoolLiquidity: new BigNumber("1000000"), + feeGrowthGlobal0: new BigNumber("0"), + feeGrowthGlobal1: new BigNumber("0"), + bitmap: { + "0": "1", // Tick 0 is initialized + }, + tickSpacing: 60, // For 0.3% fee + protocolFees: 0.1, // 10% protocol fee + protocolFeesToken0: new BigNumber("0"), + protocolFeesToken1: new BigNumber("0") + }); + pool.genPoolHash = () => poolHash; + + // Create initial swap state + const initialState: SwapState = { + amountSpecifiedRemaining: new BigNumber("100"), // 100 tokens to swap + amountCalculated: new BigNumber("0"), + sqrtPrice: new BigNumber("1"), + tick: sqrtPriceToTick(new BigNumber("1")), // Should be 0 + liquidity: new BigNumber("1000000"), + feeGrowthGlobalX: new BigNumber("0"), + protocolFee: new BigNumber("0") + }; + + // Create tick data for the test + const tickData = new TickData(poolHash, 0); + tickData.liquidityNet = new BigNumber("0"); + tickData.liquidityGross = new BigNumber("1000000"); + tickData.initialised = true; + + // Setup fixture + const { ctx } = fixture(GalaChainContext) + .savedState(tickData); + + // Set up parameters + const sqrtPriceLimit = new BigNumber("0.9"); // Allow price to move to 0.9 + const exactInput = true; // Exact input swap + const zeroForOne = true; // Swapping token0 for token1 + + // When + const resultState = await processSwapSteps( + ctx, + initialState, + pool, + sqrtPriceLimit, + exactInput, + zeroForOne + ); + + // Then + expect(resultState).toBeDefined(); + expect(resultState.sqrtPrice).toBeDefined(); + expect(resultState.amountSpecifiedRemaining).toBeDefined(); + expect(resultState.amountCalculated).toBeDefined(); + + // Verify that some swap occurred + expect(resultState.amountSpecifiedRemaining.toNumber()).toBeLessThan(100); + expect(resultState.amountCalculated.toNumber()).toBeLessThan(0); // Negative for output + + // Verify protocol fee was applied + expect(resultState.protocolFee.toNumber()).toBeGreaterThan(0); + + // Verify fee growth was updated + expect(resultState.feeGrowthGlobalX.toNumber()).toBeGreaterThan(0); + }); + + test("should handle swap with no liquidity", async () => { + // Given + const poolHash = "empty-pool-hash"; + const fee = DexFeePercentageTypes.FEE_0_3_PERCENT; + + // Create a pool with no liquidity + const pool = plainToInstance(Pool, { + token0: "GALA:Unit:none:none", + token1: "TEST:Unit:none:none", + fee, + sqrtPrice: new BigNumber("1"), + liquidity: new BigNumber("0"), // No liquidity + grossPoolLiquidity: new BigNumber("0"), + feeGrowthGlobal0: new BigNumber("0"), + feeGrowthGlobal1: new BigNumber("0"), + bitmap: {}, + tickSpacing: 60, + protocolFees: 0, + protocolFeesToken0: new BigNumber("0"), + protocolFeesToken1: new BigNumber("0") + }); + pool.genPoolHash = () => poolHash; + + // Create initial swap state + const initialState: SwapState = { + amountSpecifiedRemaining: new BigNumber("100"), + amountCalculated: new BigNumber("0"), + sqrtPrice: new BigNumber("1"), + tick: 0, + liquidity: new BigNumber("0"), + feeGrowthGlobalX: new BigNumber("0"), + protocolFee: new BigNumber("0") + }; + + const { ctx } = fixture(GalaChainContext); + + // When & Then + await expect( + processSwapSteps( + ctx, + initialState, + pool, + new BigNumber("0.9"), + true, + true + ) + ).rejects.toThrow("Not enough liquidity available in pool"); + }); + }); +}); \ No newline at end of file diff --git a/src/chaincode/dex/tickData.helper.spec.ts b/src/chaincode/dex/tickData.helper.spec.ts new file mode 100644 index 0000000..8d72f78 --- /dev/null +++ b/src/chaincode/dex/tickData.helper.spec.ts @@ -0,0 +1,171 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { GalaChainContext } from "@gala-chain/chaincode"; +import { fixture } from "@gala-chain/test"; +import BigNumber from "bignumber.js"; + +import { TickData } from "../../api"; +import { fetchOrCreateAndCrossTick, fetchOrCreateTickDataPair } from "./tickData.helper"; + +describe("tickData.helper", () => { + describe("fetchOrCreateAndCrossTick", () => { + test("should fetch existing tick and cross it", async () => { + // Given + const poolHash = "test-pool"; + const tick = 100; + const existingTickData = new TickData(poolHash, tick); + existingTickData.liquidityNet = new BigNumber("1000"); + existingTickData.liquidityGross = new BigNumber("1000"); + existingTickData.initialised = true; + existingTickData.feeGrowthOutside0 = new BigNumber("0"); + existingTickData.feeGrowthOutside1 = new BigNumber("0"); + + const { ctx } = fixture(GalaChainContext) + .savedState(existingTickData); + + const feeGrowthGlobal0 = new BigNumber("100"); + const feeGrowthGlobal1 = new BigNumber("50"); + + // When + const liquidityNet = await fetchOrCreateAndCrossTick( + ctx, + poolHash, + tick, + feeGrowthGlobal0, + feeGrowthGlobal1 + ); + + // Then + expect(liquidityNet).toBeDefined(); + expect(liquidityNet.toNumber()).toBe(1000); + }); + + test("should create new tick if not found", async () => { + // Given + const poolHash = "test-pool"; + const tick = 200; + + const { ctx } = fixture(GalaChainContext); + + const feeGrowthGlobal0 = new BigNumber("100"); + const feeGrowthGlobal1 = new BigNumber("50"); + + // When + const liquidityNet = await fetchOrCreateAndCrossTick( + ctx, + poolHash, + tick, + feeGrowthGlobal0, + feeGrowthGlobal1 + ); + + // Then + expect(liquidityNet).toBeDefined(); + expect(liquidityNet.toNumber()).toBe(0); // New tick has zero liquidity + }); + }); + + describe("fetchOrCreateTickDataPair", () => { + test("should fetch existing tick data pair", async () => { + // Given + const poolHash = "test-pool"; + const tickLower = -100; + const tickUpper = 100; + + const tickLowerData = new TickData(poolHash, tickLower); + tickLowerData.liquidityNet = new BigNumber("1000"); + tickLowerData.initialised = true; + + const tickUpperData = new TickData(poolHash, tickUpper); + tickUpperData.liquidityNet = new BigNumber("-1000"); + tickUpperData.initialised = true; + + const { ctx } = fixture(GalaChainContext) + .savedState(tickLowerData, tickUpperData); + + // When + const result = await fetchOrCreateTickDataPair( + ctx, + poolHash, + tickLower, + tickUpper + ); + + // Then + expect(result.tickLowerData).toBeDefined(); + expect(result.tickUpperData).toBeDefined(); + expect(result.tickLowerData.tick).toBe(tickLower); + expect(result.tickUpperData.tick).toBe(tickUpper); + expect(result.tickLowerData.liquidityNet.toNumber()).toBe(1000); + expect(result.tickUpperData.liquidityNet.toNumber()).toBe(-1000); + }); + + test("should create new tick data if not found", async () => { + // Given + const poolHash = "test-pool"; + const tickLower = -200; + const tickUpper = 200; + + const { ctx } = fixture(GalaChainContext); + + // When + const result = await fetchOrCreateTickDataPair( + ctx, + poolHash, + tickLower, + tickUpper + ); + + // Then + expect(result.tickLowerData).toBeDefined(); + expect(result.tickUpperData).toBeDefined(); + expect(result.tickLowerData.tick).toBe(tickLower); + expect(result.tickUpperData.tick).toBe(tickUpper); + expect(result.tickLowerData.initialised).toBe(false); + expect(result.tickUpperData.initialised).toBe(false); + expect(result.tickLowerData.liquidityNet.toNumber()).toBe(0); + expect(result.tickUpperData.liquidityNet.toNumber()).toBe(0); + }); + + test("should handle mixed case - one tick exists, one doesn't", async () => { + // Given + const poolHash = "test-pool"; + const tickLower = -300; + const tickUpper = 300; + + // Only lower tick exists + const tickLowerData = new TickData(poolHash, tickLower); + tickLowerData.liquidityNet = new BigNumber("500"); + tickLowerData.initialised = true; + + const { ctx } = fixture(GalaChainContext) + .savedState(tickLowerData); + + // When + const result = await fetchOrCreateTickDataPair( + ctx, + poolHash, + tickLower, + tickUpper + ); + + // Then + expect(result.tickLowerData.liquidityNet.toNumber()).toBe(500); + expect(result.tickLowerData.initialised).toBe(true); + expect(result.tickUpperData.liquidityNet.toNumber()).toBe(0); + expect(result.tickUpperData.initialised).toBe(false); + }); + }); +}); \ No newline at end of file From 46e1701d431fab966f3682e64f1325caaf3a499c Mon Sep 17 00:00:00 2001 From: Mike Graham Date: Fri, 1 Aug 2025 21:42:06 -0700 Subject: [PATCH 06/16] wip: testing swaps underlying functions --- src/api/utils/dex/computeSwapStep.spec.ts | 12 ++++++-- src/chaincode/dex/swap.helper.spec.ts | 34 +++++++++++++---------- src/chaincode/dex/tickData.helper.spec.ts | 12 ++++---- 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/src/api/utils/dex/computeSwapStep.spec.ts b/src/api/utils/dex/computeSwapStep.spec.ts index 72a0d64..9333d10 100644 --- a/src/api/utils/dex/computeSwapStep.spec.ts +++ b/src/api/utils/dex/computeSwapStep.spec.ts @@ -80,7 +80,7 @@ describe("computeSwapStep", () => { expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceTarget.toNumber()); }); - test("should handle swap that reaches target price", () => { + test("should handle swap with sufficient amount and liquidity", () => { // Given const sqrtPriceCurrent = new BigNumber("1"); const sqrtPriceTarget = new BigNumber("0.99"); // Small price movement @@ -98,8 +98,14 @@ describe("computeSwapStep", () => { ); // Then - // Should reach target price with large amount and liquidity - expect(sqrtPriceNext.toNumber()).toBe(sqrtPriceTarget.toNumber()); + // Should move price in correct direction toward target + expect(sqrtPriceNext.toNumber()).toBeLessThan(sqrtPriceCurrent.toNumber()); + expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceTarget.toNumber()); + expect(amountIn.toNumber()).toBeGreaterThan(0); + expect(amountOut.toNumber()).toBeGreaterThan(0); + + // With large liquidity, should consume the full amount specified + expect(amountIn.toNumber()).toBeCloseTo(997, 0); // After 0.3% fee }); test("should handle zero liquidity edge case", () => { diff --git a/src/chaincode/dex/swap.helper.spec.ts b/src/chaincode/dex/swap.helper.spec.ts index 97441b1..b8e11b1 100644 --- a/src/chaincode/dex/swap.helper.spec.ts +++ b/src/chaincode/dex/swap.helper.spec.ts @@ -12,7 +12,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { GalaChainContext } from "@gala-chain/chaincode"; import { fixture } from "@gala-chain/test"; import BigNumber from "bignumber.js"; import { plainToInstance } from "class-transformer"; @@ -24,6 +23,7 @@ import { TickData, sqrtPriceToTick } from "../../api"; +import { DexV3Contract } from "../DexV3Contract"; import { processSwapSteps } from "./swap.helper"; describe("swap.helper", () => { @@ -71,7 +71,7 @@ describe("swap.helper", () => { tickData.initialised = true; // Setup fixture - const { ctx } = fixture(GalaChainContext) + const { ctx } = fixture(DexV3Contract) .savedState(tickData); // Set up parameters @@ -106,7 +106,7 @@ describe("swap.helper", () => { expect(resultState.feeGrowthGlobalX.toNumber()).toBeGreaterThan(0); }); - test("should handle swap with no liquidity", async () => { + test("should handle swap with no liquidity gracefully", async () => { // Given const poolHash = "empty-pool-hash"; const fee = DexFeePercentageTypes.FEE_0_3_PERCENT; @@ -140,19 +140,23 @@ describe("swap.helper", () => { protocolFee: new BigNumber("0") }; - const { ctx } = fixture(GalaChainContext); + const { ctx } = fixture(DexV3Contract); - // When & Then - await expect( - processSwapSteps( - ctx, - initialState, - pool, - new BigNumber("0.9"), - true, - true - ) - ).rejects.toThrow("Not enough liquidity available in pool"); + // When + const resultState = await processSwapSteps( + ctx, + initialState, + pool, + new BigNumber("0.9"), + true, + true + ); + + // Then + // With no liquidity, the swap should hit the price limit without swapping + expect(resultState.sqrtPrice.toNumber()).toBe(0.9); // Hit price limit + expect(resultState.amountSpecifiedRemaining.toNumber()).toBe(100); // No amount consumed + expect(resultState.amountCalculated.toNumber()).toBe(0); // No output }); }); }); \ No newline at end of file diff --git a/src/chaincode/dex/tickData.helper.spec.ts b/src/chaincode/dex/tickData.helper.spec.ts index 8d72f78..1e337ed 100644 --- a/src/chaincode/dex/tickData.helper.spec.ts +++ b/src/chaincode/dex/tickData.helper.spec.ts @@ -12,11 +12,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { GalaChainContext } from "@gala-chain/chaincode"; import { fixture } from "@gala-chain/test"; import BigNumber from "bignumber.js"; import { TickData } from "../../api"; +import { DexV3Contract } from "../DexV3Contract"; import { fetchOrCreateAndCrossTick, fetchOrCreateTickDataPair } from "./tickData.helper"; describe("tickData.helper", () => { @@ -32,7 +32,7 @@ describe("tickData.helper", () => { existingTickData.feeGrowthOutside0 = new BigNumber("0"); existingTickData.feeGrowthOutside1 = new BigNumber("0"); - const { ctx } = fixture(GalaChainContext) + const { ctx } = fixture(DexV3Contract) .savedState(existingTickData); const feeGrowthGlobal0 = new BigNumber("100"); @@ -57,7 +57,7 @@ describe("tickData.helper", () => { const poolHash = "test-pool"; const tick = 200; - const { ctx } = fixture(GalaChainContext); + const { ctx } = fixture(DexV3Contract); const feeGrowthGlobal0 = new BigNumber("100"); const feeGrowthGlobal1 = new BigNumber("50"); @@ -92,7 +92,7 @@ describe("tickData.helper", () => { tickUpperData.liquidityNet = new BigNumber("-1000"); tickUpperData.initialised = true; - const { ctx } = fixture(GalaChainContext) + const { ctx } = fixture(DexV3Contract) .savedState(tickLowerData, tickUpperData); // When @@ -118,7 +118,7 @@ describe("tickData.helper", () => { const tickLower = -200; const tickUpper = 200; - const { ctx } = fixture(GalaChainContext); + const { ctx } = fixture(DexV3Contract); // When const result = await fetchOrCreateTickDataPair( @@ -150,7 +150,7 @@ describe("tickData.helper", () => { tickLowerData.liquidityNet = new BigNumber("500"); tickLowerData.initialised = true; - const { ctx } = fixture(GalaChainContext) + const { ctx } = fixture(DexV3Contract) .savedState(tickLowerData); // When From b953beffb395a34016867154f2b0c0148db474d8 Mon Sep 17 00:00:00 2001 From: Mike Graham Date: Fri, 1 Aug 2025 21:56:33 -0700 Subject: [PATCH 07/16] test: additional coverage on swap related helper functions --- src/api/utils/dex/computeSwapStep.spec.ts | 115 ++++++++++++++++++ src/chaincode/dex/swap.helper.spec.ts | 136 ++++++++++++++++++++++ src/chaincode/dex/tickData.helper.spec.ts | 135 +++++++++++++++++++++ 3 files changed, 386 insertions(+) diff --git a/src/api/utils/dex/computeSwapStep.spec.ts b/src/api/utils/dex/computeSwapStep.spec.ts index 9333d10..0a938e1 100644 --- a/src/api/utils/dex/computeSwapStep.spec.ts +++ b/src/api/utils/dex/computeSwapStep.spec.ts @@ -132,4 +132,119 @@ describe("computeSwapStep", () => { expect(amountOut.toNumber()).toBe(0); expect(feeAmount.toNumber()).toBe(0); }); + + test("should compute swap at negative tick prices", () => { + // Given - prices less than 1:1 represent negative ticks + const sqrtPriceCurrent = new BigNumber("0.5"); // Negative tick + const sqrtPriceTarget = new BigNumber("0.4"); // Even more negative + const liquidity = new BigNumber("2000000"); + const amountRemaining = new BigNumber("500"); + const fee = 3000; + + // When + const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( + sqrtPriceCurrent, + sqrtPriceTarget, + liquidity, + amountRemaining, + fee + ); + + // Then + expect(sqrtPriceNext).toBeDefined(); + expect(amountIn.toNumber()).toBeGreaterThan(0); + expect(amountOut.toNumber()).toBeGreaterThan(0); + expect(feeAmount.toNumber()).toBeGreaterThan(0); + + // Price should move towards target + expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceCurrent.toNumber()); + expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceTarget.toNumber()); + }); + + test("should handle swap crossing from negative to positive price", () => { + // Given - crossing from price < 1 to price > 1 + const sqrtPriceCurrent = new BigNumber("0.8"); // Negative tick + const sqrtPriceTarget = new BigNumber("1.2"); // Positive tick + const liquidity = new BigNumber("5000000"); + const amountRemaining = new BigNumber("1000"); + const fee = 500; // 0.05% fee + + // When + const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( + sqrtPriceCurrent, + sqrtPriceTarget, + liquidity, + amountRemaining, + fee + ); + + // Then + expect(sqrtPriceNext).toBeDefined(); + expect(amountIn.toNumber()).toBeGreaterThan(0); + expect(amountOut.toNumber()).toBeGreaterThan(0); + + // Price should move towards target (upward in this case) + expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceCurrent.toNumber()); + expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceTarget.toNumber()); + + // Fee should be proportional to input + expect(feeAmount.toNumber()).toBeCloseTo(amountIn.toNumber() * 0.0005, 2); + }); + + test("should handle exact output swap at negative ticks", () => { + // Given - negative amount for exact output, negative tick prices + const sqrtPriceCurrent = new BigNumber("0.6"); + const sqrtPriceTarget = new BigNumber("0.7"); + const liquidity = new BigNumber("3000000"); + const amountRemaining = new BigNumber("-250"); // Negative for exact output + const fee = 10000; // 1% fee + + // When + const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( + sqrtPriceCurrent, + sqrtPriceTarget, + liquidity, + amountRemaining, + fee + ); + + // Then + expect(amountIn.toNumber()).toBeGreaterThan(0); + expect(amountOut.toNumber()).toBeGreaterThan(0); + expect(feeAmount.toNumber()).toBeGreaterThan(0); + + // For exact output, output should not exceed requested amount + expect(amountOut.toNumber()).toBeLessThanOrEqual(250); + + // Price should move in correct direction + expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceCurrent.toNumber()); + expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceTarget.toNumber()); + }); + + test("should handle very small negative tick prices", () => { + // Given - very small prices representing very negative ticks + const sqrtPriceCurrent = new BigNumber("0.001"); // Very negative tick + const sqrtPriceTarget = new BigNumber("0.0009"); // Even more negative + const liquidity = new BigNumber("10000000"); + const amountRemaining = new BigNumber("10000"); + const fee = 3000; + + // When + const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( + sqrtPriceCurrent, + sqrtPriceTarget, + liquidity, + amountRemaining, + fee + ); + + // Then + expect(sqrtPriceNext).toBeDefined(); + expect(amountIn.toNumber()).toBeGreaterThan(0); + expect(amountOut.toNumber()).toBeGreaterThan(0); + + // Verify price moves correctly at extreme values + expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceCurrent.toNumber()); + expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceTarget.toNumber()); + }); }); \ No newline at end of file diff --git a/src/chaincode/dex/swap.helper.spec.ts b/src/chaincode/dex/swap.helper.spec.ts index b8e11b1..dabff98 100644 --- a/src/chaincode/dex/swap.helper.spec.ts +++ b/src/chaincode/dex/swap.helper.spec.ts @@ -158,5 +158,141 @@ describe("swap.helper", () => { expect(resultState.amountSpecifiedRemaining.toNumber()).toBe(100); // No amount consumed expect(resultState.amountCalculated.toNumber()).toBe(0); // No output }); + + test("should process swap starting from negative tick", async () => { + // Given + const poolHash = "negative-tick-pool"; + const fee = DexFeePercentageTypes.FEE_0_3_PERCENT; + + // Create a pool with price < 1 (negative tick) + const pool = plainToInstance(Pool, { + token0: "GALA:Unit:none:none", + token1: "TEST:Unit:none:none", + fee, + sqrtPrice: new BigNumber("0.5"), // Price < 1 means negative tick + liquidity: new BigNumber("2000000"), + grossPoolLiquidity: new BigNumber("2000000"), + feeGrowthGlobal0: new BigNumber("0"), + feeGrowthGlobal1: new BigNumber("0"), + bitmap: { + "-1": "1", // Negative tick initialized + }, + tickSpacing: 60, + protocolFees: 0.05, + protocolFeesToken0: new BigNumber("0"), + protocolFeesToken1: new BigNumber("0") + }); + pool.genPoolHash = () => poolHash; + + // Create tick data for negative tick + const negativeTickData = new TickData(poolHash, -6932); // Approximate tick for sqrtPrice 0.5 + negativeTickData.liquidityNet = new BigNumber("0"); + negativeTickData.liquidityGross = new BigNumber("2000000"); + negativeTickData.initialised = true; + + const { ctx } = fixture(DexV3Contract) + .savedState(negativeTickData); + + const initialState: SwapState = { + amountSpecifiedRemaining: new BigNumber("200"), + amountCalculated: new BigNumber("0"), + sqrtPrice: new BigNumber("0.5"), + tick: sqrtPriceToTick(new BigNumber("0.5")), + liquidity: new BigNumber("2000000"), + feeGrowthGlobalX: new BigNumber("0"), + protocolFee: new BigNumber("0") + }; + + // When - swap to even lower price + const resultState = await processSwapSteps( + ctx, + initialState, + pool, + new BigNumber("0.4"), // Target lower price + true, + true + ); + + // Then + expect(resultState).toBeDefined(); + expect(resultState.sqrtPrice.toNumber()).toBeLessThan(0.5); + expect(resultState.sqrtPrice.toNumber()).toBeGreaterThanOrEqual(0.4); + expect(resultState.amountSpecifiedRemaining.toNumber()).toBeLessThan(200); + expect(resultState.amountCalculated.toNumber()).toBeLessThan(0); + }); + + test("should handle swap crossing from negative to positive ticks", async () => { + // Given + const poolHash = "crossing-zero-pool"; + const fee = DexFeePercentageTypes.FEE_0_05_PERCENT; // 5 bps fee + + // Start at negative tick, will cross to positive + const pool = plainToInstance(Pool, { + token0: "GALA:Unit:none:none", + token1: "TEST:Unit:none:none", + fee, + sqrtPrice: new BigNumber("0.8"), // Negative tick + liquidity: new BigNumber("5000000"), + grossPoolLiquidity: new BigNumber("5000000"), + feeGrowthGlobal0: new BigNumber("0"), + feeGrowthGlobal1: new BigNumber("0"), + bitmap: { + "-1": "3", // Ticks -10 and 0 initialized (binary 11) + "0": "1", // Tick 10 initialized + }, + tickSpacing: 10, // For 0.05% fee + protocolFees: 0.1, + protocolFeesToken0: new BigNumber("0"), + protocolFeesToken1: new BigNumber("0") + }); + pool.genPoolHash = () => poolHash; + + // Create tick data at key crossing points + const tickNeg10 = new TickData(poolHash, -10); + tickNeg10.liquidityNet = new BigNumber("1000000"); + tickNeg10.liquidityGross = new BigNumber("1000000"); + tickNeg10.initialised = true; + + const tick0 = new TickData(poolHash, 0); + tick0.liquidityNet = new BigNumber("-500000"); + tick0.liquidityGross = new BigNumber("500000"); + tick0.initialised = true; + + const tick10 = new TickData(poolHash, 10); + tick10.liquidityNet = new BigNumber("-500000"); + tick10.liquidityGross = new BigNumber("500000"); + tick10.initialised = true; + + const { ctx } = fixture(DexV3Contract) + .savedState(tickNeg10, tick0, tick10); + + const initialState: SwapState = { + amountSpecifiedRemaining: new BigNumber("1000"), + amountCalculated: new BigNumber("0"), + sqrtPrice: new BigNumber("0.8"), + tick: sqrtPriceToTick(new BigNumber("0.8")), + liquidity: new BigNumber("5000000"), + feeGrowthGlobalX: new BigNumber("0"), + protocolFee: new BigNumber("0") + }; + + // When - swap to positive tick range + const resultState = await processSwapSteps( + ctx, + initialState, + pool, + new BigNumber("1.2"), // Target price > 1 (positive tick) + true, + false // zeroForOne = false to increase price + ); + + // Then + expect(resultState).toBeDefined(); + expect(resultState.sqrtPrice.toNumber()).toBeGreaterThan(0.8); + expect(resultState.sqrtPrice.toNumber()).toBeLessThanOrEqual(1.2); + // Verify swap occurred + expect(resultState.amountSpecifiedRemaining.toNumber()).toBeLessThan(1000); + expect(resultState.amountCalculated.toNumber()).toBeLessThan(0); + }); }); }); \ No newline at end of file diff --git a/src/chaincode/dex/tickData.helper.spec.ts b/src/chaincode/dex/tickData.helper.spec.ts index 1e337ed..27f8a36 100644 --- a/src/chaincode/dex/tickData.helper.spec.ts +++ b/src/chaincode/dex/tickData.helper.spec.ts @@ -167,5 +167,140 @@ describe("tickData.helper", () => { expect(result.tickUpperData.liquidityNet.toNumber()).toBe(0); expect(result.tickUpperData.initialised).toBe(false); }); + + test("should handle negative tick ranges correctly", async () => { + // Given + const poolHash = "test-pool"; + const tickLower = -1000; + const tickUpper = -500; + + const tickLowerData = new TickData(poolHash, tickLower); + tickLowerData.liquidityNet = new BigNumber("2000"); + tickLowerData.liquidityGross = new BigNumber("2000"); + tickLowerData.initialised = true; + + const tickUpperData = new TickData(poolHash, tickUpper); + tickUpperData.liquidityNet = new BigNumber("-2000"); + tickUpperData.liquidityGross = new BigNumber("2000"); + tickUpperData.initialised = true; + + const { ctx } = fixture(DexV3Contract) + .savedState(tickLowerData, tickUpperData); + + // When + const result = await fetchOrCreateTickDataPair( + ctx, + poolHash, + tickLower, + tickUpper + ); + + // Then + expect(result.tickLowerData.tick).toBe(-1000); + expect(result.tickUpperData.tick).toBe(-500); + expect(result.tickLowerData.liquidityNet.toNumber()).toBe(2000); + expect(result.tickUpperData.liquidityNet.toNumber()).toBe(-2000); + }); + + test("should handle range crossing zero (negative to positive)", async () => { + // Given + const poolHash = "test-pool"; + const tickLower = -600; + const tickUpper = 600; + + const tickLowerData = new TickData(poolHash, tickLower); + tickLowerData.liquidityNet = new BigNumber("1500"); + tickLowerData.liquidityGross = new BigNumber("1500"); + tickLowerData.initialised = true; + tickLowerData.feeGrowthOutside0 = new BigNumber("10"); + tickLowerData.feeGrowthOutside1 = new BigNumber("5"); + + const tickUpperData = new TickData(poolHash, tickUpper); + tickUpperData.liquidityNet = new BigNumber("-1500"); + tickUpperData.liquidityGross = new BigNumber("1500"); + tickUpperData.initialised = true; + tickUpperData.feeGrowthOutside0 = new BigNumber("20"); + tickUpperData.feeGrowthOutside1 = new BigNumber("10"); + + const { ctx } = fixture(DexV3Contract) + .savedState(tickLowerData, tickUpperData); + + // When + const result = await fetchOrCreateTickDataPair( + ctx, + poolHash, + tickLower, + tickUpper + ); + + // Then + expect(result.tickLowerData.tick).toBe(-600); + expect(result.tickUpperData.tick).toBe(600); + expect(result.tickLowerData.feeGrowthOutside0.toNumber()).toBe(10); + expect(result.tickUpperData.feeGrowthOutside1.toNumber()).toBe(10); + }); + }); + + describe("fetchOrCreateAndCrossTick with negative ticks", () => { + test("should handle crossing negative tick", async () => { + // Given + const poolHash = "test-pool"; + const tick = -1200; + const existingTickData = new TickData(poolHash, tick); + existingTickData.liquidityNet = new BigNumber("3000"); + existingTickData.liquidityGross = new BigNumber("3000"); + existingTickData.initialised = true; + existingTickData.feeGrowthOutside0 = new BigNumber("0"); + existingTickData.feeGrowthOutside1 = new BigNumber("0"); + + const { ctx } = fixture(DexV3Contract) + .savedState(existingTickData); + + const feeGrowthGlobal0 = new BigNumber("150"); + const feeGrowthGlobal1 = new BigNumber("75"); + + // When + const liquidityNet = await fetchOrCreateAndCrossTick( + ctx, + poolHash, + tick, + feeGrowthGlobal0, + feeGrowthGlobal1 + ); + + // Then + expect(liquidityNet).toBeDefined(); + expect(liquidityNet.toNumber()).toBe(3000); + + // Verify tick was updated with fee growth + const updatedTick = await ctx.stub.getState( + ctx.stub.createCompositeKey("TICK", [poolHash, tick.toString()]) + ); + expect(updatedTick).toBeDefined(); + }); + + test("should create and cross very negative tick", async () => { + // Given + const poolHash = "test-pool"; + const tick = -887272; // Near min tick for common tick spacing + + const { ctx } = fixture(DexV3Contract); + + const feeGrowthGlobal0 = new BigNumber("1000000"); + const feeGrowthGlobal1 = new BigNumber("500000"); + + // When + const liquidityNet = await fetchOrCreateAndCrossTick( + ctx, + poolHash, + tick, + feeGrowthGlobal0, + feeGrowthGlobal1 + ); + + // Then + expect(liquidityNet).toBeDefined(); + expect(liquidityNet.toNumber()).toBe(0); // New tick has zero liquidity + }); }); }); \ No newline at end of file From 70d498f730bc56647108245fc97f4fb8408935d4 Mon Sep 17 00:00:00 2001 From: Mike Graham Date: Sat, 2 Aug 2025 11:30:45 -0700 Subject: [PATCH 08/16] fix: tick calculation wrong direction --- src/chaincode/dex/swap.helper.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/chaincode/dex/swap.helper.ts b/src/chaincode/dex/swap.helper.ts index 0409fb8..488f702 100644 --- a/src/chaincode/dex/swap.helper.ts +++ b/src/chaincode/dex/swap.helper.ts @@ -89,13 +89,22 @@ export async function processSwapSteps( // Compute the sqrt price for the next tick step.sqrtPriceNext = tickToSqrtPrice(step.tickNext); + // Check if the next tick is actually in the wrong direction + // This can happen when we're at extreme prices and all initialized ticks are on the wrong side + const isWrongDirection = zeroForOne + ? step.sqrtPriceNext.isGreaterThan(state.sqrtPrice) // Should go down but would go up + : step.sqrtPriceNext.isLessThan(state.sqrtPrice); // Should go up but would go down + // Compute the result of the swap step based on price movement [state.sqrtPrice, step.amountIn, step.amountOut, step.feeAmount] = computeSwapStep( state.sqrtPrice, ( - zeroForOne - ? step.sqrtPriceNext.isLessThan(sqrtPriceLimit) - : step.sqrtPriceNext.isGreaterThan(sqrtPriceLimit) + isWrongDirection || + ( + zeroForOne + ? step.sqrtPriceNext.isLessThan(sqrtPriceLimit) + : step.sqrtPriceNext.isGreaterThan(sqrtPriceLimit) + ) ) ? sqrtPriceLimit : step.sqrtPriceNext, From 5bedcda4259696710fa7544a98ee58fc43caf1d9 Mon Sep 17 00:00:00 2001 From: Mike Graham Date: Sat, 2 Aug 2025 21:33:52 -0700 Subject: [PATCH 09/16] test: swap compute steps zeroForOne --- jest.config.ts | 2 +- src/api/types/DexDtos.spec.ts | 317 ++++++++++++++++++++++ src/api/utils/dex/computeSwapStep.spec.ts | 36 ++- src/api/utils/dex/swapMath.helper.spec.ts | 20 +- src/api/utils/dex/swapMath.helper.ts | 18 +- src/api/utils/dex/tickToSqrtPrice.spec.ts | 308 +++++++++++++++++++++ src/chaincode/dex/swap.helper.ts | 18 +- src/chaincode/dex/swap.spec.ts | 154 ++++++++--- tsconfig.json | 2 +- 9 files changed, 794 insertions(+), 81 deletions(-) create mode 100644 src/api/utils/dex/tickToSqrtPrice.spec.ts diff --git a/jest.config.ts b/jest.config.ts index 9bd640f..e0a2942 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -23,5 +23,5 @@ export default { "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }] }, moduleFileExtensions: ["ts", "js"], - modulePathIgnorePatterns: ["lib", "e2e"] + modulePathIgnorePatterns: ["lib", "e2e", "ctx", "ext"] }; diff --git a/src/api/types/DexDtos.spec.ts b/src/api/types/DexDtos.spec.ts index 421dd3d..55e9bcb 100644 --- a/src/api/types/DexDtos.spec.ts +++ b/src/api/types/DexDtos.spec.ts @@ -21,6 +21,7 @@ import { plainToInstance } from "class-transformer"; import { AddLiquidityDTO, BurnDto, + BurnEstimateDto, CollectDto, CollectProtocolFeesDto, CreatePoolDto, @@ -29,6 +30,8 @@ import { GetPoolDto, GetPositionDto, GetUserPositionsDto, + GetUserPositionsResDto, + IPosition, PlaceLimitOrderDto, PositionDto, QuoteExactAmountDto, @@ -511,6 +514,56 @@ describe("DexDtos", () => { expect(dto.owner).toBe("owner-address"); expect(dto.positionId).toBe("position-456"); }); + + it("should create valid GetPositionDto with optional positionId undefined", async () => { + // Given + const dto = new GetPositionDto( + mockToken0, + mockToken1, + DexFeePercentageTypes.FEE_0_3_PERCENT, + -500, + 1000, + "test-owner", + undefined + ); + + // When + const validationErrors = await dto.validate(); + + // Then + expect(validationErrors.length).toBe(0); + expect(dto.owner).toBe("test-owner"); + expect(dto.positionId).toBeUndefined(); + }); + + it("should serialize GetPositionDto correctly with undefined positionId", async () => { + // Given + const testUser = ChainUser.withRandomKeys("get-position-test-user"); + const dto = new GetPositionDto( + mockToken0, + mockToken1, + DexFeePercentageTypes.FEE_1_PERCENT, + -100, + 300, + "serialization-test-owner", + undefined + ); + + // When + const validationErrors = await dto.validate(); + dto.sign(testUser.privateKey); + + // Then + expect(validationErrors.length).toBe(0); + expect(dto.positionId).toBeUndefined(); + expect(dto.signature).toBeDefined(); + expect(dto.signature?.length).toBeGreaterThanOrEqual(128); + expect(dto.isSignatureValid(testUser.publicKey)).toBe(true); + + // Verify signature recovery with undefined optional property + const recoveredPublicKey = signatures.recoverPublicKey(dto.signature!, dto); + expect(recoveredPublicKey).toBe(testUser.publicKey); + }); }); describe("GetUserPositionsDto", () => { @@ -548,6 +601,70 @@ describe("DexDtos", () => { // Then expect(validationErrors.length).toBeGreaterThan(0); }); + + it("should create valid GetUserPositionsDto with bookmark undefined", async () => { + // Given + const dto = new GetUserPositionsDto(mockUserRef, undefined, 7); + + // When + const validationErrors = await dto.validate(); + + // Then + expect(validationErrors.length).toBe(0); + expect(dto.bookmark).toBeUndefined(); + expect(dto.limit).toBe(7); + expect(dto.user).toBe(mockUserRef); + }); + + it("should serialize GetUserPositionsDto correctly with undefined bookmark", async () => { + // Given + const testUser = ChainUser.withRandomKeys("get-user-positions-test-user"); + const dto = new GetUserPositionsDto( + asValidUserRef("client|get-user-positions-test"), + undefined, // bookmark omitted + 3 + ); + + // When + const validationErrors = await dto.validate(); + dto.sign(testUser.privateKey); + + // Then + expect(validationErrors.length).toBe(0); + expect(dto.bookmark).toBeUndefined(); + expect(dto.signature).toBeDefined(); + expect(dto.signature?.length).toBeGreaterThanOrEqual(128); + expect(dto.isSignatureValid(testUser.publicKey)).toBe(true); + + // Verify signature recovery with undefined optional property + const recoveredPublicKey = signatures.recoverPublicKey(dto.signature!, dto); + expect(recoveredPublicKey).toBe(testUser.publicKey); + }); + + it("should create GetUserPositionsDto with bookmark defined and verify serialization", async () => { + // Given + const testUser = ChainUser.withRandomKeys("get-user-positions-bookmark-test"); + const dto = new GetUserPositionsDto( + asValidUserRef("client|bookmark-test"), + "test-bookmark-abc123", // bookmark provided + 8 + ); + + // When + const validationErrors = await dto.validate(); + dto.sign(testUser.privateKey); + + // Then + expect(validationErrors.length).toBe(0); + expect(dto.bookmark).toBe("test-bookmark-abc123"); + expect(dto.signature).toBeDefined(); + expect(dto.signature?.length).toBeGreaterThanOrEqual(128); + expect(dto.isSignatureValid(testUser.publicKey)).toBe(true); + + // Verify signature recovery with defined optional property + const recoveredPublicKey = signatures.recoverPublicKey(dto.signature!, dto); + expect(recoveredPublicKey).toBe(testUser.publicKey); + }); }); describe("GetAddLiquidityEstimationDto", () => { @@ -677,6 +794,206 @@ describe("DexDtos", () => { }); }); + describe("GetUserPositionsResDto", () => { + const mockPosition: IPosition = { + poolHash: "test-pool-hash", + tickUpper: 1000, + tickLower: -1000, + liquidity: "500000", + positionId: "test-position-123", + token0Img: "https://example.com/token0.png", + token1Img: "https://example.com/token1.png", + token0ClassKey: mockToken0, + token1ClassKey: mockToken1, + fee: DexFeePercentageTypes.FEE_0_3_PERCENT, + token0Symbol: "GALA", + token1Symbol: "TOWN" + }; + + it("should create valid GetUserPositionsResDto with constructor", async () => { + // Given + const positions = [mockPosition]; + const dto = new GetUserPositionsResDto(positions, "next-bookmark-456"); + + // When + const validationErrors = await dto.validate(); + + // Then + expect(validationErrors.length).toBe(0); + expect(dto.positions).toHaveLength(1); + expect(dto.positions[0]).toBe(mockPosition); + expect(dto.nextBookMark).toBe("next-bookmark-456"); + }); + + it("should create valid GetUserPositionsResDto with undefined nextBookMark", async () => { + // Given + const positions = [mockPosition]; + // Note: The constructor signature shows nextBookMark as required, but the property is optional + // This suggests there might be a mismatch. Let me test both scenarios. + const dto = new GetUserPositionsResDto(positions, undefined as any); + + // When + const validationErrors = await dto.validate(); + + // Then + expect(validationErrors.length).toBe(0); + expect(dto.positions).toHaveLength(1); + expect(dto.nextBookMark).toBeUndefined(); + }); + + it("should create GetUserPositionsResDto with empty positions array", async () => { + // Given + const dto = new GetUserPositionsResDto([], "bookmark-empty-results"); + + // When + const validationErrors = await dto.validate(); + + // Then + expect(validationErrors.length).toBe(0); + expect(dto.positions).toHaveLength(0); + expect(dto.nextBookMark).toBe("bookmark-empty-results"); + }); + + it("should serialize GetUserPositionsResDto correctly with undefined nextBookMark", async () => { + // Given + const testUser = ChainUser.withRandomKeys("get-user-positions-res-test"); + const positions = [mockPosition]; + const dto = new GetUserPositionsResDto(positions, undefined as any); + + // When + const validationErrors = await dto.validate(); + dto.sign(testUser.privateKey); + + // Then + expect(validationErrors.length).toBe(0); + expect(dto.nextBookMark).toBeUndefined(); + expect(dto.signature).toBeDefined(); + expect(dto.signature?.length).toBeGreaterThanOrEqual(128); + expect(dto.isSignatureValid(testUser.publicKey)).toBe(true); + + // Verify signature recovery with undefined optional property + const recoveredPublicKey = signatures.recoverPublicKey(dto.signature!, dto); + expect(recoveredPublicKey).toBe(testUser.publicKey); + }); + + it("should handle multiple positions with nextBookMark", async () => { + // Given + const position2: IPosition = { + poolHash: "test-pool-hash-2", + tickUpper: 2000, + tickLower: -500, + liquidity: "750000", + positionId: "test-position-456" + }; + const positions = [mockPosition, position2]; + const dto = new GetUserPositionsResDto(positions, "has-more-results-bookmark"); + + // When + const validationErrors = await dto.validate(); + + // Then + expect(validationErrors.length).toBe(0); + expect(dto.positions).toHaveLength(2); + expect(dto.nextBookMark).toBe("has-more-results-bookmark"); + }); + }); + + describe("BurnEstimateDto", () => { + it("should create valid BurnEstimateDto with constructor", async () => { + // Given + const dto = new BurnEstimateDto( + mockToken0, + mockToken1, + DexFeePercentageTypes.FEE_0_3_PERCENT, + new BigNumber("1000"), + -200, + 200, + mockUserRef, + "burn-estimate-position-123" + ); + + // When + const validationErrors = await dto.validate(); + + // Then + expect(validationErrors.length).toBe(0); + expect(dto.amount.toString()).toBe("1000"); + expect(dto.positionId).toBe("burn-estimate-position-123"); + }); + + it("should create valid BurnEstimateDto with optional positionId undefined", async () => { + // Given + const dto = new BurnEstimateDto( + mockToken0, + mockToken1, + DexFeePercentageTypes.FEE_1_PERCENT, + new BigNumber("2500"), + -1000, + 500, + mockUserRef, + undefined + ); + + // When + const validationErrors = await dto.validate(); + + // Then + expect(validationErrors.length).toBe(0); + expect(dto.amount.toString()).toBe("2500"); + expect(dto.positionId).toBeUndefined(); + }); + + it("should serialize BurnEstimateDto correctly with undefined positionId", async () => { + // Given + const testUser = ChainUser.withRandomKeys("burn-estimate-test-user"); + const dto = new BurnEstimateDto( + mockToken0, + mockToken1, + DexFeePercentageTypes.FEE_0_05_PERCENT, + new BigNumber("750"), + -300, + 400, + asValidUserRef("client|burn-estimate-test"), + undefined + ); + + // When + const validationErrors = await dto.validate(); + dto.sign(testUser.privateKey); + + // Then + expect(validationErrors.length).toBe(0); + expect(dto.positionId).toBeUndefined(); + expect(dto.signature).toBeDefined(); + expect(dto.signature?.length).toBeGreaterThanOrEqual(128); + expect(dto.isSignatureValid(testUser.publicKey)).toBe(true); + + // Verify signature recovery with undefined optional property + const recoveredPublicKey = signatures.recoverPublicKey(dto.signature!, dto); + expect(recoveredPublicKey).toBe(testUser.publicKey); + }); + + it("should fail validation with negative amount", async () => { + // Given + const dto = new BurnEstimateDto( + mockToken0, + mockToken1, + DexFeePercentageTypes.FEE_0_3_PERCENT, + new BigNumber("-100"), // Negative amount should fail + -100, + 100, + mockUserRef, + undefined + ); + + // When + const validationErrors = await dto.validate(); + + // Then + expect(validationErrors.length).toBeGreaterThan(0); + }); + }); + describe("PlaceLimitOrderDto", () => { it("should create valid PlaceLimitOrderDto with constructor", async () => { // Given diff --git a/src/api/utils/dex/computeSwapStep.spec.ts b/src/api/utils/dex/computeSwapStep.spec.ts index 0a938e1..eec7a7e 100644 --- a/src/api/utils/dex/computeSwapStep.spec.ts +++ b/src/api/utils/dex/computeSwapStep.spec.ts @@ -24,14 +24,16 @@ describe("computeSwapStep", () => { const liquidity = new BigNumber("1000000"); // 1M liquidity const amountRemaining = new BigNumber("100"); // 100 tokens remaining to swap const fee = 3000; // 0.3% fee - + const zeroForOne = false; + // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( sqrtPriceCurrent, sqrtPriceTarget, liquidity, amountRemaining, - fee + fee, + zeroForOne ); // Then @@ -60,6 +62,7 @@ describe("computeSwapStep", () => { const liquidity = new BigNumber("1000000"); const amountRemaining = new BigNumber("-50"); // Negative for exact output const fee = 3000; + const zeroForOne = true; // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( @@ -67,7 +70,8 @@ describe("computeSwapStep", () => { sqrtPriceTarget, liquidity, amountRemaining, - fee + fee, + zeroForOne ); // Then @@ -87,6 +91,7 @@ describe("computeSwapStep", () => { const liquidity = new BigNumber("10000000"); // Large liquidity const amountRemaining = new BigNumber("1000"); // Large amount to swap const fee = 3000; + const zeroForOne = true; // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( @@ -94,7 +99,8 @@ describe("computeSwapStep", () => { sqrtPriceTarget, liquidity, amountRemaining, - fee + fee, + zeroForOne ); // Then @@ -115,6 +121,7 @@ describe("computeSwapStep", () => { const liquidity = new BigNumber("0"); // No liquidity const amountRemaining = new BigNumber("100"); const fee = 3000; + const zeroForOne = true; // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( @@ -122,7 +129,8 @@ describe("computeSwapStep", () => { sqrtPriceTarget, liquidity, amountRemaining, - fee + fee, + zeroForOne ); // Then @@ -140,6 +148,7 @@ describe("computeSwapStep", () => { const liquidity = new BigNumber("2000000"); const amountRemaining = new BigNumber("500"); const fee = 3000; + const zeroForOne = true; // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( @@ -147,7 +156,8 @@ describe("computeSwapStep", () => { sqrtPriceTarget, liquidity, amountRemaining, - fee + fee, + zeroForOne ); // Then @@ -168,6 +178,7 @@ describe("computeSwapStep", () => { const liquidity = new BigNumber("5000000"); const amountRemaining = new BigNumber("1000"); const fee = 500; // 0.05% fee + const zeroForOne = true; // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( @@ -175,7 +186,8 @@ describe("computeSwapStep", () => { sqrtPriceTarget, liquidity, amountRemaining, - fee + fee, + zeroForOne ); // Then @@ -198,14 +210,16 @@ describe("computeSwapStep", () => { const liquidity = new BigNumber("3000000"); const amountRemaining = new BigNumber("-250"); // Negative for exact output const fee = 10000; // 1% fee - + const zeroForOne = true; + // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( sqrtPriceCurrent, sqrtPriceTarget, liquidity, amountRemaining, - fee + fee, + zeroForOne ); // Then @@ -228,6 +242,7 @@ describe("computeSwapStep", () => { const liquidity = new BigNumber("10000000"); const amountRemaining = new BigNumber("10000"); const fee = 3000; + const zeroForOne = true; // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( @@ -235,7 +250,8 @@ describe("computeSwapStep", () => { sqrtPriceTarget, liquidity, amountRemaining, - fee + fee, + zeroForOne ); // Then diff --git a/src/api/utils/dex/swapMath.helper.spec.ts b/src/api/utils/dex/swapMath.helper.spec.ts index 8d99c21..bbd2a6a 100644 --- a/src/api/utils/dex/swapMath.helper.spec.ts +++ b/src/api/utils/dex/swapMath.helper.spec.ts @@ -38,6 +38,7 @@ describe("computeSwapStep", () => { const sqrtPriceCurrent = new BigNumber(2); const sqrtPriceTarget = new BigNumber(1.5); const amountRemaining = new BigNumber(100); + const zeroForOne = true; (getAmount0Delta as jest.Mock).mockReturnValue(new BigNumber(90)); (getNextSqrtPriceFromInput as jest.Mock).mockReturnValue(sqrtPriceTarget); @@ -49,7 +50,8 @@ describe("computeSwapStep", () => { sqrtPriceTarget, liquidity, amountRemaining, - fee + fee, + zeroForOne ); // Then @@ -64,6 +66,7 @@ describe("computeSwapStep", () => { const sqrtPriceCurrent = new BigNumber(2); const sqrtPriceTarget = new BigNumber(1.5); const amountRemaining = new BigNumber(60); + const zeroForOne = true; (getNextSqrtPriceFromInput as jest.Mock).mockReturnValue(new BigNumber(1.6)); (getAmount0Delta as jest.Mock).mockReturnValue(new BigNumber(60)); @@ -75,7 +78,8 @@ describe("computeSwapStep", () => { sqrtPriceTarget, liquidity, amountRemaining, - fee + fee, + zeroForOne ); // Then @@ -90,6 +94,7 @@ describe("computeSwapStep", () => { const sqrtPriceCurrent = new BigNumber(2); const sqrtPriceTarget = new BigNumber(1.5); const amountRemaining = new BigNumber(-50); + const zeroForOne = true; (getAmount1Delta as jest.Mock).mockReturnValue(new BigNumber(40)); (getAmount0Delta as jest.Mock).mockReturnValue(new BigNumber(70)); @@ -100,7 +105,8 @@ describe("computeSwapStep", () => { sqrtPriceTarget, liquidity, amountRemaining, - fee + fee, + zeroForOne ); // Then @@ -115,6 +121,7 @@ describe("computeSwapStep", () => { const sqrtPriceCurrent = new BigNumber(2); const sqrtPriceTarget = new BigNumber(1.5); const amountRemaining = new BigNumber(-25); + const zeroForOne = true; (getAmount1Delta as jest.Mock).mockReturnValue(new BigNumber(40)); (getNextSqrtPriceFromOutput as jest.Mock).mockReturnValue(new BigNumber(1.6)); @@ -126,7 +133,8 @@ describe("computeSwapStep", () => { sqrtPriceTarget, liquidity, amountRemaining, - fee + fee, + zeroForOne ); // Then @@ -141,6 +149,7 @@ describe("computeSwapStep", () => { const sqrtPriceCurrent = new BigNumber(1.5); const sqrtPriceTarget = new BigNumber(2); // implies zeroForOne = false const amountRemaining = new BigNumber(100); + const zeroForOne = false; (getAmount1Delta as jest.Mock).mockReturnValue(new BigNumber(80)); (getNextSqrtPriceFromInput as jest.Mock).mockReturnValue(sqrtPriceTarget); @@ -152,7 +161,8 @@ describe("computeSwapStep", () => { sqrtPriceTarget, liquidity, amountRemaining, - fee + fee, + zeroForOne ); // Then diff --git a/src/api/utils/dex/swapMath.helper.ts b/src/api/utils/dex/swapMath.helper.ts index dea44c1..04bec8e 100644 --- a/src/api/utils/dex/swapMath.helper.ts +++ b/src/api/utils/dex/swapMath.helper.ts @@ -41,7 +41,8 @@ export function computeSwapStep( sqrtPriceTarget: BigNumber, liquidity: BigNumber, amountRemaining: BigNumber, - fee: number + fee: number, + zeroForOne: boolean ): BigNumber[] { //returns let amountIn = new BigNumber(0), @@ -49,8 +50,6 @@ export function computeSwapStep( sqrtPriceNext: BigNumber, feeAmount: BigNumber; - //define direction - const zeroForOne = sqrtPriceCurrent.isGreaterThanOrEqualTo(sqrtPriceTarget); const exactInput = amountRemaining.isGreaterThanOrEqualTo(0); if (exactInput) { const amountRemainingLessFee = amountRemaining.times(FEE_PIPS - fee).dividedBy(FEE_PIPS); @@ -58,27 +57,32 @@ export function computeSwapStep( amountIn = zeroForOne ? getAmount0Delta(sqrtPriceTarget, sqrtPriceCurrent, liquidity) : getAmount1Delta(sqrtPriceCurrent, sqrtPriceTarget, liquidity); - if (amountRemainingLessFee.isGreaterThanOrEqualTo(amountIn)) sqrtPriceNext = sqrtPriceTarget; - else + + if (amountRemainingLessFee.isGreaterThanOrEqualTo(amountIn)) { + sqrtPriceNext = sqrtPriceTarget; + } else { sqrtPriceNext = getNextSqrtPriceFromInput( sqrtPriceCurrent, liquidity, amountRemainingLessFee, zeroForOne ); + } } else { amountOut = zeroForOne ? getAmount1Delta(sqrtPriceTarget, sqrtPriceCurrent, liquidity) : getAmount0Delta(sqrtPriceCurrent, sqrtPriceTarget, liquidity); - if (amountRemaining.multipliedBy(-1).isGreaterThanOrEqualTo(amountOut)) sqrtPriceNext = sqrtPriceTarget; - else + if (amountRemaining.multipliedBy(-1).isGreaterThanOrEqualTo(amountOut)) { + sqrtPriceNext = sqrtPriceTarget; + } else { sqrtPriceNext = getNextSqrtPriceFromOutput( sqrtPriceCurrent, liquidity, amountRemaining.multipliedBy(-1), zeroForOne ); + } } const max = sqrtPriceTarget.isEqualTo(sqrtPriceNext); diff --git a/src/api/utils/dex/tickToSqrtPrice.spec.ts b/src/api/utils/dex/tickToSqrtPrice.spec.ts new file mode 100644 index 0000000..61fae05 --- /dev/null +++ b/src/api/utils/dex/tickToSqrtPrice.spec.ts @@ -0,0 +1,308 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; + +import { tickToSqrtPrice, sqrtPriceToTick } from "./tick.helper"; + +describe("tickToSqrtPrice", () => { + describe("Base cases - normal tick ranges", () => { + test("should handle tick 0 (1:1 price ratio)", () => { + // Given + const tick = 0; + + // When + const sqrtPrice = tickToSqrtPrice(tick); + + // Then + expect(sqrtPrice.toNumber()).toBeCloseTo(1.0, 6); + }); + + test("should handle positive ticks (token1 more expensive)", () => { + // Given - positive ticks mean token1 is more expensive than token0 + const testCases = [1000, 5000, 10000, 23000]; + + testCases.forEach(tick => { + // When + const sqrtPrice = tickToSqrtPrice(tick); + + // Then + expect(sqrtPrice.toNumber()).toBeGreaterThan(1); // Positive ticks should increase price + expect(Number.isFinite(sqrtPrice.toNumber())).toBe(true); + + // Higher ticks should produce higher prices + if (tick > 1000) { + const lowerTick = tick - 1000; + const lowerSqrtPrice = tickToSqrtPrice(lowerTick); + expect(sqrtPrice.toNumber()).toBeGreaterThan(lowerSqrtPrice.toNumber()); + } + + // Verify round-trip conversion + const backToTick = sqrtPriceToTick(sqrtPrice); + expect(Math.abs(backToTick - tick)).toBeLessThanOrEqual(1); + }); + }); + + test("should handle moderate negative ticks (token0 more expensive)", () => { + // Given - negative ticks mean token0 is more expensive than token1 + const testCases = [-1000, -5000, -10000, -23000]; + + testCases.forEach(tick => { + // When + const sqrtPrice = tickToSqrtPrice(tick); + + // Then + expect(sqrtPrice.toNumber()).toBeLessThan(1); // Negative ticks should decrease price + expect(sqrtPrice.toNumber()).toBeGreaterThan(0); + + // More negative ticks should produce lower prices + if (tick < -1000) { + const higherTick = tick + 1000; + const higherSqrtPrice = tickToSqrtPrice(higherTick); + expect(sqrtPrice.toNumber()).toBeLessThan(higherSqrtPrice.toNumber()); + } + + // Verify round-trip conversion + const backToTick = sqrtPriceToTick(sqrtPrice); + expect(Math.abs(backToTick - tick)).toBeLessThanOrEqual(1); + }); + }); + }); + + describe("Edge cases - extreme negative ticks", () => { + test("should handle edge case tick from swap bug (-81920)", () => { + // Given - this is the tick that caused the swap direction bug + const tick = -81920; + + // When + const sqrtPrice = tickToSqrtPrice(tick); + + // Then + expect(sqrtPrice.toNumber()).toBeCloseTo(0.01664222241481084743, 5); + + // Verify this is much lower than bitmap ticks + const bitmapTicks = [-30, -31, -32, -33, -346]; + bitmapTicks.forEach(bitmapTick => { + const bitmapSqrtPrice = tickToSqrtPrice(bitmapTick); + expect(bitmapSqrtPrice.toNumber()).toBeGreaterThan(sqrtPrice.toNumber()); + }); + + // Verify round-trip conversion + const backToTick = sqrtPriceToTick(sqrtPrice); + expect(Math.abs(backToTick - tick)).toBeLessThanOrEqual(1); + }); + + test("should handle very negative ticks approaching minimum", () => { + // Given - test ticks approaching the theoretical minimum + const testCases = [ + { tick: -100000, description: "Very low price" }, + { tick: -200000, description: "Extremely low price" }, + { tick: -500000, description: "Near minimum tick" }, + { tick: -800000, description: "Close to theoretical min" } + ]; + + testCases.forEach(({ tick, description }) => { + // When + const sqrtPrice = tickToSqrtPrice(tick); + + // Then + expect(sqrtPrice.toNumber()).toBeGreaterThan(0); + expect(sqrtPrice.toNumber()).toBeLessThan(1); + + // More negative ticks should produce smaller sqrt prices + if (tick > -800000) { + const morenegativeTick = tick - 10000; + const moreNegativeSqrtPrice = tickToSqrtPrice(morenegativeTick); + expect(moreNegativeSqrtPrice.toNumber()).toBeLessThan(sqrtPrice.toNumber()); + } + + // Verify conversion maintains precision + const backToTick = sqrtPriceToTick(sqrtPrice); + expect(Math.abs(backToTick - tick)).toBeLessThanOrEqual(2); // Allow slightly more tolerance for extreme values + }); + }); + + test("should handle bitmap ticks from edge case scenario", () => { + // Given - these are the actual bitmap ticks that were above the current tick + const bitmapTicks = [-30, -31, -32, -33, -346]; + + bitmapTicks.forEach(tick => { + // When + const sqrtPrice = tickToSqrtPrice(tick); + + // Then + expect(sqrtPrice.toNumber()).toBeGreaterThan(0); + expect(sqrtPrice.toNumber()).toBeLessThan(1); // All negative ticks should be < 1 + + // More negative should be smaller + const moreNegativeTick = tick - 1000; + const moreNegativeSqrtPrice = tickToSqrtPrice(moreNegativeTick); + expect(moreNegativeSqrtPrice.toNumber()).toBeLessThan(sqrtPrice.toNumber()); + + // Verify round-trip conversion + const backToTick = sqrtPriceToTick(sqrtPrice); + expect(Math.abs(backToTick - tick)).toBeLessThanOrEqual(1); + }); + }); + }); + + describe("Extreme positive cases", () => { + test("should handle very positive ticks approaching maximum", () => { + // Given - test ticks approaching the theoretical maximum + const testCases = [ + { tick: 100000, description: "Very high price" }, + { tick: 200000, description: "Extremely high price" }, + { tick: 500000, description: "Near maximum tick" }, + { tick: 800000, description: "Close to theoretical max" } + ]; + + testCases.forEach(({ tick, description }) => { + // When + const sqrtPrice = tickToSqrtPrice(tick); + + // Then + expect(sqrtPrice.toNumber()).toBeGreaterThan(1); + expect(Number.isFinite(sqrtPrice.toNumber())).toBe(true); + + // More positive ticks should produce larger sqrt prices + if (tick < 800000) { + const morePositiveTick = tick + 10000; + const morePositiveSqrtPrice = tickToSqrtPrice(morePositiveTick); + expect(morePositiveSqrtPrice.toNumber()).toBeGreaterThan(sqrtPrice.toNumber()); + } + + // Verify conversion maintains precision + const backToTick = sqrtPriceToTick(sqrtPrice); + expect(Math.abs(backToTick - tick)).toBeLessThanOrEqual(2); + }); + }); + }); + + describe("Mathematical properties", () => { + test("should maintain exponential relationship (1.0001^(tick/2))", () => { + // Given + const testTicks = [-50000, -10000, -1000, 0, 1000, 10000, 50000]; + + testTicks.forEach(tick => { + // When + const sqrtPrice = tickToSqrtPrice(tick); + const expectedSqrtPrice = new BigNumber(1.0001 ** (tick / 2)); + + // Then - should match the mathematical formula + expect(sqrtPrice.toNumber()).toBeCloseTo(expectedSqrtPrice.toNumber(), 10); + }); + }); + + test("should demonstrate monotonic increasing property", () => { + // Given - array of increasing ticks + const increasingTicks = [-100000, -50000, -10000, -1000, 0, 1000, 10000, 50000, 100000]; + + // When & Then - each tick should produce a larger sqrt price than the previous + for (let i = 1; i < increasingTicks.length; i++) { + const prevSqrtPrice = tickToSqrtPrice(increasingTicks[i - 1]); + const currSqrtPrice = tickToSqrtPrice(increasingTicks[i]); + + expect(currSqrtPrice.toNumber()).toBeGreaterThan(prevSqrtPrice.toNumber()); + } + }); + + test("should handle tick spacing boundaries correctly", () => { + // Given - test around common tick spacing values + const tickSpacings = [10, 60, 200]; // For 0.05%, 0.3%, 1% fees + + tickSpacings.forEach(spacing => { + // Test around various multiples of spacing + const baseTicks = [-10000, -1000, 0, 1000, 10000]; + + baseTicks.forEach(baseTick => { + const spacedTick = Math.floor(baseTick / spacing) * spacing; + + // When + const sqrtPrice = tickToSqrtPrice(spacedTick); + + // Then + expect(sqrtPrice.toNumber()).toBeGreaterThan(0); + expect(Number.isFinite(sqrtPrice.toNumber())).toBe(true); + + // Verify tick spacing doesn't break calculations + const nextSpacedTick = spacedTick + spacing; + const nextSqrtPrice = tickToSqrtPrice(nextSpacedTick); + expect(nextSqrtPrice.toNumber()).toBeGreaterThan(sqrtPrice.toNumber()); + }); + }); + }); + + test("should handle precision at extreme scales", () => { + // Given - test cases that push precision limits + const extremeCases = [ + { tick: -887200, description: "Near theoretical minimum" }, + { tick: -81920, description: "Edge case from swap bug" }, + { tick: -346, description: "Bitmap edge case" }, + { tick: 887200, description: "Near theoretical maximum" } + ]; + + extremeCases.forEach(({ tick, description }) => { + // When + const sqrtPrice = tickToSqrtPrice(tick); + + // Then + expect(sqrtPrice.toNumber()).toBeGreaterThan(0); + expect(Number.isFinite(sqrtPrice.toNumber())).toBe(true); + expect(sqrtPrice.toString()).not.toBe('NaN'); + expect(sqrtPrice.toString()).not.toBe('Infinity'); + + // Verify we can still do round-trip conversion with reasonable precision + const backToTick = sqrtPriceToTick(sqrtPrice); + const tolerance = Math.abs(tick) > 100000 ? 5 : 1; // Higher tolerance for extreme values + expect(Math.abs(backToTick - tick)).toBeLessThanOrEqual(tolerance); + }); + }); + }); + + describe("Relationship to swap edge case", () => { + test("should demonstrate the problematic price relationships from edge case", () => { + // Given - recreate the exact scenario from the swap bug + const currentTick = -81920; + const bitmapTicks = [-30, -31, -32, -33, -346]; + + // When + const currentSqrtPrice = tickToSqrtPrice(currentTick); + const bitmapSqrtPrices = bitmapTicks.map(tick => ({ + tick, + sqrtPrice: tickToSqrtPrice(tick) + })); + + // Then - demonstrate why the edge case occurred + console.log('Current position: tick', currentTick, 'sqrtPrice', currentSqrtPrice.toString()); + + bitmapSqrtPrices.forEach(({ tick, sqrtPrice }) => { + console.log('Bitmap tick', tick, 'sqrtPrice', sqrtPrice.toString()); + + // All bitmap ticks should have higher sqrt prices than current + expect(sqrtPrice.toNumber()).toBeGreaterThan(currentSqrtPrice.toNumber()); + + // This demonstrates why nextInitialisedTickWithInSameWord + // returns a tick that moves price UP instead of DOWN + if (tick === -346) { + // This specific tick was likely returned by nextInitialisedTickWithInSameWord + expect(sqrtPrice.toNumber()).toBeCloseTo(0.982849635874457, 10); + + // Show the massive difference that caused the bug + const priceRatio = sqrtPrice.dividedBy(currentSqrtPrice); + expect(priceRatio.toNumber()).toBeGreaterThan(50); // Price would increase by 50x+! + } + }); + }); + }); +}); \ No newline at end of file diff --git a/src/chaincode/dex/swap.helper.ts b/src/chaincode/dex/swap.helper.ts index 488f702..4b8cc8f 100644 --- a/src/chaincode/dex/swap.helper.ts +++ b/src/chaincode/dex/swap.helper.ts @@ -89,28 +89,20 @@ export async function processSwapSteps( // Compute the sqrt price for the next tick step.sqrtPriceNext = tickToSqrtPrice(step.tickNext); - // Check if the next tick is actually in the wrong direction - // This can happen when we're at extreme prices and all initialized ticks are on the wrong side - const isWrongDirection = zeroForOne - ? step.sqrtPriceNext.isGreaterThan(state.sqrtPrice) // Should go down but would go up - : step.sqrtPriceNext.isLessThan(state.sqrtPrice); // Should go up but would go down - // Compute the result of the swap step based on price movement [state.sqrtPrice, step.amountIn, step.amountOut, step.feeAmount] = computeSwapStep( state.sqrtPrice, ( - isWrongDirection || - ( - zeroForOne - ? step.sqrtPriceNext.isLessThan(sqrtPriceLimit) - : step.sqrtPriceNext.isGreaterThan(sqrtPriceLimit) - ) + zeroForOne + ? step.sqrtPriceNext.isLessThan(sqrtPriceLimit) + : step.sqrtPriceNext.isGreaterThan(sqrtPriceLimit) ) ? sqrtPriceLimit : step.sqrtPriceNext, state.liquidity, state.amountSpecifiedRemaining, - pool.fee + pool.fee, + zeroForOne ); // Adjust remaining and calculated amounts depending on exact input/output diff --git a/src/chaincode/dex/swap.spec.ts b/src/chaincode/dex/swap.spec.ts index adc84bd..9ec19c2 100644 --- a/src/chaincode/dex/swap.spec.ts +++ b/src/chaincode/dex/swap.spec.ts @@ -13,7 +13,7 @@ * limitations under the License. */ import { randomUniqueKey, TokenBalance, TokenClass, TokenClassKey, TokenInstance } from "@gala-chain/api"; -import { currency, fixture, transactionSuccess, users } from "@gala-chain/test"; +import { currency, fixture, transactionError, transactionSuccess, users } from "@gala-chain/test"; import BigNumber from "bignumber.js"; import { plainToInstance } from "class-transformer"; @@ -30,7 +30,7 @@ import dex from "../test/dex"; import { generateKeyFromClassKey } from "./dexUtils"; describe("swap", () => { - test("should execute a successful token swap in the happy path", async () => { + it("should execute a successful token swap in the happy path", async () => { // Given const currencyClass: TokenClass = currency.tokenClass(); const currencyInstance: TokenInstance = currency.tokenInstance(); @@ -94,41 +94,6 @@ describe("swap", () => { quantity: new BigNumber("10000") // User has 10k CURRENCY tokens }); - const tickLower = -100; - const tickUpper = 100; - - const tickLowerData = plainToInstance(TickData, { - poolHash: pool.genPoolHash(), - tick: tickLower, - liquidityGross: new BigNumber("1000000"), - liquidityNet: new BigNumber("1000000"), - feeGrowthOutside0: new BigNumber("0"), - feeGrowthOutside1: new BigNumber("0"), - initialised: true - }); - - const tickUpperData = plainToInstance(TickData, { - poolHash: pool.genPoolHash(), - tick: tickUpper, - liquidityGross: new BigNumber("1000000"), - liquidityNet: new BigNumber("-1000000"), - feeGrowthOutside0: new BigNumber("0"), - feeGrowthOutside1: new BigNumber("0"), - initialised: true - }); - - // Create position to represent the liquidity - const position = new DexPositionData( - pool.genPoolHash(), - "test-position", - tickUpper, - tickLower, - dexClassKey, - currencyClassKey, - fee - ); - position.liquidity = new BigNumber("1000000"); - // Setup the fixture const { ctx, contract } = fixture(DexV3Contract) .registeredUsers(users.testUser1) @@ -141,10 +106,7 @@ describe("swap", () => { poolDexBalance, poolCurrencyBalance, userDexBalance, - userCurrencyBalance, - // tickLowerData, - // tickUpperData, - // position + userCurrencyBalance ); const swapDto = new SwapDto( @@ -153,9 +115,9 @@ describe("swap", () => { fee, new BigNumber("151.714011"), true, // zeroForOne - swapping token0 (DEX) for token1 (CURRENCY) - new BigNumber("0.000000000000000000094212147"), + new BigNumber("0.015"), new BigNumber("151.714011"), - new BigNumber("-75.8849266551571701291") + new BigNumber("-0.04") ); swapDto.uniqueKey = randomUniqueKey(); @@ -168,7 +130,7 @@ describe("swap", () => { currencyClass.symbol, "https://app.gala.games/test-image-placeholder-url.png", "151.7140110000", - "-79.8788701633", + "-0.0419989955", "client|testUser1", pool.genPoolHash(), poolAlias, @@ -194,4 +156,108 @@ describe("swap", () => { expect(new BigNumber(swapResult.amount0).toNumber()).toBeGreaterThan(0); // User pays DEX expect(new BigNumber(swapResult.amount1).toNumber()).toBeLessThan(100); // User receives CURRENCY }); + + it("should fail to execute a token swap when amount out is less than minimum", async () => { + // Given + const currencyClass: TokenClass = currency.tokenClass(); + const currencyInstance: TokenInstance = currency.tokenInstance(); + const currencyClassKey: TokenClassKey = currency.tokenClassKey(); + const dexClass: TokenClass = dex.tokenClass(); + const dexInstance: TokenInstance = dex.tokenInstance(); + const dexClassKey: TokenClassKey = dex.tokenClassKey(); + + // Create normalized token keys for pool + const token0Key = generateKeyFromClassKey(dexClassKey); + const token1Key = generateKeyFromClassKey(currencyClassKey); + const fee = DexFeePercentageTypes.FEE_0_05_PERCENT; + + // Initialize pool with manual values + const pool = new Pool( + token0Key, + token1Key, + dexClassKey, + currencyClassKey, + fee, + new BigNumber("0.01664222241481084743"), + 0.1 + ); + + const bitmap: Record = { + "-30":"75557863725914323419136", + "-31":"37778931862957161709568", + "-32":"40564819207303340847894502572032", + "-33":"26959946667150639794667015087019630673637144422540572481103610249216", + "-346":"5708990770823839524233143877797980545530986496", + "346":"20282409603651670423947251286016" + }; + + // Add initial liquidity to the pool + pool.liquidity = new BigNumber("77789.999499306764803261"); + pool.grossPoolLiquidity = new BigNumber("348717210.55494320449679994"); + pool.sqrtPrice = new BigNumber("0.01664222241481084743"); + pool.bitmap = bitmap; + // Create pool balances - pool needs tokens to pay out + const poolAlias = pool.getPoolAlias(); + const poolDexBalance = plainToInstance(TokenBalance, { + ...dex.tokenBalance(), + owner: poolAlias, + quantity: new BigNumber("97.238975330345368866") + }); + const poolCurrencyBalance = plainToInstance(TokenBalance, { + ...currency.tokenBalance(), + owner: poolAlias, + quantity: new BigNumber("188809.790718") + }); + + // Create user balances - user needs tokens to swap + const userDexBalance = plainToInstance(TokenBalance, { + ...dex.tokenBalance(), + owner: users.testUser1.identityKey, + quantity: new BigNumber("10000") // User has 10k DEX tokens + }); + const userCurrencyBalance = plainToInstance(TokenBalance, { + ...currency.tokenBalance(), + owner: users.testUser1.identityKey, + quantity: new BigNumber("10000") // User has 10k CURRENCY tokens + }); + + // Setup the fixture + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + currencyInstance, + dexClass, + dexInstance, + pool, + poolDexBalance, + poolCurrencyBalance, + userDexBalance, + userCurrencyBalance + ); + + const swapDto = new SwapDto( + dexClassKey, + currencyClassKey, + fee, + new BigNumber("151.714011"), + true, // zeroForOne - swapping token0 (DEX) for token1 (CURRENCY) + new BigNumber("0.000000000000000000094212147"), + new BigNumber("151.714011"), + new BigNumber("-75.8849266551571701291") + ); + + swapDto.uniqueKey = randomUniqueKey(); + + const signedDto = swapDto.signed(users.testUser1.privateKey); + + // When + const response = await contract.Swap(ctx, signedDto); + + // Then + expect(response).toEqual(transactionError( + "Slippage tolerance exceeded: minimum received tokens (-75.8849266551571701291) " + + "is less than actual received amount (-0.04199899554428437042776361879722152347)." + )); + }); }); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 3377cfb..5b63b3c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "sourceMap": true }, "files": [], - "include": ["**/*", "package.json"], + "include": ["src/**/*", "package.json"], "exclude": ["lib", "**/*spec.ts", "src/__mocks__", "src/__test__"], "references": [ { From 44311cad57a40c73fbf97bfcbf7283f0fc3598ce Mon Sep 17 00:00:00 2001 From: Mike Graham Date: Sat, 2 Aug 2025 21:51:26 -0700 Subject: [PATCH 10/16] fix: tests pass --- src/api/utils/dex/computeSwapStep.spec.ts | 52 +++++++++++------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/api/utils/dex/computeSwapStep.spec.ts b/src/api/utils/dex/computeSwapStep.spec.ts index eec7a7e..cae1900 100644 --- a/src/api/utils/dex/computeSwapStep.spec.ts +++ b/src/api/utils/dex/computeSwapStep.spec.ts @@ -17,14 +17,14 @@ import BigNumber from "bignumber.js"; import { computeSwapStep } from "./swapMath.helper"; describe("computeSwapStep", () => { - test("should compute swap step for exact input swap", () => { + test("should compute swap step for exact input swap (token1 → token0)", () => { // Given const sqrtPriceCurrent = new BigNumber("1"); // Starting at 1:1 price - const sqrtPriceTarget = new BigNumber("0.9"); // Target price + const sqrtPriceTarget = new BigNumber("1.1"); // Target price higher (token1 → token0 increases sqrt price) const liquidity = new BigNumber("1000000"); // 1M liquidity const amountRemaining = new BigNumber("100"); // 100 tokens remaining to swap const fee = 3000; // 0.3% fee - const zeroForOne = false; + const zeroForOne = false; // token1 → token0 // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( @@ -50,19 +50,19 @@ describe("computeSwapStep", () => { // Verify fee calculation expect(feeAmount.toNumber()).toBeCloseTo(amountIn.toNumber() * 0.003, 2); - // Verify price moved in correct direction - expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceCurrent.toNumber()); - expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceTarget.toNumber()); + // Verify price moved in correct direction (token1 → token0 increases sqrt price) + expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceCurrent.toNumber()); + expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceTarget.toNumber()); }); - test("should compute swap step for exact output swap", () => { + test("should compute swap step for exact output swap (token0 → token1)", () => { // Given const sqrtPriceCurrent = new BigNumber("1"); - const sqrtPriceTarget = new BigNumber("1.1"); + const sqrtPriceTarget = new BigNumber("0.9"); // Target price lower (token0 → token1 decreases sqrt price) const liquidity = new BigNumber("1000000"); const amountRemaining = new BigNumber("-50"); // Negative for exact output const fee = 3000; - const zeroForOne = true; + const zeroForOne = true; // token0 → token1 // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( @@ -79,19 +79,19 @@ describe("computeSwapStep", () => { expect(amountOut.toNumber()).toBeGreaterThan(0); expect(feeAmount.toNumber()).toBeGreaterThan(0); - // Verify price moved in correct direction for opposite swap - expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceCurrent.toNumber()); - expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceTarget.toNumber()); + // Verify price moved in correct direction (token0 → token1 decreases sqrt price) + expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceCurrent.toNumber()); + expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceTarget.toNumber()); }); test("should handle swap with sufficient amount and liquidity", () => { // Given const sqrtPriceCurrent = new BigNumber("1"); - const sqrtPriceTarget = new BigNumber("0.99"); // Small price movement + const sqrtPriceTarget = new BigNumber("0.99"); // Small price movement downward const liquidity = new BigNumber("10000000"); // Large liquidity const amountRemaining = new BigNumber("1000"); // Large amount to swap const fee = 3000; - const zeroForOne = true; + const zeroForOne = true; // token0 → token1 (decreases sqrt price) // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( @@ -144,11 +144,11 @@ describe("computeSwapStep", () => { test("should compute swap at negative tick prices", () => { // Given - prices less than 1:1 represent negative ticks const sqrtPriceCurrent = new BigNumber("0.5"); // Negative tick - const sqrtPriceTarget = new BigNumber("0.4"); // Even more negative + const sqrtPriceTarget = new BigNumber("0.4"); // Even more negative (price decreasing) const liquidity = new BigNumber("2000000"); const amountRemaining = new BigNumber("500"); const fee = 3000; - const zeroForOne = true; + const zeroForOne = true; // token0 → token1 (decreases sqrt price) // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( @@ -166,7 +166,7 @@ describe("computeSwapStep", () => { expect(amountOut.toNumber()).toBeGreaterThan(0); expect(feeAmount.toNumber()).toBeGreaterThan(0); - // Price should move towards target + // Price should move towards target (downward for zeroForOne=true) expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceCurrent.toNumber()); expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceTarget.toNumber()); }); @@ -174,11 +174,11 @@ describe("computeSwapStep", () => { test("should handle swap crossing from negative to positive price", () => { // Given - crossing from price < 1 to price > 1 const sqrtPriceCurrent = new BigNumber("0.8"); // Negative tick - const sqrtPriceTarget = new BigNumber("1.2"); // Positive tick + const sqrtPriceTarget = new BigNumber("1.2"); // Positive tick (price increasing) const liquidity = new BigNumber("5000000"); const amountRemaining = new BigNumber("1000"); const fee = 500; // 0.05% fee - const zeroForOne = true; + const zeroForOne = false; // token1 → token0 (increases sqrt price) // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( @@ -195,7 +195,7 @@ describe("computeSwapStep", () => { expect(amountIn.toNumber()).toBeGreaterThan(0); expect(amountOut.toNumber()).toBeGreaterThan(0); - // Price should move towards target (upward in this case) + // Price should move towards target (upward for zeroForOne=false) expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceCurrent.toNumber()); expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceTarget.toNumber()); @@ -206,11 +206,11 @@ describe("computeSwapStep", () => { test("should handle exact output swap at negative ticks", () => { // Given - negative amount for exact output, negative tick prices const sqrtPriceCurrent = new BigNumber("0.6"); - const sqrtPriceTarget = new BigNumber("0.7"); + const sqrtPriceTarget = new BigNumber("0.7"); // Price increasing const liquidity = new BigNumber("3000000"); const amountRemaining = new BigNumber("-250"); // Negative for exact output const fee = 10000; // 1% fee - const zeroForOne = true; + const zeroForOne = false; // token1 → token0 (increases sqrt price) // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( @@ -230,7 +230,7 @@ describe("computeSwapStep", () => { // For exact output, output should not exceed requested amount expect(amountOut.toNumber()).toBeLessThanOrEqual(250); - // Price should move in correct direction + // Price should move in correct direction (upward for zeroForOne=false) expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceCurrent.toNumber()); expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceTarget.toNumber()); }); @@ -238,11 +238,11 @@ describe("computeSwapStep", () => { test("should handle very small negative tick prices", () => { // Given - very small prices representing very negative ticks const sqrtPriceCurrent = new BigNumber("0.001"); // Very negative tick - const sqrtPriceTarget = new BigNumber("0.0009"); // Even more negative + const sqrtPriceTarget = new BigNumber("0.0009"); // Even more negative (price decreasing) const liquidity = new BigNumber("10000000"); const amountRemaining = new BigNumber("10000"); const fee = 3000; - const zeroForOne = true; + const zeroForOne = true; // token0 → token1 (decreases sqrt price) // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( @@ -259,7 +259,7 @@ describe("computeSwapStep", () => { expect(amountIn.toNumber()).toBeGreaterThan(0); expect(amountOut.toNumber()).toBeGreaterThan(0); - // Verify price moves correctly at extreme values + // Verify price moves correctly at extreme values (downward for zeroForOne=true) expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceCurrent.toNumber()); expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceTarget.toNumber()); }); From bffbc823a8f7ed7f1e47efe77bd3ee6b10b78a1d Mon Sep 17 00:00:00 2001 From: Mike Graham Date: Sat, 2 Aug 2025 22:08:44 -0700 Subject: [PATCH 11/16] chore: lint --- .eslintignore | 4 +- src/api/utils/dex/computeSwapStep.spec.ts | 66 +++++----- src/api/utils/dex/tickToSqrtPrice.spec.ts | 144 ++++++++++----------- src/chaincode/dex/swap.helper.spec.ts | 98 ++++++-------- src/chaincode/dex/swap.spec.ts | 91 +++++++------ src/chaincode/dex/tickData.helper.spec.ts | 149 +++++++++------------- 6 files changed, 251 insertions(+), 301 deletions(-) diff --git a/.eslintignore b/.eslintignore index b655af6..4e133fb 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,6 @@ node_modules lib coverage -.eslintrc.json \ No newline at end of file +.eslintrc.json +ext +ctx diff --git a/src/api/utils/dex/computeSwapStep.spec.ts b/src/api/utils/dex/computeSwapStep.spec.ts index cae1900..9e4b049 100644 --- a/src/api/utils/dex/computeSwapStep.spec.ts +++ b/src/api/utils/dex/computeSwapStep.spec.ts @@ -35,26 +35,26 @@ describe("computeSwapStep", () => { fee, zeroForOne ); - + // Then expect(sqrtPriceNext).toBeDefined(); expect(amountIn).toBeDefined(); expect(amountOut).toBeDefined(); expect(feeAmount).toBeDefined(); - + // Verify amounts are positive/correct sign expect(amountIn.toNumber()).toBeGreaterThan(0); // Input amount should be positive expect(amountOut.toNumber()).toBeGreaterThan(0); // Output amount should be positive expect(feeAmount.toNumber()).toBeGreaterThan(0); // Fee should be positive - + // Verify fee calculation expect(feeAmount.toNumber()).toBeCloseTo(amountIn.toNumber() * 0.003, 2); - + // Verify price moved in correct direction (token1 → token0 increases sqrt price) expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceCurrent.toNumber()); expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceTarget.toNumber()); }); - + test("should compute swap step for exact output swap (token0 → token1)", () => { // Given const sqrtPriceCurrent = new BigNumber("1"); @@ -63,7 +63,7 @@ describe("computeSwapStep", () => { const amountRemaining = new BigNumber("-50"); // Negative for exact output const fee = 3000; const zeroForOne = true; // token0 → token1 - + // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( sqrtPriceCurrent, @@ -73,17 +73,17 @@ describe("computeSwapStep", () => { fee, zeroForOne ); - + // Then expect(amountIn.toNumber()).toBeGreaterThan(0); expect(amountOut.toNumber()).toBeGreaterThan(0); expect(feeAmount.toNumber()).toBeGreaterThan(0); - + // Verify price moved in correct direction (token0 → token1 decreases sqrt price) expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceCurrent.toNumber()); expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceTarget.toNumber()); }); - + test("should handle swap with sufficient amount and liquidity", () => { // Given const sqrtPriceCurrent = new BigNumber("1"); @@ -92,7 +92,7 @@ describe("computeSwapStep", () => { const amountRemaining = new BigNumber("1000"); // Large amount to swap const fee = 3000; const zeroForOne = true; // token0 → token1 (decreases sqrt price) - + // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( sqrtPriceCurrent, @@ -102,18 +102,18 @@ describe("computeSwapStep", () => { fee, zeroForOne ); - + // Then // Should move price in correct direction toward target expect(sqrtPriceNext.toNumber()).toBeLessThan(sqrtPriceCurrent.toNumber()); expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceTarget.toNumber()); expect(amountIn.toNumber()).toBeGreaterThan(0); expect(amountOut.toNumber()).toBeGreaterThan(0); - + // With large liquidity, should consume the full amount specified expect(amountIn.toNumber()).toBeCloseTo(997, 0); // After 0.3% fee }); - + test("should handle zero liquidity edge case", () => { // Given const sqrtPriceCurrent = new BigNumber("1"); @@ -122,7 +122,7 @@ describe("computeSwapStep", () => { const amountRemaining = new BigNumber("100"); const fee = 3000; const zeroForOne = true; - + // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( sqrtPriceCurrent, @@ -132,7 +132,7 @@ describe("computeSwapStep", () => { fee, zeroForOne ); - + // Then // With zero liquidity, should reach target price but no amounts expect(sqrtPriceNext.toNumber()).toBe(sqrtPriceTarget.toNumber()); @@ -140,7 +140,7 @@ describe("computeSwapStep", () => { expect(amountOut.toNumber()).toBe(0); expect(feeAmount.toNumber()).toBe(0); }); - + test("should compute swap at negative tick prices", () => { // Given - prices less than 1:1 represent negative ticks const sqrtPriceCurrent = new BigNumber("0.5"); // Negative tick @@ -149,7 +149,7 @@ describe("computeSwapStep", () => { const amountRemaining = new BigNumber("500"); const fee = 3000; const zeroForOne = true; // token0 → token1 (decreases sqrt price) - + // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( sqrtPriceCurrent, @@ -159,18 +159,18 @@ describe("computeSwapStep", () => { fee, zeroForOne ); - + // Then expect(sqrtPriceNext).toBeDefined(); expect(amountIn.toNumber()).toBeGreaterThan(0); expect(amountOut.toNumber()).toBeGreaterThan(0); expect(feeAmount.toNumber()).toBeGreaterThan(0); - + // Price should move towards target (downward for zeroForOne=true) expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceCurrent.toNumber()); expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceTarget.toNumber()); }); - + test("should handle swap crossing from negative to positive price", () => { // Given - crossing from price < 1 to price > 1 const sqrtPriceCurrent = new BigNumber("0.8"); // Negative tick @@ -179,7 +179,7 @@ describe("computeSwapStep", () => { const amountRemaining = new BigNumber("1000"); const fee = 500; // 0.05% fee const zeroForOne = false; // token1 → token0 (increases sqrt price) - + // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( sqrtPriceCurrent, @@ -189,20 +189,20 @@ describe("computeSwapStep", () => { fee, zeroForOne ); - + // Then expect(sqrtPriceNext).toBeDefined(); expect(amountIn.toNumber()).toBeGreaterThan(0); expect(amountOut.toNumber()).toBeGreaterThan(0); - + // Price should move towards target (upward for zeroForOne=false) expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceCurrent.toNumber()); expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceTarget.toNumber()); - + // Fee should be proportional to input expect(feeAmount.toNumber()).toBeCloseTo(amountIn.toNumber() * 0.0005, 2); }); - + test("should handle exact output swap at negative ticks", () => { // Given - negative amount for exact output, negative tick prices const sqrtPriceCurrent = new BigNumber("0.6"); @@ -221,20 +221,20 @@ describe("computeSwapStep", () => { fee, zeroForOne ); - + // Then expect(amountIn.toNumber()).toBeGreaterThan(0); expect(amountOut.toNumber()).toBeGreaterThan(0); expect(feeAmount.toNumber()).toBeGreaterThan(0); - + // For exact output, output should not exceed requested amount expect(amountOut.toNumber()).toBeLessThanOrEqual(250); - + // Price should move in correct direction (upward for zeroForOne=false) expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceCurrent.toNumber()); expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceTarget.toNumber()); }); - + test("should handle very small negative tick prices", () => { // Given - very small prices representing very negative ticks const sqrtPriceCurrent = new BigNumber("0.001"); // Very negative tick @@ -243,7 +243,7 @@ describe("computeSwapStep", () => { const amountRemaining = new BigNumber("10000"); const fee = 3000; const zeroForOne = true; // token0 → token1 (decreases sqrt price) - + // When const [sqrtPriceNext, amountIn, amountOut, feeAmount] = computeSwapStep( sqrtPriceCurrent, @@ -253,14 +253,14 @@ describe("computeSwapStep", () => { fee, zeroForOne ); - + // Then expect(sqrtPriceNext).toBeDefined(); expect(amountIn.toNumber()).toBeGreaterThan(0); expect(amountOut.toNumber()).toBeGreaterThan(0); - + // Verify price moves correctly at extreme values (downward for zeroForOne=true) expect(sqrtPriceNext.toNumber()).toBeLessThanOrEqual(sqrtPriceCurrent.toNumber()); expect(sqrtPriceNext.toNumber()).toBeGreaterThanOrEqual(sqrtPriceTarget.toNumber()); }); -}); \ No newline at end of file +}); diff --git a/src/api/utils/dex/tickToSqrtPrice.spec.ts b/src/api/utils/dex/tickToSqrtPrice.spec.ts index 61fae05..6f77634 100644 --- a/src/api/utils/dex/tickToSqrtPrice.spec.ts +++ b/src/api/utils/dex/tickToSqrtPrice.spec.ts @@ -14,149 +14,149 @@ */ import BigNumber from "bignumber.js"; -import { tickToSqrtPrice, sqrtPriceToTick } from "./tick.helper"; +import { sqrtPriceToTick, tickToSqrtPrice } from "./tick.helper"; describe("tickToSqrtPrice", () => { describe("Base cases - normal tick ranges", () => { test("should handle tick 0 (1:1 price ratio)", () => { // Given const tick = 0; - + // When const sqrtPrice = tickToSqrtPrice(tick); - + // Then expect(sqrtPrice.toNumber()).toBeCloseTo(1.0, 6); }); - + test("should handle positive ticks (token1 more expensive)", () => { // Given - positive ticks mean token1 is more expensive than token0 const testCases = [1000, 5000, 10000, 23000]; - - testCases.forEach(tick => { + + testCases.forEach((tick) => { // When const sqrtPrice = tickToSqrtPrice(tick); - + // Then expect(sqrtPrice.toNumber()).toBeGreaterThan(1); // Positive ticks should increase price expect(Number.isFinite(sqrtPrice.toNumber())).toBe(true); - + // Higher ticks should produce higher prices if (tick > 1000) { const lowerTick = tick - 1000; const lowerSqrtPrice = tickToSqrtPrice(lowerTick); expect(sqrtPrice.toNumber()).toBeGreaterThan(lowerSqrtPrice.toNumber()); } - + // Verify round-trip conversion const backToTick = sqrtPriceToTick(sqrtPrice); expect(Math.abs(backToTick - tick)).toBeLessThanOrEqual(1); }); }); - + test("should handle moderate negative ticks (token0 more expensive)", () => { // Given - negative ticks mean token0 is more expensive than token1 const testCases = [-1000, -5000, -10000, -23000]; - - testCases.forEach(tick => { + + testCases.forEach((tick) => { // When const sqrtPrice = tickToSqrtPrice(tick); - + // Then expect(sqrtPrice.toNumber()).toBeLessThan(1); // Negative ticks should decrease price expect(sqrtPrice.toNumber()).toBeGreaterThan(0); - + // More negative ticks should produce lower prices if (tick < -1000) { const higherTick = tick + 1000; const higherSqrtPrice = tickToSqrtPrice(higherTick); expect(sqrtPrice.toNumber()).toBeLessThan(higherSqrtPrice.toNumber()); } - + // Verify round-trip conversion const backToTick = sqrtPriceToTick(sqrtPrice); expect(Math.abs(backToTick - tick)).toBeLessThanOrEqual(1); }); }); }); - + describe("Edge cases - extreme negative ticks", () => { test("should handle edge case tick from swap bug (-81920)", () => { // Given - this is the tick that caused the swap direction bug const tick = -81920; - + // When const sqrtPrice = tickToSqrtPrice(tick); - + // Then - expect(sqrtPrice.toNumber()).toBeCloseTo(0.01664222241481084743, 5); - + expect(sqrtPrice.toNumber()).toBeCloseTo(0.016642222414811, 5); + // Verify this is much lower than bitmap ticks const bitmapTicks = [-30, -31, -32, -33, -346]; - bitmapTicks.forEach(bitmapTick => { + bitmapTicks.forEach((bitmapTick) => { const bitmapSqrtPrice = tickToSqrtPrice(bitmapTick); expect(bitmapSqrtPrice.toNumber()).toBeGreaterThan(sqrtPrice.toNumber()); }); - + // Verify round-trip conversion const backToTick = sqrtPriceToTick(sqrtPrice); expect(Math.abs(backToTick - tick)).toBeLessThanOrEqual(1); }); - + test("should handle very negative ticks approaching minimum", () => { // Given - test ticks approaching the theoretical minimum const testCases = [ { tick: -100000, description: "Very low price" }, - { tick: -200000, description: "Extremely low price" }, + { tick: -200000, description: "Extremely low price" }, { tick: -500000, description: "Near minimum tick" }, { tick: -800000, description: "Close to theoretical min" } ]; - + testCases.forEach(({ tick, description }) => { // When const sqrtPrice = tickToSqrtPrice(tick); - + // Then expect(sqrtPrice.toNumber()).toBeGreaterThan(0); expect(sqrtPrice.toNumber()).toBeLessThan(1); - + // More negative ticks should produce smaller sqrt prices if (tick > -800000) { const morenegativeTick = tick - 10000; const moreNegativeSqrtPrice = tickToSqrtPrice(morenegativeTick); expect(moreNegativeSqrtPrice.toNumber()).toBeLessThan(sqrtPrice.toNumber()); } - + // Verify conversion maintains precision const backToTick = sqrtPriceToTick(sqrtPrice); expect(Math.abs(backToTick - tick)).toBeLessThanOrEqual(2); // Allow slightly more tolerance for extreme values }); }); - + test("should handle bitmap ticks from edge case scenario", () => { // Given - these are the actual bitmap ticks that were above the current tick const bitmapTicks = [-30, -31, -32, -33, -346]; - - bitmapTicks.forEach(tick => { + + bitmapTicks.forEach((tick) => { // When const sqrtPrice = tickToSqrtPrice(tick); - + // Then expect(sqrtPrice.toNumber()).toBeGreaterThan(0); expect(sqrtPrice.toNumber()).toBeLessThan(1); // All negative ticks should be < 1 - + // More negative should be smaller const moreNegativeTick = tick - 1000; const moreNegativeSqrtPrice = tickToSqrtPrice(moreNegativeTick); expect(moreNegativeSqrtPrice.toNumber()).toBeLessThan(sqrtPrice.toNumber()); - + // Verify round-trip conversion const backToTick = sqrtPriceToTick(sqrtPrice); expect(Math.abs(backToTick - tick)).toBeLessThanOrEqual(1); }); }); }); - + describe("Extreme positive cases", () => { test("should handle very positive ticks approaching maximum", () => { // Given - test ticks approaching the theoretical maximum @@ -166,75 +166,75 @@ describe("tickToSqrtPrice", () => { { tick: 500000, description: "Near maximum tick" }, { tick: 800000, description: "Close to theoretical max" } ]; - + testCases.forEach(({ tick, description }) => { // When const sqrtPrice = tickToSqrtPrice(tick); - + // Then expect(sqrtPrice.toNumber()).toBeGreaterThan(1); expect(Number.isFinite(sqrtPrice.toNumber())).toBe(true); - + // More positive ticks should produce larger sqrt prices if (tick < 800000) { const morePositiveTick = tick + 10000; const morePositiveSqrtPrice = tickToSqrtPrice(morePositiveTick); expect(morePositiveSqrtPrice.toNumber()).toBeGreaterThan(sqrtPrice.toNumber()); } - + // Verify conversion maintains precision const backToTick = sqrtPriceToTick(sqrtPrice); expect(Math.abs(backToTick - tick)).toBeLessThanOrEqual(2); }); }); }); - + describe("Mathematical properties", () => { test("should maintain exponential relationship (1.0001^(tick/2))", () => { // Given const testTicks = [-50000, -10000, -1000, 0, 1000, 10000, 50000]; - - testTicks.forEach(tick => { + + testTicks.forEach((tick) => { // When const sqrtPrice = tickToSqrtPrice(tick); const expectedSqrtPrice = new BigNumber(1.0001 ** (tick / 2)); - + // Then - should match the mathematical formula expect(sqrtPrice.toNumber()).toBeCloseTo(expectedSqrtPrice.toNumber(), 10); }); }); - + test("should demonstrate monotonic increasing property", () => { // Given - array of increasing ticks const increasingTicks = [-100000, -50000, -10000, -1000, 0, 1000, 10000, 50000, 100000]; - + // When & Then - each tick should produce a larger sqrt price than the previous for (let i = 1; i < increasingTicks.length; i++) { const prevSqrtPrice = tickToSqrtPrice(increasingTicks[i - 1]); const currSqrtPrice = tickToSqrtPrice(increasingTicks[i]); - + expect(currSqrtPrice.toNumber()).toBeGreaterThan(prevSqrtPrice.toNumber()); } }); - + test("should handle tick spacing boundaries correctly", () => { // Given - test around common tick spacing values const tickSpacings = [10, 60, 200]; // For 0.05%, 0.3%, 1% fees - - tickSpacings.forEach(spacing => { + + tickSpacings.forEach((spacing) => { // Test around various multiples of spacing const baseTicks = [-10000, -1000, 0, 1000, 10000]; - - baseTicks.forEach(baseTick => { + + baseTicks.forEach((baseTick) => { const spacedTick = Math.floor(baseTick / spacing) * spacing; - + // When const sqrtPrice = tickToSqrtPrice(spacedTick); - + // Then expect(sqrtPrice.toNumber()).toBeGreaterThan(0); expect(Number.isFinite(sqrtPrice.toNumber())).toBe(true); - + // Verify tick spacing doesn't break calculations const nextSpacedTick = spacedTick + spacing; const nextSqrtPrice = tickToSqrtPrice(nextSpacedTick); @@ -242,7 +242,7 @@ describe("tickToSqrtPrice", () => { }); }); }); - + test("should handle precision at extreme scales", () => { // Given - test cases that push precision limits const extremeCases = [ @@ -251,17 +251,17 @@ describe("tickToSqrtPrice", () => { { tick: -346, description: "Bitmap edge case" }, { tick: 887200, description: "Near theoretical maximum" } ]; - + extremeCases.forEach(({ tick, description }) => { // When const sqrtPrice = tickToSqrtPrice(tick); - + // Then expect(sqrtPrice.toNumber()).toBeGreaterThan(0); expect(Number.isFinite(sqrtPrice.toNumber())).toBe(true); - expect(sqrtPrice.toString()).not.toBe('NaN'); - expect(sqrtPrice.toString()).not.toBe('Infinity'); - + expect(sqrtPrice.toString()).not.toBe("NaN"); + expect(sqrtPrice.toString()).not.toBe("Infinity"); + // Verify we can still do round-trip conversion with reasonable precision const backToTick = sqrtPriceToTick(sqrtPrice); const tolerance = Math.abs(tick) > 100000 ? 5 : 1; // Higher tolerance for extreme values @@ -269,35 +269,35 @@ describe("tickToSqrtPrice", () => { }); }); }); - + describe("Relationship to swap edge case", () => { test("should demonstrate the problematic price relationships from edge case", () => { // Given - recreate the exact scenario from the swap bug const currentTick = -81920; const bitmapTicks = [-30, -31, -32, -33, -346]; - + // When const currentSqrtPrice = tickToSqrtPrice(currentTick); - const bitmapSqrtPrices = bitmapTicks.map(tick => ({ + const bitmapSqrtPrices = bitmapTicks.map((tick) => ({ tick, sqrtPrice: tickToSqrtPrice(tick) })); - + // Then - demonstrate why the edge case occurred - console.log('Current position: tick', currentTick, 'sqrtPrice', currentSqrtPrice.toString()); - + console.log("Current position: tick", currentTick, "sqrtPrice", currentSqrtPrice.toString()); + bitmapSqrtPrices.forEach(({ tick, sqrtPrice }) => { - console.log('Bitmap tick', tick, 'sqrtPrice', sqrtPrice.toString()); - + console.log("Bitmap tick", tick, "sqrtPrice", sqrtPrice.toString()); + // All bitmap ticks should have higher sqrt prices than current expect(sqrtPrice.toNumber()).toBeGreaterThan(currentSqrtPrice.toNumber()); - - // This demonstrates why nextInitialisedTickWithInSameWord + + // This demonstrates why nextInitialisedTickWithInSameWord // returns a tick that moves price UP instead of DOWN if (tick === -346) { // This specific tick was likely returned by nextInitialisedTickWithInSameWord expect(sqrtPrice.toNumber()).toBeCloseTo(0.982849635874457, 10); - + // Show the massive difference that caused the bug const priceRatio = sqrtPrice.dividedBy(currentSqrtPrice); expect(priceRatio.toNumber()).toBeGreaterThan(50); // Price would increase by 50x+! @@ -305,4 +305,4 @@ describe("tickToSqrtPrice", () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/src/chaincode/dex/swap.helper.spec.ts b/src/chaincode/dex/swap.helper.spec.ts index dabff98..ed8fbda 100644 --- a/src/chaincode/dex/swap.helper.spec.ts +++ b/src/chaincode/dex/swap.helper.spec.ts @@ -16,13 +16,7 @@ import { fixture } from "@gala-chain/test"; import BigNumber from "bignumber.js"; import { plainToInstance } from "class-transformer"; -import { - DexFeePercentageTypes, - Pool, - SwapState, - TickData, - sqrtPriceToTick -} from "../../api"; +import { DexFeePercentageTypes, Pool, SwapState, TickData, sqrtPriceToTick } from "../../api"; import { DexV3Contract } from "../DexV3Contract"; import { processSwapSteps } from "./swap.helper"; @@ -32,7 +26,7 @@ describe("swap.helper", () => { // Given const poolHash = "test-pool-hash"; const fee = DexFeePercentageTypes.FEE_0_3_PERCENT; - + // Create a pool with initialized bitmap const pool = plainToInstance(Pool, { token0: "GALA:Unit:none:none", @@ -44,7 +38,7 @@ describe("swap.helper", () => { feeGrowthGlobal0: new BigNumber("0"), feeGrowthGlobal1: new BigNumber("0"), bitmap: { - "0": "1", // Tick 0 is initialized + "0": "1" // Tick 0 is initialized }, tickSpacing: 60, // For 0.3% fee protocolFees: 0.1, // 10% protocol fee @@ -52,7 +46,7 @@ describe("swap.helper", () => { protocolFeesToken1: new BigNumber("0") }); pool.genPoolHash = () => poolHash; - + // Create initial swap state const initialState: SwapState = { amountSpecifiedRemaining: new BigNumber("100"), // 100 tokens to swap @@ -63,22 +57,21 @@ describe("swap.helper", () => { feeGrowthGlobalX: new BigNumber("0"), protocolFee: new BigNumber("0") }; - + // Create tick data for the test const tickData = new TickData(poolHash, 0); tickData.liquidityNet = new BigNumber("0"); tickData.liquidityGross = new BigNumber("1000000"); tickData.initialised = true; - + // Setup fixture - const { ctx } = fixture(DexV3Contract) - .savedState(tickData); - + const { ctx } = fixture(DexV3Contract).savedState(tickData); + // Set up parameters const sqrtPriceLimit = new BigNumber("0.9"); // Allow price to move to 0.9 const exactInput = true; // Exact input swap const zeroForOne = true; // Swapping token0 for token1 - + // When const resultState = await processSwapSteps( ctx, @@ -88,29 +81,29 @@ describe("swap.helper", () => { exactInput, zeroForOne ); - + // Then expect(resultState).toBeDefined(); expect(resultState.sqrtPrice).toBeDefined(); expect(resultState.amountSpecifiedRemaining).toBeDefined(); expect(resultState.amountCalculated).toBeDefined(); - + // Verify that some swap occurred expect(resultState.amountSpecifiedRemaining.toNumber()).toBeLessThan(100); expect(resultState.amountCalculated.toNumber()).toBeLessThan(0); // Negative for output - + // Verify protocol fee was applied expect(resultState.protocolFee.toNumber()).toBeGreaterThan(0); - + // Verify fee growth was updated expect(resultState.feeGrowthGlobalX.toNumber()).toBeGreaterThan(0); }); - + test("should handle swap with no liquidity gracefully", async () => { // Given const poolHash = "empty-pool-hash"; const fee = DexFeePercentageTypes.FEE_0_3_PERCENT; - + // Create a pool with no liquidity const pool = plainToInstance(Pool, { token0: "GALA:Unit:none:none", @@ -128,7 +121,7 @@ describe("swap.helper", () => { protocolFeesToken1: new BigNumber("0") }); pool.genPoolHash = () => poolHash; - + // Create initial swap state const initialState: SwapState = { amountSpecifiedRemaining: new BigNumber("100"), @@ -139,31 +132,24 @@ describe("swap.helper", () => { feeGrowthGlobalX: new BigNumber("0"), protocolFee: new BigNumber("0") }; - + const { ctx } = fixture(DexV3Contract); - + // When - const resultState = await processSwapSteps( - ctx, - initialState, - pool, - new BigNumber("0.9"), - true, - true - ); - + const resultState = await processSwapSteps(ctx, initialState, pool, new BigNumber("0.9"), true, true); + // Then // With no liquidity, the swap should hit the price limit without swapping expect(resultState.sqrtPrice.toNumber()).toBe(0.9); // Hit price limit expect(resultState.amountSpecifiedRemaining.toNumber()).toBe(100); // No amount consumed expect(resultState.amountCalculated.toNumber()).toBe(0); // No output }); - + test("should process swap starting from negative tick", async () => { // Given const poolHash = "negative-tick-pool"; const fee = DexFeePercentageTypes.FEE_0_3_PERCENT; - + // Create a pool with price < 1 (negative tick) const pool = plainToInstance(Pool, { token0: "GALA:Unit:none:none", @@ -175,7 +161,7 @@ describe("swap.helper", () => { feeGrowthGlobal0: new BigNumber("0"), feeGrowthGlobal1: new BigNumber("0"), bitmap: { - "-1": "1", // Negative tick initialized + "-1": "1" // Negative tick initialized }, tickSpacing: 60, protocolFees: 0.05, @@ -183,16 +169,15 @@ describe("swap.helper", () => { protocolFeesToken1: new BigNumber("0") }); pool.genPoolHash = () => poolHash; - + // Create tick data for negative tick const negativeTickData = new TickData(poolHash, -6932); // Approximate tick for sqrtPrice 0.5 negativeTickData.liquidityNet = new BigNumber("0"); negativeTickData.liquidityGross = new BigNumber("2000000"); negativeTickData.initialised = true; - - const { ctx } = fixture(DexV3Contract) - .savedState(negativeTickData); - + + const { ctx } = fixture(DexV3Contract).savedState(negativeTickData); + const initialState: SwapState = { amountSpecifiedRemaining: new BigNumber("200"), amountCalculated: new BigNumber("0"), @@ -202,7 +187,7 @@ describe("swap.helper", () => { feeGrowthGlobalX: new BigNumber("0"), protocolFee: new BigNumber("0") }; - + // When - swap to even lower price const resultState = await processSwapSteps( ctx, @@ -212,7 +197,7 @@ describe("swap.helper", () => { true, true ); - + // Then expect(resultState).toBeDefined(); expect(resultState.sqrtPrice.toNumber()).toBeLessThan(0.5); @@ -220,12 +205,12 @@ describe("swap.helper", () => { expect(resultState.amountSpecifiedRemaining.toNumber()).toBeLessThan(200); expect(resultState.amountCalculated.toNumber()).toBeLessThan(0); }); - + test("should handle swap crossing from negative to positive ticks", async () => { // Given const poolHash = "crossing-zero-pool"; const fee = DexFeePercentageTypes.FEE_0_05_PERCENT; // 5 bps fee - + // Start at negative tick, will cross to positive const pool = plainToInstance(Pool, { token0: "GALA:Unit:none:none", @@ -238,7 +223,7 @@ describe("swap.helper", () => { feeGrowthGlobal1: new BigNumber("0"), bitmap: { "-1": "3", // Ticks -10 and 0 initialized (binary 11) - "0": "1", // Tick 10 initialized + "0": "1" // Tick 10 initialized }, tickSpacing: 10, // For 0.05% fee protocolFees: 0.1, @@ -246,26 +231,25 @@ describe("swap.helper", () => { protocolFeesToken1: new BigNumber("0") }); pool.genPoolHash = () => poolHash; - + // Create tick data at key crossing points const tickNeg10 = new TickData(poolHash, -10); tickNeg10.liquidityNet = new BigNumber("1000000"); tickNeg10.liquidityGross = new BigNumber("1000000"); tickNeg10.initialised = true; - + const tick0 = new TickData(poolHash, 0); tick0.liquidityNet = new BigNumber("-500000"); tick0.liquidityGross = new BigNumber("500000"); tick0.initialised = true; - + const tick10 = new TickData(poolHash, 10); tick10.liquidityNet = new BigNumber("-500000"); tick10.liquidityGross = new BigNumber("500000"); tick10.initialised = true; - - const { ctx } = fixture(DexV3Contract) - .savedState(tickNeg10, tick0, tick10); - + + const { ctx } = fixture(DexV3Contract).savedState(tickNeg10, tick0, tick10); + const initialState: SwapState = { amountSpecifiedRemaining: new BigNumber("1000"), amountCalculated: new BigNumber("0"), @@ -275,7 +259,7 @@ describe("swap.helper", () => { feeGrowthGlobalX: new BigNumber("0"), protocolFee: new BigNumber("0") }; - + // When - swap to positive tick range const resultState = await processSwapSteps( ctx, @@ -285,7 +269,7 @@ describe("swap.helper", () => { true, false // zeroForOne = false to increase price ); - + // Then expect(resultState).toBeDefined(); expect(resultState.sqrtPrice.toNumber()).toBeGreaterThan(0.8); @@ -295,4 +279,4 @@ describe("swap.helper", () => { expect(resultState.amountCalculated.toNumber()).toBeLessThan(0); }); }); -}); \ No newline at end of file +}); diff --git a/src/chaincode/dex/swap.spec.ts b/src/chaincode/dex/swap.spec.ts index 9ec19c2..f0db080 100644 --- a/src/chaincode/dex/swap.spec.ts +++ b/src/chaincode/dex/swap.spec.ts @@ -12,19 +12,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { randomUniqueKey, TokenBalance, TokenClass, TokenClassKey, TokenInstance } from "@gala-chain/api"; +import { TokenBalance, TokenClass, TokenClassKey, TokenInstance, randomUniqueKey } from "@gala-chain/api"; import { currency, fixture, transactionError, transactionSuccess, users } from "@gala-chain/test"; import BigNumber from "bignumber.js"; import { plainToInstance } from "class-transformer"; -import { - DexFeePercentageTypes, - DexPositionData, - Pool, - SwapDto, - SwapResDto, - TickData -} from "../../api"; +import { DexFeePercentageTypes, DexPositionData, Pool, SwapDto, SwapResDto, TickData } from "../../api"; import { DexV3Contract } from "../DexV3Contract"; import dex from "../test/dex"; import { generateKeyFromClassKey } from "./dexUtils"; @@ -38,12 +31,12 @@ describe("swap", () => { const dexClass: TokenClass = dex.tokenClass(); const dexInstance: TokenInstance = dex.tokenInstance(); const dexClassKey: TokenClassKey = dex.tokenClassKey(); - + // Create normalized token keys for pool const token0Key = generateKeyFromClassKey(dexClassKey); const token1Key = generateKeyFromClassKey(currencyClassKey); const fee = DexFeePercentageTypes.FEE_0_05_PERCENT; - + // Initialize pool with manual values const pool = new Pool( token0Key, @@ -56,14 +49,14 @@ describe("swap", () => { ); const bitmap: Record = { - "-30":"75557863725914323419136", - "-31":"37778931862957161709568", - "-32":"40564819207303340847894502572032", - "-33":"26959946667150639794667015087019630673637144422540572481103610249216", - "-346":"5708990770823839524233143877797980545530986496", - "346":"20282409603651670423947251286016" + "-30": "75557863725914323419136", + "-31": "37778931862957161709568", + "-32": "40564819207303340847894502572032", + "-33": "26959946667150639794667015087019630673637144422540572481103610249216", + "-346": "5708990770823839524233143877797980545530986496", + "346": "20282409603651670423947251286016" }; - + // Add initial liquidity to the pool pool.liquidity = new BigNumber("77789.999499306764803261"); pool.grossPoolLiquidity = new BigNumber("348717210.55494320449679994"); @@ -81,7 +74,7 @@ describe("swap", () => { owner: poolAlias, quantity: new BigNumber("188809.790718") }); - + // Create user balances - user needs tokens to swap const userDexBalance = plainToInstance(TokenBalance, { ...dex.tokenBalance(), @@ -93,7 +86,7 @@ describe("swap", () => { owner: users.testUser1.identityKey, quantity: new BigNumber("10000") // User has 10k CURRENCY tokens }); - + // Setup the fixture const { ctx, contract } = fixture(DexV3Contract) .registeredUsers(users.testUser1) @@ -108,22 +101,22 @@ describe("swap", () => { userDexBalance, userCurrencyBalance ); - + const swapDto = new SwapDto( dexClassKey, currencyClassKey, fee, - new BigNumber("151.714011"), + new BigNumber("151.714011"), true, // zeroForOne - swapping token0 (DEX) for token1 (CURRENCY) new BigNumber("0.015"), - new BigNumber("151.714011"), + new BigNumber("151.714011"), new BigNumber("-0.04") ); swapDto.uniqueKey = randomUniqueKey(); const signedDto = swapDto.signed(users.testUser1.privateKey); - + const expectedResponse = new SwapResDto( dexClass.symbol, "https://app.gala.games/test-image-placeholder-url.png", @@ -139,11 +132,11 @@ describe("swap", () => { ); // When const response = await contract.Swap(ctx, signedDto); - + // Then expect(response).toEqual(transactionSuccess(expectedResponse)); expect(response.Data).toBeDefined(); - + const swapResult = response.Data as SwapResDto; expect(swapResult.token0).toBe(dexClass.symbol); expect(swapResult.token1).toBe(currencyClass.symbol); @@ -151,7 +144,7 @@ describe("swap", () => { expect(swapResult.poolHash).toBe(pool.genPoolHash()); expect(swapResult.poolAlias).toBe(poolAlias); expect(swapResult.poolFee).toBe(fee); - + // Verify amounts - exact amounts will depend on swap math expect(new BigNumber(swapResult.amount0).toNumber()).toBeGreaterThan(0); // User pays DEX expect(new BigNumber(swapResult.amount1).toNumber()).toBeLessThan(100); // User receives CURRENCY @@ -165,12 +158,12 @@ describe("swap", () => { const dexClass: TokenClass = dex.tokenClass(); const dexInstance: TokenInstance = dex.tokenInstance(); const dexClassKey: TokenClassKey = dex.tokenClassKey(); - + // Create normalized token keys for pool const token0Key = generateKeyFromClassKey(dexClassKey); const token1Key = generateKeyFromClassKey(currencyClassKey); const fee = DexFeePercentageTypes.FEE_0_05_PERCENT; - + // Initialize pool with manual values const pool = new Pool( token0Key, @@ -183,14 +176,14 @@ describe("swap", () => { ); const bitmap: Record = { - "-30":"75557863725914323419136", - "-31":"37778931862957161709568", - "-32":"40564819207303340847894502572032", - "-33":"26959946667150639794667015087019630673637144422540572481103610249216", - "-346":"5708990770823839524233143877797980545530986496", - "346":"20282409603651670423947251286016" + "-30": "75557863725914323419136", + "-31": "37778931862957161709568", + "-32": "40564819207303340847894502572032", + "-33": "26959946667150639794667015087019630673637144422540572481103610249216", + "-346": "5708990770823839524233143877797980545530986496", + "346": "20282409603651670423947251286016" }; - + // Add initial liquidity to the pool pool.liquidity = new BigNumber("77789.999499306764803261"); pool.grossPoolLiquidity = new BigNumber("348717210.55494320449679994"); @@ -208,7 +201,7 @@ describe("swap", () => { owner: poolAlias, quantity: new BigNumber("188809.790718") }); - + // Create user balances - user needs tokens to swap const userDexBalance = plainToInstance(TokenBalance, { ...dex.tokenBalance(), @@ -220,7 +213,7 @@ describe("swap", () => { owner: users.testUser1.identityKey, quantity: new BigNumber("10000") // User has 10k CURRENCY tokens }); - + // Setup the fixture const { ctx, contract } = fixture(DexV3Contract) .registeredUsers(users.testUser1) @@ -235,29 +228,31 @@ describe("swap", () => { userDexBalance, userCurrencyBalance ); - + const swapDto = new SwapDto( dexClassKey, currencyClassKey, fee, - new BigNumber("151.714011"), + new BigNumber("151.714011"), true, // zeroForOne - swapping token0 (DEX) for token1 (CURRENCY) new BigNumber("0.000000000000000000094212147"), - new BigNumber("151.714011"), + new BigNumber("151.714011"), new BigNumber("-75.8849266551571701291") ); swapDto.uniqueKey = randomUniqueKey(); const signedDto = swapDto.signed(users.testUser1.privateKey); - + // When const response = await contract.Swap(ctx, signedDto); - + // Then - expect(response).toEqual(transactionError( - "Slippage tolerance exceeded: minimum received tokens (-75.8849266551571701291) " + - "is less than actual received amount (-0.04199899554428437042776361879722152347)." - )); + expect(response).toEqual( + transactionError( + "Slippage tolerance exceeded: minimum received tokens (-75.8849266551571701291) " + + "is less than actual received amount (-0.04199899554428437042776361879722152347)." + ) + ); }); -}); \ No newline at end of file +}); diff --git a/src/chaincode/dex/tickData.helper.spec.ts b/src/chaincode/dex/tickData.helper.spec.ts index 27f8a36..3033018 100644 --- a/src/chaincode/dex/tickData.helper.spec.ts +++ b/src/chaincode/dex/tickData.helper.spec.ts @@ -31,13 +31,12 @@ describe("tickData.helper", () => { existingTickData.initialised = true; existingTickData.feeGrowthOutside0 = new BigNumber("0"); existingTickData.feeGrowthOutside1 = new BigNumber("0"); - - const { ctx } = fixture(DexV3Contract) - .savedState(existingTickData); - + + const { ctx } = fixture(DexV3Contract).savedState(existingTickData); + const feeGrowthGlobal0 = new BigNumber("100"); const feeGrowthGlobal1 = new BigNumber("50"); - + // When const liquidityNet = await fetchOrCreateAndCrossTick( ctx, @@ -46,22 +45,22 @@ describe("tickData.helper", () => { feeGrowthGlobal0, feeGrowthGlobal1 ); - + // Then expect(liquidityNet).toBeDefined(); expect(liquidityNet.toNumber()).toBe(1000); }); - + test("should create new tick if not found", async () => { // Given const poolHash = "test-pool"; const tick = 200; - + const { ctx } = fixture(DexV3Contract); - + const feeGrowthGlobal0 = new BigNumber("100"); const feeGrowthGlobal1 = new BigNumber("50"); - + // When const liquidityNet = await fetchOrCreateAndCrossTick( ctx, @@ -70,39 +69,33 @@ describe("tickData.helper", () => { feeGrowthGlobal0, feeGrowthGlobal1 ); - + // Then expect(liquidityNet).toBeDefined(); expect(liquidityNet.toNumber()).toBe(0); // New tick has zero liquidity }); }); - + describe("fetchOrCreateTickDataPair", () => { test("should fetch existing tick data pair", async () => { // Given const poolHash = "test-pool"; const tickLower = -100; const tickUpper = 100; - + const tickLowerData = new TickData(poolHash, tickLower); tickLowerData.liquidityNet = new BigNumber("1000"); tickLowerData.initialised = true; - + const tickUpperData = new TickData(poolHash, tickUpper); tickUpperData.liquidityNet = new BigNumber("-1000"); tickUpperData.initialised = true; - - const { ctx } = fixture(DexV3Contract) - .savedState(tickLowerData, tickUpperData); - + + const { ctx } = fixture(DexV3Contract).savedState(tickLowerData, tickUpperData); + // When - const result = await fetchOrCreateTickDataPair( - ctx, - poolHash, - tickLower, - tickUpper - ); - + const result = await fetchOrCreateTickDataPair(ctx, poolHash, tickLower, tickUpper); + // Then expect(result.tickLowerData).toBeDefined(); expect(result.tickUpperData).toBeDefined(); @@ -111,23 +104,18 @@ describe("tickData.helper", () => { expect(result.tickLowerData.liquidityNet.toNumber()).toBe(1000); expect(result.tickUpperData.liquidityNet.toNumber()).toBe(-1000); }); - + test("should create new tick data if not found", async () => { // Given const poolHash = "test-pool"; const tickLower = -200; const tickUpper = 200; - + const { ctx } = fixture(DexV3Contract); - + // When - const result = await fetchOrCreateTickDataPair( - ctx, - poolHash, - tickLower, - tickUpper - ); - + const result = await fetchOrCreateTickDataPair(ctx, poolHash, tickLower, tickUpper); + // Then expect(result.tickLowerData).toBeDefined(); expect(result.tickUpperData).toBeDefined(); @@ -138,101 +126,83 @@ describe("tickData.helper", () => { expect(result.tickLowerData.liquidityNet.toNumber()).toBe(0); expect(result.tickUpperData.liquidityNet.toNumber()).toBe(0); }); - + test("should handle mixed case - one tick exists, one doesn't", async () => { // Given const poolHash = "test-pool"; const tickLower = -300; const tickUpper = 300; - + // Only lower tick exists const tickLowerData = new TickData(poolHash, tickLower); tickLowerData.liquidityNet = new BigNumber("500"); tickLowerData.initialised = true; - - const { ctx } = fixture(DexV3Contract) - .savedState(tickLowerData); - + + const { ctx } = fixture(DexV3Contract).savedState(tickLowerData); + // When - const result = await fetchOrCreateTickDataPair( - ctx, - poolHash, - tickLower, - tickUpper - ); - + const result = await fetchOrCreateTickDataPair(ctx, poolHash, tickLower, tickUpper); + // Then expect(result.tickLowerData.liquidityNet.toNumber()).toBe(500); expect(result.tickLowerData.initialised).toBe(true); expect(result.tickUpperData.liquidityNet.toNumber()).toBe(0); expect(result.tickUpperData.initialised).toBe(false); }); - + test("should handle negative tick ranges correctly", async () => { // Given const poolHash = "test-pool"; const tickLower = -1000; const tickUpper = -500; - + const tickLowerData = new TickData(poolHash, tickLower); tickLowerData.liquidityNet = new BigNumber("2000"); tickLowerData.liquidityGross = new BigNumber("2000"); tickLowerData.initialised = true; - + const tickUpperData = new TickData(poolHash, tickUpper); tickUpperData.liquidityNet = new BigNumber("-2000"); tickUpperData.liquidityGross = new BigNumber("2000"); tickUpperData.initialised = true; - - const { ctx } = fixture(DexV3Contract) - .savedState(tickLowerData, tickUpperData); - + + const { ctx } = fixture(DexV3Contract).savedState(tickLowerData, tickUpperData); + // When - const result = await fetchOrCreateTickDataPair( - ctx, - poolHash, - tickLower, - tickUpper - ); - + const result = await fetchOrCreateTickDataPair(ctx, poolHash, tickLower, tickUpper); + // Then expect(result.tickLowerData.tick).toBe(-1000); expect(result.tickUpperData.tick).toBe(-500); expect(result.tickLowerData.liquidityNet.toNumber()).toBe(2000); expect(result.tickUpperData.liquidityNet.toNumber()).toBe(-2000); }); - + test("should handle range crossing zero (negative to positive)", async () => { // Given const poolHash = "test-pool"; const tickLower = -600; const tickUpper = 600; - + const tickLowerData = new TickData(poolHash, tickLower); tickLowerData.liquidityNet = new BigNumber("1500"); tickLowerData.liquidityGross = new BigNumber("1500"); tickLowerData.initialised = true; tickLowerData.feeGrowthOutside0 = new BigNumber("10"); tickLowerData.feeGrowthOutside1 = new BigNumber("5"); - + const tickUpperData = new TickData(poolHash, tickUpper); tickUpperData.liquidityNet = new BigNumber("-1500"); tickUpperData.liquidityGross = new BigNumber("1500"); tickUpperData.initialised = true; tickUpperData.feeGrowthOutside0 = new BigNumber("20"); tickUpperData.feeGrowthOutside1 = new BigNumber("10"); - - const { ctx } = fixture(DexV3Contract) - .savedState(tickLowerData, tickUpperData); - + + const { ctx } = fixture(DexV3Contract).savedState(tickLowerData, tickUpperData); + // When - const result = await fetchOrCreateTickDataPair( - ctx, - poolHash, - tickLower, - tickUpper - ); - + const result = await fetchOrCreateTickDataPair(ctx, poolHash, tickLower, tickUpper); + // Then expect(result.tickLowerData.tick).toBe(-600); expect(result.tickUpperData.tick).toBe(600); @@ -240,7 +210,7 @@ describe("tickData.helper", () => { expect(result.tickUpperData.feeGrowthOutside1.toNumber()).toBe(10); }); }); - + describe("fetchOrCreateAndCrossTick with negative ticks", () => { test("should handle crossing negative tick", async () => { // Given @@ -252,13 +222,12 @@ describe("tickData.helper", () => { existingTickData.initialised = true; existingTickData.feeGrowthOutside0 = new BigNumber("0"); existingTickData.feeGrowthOutside1 = new BigNumber("0"); - - const { ctx } = fixture(DexV3Contract) - .savedState(existingTickData); - + + const { ctx } = fixture(DexV3Contract).savedState(existingTickData); + const feeGrowthGlobal0 = new BigNumber("150"); const feeGrowthGlobal1 = new BigNumber("75"); - + // When const liquidityNet = await fetchOrCreateAndCrossTick( ctx, @@ -267,28 +236,28 @@ describe("tickData.helper", () => { feeGrowthGlobal0, feeGrowthGlobal1 ); - + // Then expect(liquidityNet).toBeDefined(); expect(liquidityNet.toNumber()).toBe(3000); - + // Verify tick was updated with fee growth const updatedTick = await ctx.stub.getState( ctx.stub.createCompositeKey("TICK", [poolHash, tick.toString()]) ); expect(updatedTick).toBeDefined(); }); - + test("should create and cross very negative tick", async () => { // Given const poolHash = "test-pool"; const tick = -887272; // Near min tick for common tick spacing - + const { ctx } = fixture(DexV3Contract); - + const feeGrowthGlobal0 = new BigNumber("1000000"); const feeGrowthGlobal1 = new BigNumber("500000"); - + // When const liquidityNet = await fetchOrCreateAndCrossTick( ctx, @@ -297,10 +266,10 @@ describe("tickData.helper", () => { feeGrowthGlobal0, feeGrowthGlobal1 ); - + // Then expect(liquidityNet).toBeDefined(); expect(liquidityNet.toNumber()).toBe(0); // New tick has zero liquidity }); }); -}); \ No newline at end of file +}); From bb1af120f0f3525adbc6199db6a00a0610732029 Mon Sep 17 00:00:00 2001 From: Mike Graham Date: Mon, 4 Aug 2025 19:57:47 -0700 Subject: [PATCH 12/16] fix: sqrtPriceToTick calculations --- src/api/utils/dex/tick.helper.spec.ts | 4 ++++ src/api/utils/dex/tick.helper.ts | 12 ++++++++++-- src/chaincode/dex/swap.spec.ts | 4 ++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/api/utils/dex/tick.helper.spec.ts b/src/api/utils/dex/tick.helper.spec.ts index 328e645..d5a63bc 100644 --- a/src/api/utils/dex/tick.helper.spec.ts +++ b/src/api/utils/dex/tick.helper.spec.ts @@ -49,9 +49,13 @@ describe("tick.helper", () => { // When const tick = sqrtPriceToTick(sqrtPrice); + const tickPrice = tickToSqrtPrice(tick); + const higherTickPrice = tickToSqrtPrice(tick + 1); // Then expect(tick).toBe(200); + expect(tickPrice.toNumber()).toBeLessThanOrEqual(sqrtPrice.toNumber()); + expect(higherTickPrice.toNumber()).toBeGreaterThan(sqrtPrice.toNumber()); }); }); diff --git a/src/api/utils/dex/tick.helper.ts b/src/api/utils/dex/tick.helper.ts index 6464642..9332715 100644 --- a/src/api/utils/dex/tick.helper.ts +++ b/src/api/utils/dex/tick.helper.ts @@ -34,12 +34,20 @@ export function tickToSqrtPrice(tick: number): BigNumber { /** * - * @notice Calculates the greatest tick value such that getRatioAtTick(tick) <= ratio + * @notice Calculates the greatest tick value such that tickToSqrtPrice(tick) <= sqrtPrice * @param sqrtPrice The sqrt ratio for which to compute the tick * @return tick The greatest tick for which the ratio is less than or equal to the input ratio */ export function sqrtPriceToTick(sqrtPrice: BigNumber): number { - return Number((Math.log(sqrtPrice.toNumber() ** 2) / Math.log(1.0001)).toFixed(0)); + const calculatedTick: number = Number( + (Math.log(sqrtPrice.toNumber() ** 2) / Math.log(1.0001)).toFixed(0) + ); + + const tickPrice = tickToSqrtPrice(calculatedTick); + + const tick = tickPrice.isLessThanOrEqualTo(sqrtPrice) ? calculatedTick : calculatedTick - 1; + + return tick; } /** diff --git a/src/chaincode/dex/swap.spec.ts b/src/chaincode/dex/swap.spec.ts index f0db080..ca6e392 100644 --- a/src/chaincode/dex/swap.spec.ts +++ b/src/chaincode/dex/swap.spec.ts @@ -123,7 +123,7 @@ describe("swap", () => { currencyClass.symbol, "https://app.gala.games/test-image-placeholder-url.png", "151.7140110000", - "-0.0419989955", + "-0.0419968816", "client|testUser1", pool.genPoolHash(), poolAlias, @@ -251,7 +251,7 @@ describe("swap", () => { expect(response).toEqual( transactionError( "Slippage tolerance exceeded: minimum received tokens (-75.8849266551571701291) " + - "is less than actual received amount (-0.04199899554428437042776361879722152347)." + "is less than actual received amount (-0.04199688158254951488549494150933105767)." ) ); }); From dacfe04ff3ef18ea494e34b7664914a90d9b6612 Mon Sep 17 00:00:00 2001 From: Mike Graham Date: Tue, 5 Aug 2025 12:08:55 -0700 Subject: [PATCH 13/16] wip: test perf bug --- src/chaincode/dex/swap.helper.spec.ts | 108 +++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/src/chaincode/dex/swap.helper.spec.ts b/src/chaincode/dex/swap.helper.spec.ts index ed8fbda..4a74362 100644 --- a/src/chaincode/dex/swap.helper.spec.ts +++ b/src/chaincode/dex/swap.helper.spec.ts @@ -12,13 +12,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { fixture } from "@gala-chain/test"; +import { fixture, transactionError } from "@gala-chain/test"; import BigNumber from "bignumber.js"; import { plainToInstance } from "class-transformer"; import { DexFeePercentageTypes, Pool, SwapState, TickData, sqrtPriceToTick } from "../../api"; import { DexV3Contract } from "../DexV3Contract"; import { processSwapSteps } from "./swap.helper"; +import { GalaChainResponse } from "@gala-chain/api"; describe("swap.helper", () => { describe("processSwapSteps", () => { @@ -279,4 +280,109 @@ describe("swap.helper", () => { expect(resultState.amountCalculated.toNumber()).toBeLessThan(0); }); }); + + test("loop performance", async () => { + // Given + const poolHash = "loop-pool"; + const poolWithDustLiquidity = plainToInstance(Pool, { + "fee": 500, + "bitmap": { + "-2": "0", + "-3": "0", + "-4": "0", + "-5": "0", + "-6": "6129982163463555433433388108601236734474956488734408704", + "-7": "0", + "-8": "0", + "-9": "0", + "-10": "0", + "-11": "0", + "-12": "0", + "-13": "0", + "-14": "0", + "-15": "0", + "-16": "0", + "-17": "0", + "-18": "0", + "-19": "0", + "-20": "0", + "-21": "0", + "-22": "0", + "-23": "0", + "-24": "0", + "-25": "0", + "-26": "8" + }, + "token0": "GALA$Unit$none$none", + "token1": "GOSMI$Unit$none$none", + "liquidity": "0.034029613108643226", + "sqrtPrice": "0.86759926423373788029", + "tickSpacing": 10, + "protocolFees": 0.1, + "token0ClassKey": { + "type": "none", + "category": "Unit", + "collection": "GALA", + "additionalKey": "none" + }, + "token1ClassKey": { + "type": "none", + "category": "Unit", + "collection": "GOSMI", + "additionalKey": "none" + }, + "feeGrowthGlobal0": "0", + "feeGrowthGlobal1": "0.00037637760823854262", + "grossPoolLiquidity": "1803.22919862700454574", + "protocolFeesToken0": "0", + "protocolFeesToken1": "0.0000014231093767904548846723852534735862941902344", + "maxLiquidityPerTick": "1917565579412846627735051215301243.08110657663841167978" + }); + poolWithDustLiquidity.genPoolHash = () => poolHash; + + + const { ctx } = fixture(DexV3Contract).savedState(poolWithDustLiquidity); + + const state: SwapState = { + amountSpecifiedRemaining: new BigNumber("-41.62"), + amountCalculated: new BigNumber("0"), + sqrtPrice: new BigNumber(poolWithDustLiquidity.sqrtPrice), + tick: sqrtPriceToTick(poolWithDustLiquidity.sqrtPrice), + liquidity: new BigNumber("0.034029613108643226"), + feeGrowthGlobalX: new BigNumber("0"), + protocolFee: new BigNumber("500") + }; + + const bitmapEntriesStart = Object.keys(poolWithDustLiquidity.bitmap).length; + + // When + const exactInput = true; + const zeroForOne = false; + + // logic from quoteExactAmount + const sqrtPriceLimit = zeroForOne + ? new BigNumber("0.000000000000000000054212147") + : new BigNumber("18446050999999999999"); + + const swapPromise = await processSwapSteps( + ctx, + state, + poolWithDustLiquidity, + sqrtPriceLimit, + exactInput, + zeroForOne + ).catch((e) => e); + + // Then + const bitmapEntriesEnd = Object.keys(poolWithDustLiquidity.bitmap).length; + + // started with 25, iterated through to 373! + expect(bitmapEntriesStart).toBe(25); + expect(bitmapEntriesEnd).toBe(373); + + // todo: add real values for test later + // expect to fail, useful for analyzing actual output in test result + expect(poolWithDustLiquidity).toEqual({}); + expect(state).toEqual({}); + }); }); From a53d28b394b233dabf38094c0b01b88c0344f058 Mon Sep 17 00:00:00 2001 From: Mike Graham Date: Tue, 5 Aug 2025 12:41:32 -0700 Subject: [PATCH 14/16] test: event loop utilization for processSwapSteps --- src/chaincode/dex/swap.helper.spec.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/chaincode/dex/swap.helper.spec.ts b/src/chaincode/dex/swap.helper.spec.ts index 4a74362..1edf94e 100644 --- a/src/chaincode/dex/swap.helper.spec.ts +++ b/src/chaincode/dex/swap.helper.spec.ts @@ -15,6 +15,7 @@ import { fixture, transactionError } from "@gala-chain/test"; import BigNumber from "bignumber.js"; import { plainToInstance } from "class-transformer"; +import { performance } from "perf_hooks"; import { DexFeePercentageTypes, Pool, SwapState, TickData, sqrtPriceToTick } from "../../api"; import { DexV3Contract } from "../DexV3Contract"; @@ -354,7 +355,7 @@ describe("swap.helper", () => { }; const bitmapEntriesStart = Object.keys(poolWithDustLiquidity.bitmap).length; - + // When const exactInput = true; const zeroForOne = false; @@ -364,6 +365,9 @@ describe("swap.helper", () => { ? new BigNumber("0.000000000000000000054212147") : new BigNumber("18446050999999999999"); + + const start = performance.eventLoopUtilization(); + const swapPromise = await processSwapSteps( ctx, state, @@ -373,13 +377,19 @@ describe("swap.helper", () => { zeroForOne ).catch((e) => e); + const end = performance.eventLoopUtilization(start); + // Then const bitmapEntriesEnd = Object.keys(poolWithDustLiquidity.bitmap).length; + expect(end.idle).toBeGreaterThan(0); + expect(end.utilization).toBeLessThan(1); + // started with 25, iterated through to 373! expect(bitmapEntriesStart).toBe(25); expect(bitmapEntriesEnd).toBe(373); + // todo: add real values for test later // expect to fail, useful for analyzing actual output in test result expect(poolWithDustLiquidity).toEqual({}); From 52a8cf48084d8904e213dbfa2880075fc92b5070 Mon Sep 17 00:00:00 2001 From: Mike Graham Date: Tue, 5 Aug 2025 14:37:59 -0700 Subject: [PATCH 15/16] test: replicate event loop blocking from tick processing --- src/chaincode/dex/swap.helper.spec.ts | 131 +++++++++++++++++--------- 1 file changed, 84 insertions(+), 47 deletions(-) diff --git a/src/chaincode/dex/swap.helper.spec.ts b/src/chaincode/dex/swap.helper.spec.ts index 1edf94e..d81f153 100644 --- a/src/chaincode/dex/swap.helper.spec.ts +++ b/src/chaincode/dex/swap.helper.spec.ts @@ -12,15 +12,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { GalaChainResponse } from "@gala-chain/api"; import { fixture, transactionError } from "@gala-chain/test"; import BigNumber from "bignumber.js"; -import { plainToInstance } from "class-transformer"; -import { performance } from "perf_hooks"; +import { instanceToInstance, plainToInstance } from "class-transformer"; +import dns from "dns"; +import { monitorEventLoopDelay, performance } from "perf_hooks"; +import { setImmediate } from "timers/promises"; import { DexFeePercentageTypes, Pool, SwapState, TickData, sqrtPriceToTick } from "../../api"; import { DexV3Contract } from "../DexV3Contract"; import { processSwapSteps } from "./swap.helper"; -import { GalaChainResponse } from "@gala-chain/api"; describe("swap.helper", () => { describe("processSwapSteps", () => { @@ -286,8 +288,8 @@ describe("swap.helper", () => { // Given const poolHash = "loop-pool"; const poolWithDustLiquidity = plainToInstance(Pool, { - "fee": 500, - "bitmap": { + fee: 500, + bitmap: { "-2": "0", "-3": "0", "-4": "0", @@ -314,38 +316,37 @@ describe("swap.helper", () => { "-25": "0", "-26": "8" }, - "token0": "GALA$Unit$none$none", - "token1": "GOSMI$Unit$none$none", - "liquidity": "0.034029613108643226", - "sqrtPrice": "0.86759926423373788029", - "tickSpacing": 10, - "protocolFees": 0.1, - "token0ClassKey": { - "type": "none", - "category": "Unit", - "collection": "GALA", - "additionalKey": "none" + token0: "GALA$Unit$none$none", + token1: "GOSMI$Unit$none$none", + liquidity: "0.034029613108643226", + sqrtPrice: "0.86759926423373788029", + tickSpacing: 10, + protocolFees: 0.1, + token0ClassKey: { + type: "none", + category: "Unit", + collection: "GALA", + additionalKey: "none" }, - "token1ClassKey": { - "type": "none", - "category": "Unit", - "collection": "GOSMI", - "additionalKey": "none" + token1ClassKey: { + type: "none", + category: "Unit", + collection: "GOSMI", + additionalKey: "none" }, - "feeGrowthGlobal0": "0", - "feeGrowthGlobal1": "0.00037637760823854262", - "grossPoolLiquidity": "1803.22919862700454574", - "protocolFeesToken0": "0", - "protocolFeesToken1": "0.0000014231093767904548846723852534735862941902344", - "maxLiquidityPerTick": "1917565579412846627735051215301243.08110657663841167978" + feeGrowthGlobal0: "0", + feeGrowthGlobal1: "0.00037637760823854262", + grossPoolLiquidity: "1803.22919862700454574", + protocolFeesToken0: "0", + protocolFeesToken1: "0.0000014231093767904548846723852534735862941902344", + maxLiquidityPerTick: "1917565579412846627735051215301243.08110657663841167978" }); poolWithDustLiquidity.genPoolHash = () => poolHash; - const { ctx } = fixture(DexV3Contract).savedState(poolWithDustLiquidity); const state: SwapState = { - amountSpecifiedRemaining: new BigNumber("-41.62"), + amountSpecifiedRemaining: new BigNumber("-41.62"), amountCalculated: new BigNumber("0"), sqrtPrice: new BigNumber(poolWithDustLiquidity.sqrtPrice), tick: sqrtPriceToTick(poolWithDustLiquidity.sqrtPrice), @@ -355,7 +356,7 @@ describe("swap.helper", () => { }; const bitmapEntriesStart = Object.keys(poolWithDustLiquidity.bitmap).length; - + // When const exactInput = true; const zeroForOne = false; @@ -365,34 +366,70 @@ describe("swap.helper", () => { ? new BigNumber("0.000000000000000000054212147") : new BigNumber("18446050999999999999"); - const start = performance.eventLoopUtilization(); - const swapPromise = await processSwapSteps( - ctx, - state, - poolWithDustLiquidity, - sqrtPriceLimit, - exactInput, - zeroForOne - ).catch((e) => e); + const h = monitorEventLoopDelay({ resolution: 20 }); + h.enable(); + + let timeoutCallbackCount = 0; + let dnsLookupCallbackCount = 0; + + setTimeout(() => { + timeoutCallbackCount++; + }, 0); + dns.lookup("1.1.1.1", {}, () => { + dnsLookupCallbackCount++; + }); + + const iterations = 2000; + + for (let i = 0; i < iterations; i++) { + setTimeout(() => { + timeoutCallbackCount++; + }, 0); + dns.lookup("1.1.1.1", {}, () => { + dnsLookupCallbackCount++; + }); + const tmpState = i === 0 ? state : instanceToInstance(state); + await processSwapSteps( + ctx, + tmpState, + poolWithDustLiquidity, + sqrtPriceLimit, + exactInput, + zeroForOne + ).catch((e) => e); + setTimeout(() => { + timeoutCallbackCount++; + }, 0); + dns.lookup("1.1.1.1", {}, () => { + dnsLookupCallbackCount++; + }); + } + + await setImmediate(); + h.disable(); const end = performance.eventLoopUtilization(start); // Then const bitmapEntriesEnd = Object.keys(poolWithDustLiquidity.bitmap).length; - expect(end.idle).toBeGreaterThan(0); - expect(end.utilization).toBeLessThan(1); - // started with 25, iterated through to 373! expect(bitmapEntriesStart).toBe(25); expect(bitmapEntriesEnd).toBe(373); - - // todo: add real values for test later - // expect to fail, useful for analyzing actual output in test result - expect(poolWithDustLiquidity).toEqual({}); - expect(state).toEqual({}); + console.log(h.min); + console.log(h.max); + console.log(h.mean); + console.log(h.stddev); + console.log(h.percentiles); + console.log(h.percentile(50)); + console.log(h.percentile(99)); + console.log(end.idle); + console.log(end.utilization); + + expect(timeoutCallbackCount).toBeGreaterThan(iterations); + expect(dnsLookupCallbackCount).toBeGreaterThan(iterations); }); }); From f8bd21544c7d2335e19f7a4b697736380c1685e1 Mon Sep 17 00:00:00 2001 From: Mike Graham Date: Tue, 5 Aug 2025 14:54:32 -0700 Subject: [PATCH 16/16] fix: yield event loop during processSwapStep iterations --- src/chaincode/dex/swap.helper.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/chaincode/dex/swap.helper.ts b/src/chaincode/dex/swap.helper.ts index 4b8cc8f..80484a5 100644 --- a/src/chaincode/dex/swap.helper.ts +++ b/src/chaincode/dex/swap.helper.ts @@ -15,6 +15,7 @@ import { ConflictError } from "@gala-chain/api"; import { GalaChainContext } from "@gala-chain/chaincode"; import BigNumber from "bignumber.js"; +import { setImmediate } from "timers/promises"; import { Pool, @@ -150,6 +151,8 @@ export async function processSwapSteps( // Update tick based on new sqrtPrice state.tick = sqrtPriceToTick(state.sqrtPrice); } + + await setImmediate(); } return state;