diff --git a/src/api/types/DexDtos.spec.ts b/src/api/types/DexDtos.spec.ts index 66f42a0..b32b1e8 100644 --- a/src/api/types/DexDtos.spec.ts +++ b/src/api/types/DexDtos.spec.ts @@ -518,6 +518,32 @@ describe("DexDtos", () => { expect(dto.positionId).toBe("position-123"); }); + it("should create valid BurnDto with recipient", async () => { + // Given + const dto = new BurnDto( + mockToken0, + mockToken1, + DexFeePercentageTypes.FEE_0_3_PERCENT, + new BigNumber("1000"), + -500, + 500, + new BigNumber("10"), + new BigNumber("20"), + "position-123", + asValidUserAlias("client|user789") + ); + + // When + const validationErrors = await dto.validate(); + + // Then + if (validationErrors.length > 0) { + console.log("Validation errors:", validationErrors); + } + expect(validationErrors.length).toBe(0); + expect(dto.recipient).toEqual(asValidUserAlias("client|user789")); + }); + it("should fail validation with negative amounts", async () => { // Given const dto = new BurnDto( @@ -807,6 +833,28 @@ describe("DexDtos", () => { expect(dto.amount0Requested).toEqual(new BigNumber("100")); expect(dto.amount1Requested).toEqual(new BigNumber("200")); }); + + it("should create valid CollectDto with recipient", async () => { + // Given + const dto = new CollectDto( + mockToken0, + mockToken1, + DexFeePercentageTypes.FEE_1_PERCENT, + new BigNumber("100"), + new BigNumber("200"), + -400, + 400, + "position-789", + asValidUserAlias("client|user456") + ); + + // When + const validationErrors = await dto.validate(); + + // Then + expect(validationErrors.length).toBe(0); + expect(dto.recipient).toEqual(asValidUserAlias("client|user456")); + }); }); describe("AddLiquidityDTO", () => { @@ -833,6 +881,30 @@ describe("DexDtos", () => { expect(dto.amount0Desired).toEqual(new BigNumber("1000")); expect(dto.amount1Desired).toEqual(new BigNumber("2000")); }); + + it("should create valid AddLiquidityDTO with liquidityProvider", async () => { + // Given + const dto = new AddLiquidityDTO( + mockToken0, + mockToken1, + DexFeePercentageTypes.FEE_0_05_PERCENT, + -600, + 600, + new BigNumber("1000"), + new BigNumber("2000"), + new BigNumber("900"), + new BigNumber("1800"), + "position-abc", + asValidUserAlias("client|user123") + ); + + // When + const validationErrors = await dto.validate(); + + // Then + expect(validationErrors.length).toBe(0); + expect(dto.liquidityProvider).toEqual(asValidUserAlias("client|user123")); + }); }); describe("CollectProtocolFeesDto", () => { diff --git a/src/api/types/DexDtos.ts b/src/api/types/DexDtos.ts index 412ace6..2b2b61d 100644 --- a/src/api/types/DexDtos.ts +++ b/src/api/types/DexDtos.ts @@ -305,6 +305,10 @@ export class BurnDto extends SubmitCallDTO { @IsString() public positionId?: string; + @IsOptional() + @IsUserAlias() + public recipient?: UserAlias; + constructor( token0: TokenClassKey, token1: TokenClassKey, @@ -314,7 +318,8 @@ export class BurnDto extends SubmitCallDTO { tickUpper: number, amount0Min: BigNumber, amount1Min: BigNumber, - positionId: string | undefined + positionId: string | undefined, + recipient?: UserAlias ) { super(); this.tickLower = tickLower; @@ -326,6 +331,7 @@ export class BurnDto extends SubmitCallDTO { this.amount0Min = amount0Min; this.amount1Min = amount1Min; this.positionId = positionId; + this.recipient = recipient; } } @@ -570,6 +576,10 @@ export class CollectDto extends SubmitCallDTO { @IsString() public positionId?: string; + @IsOptional() + @IsUserAlias() + public recipient?: UserAlias; + constructor( token0: TokenClassKey, token1: TokenClassKey, @@ -578,7 +588,8 @@ export class CollectDto extends SubmitCallDTO { amount1Requested: BigNumber, tickLower: number, tickUpper: number, - positionId: string | undefined + positionId: string | undefined, + recipient?: UserAlias ) { super(); this.token0 = token0; @@ -589,6 +600,7 @@ export class CollectDto extends SubmitCallDTO { this.tickLower = tickLower; this.tickUpper = tickUpper; this.positionId = positionId; + this.recipient = recipient; } } @@ -637,6 +649,10 @@ export class AddLiquidityDTO extends SubmitCallDTO { @IsString() public positionId?: string; + @IsOptional() + @IsUserAlias() + public liquidityProvider?: UserAlias; + constructor( token0: TokenClassKey, token1: TokenClassKey, @@ -647,7 +663,8 @@ export class AddLiquidityDTO extends SubmitCallDTO { amount1Desired: BigNumber, amount0Min: BigNumber, amount1Min: BigNumber, - positionId: string | undefined + positionId: string | undefined, + liquidityProvider?: UserAlias ) { super(); this.token0 = token0; @@ -660,6 +677,7 @@ export class AddLiquidityDTO extends SubmitCallDTO { this.amount0Min = amount0Min; this.amount1Min = amount1Min; this.positionId = positionId; + this.liquidityProvider = liquidityProvider; } } diff --git a/src/chaincode/dex/addLiquidity.ts b/src/chaincode/dex/addLiquidity.ts index e61db77..80b0e6b 100644 --- a/src/chaincode/dex/addLiquidity.ts +++ b/src/chaincode/dex/addLiquidity.ts @@ -12,7 +12,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TokenInstanceKey, UserAlias } from "@gala-chain/api"; +import { TokenInstanceKey, UserAlias, asValidUserAlias } from "@gala-chain/api"; import { GalaChainContext, fetchOrCreateBalance, @@ -63,7 +63,11 @@ export async function addLiquidity( const token0InstanceKey = TokenInstanceKey.fungibleKey(pool.token0ClassKey); const token1InstanceKey = TokenInstanceKey.fungibleKey(pool.token1ClassKey); - const liquidityProvider = launchpadAddress ?? ctx.callingUser; + // Determine the actual liquidity provider - this may be different from the caller if adding liquidity on behalf of another user + const liquidityProvider = + dto.liquidityProvider && dto.liquidityProvider !== ctx.callingUser + ? asValidUserAlias(dto.liquidityProvider) + : (launchpadAddress ?? ctx.callingUser); const tickLower = parseInt(dto.tickLower.toString()), tickUpper = parseInt(dto.tickUpper.toString()); @@ -131,10 +135,7 @@ export async function addLiquidity( tokenInstanceKey: token0InstanceKey, quantity: roundedToken0Amount, allowancesToUse: [], - authorizedOnBehalf: { - callingOnBehalf: liquidityProvider, - callingUser: liquidityProvider - } + authorizedOnBehalf: undefined }); // transfer token1 @@ -144,10 +145,7 @@ export async function addLiquidity( tokenInstanceKey: token1InstanceKey, quantity: roundedToken1Amount, allowancesToUse: [], - authorizedOnBehalf: { - callingOnBehalf: liquidityProvider, - callingUser: liquidityProvider - } + authorizedOnBehalf: undefined }); await putChainObject(ctx, pool); diff --git a/src/chaincode/dex/burn.spec.ts b/src/chaincode/dex/burn.spec.ts index 47d51da..d84d529 100644 --- a/src/chaincode/dex/burn.spec.ts +++ b/src/chaincode/dex/burn.spec.ts @@ -12,7 +12,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { GalaChainResponse, NotFoundError } from "@gala-chain/api"; +import { GalaChainResponse, NotFoundError, TokenInstanceQueryKey } from "@gala-chain/api"; import { TokenBalance, TokenClass, TokenClassKey, TokenInstance } from "@gala-chain/api"; import { currency, transactionSuccess } from "@gala-chain/test"; import { fixture, users } from "@gala-chain/test"; @@ -26,6 +26,7 @@ import { DexOperationResDto, DexPositionData, DexPositionOwner, + GrantSwapAllowanceDto, InsufficientLiquidityError, Pool, SlippageToleranceExceededError, @@ -693,4 +694,295 @@ describe("Remove Liquidity Test", () => { ) ); }); + + describe("Burn with Recipient Parameter", () => { + it("Should allow burning with recipient parameter when recipient is the same as calling user", async () => { + //Given + const positionOwner = new DexPositionOwner(users.testUser1.identityKey, pool.genPoolHash()); + positionOwner.addPosition("75920:76110", "POSITION-ID"); + + const positionData = new DexPositionData( + pool.genPoolHash(), + "POSITION-ID", + 76110, + 75920, + dexClassKey, + currencyClassKey, + fee + ); + + const tickLowerData = new TickData(pool.genPoolHash(), 75920); + const tickUpperData = new TickData(pool.genPoolHash(), 76110); + + pool.mint(positionData, tickLowerData, tickUpperData, new BigNumber("75646")); + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + currencyInstance, + dexInstance, + dexClass, + pool, + positionOwner, + positionData, + tickLowerData, + tickUpperData, + currencyPoolBalance, + dexPoolBalance + ); + + const dto = new BurnDto( + dexClassKey, + currencyClassKey, + fee, + new BigNumber("346"), + 75920, + 76110, + new BigNumber("0"), + new BigNumber("0"), + "POSITION-ID", + users.testUser1.identityKey // recipient same as calling user + ); + + dto.uniqueKey = randomUUID(); + dto.sign(users.testUser1.privateKey); + + //When + const burnRes = await contract.RemoveLiquidity(ctx, dto); + + //Then + expect(burnRes.Status).toBe(1); + expect(burnRes.Data).toBeDefined(); + if (burnRes.Data) { + expect(burnRes.Data.positionId).toBe("POSITION-ID"); + expect(burnRes.Data.userAddress).toBe("client|testUser1"); + } + }); + + it("Should allow burning with recipient parameter when recipient is different from calling user and has proper allowances", async () => { + //Given + const positionOwner = new DexPositionOwner(users.testUser1.identityKey, pool.genPoolHash()); + positionOwner.addPosition("75920:76110", "POSITION-ID"); + + const positionData = new DexPositionData( + pool.genPoolHash(), + "POSITION-ID", + 76110, + 75920, + dexClassKey, + currencyClassKey, + fee + ); + + const tickLowerData = new TickData(pool.genPoolHash(), 75920); + const tickUpperData = new TickData(pool.genPoolHash(), 76110); + + pool.mint(positionData, tickLowerData, tickUpperData, new BigNumber("75646")); + + // Create transfer allowances for both tokens + const grantAllowanceDto0 = new GrantSwapAllowanceDto(); + const tokenInstanceQueryKey0 = new TokenInstanceQueryKey(); + tokenInstanceQueryKey0.collection = dexClassKey.collection; + tokenInstanceQueryKey0.category = dexClassKey.category; + tokenInstanceQueryKey0.type = dexClassKey.type; + tokenInstanceQueryKey0.additionalKey = dexClassKey.additionalKey; + tokenInstanceQueryKey0.instance = new BigNumber("0"); + grantAllowanceDto0.tokenInstance = tokenInstanceQueryKey0; + grantAllowanceDto0.quantities = [ + { user: users.testUser2.identityKey, quantity: new BigNumber("1000") } + ]; + grantAllowanceDto0.uses = new BigNumber(5); + grantAllowanceDto0.expires = 0; + grantAllowanceDto0.uniqueKey = randomUUID(); + grantAllowanceDto0.sign(users.testUser1.privateKey); + + const grantAllowanceDto1 = new GrantSwapAllowanceDto(); + const tokenInstanceQueryKey1 = new TokenInstanceQueryKey(); + tokenInstanceQueryKey1.collection = currencyClassKey.collection; + tokenInstanceQueryKey1.category = currencyClassKey.category; + tokenInstanceQueryKey1.type = currencyClassKey.type; + tokenInstanceQueryKey1.additionalKey = currencyClassKey.additionalKey; + tokenInstanceQueryKey1.instance = new BigNumber("0"); + grantAllowanceDto1.tokenInstance = tokenInstanceQueryKey1; + grantAllowanceDto1.quantities = [ + { user: users.testUser2.identityKey, quantity: new BigNumber("1000") } + ]; + grantAllowanceDto1.uses = new BigNumber(5); + grantAllowanceDto1.expires = 0; + grantAllowanceDto1.uniqueKey = randomUUID(); + grantAllowanceDto1.sign(users.testUser1.privateKey); + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1, users.testUser2) + .savedState( + currencyClass, + currencyInstance, + dexInstance, + dexClass, + pool, + positionOwner, + positionData, + tickLowerData, + tickUpperData, + currencyPoolBalance, + dexPoolBalance + ); + + // Grant allowances first + await contract.GrantSwapAllowance(ctx, grantAllowanceDto0); + await contract.GrantSwapAllowance(ctx, grantAllowanceDto1); + + const dto = new BurnDto( + dexClassKey, + currencyClassKey, + fee, + new BigNumber("346"), + 75920, + 76110, + new BigNumber("0"), + new BigNumber("0"), + "POSITION-ID", + users.testUser1.identityKey // recipient + ); + + dto.uniqueKey = randomUUID(); + dto.sign(users.testUser2.privateKey); // testUser2 is calling on behalf of testUser1 + + //When + const burnRes = await contract.RemoveLiquidity(ctx, dto); + + //Then + expect(burnRes.Status).toBe(1); + expect(burnRes.Data).toBeDefined(); + if (burnRes.Data) { + expect(burnRes.Data.positionId).toBe("POSITION-ID"); + expect(burnRes.Data.userAddress).toBe("client|testUser1"); + expect(burnRes.Data.userBalanceDelta.token0Balance.owner).toBe("client|testUser1"); + expect(burnRes.Data.userBalanceDelta.token1Balance.owner).toBe("client|testUser1"); + } + }); + + it("Should throw error when trying to burn on behalf of another user without transfer allowances", async () => { + //Given + const positionOwner = new DexPositionOwner(users.testUser1.identityKey, pool.genPoolHash()); + positionOwner.addPosition("75920:76110", "POSITION-ID"); + + const positionData = new DexPositionData( + pool.genPoolHash(), + "POSITION-ID", + 76110, + 75920, + dexClassKey, + currencyClassKey, + fee + ); + + const tickLowerData = new TickData(pool.genPoolHash(), 75920); + const tickUpperData = new TickData(pool.genPoolHash(), 76110); + + pool.mint(positionData, tickLowerData, tickUpperData, new BigNumber("75646")); + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1, users.testUser2) + .savedState( + currencyClass, + currencyInstance, + dexInstance, + dexClass, + pool, + positionOwner, + positionData, + tickLowerData, + tickUpperData, + currencyPoolBalance, + dexPoolBalance + ); + + const dto = new BurnDto( + dexClassKey, + currencyClassKey, + fee, + new BigNumber("346"), + 75920, + 76110, + new BigNumber("0"), + new BigNumber("0"), + "POSITION-ID", + users.testUser1.identityKey // recipient + ); + + dto.uniqueKey = randomUUID(); + dto.sign(users.testUser2.privateKey); // testUser2 is calling on behalf of testUser1 without allowances + + //When + const burnRes = await contract.RemoveLiquidity(ctx, dto); + + //Then + expect(burnRes.Status).toBe(0); + expect(burnRes.Message).toContain( + "Recipient has not granted transfer allowances to the calling user for token0" + ); + }); + + it("Should throw error when trying to burn on behalf of a user who doesn't own the position", async () => { + //Given + const positionOwner = new DexPositionOwner(users.testUser1.identityKey, pool.genPoolHash()); + positionOwner.addPosition("75920:76110", "POSITION-ID"); + + const positionData = new DexPositionData( + pool.genPoolHash(), + "POSITION-ID", + 76110, + 75920, + dexClassKey, + currencyClassKey, + fee + ); + + const tickLowerData = new TickData(pool.genPoolHash(), 75920); + const tickUpperData = new TickData(pool.genPoolHash(), 76110); + + pool.mint(positionData, tickLowerData, tickUpperData, new BigNumber("75646")); + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1, users.testUser2, users.testUser3) + .savedState( + currencyClass, + currencyInstance, + dexInstance, + dexClass, + pool, + positionOwner, + positionData, + tickLowerData, + tickUpperData, + currencyPoolBalance, + dexPoolBalance + ); + + const dto = new BurnDto( + dexClassKey, + currencyClassKey, + fee, + new BigNumber("346"), + 75920, + 76110, + new BigNumber("0"), + new BigNumber("0"), + "POSITION-ID", + users.testUser3.identityKey // recipient who doesn't own the position + ); + + dto.uniqueKey = randomUUID(); + dto.sign(users.testUser2.privateKey); // testUser2 is calling on behalf of testUser3 + + //When + const burnRes = await contract.RemoveLiquidity(ctx, dto); + + //Then + expect(burnRes.Status).toBe(0); + expect(burnRes.Message).toContain("No object with id"); + }); + }); }); diff --git a/src/chaincode/dex/burn.ts b/src/chaincode/dex/burn.ts index d665ab3..4fb77b0 100644 --- a/src/chaincode/dex/burn.ts +++ b/src/chaincode/dex/burn.ts @@ -12,9 +12,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { NotFoundError, TokenInstanceKey } from "@gala-chain/api"; +import { AllowanceType, NotFoundError, TokenInstanceKey, asValidUserAlias } from "@gala-chain/api"; import { GalaChainContext, + fetchAllowancesWithPagination, fetchOrCreateBalance, getObjectByKey, putChainObject, @@ -52,16 +53,65 @@ export async function burn(ctx: GalaChainContext, dto: BurnDto): Promise { + const fee = DexFeePercentageTypes.FEE_0_05_PERCENT; + + const currencyInstance: TokenInstance = currency.tokenInstance(); + const currencyClassKey: TokenClassKey = currency.tokenClassKey(); + const currencyClass: TokenClass = currency.tokenClass(); + + const dexInstance: TokenInstance = dex.tokenInstance(); + const dexClassKey: TokenClassKey = dex.tokenClassKey(); + const dexClass: TokenClass = dex.tokenClass(); + + let pool: Pool; + let currencyPoolBalance: TokenBalance; + let dexPoolBalance: TokenBalance; + + beforeEach(() => { + pool = new Pool( + dexClassKey.toString(), + currencyClassKey.toString(), + dexClassKey, + currencyClassKey, + DexFeePercentageTypes.FEE_0_05_PERCENT, + new BigNumber("44.71236") + ); + + currencyPoolBalance = plainToInstance(TokenBalance, { + ...currency.tokenBalancePlain(), + owner: pool.getPoolAlias() + }); + + dexPoolBalance = plainToInstance(TokenBalance, { + ...dex.tokenBalancePlain(), + owner: pool.getPoolAlias() + }); + }); + + it("Should allow the position owner to successfully collect fees from the pool", async () => { + //Given + const positionOwner = new DexPositionOwner(users.testUser1.identityKey, pool.genPoolHash()); + positionOwner.addPosition("75920:76110", "POSITION-ID"); + + const positionData = new DexPositionData( + pool.genPoolHash(), + "POSITION-ID", + 76110, + 75920, + dexClassKey, + currencyClassKey, + fee + ); + + const tickLowerData = new TickData(pool.genPoolHash(), 75920); + const tickUpperData = new TickData(pool.genPoolHash(), 76110); + + pool.mint(positionData, tickLowerData, tickUpperData, new BigNumber("75646")); + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + currencyInstance, + dexInstance, + dexClass, + pool, + positionOwner, + positionData, + tickLowerData, + tickUpperData, + currencyPoolBalance, + dexPoolBalance + ); + + const dto = new CollectDto( + dexClassKey, + currencyClassKey, + fee, + new BigNumber("0"), // Collect 0 fees since no trading has occurred + new BigNumber("0"), + 75920, + 76110, + "POSITION-ID" + ); + + dto.uniqueKey = randomUUID(); + dto.sign(users.testUser1.privateKey); + + //When + const collectRes = await contract.CollectPositionFees(ctx, dto); + + //Then + expect(collectRes.Status).toBe(1); + expect(collectRes.Data).toBeDefined(); + if (collectRes.Data) { + expect(collectRes.Data.positionId).toBe("POSITION-ID"); + expect(collectRes.Data.userAddress).toBe("client|testUser1"); + expect(collectRes.Data.amounts).toEqual(["0", "0"]); + } + }); + + it("Should throw error if position doesn't exist", async () => { + //Given + const positionOwner = new DexPositionOwner(users.testUser1.identityKey, pool.genPoolHash()); + positionOwner.addPosition("75920:76110", "POSITION-ID-1"); + + const positionData = new DexPositionData( + pool.genPoolHash(), + "POSITION-ID-1", + 76110, + 75920, + dexClassKey, + currencyClassKey, + fee + ); + + const tickLowerData = new TickData(pool.genPoolHash(), 75920); + const tickUpperData = new TickData(pool.genPoolHash(), 76110); + + pool.mint(positionData, tickLowerData, tickUpperData, new BigNumber("400")); + + const dto = new CollectDto( + dexClassKey, + currencyClassKey, + fee, + new BigNumber("5"), + new BigNumber("10"), + 75920, + 76110, + "NON-EXISTENT" + ); + dto.sign(users.testUser1.privateKey); + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1) + .savedState( + dexClass, + currencyClass, + dexInstance, + currencyInstance, + pool, + dexPoolBalance, + currencyPoolBalance, + positionData, + positionOwner, + tickLowerData, + tickUpperData + ); + dto.uniqueKey = randomUUID(); + + dto.sign(users.testUser1.privateKey); + + //When + const res = await contract.CollectPositionFees(ctx, dto); + + //Then + expect(res).toEqual( + GalaChainResponse.Error( + new NotFoundError( + "Cannot find any position with the id NON-EXISTENT in the tick range 75920:76110 that belongs to client|testUser1 in this pool." + ) + ) + ); + }); + + it("Should throw error for negative amounts", async () => { + //Given + const positionOwner = new DexPositionOwner(users.testUser1.identityKey, pool.genPoolHash()); + positionOwner.addPosition("75920:76110", "POSITION-ID"); + + const positionData = new DexPositionData( + pool.genPoolHash(), + "POSITION-ID", + 76110, + 75920, + dexClassKey, + currencyClassKey, + fee + ); + + const tickLowerData = new TickData(pool.genPoolHash(), 75920); + const tickUpperData = new TickData(pool.genPoolHash(), 76110); + + pool.mint(positionData, tickLowerData, tickUpperData, new BigNumber("75646")); + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + currencyInstance, + dexInstance, + dexClass, + pool, + positionOwner, + positionData, + tickLowerData, + tickUpperData, + currencyPoolBalance, + dexPoolBalance + ); + + const dto = new CollectDto( + dexClassKey, + currencyClassKey, + fee, + new BigNumber("-100"), // negative amount + new BigNumber("200"), + 75920, + 76110, + "POSITION-ID" + ); + + dto.uniqueKey = randomUUID(); + dto.sign(users.testUser1.privateKey); + + //When + const res = await contract.CollectPositionFees(ctx, dto); + + //Then + expect(res.Status).toBe(0); + expect(res.Message).toContain("BigNumberIsPositive: amount0Requested must be positive but is -100"); + }); + + describe("Collect with Transfer Allowances", () => { + it("Should allow collecting on behalf of another user when recipient has granted transfer allowances for both tokens", async () => { + //Given + const positionOwner = new DexPositionOwner(users.testUser1.identityKey, pool.genPoolHash()); + positionOwner.addPosition("75920:76110", "POSITION-ID"); + + const positionData = new DexPositionData( + pool.genPoolHash(), + "POSITION-ID", + 76110, + 75920, + dexClassKey, + currencyClassKey, + fee + ); + + const tickLowerData = new TickData(pool.genPoolHash(), 75920); + const tickUpperData = new TickData(pool.genPoolHash(), 76110); + + pool.mint(positionData, tickLowerData, tickUpperData, new BigNumber("75646")); + + // Create transfer allowances for both tokens + const grantAllowanceDto0 = new GrantSwapAllowanceDto(); + const tokenInstanceQueryKey0 = new TokenInstanceQueryKey(); + tokenInstanceQueryKey0.collection = dexClassKey.collection; + tokenInstanceQueryKey0.category = dexClassKey.category; + tokenInstanceQueryKey0.type = dexClassKey.type; + tokenInstanceQueryKey0.additionalKey = dexClassKey.additionalKey; + tokenInstanceQueryKey0.instance = new BigNumber("0"); + grantAllowanceDto0.tokenInstance = tokenInstanceQueryKey0; + grantAllowanceDto0.quantities = [ + { user: users.testUser2.identityKey, quantity: new BigNumber("1000") } + ]; + grantAllowanceDto0.uses = new BigNumber(5); + grantAllowanceDto0.expires = 0; + grantAllowanceDto0.uniqueKey = randomUUID(); + grantAllowanceDto0.sign(users.testUser1.privateKey); + + const grantAllowanceDto1 = new GrantSwapAllowanceDto(); + const tokenInstanceQueryKey1 = new TokenInstanceQueryKey(); + tokenInstanceQueryKey1.collection = currencyClassKey.collection; + tokenInstanceQueryKey1.category = currencyClassKey.category; + tokenInstanceQueryKey1.type = currencyClassKey.type; + tokenInstanceQueryKey1.additionalKey = currencyClassKey.additionalKey; + tokenInstanceQueryKey1.instance = new BigNumber("0"); + grantAllowanceDto1.tokenInstance = tokenInstanceQueryKey1; + grantAllowanceDto1.quantities = [ + { user: users.testUser2.identityKey, quantity: new BigNumber("1000") } + ]; + grantAllowanceDto1.uses = new BigNumber(5); + grantAllowanceDto1.expires = 0; + grantAllowanceDto1.uniqueKey = randomUUID(); + grantAllowanceDto1.sign(users.testUser1.privateKey); + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1, users.testUser2) + .savedState( + currencyClass, + currencyInstance, + dexInstance, + dexClass, + pool, + positionOwner, + positionData, + tickLowerData, + tickUpperData, + currencyPoolBalance, + dexPoolBalance + ); + + // Grant allowances first + await contract.GrantSwapAllowance(ctx, grantAllowanceDto0); + await contract.GrantSwapAllowance(ctx, grantAllowanceDto1); + + const dto = new CollectDto( + dexClassKey, + currencyClassKey, + fee, + new BigNumber("0"), // Collect 0 fees since no trading has occurred + new BigNumber("0"), + 75920, + 76110, + "POSITION-ID", + users.testUser1.identityKey // recipient + ); + + dto.uniqueKey = randomUUID(); + dto.sign(users.testUser2.privateKey); // testUser2 is calling on behalf of testUser1 + + //When + const collectRes = await contract.CollectPositionFees(ctx, dto); + + //Then + expect(collectRes.Status).toBe(1); + expect(collectRes.Data).toBeDefined(); + if (collectRes.Data) { + expect(collectRes.Data.positionId).toBe("POSITION-ID"); + expect(collectRes.Data.userAddress).toBe("client|testUser1"); + expect(collectRes.Data.userBalanceDelta.token0Balance.owner).toBe("client|testUser1"); + expect(collectRes.Data.userBalanceDelta.token1Balance.owner).toBe("client|testUser1"); + expect(collectRes.Data.amounts).toEqual(["0", "0"]); + } + }); + + it("Should throw error when trying to collect on behalf of another user without transfer allowances", async () => { + //Given + const positionOwner = new DexPositionOwner(users.testUser1.identityKey, pool.genPoolHash()); + positionOwner.addPosition("75920:76110", "POSITION-ID"); + + const positionData = new DexPositionData( + pool.genPoolHash(), + "POSITION-ID", + 76110, + 75920, + dexClassKey, + currencyClassKey, + fee + ); + + const tickLowerData = new TickData(pool.genPoolHash(), 75920); + const tickUpperData = new TickData(pool.genPoolHash(), 76110); + + pool.mint(positionData, tickLowerData, tickUpperData, new BigNumber("75646")); + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1, users.testUser2) + .savedState( + currencyClass, + currencyInstance, + dexInstance, + dexClass, + pool, + positionOwner, + positionData, + tickLowerData, + tickUpperData, + currencyPoolBalance, + dexPoolBalance + ); + + const dto = new CollectDto( + dexClassKey, + currencyClassKey, + fee, + new BigNumber("0"), + new BigNumber("0"), + 75920, + 76110, + "POSITION-ID", + users.testUser1.identityKey // recipient + ); + + dto.uniqueKey = randomUUID(); + dto.sign(users.testUser2.privateKey); // testUser2 is calling on behalf of testUser1 without allowances + + //When + const collectRes = await contract.CollectPositionFees(ctx, dto); + + //Then + expect(collectRes.Status).toBe(0); + expect(collectRes.Message).toContain( + "Recipient has not granted transfer allowances to the calling user for token0" + ); + }); + + it("Should throw error when trying to collect on behalf of another user with only token0 allowance (missing token1)", async () => { + //Given + const positionOwner = new DexPositionOwner(users.testUser1.identityKey, pool.genPoolHash()); + positionOwner.addPosition("75920:76110", "POSITION-ID"); + + const positionData = new DexPositionData( + pool.genPoolHash(), + "POSITION-ID", + 76110, + 75920, + dexClassKey, + currencyClassKey, + fee + ); + + const tickLowerData = new TickData(pool.genPoolHash(), 75920); + const tickUpperData = new TickData(pool.genPoolHash(), 76110); + + pool.mint(positionData, tickLowerData, tickUpperData, new BigNumber("75646")); + + // Create transfer allowance only for token0 + const grantAllowanceDto0 = new GrantSwapAllowanceDto(); + const tokenInstanceQueryKey0 = new TokenInstanceQueryKey(); + tokenInstanceQueryKey0.collection = dexClassKey.collection; + tokenInstanceQueryKey0.category = dexClassKey.category; + tokenInstanceQueryKey0.type = dexClassKey.type; + tokenInstanceQueryKey0.additionalKey = dexClassKey.additionalKey; + tokenInstanceQueryKey0.instance = new BigNumber("0"); + grantAllowanceDto0.tokenInstance = tokenInstanceQueryKey0; + grantAllowanceDto0.quantities = [ + { user: users.testUser2.identityKey, quantity: new BigNumber("1000") } + ]; + grantAllowanceDto0.uses = new BigNumber(5); + grantAllowanceDto0.expires = 0; + grantAllowanceDto0.uniqueKey = randomUUID(); + grantAllowanceDto0.sign(users.testUser1.privateKey); + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1, users.testUser2) + .savedState( + currencyClass, + currencyInstance, + dexInstance, + dexClass, + pool, + positionOwner, + positionData, + tickLowerData, + tickUpperData, + currencyPoolBalance, + dexPoolBalance + ); + + // Grant allowance only for token0 + await contract.GrantSwapAllowance(ctx, grantAllowanceDto0); + + const dto = new CollectDto( + dexClassKey, + currencyClassKey, + fee, + new BigNumber("0"), + new BigNumber("0"), + 75920, + 76110, + "POSITION-ID", + users.testUser1.identityKey // recipient + ); + + dto.uniqueKey = randomUUID(); + dto.sign(users.testUser2.privateKey); // testUser2 is calling on behalf of testUser1 + + //When + const collectRes = await contract.CollectPositionFees(ctx, dto); + + //Then + expect(collectRes.Status).toBe(0); + expect(collectRes.Message).toContain( + "Recipient has not granted transfer allowances to the calling user for token1" + ); + }); + + it("Should throw error when trying to collect on behalf of a user who doesn't own the position", async () => { + //Given + const positionOwner = new DexPositionOwner(users.testUser1.identityKey, pool.genPoolHash()); + positionOwner.addPosition("75920:76110", "POSITION-ID"); + + const positionData = new DexPositionData( + pool.genPoolHash(), + "POSITION-ID", + 76110, + 75920, + dexClassKey, + currencyClassKey, + fee + ); + + const tickLowerData = new TickData(pool.genPoolHash(), 75920); + const tickUpperData = new TickData(pool.genPoolHash(), 76110); + + pool.mint(positionData, tickLowerData, tickUpperData, new BigNumber("75646")); + + const { ctx, contract } = fixture(DexV3Contract) + .registeredUsers(users.testUser1, users.testUser2, users.testUser3) + .savedState( + currencyClass, + currencyInstance, + dexInstance, + dexClass, + pool, + positionOwner, + positionData, + tickLowerData, + tickUpperData, + currencyPoolBalance, + dexPoolBalance + ); + + const dto = new CollectDto( + dexClassKey, + currencyClassKey, + fee, + new BigNumber("0"), + new BigNumber("0"), + 75920, + 76110, + "POSITION-ID", + users.testUser3.identityKey // recipient who doesn't own the position + ); + + dto.uniqueKey = randomUUID(); + dto.sign(users.testUser2.privateKey); // testUser2 is calling on behalf of testUser3 + + //When + const collectRes = await contract.CollectPositionFees(ctx, dto); + + //Then + expect(collectRes.Status).toBe(0); + expect(collectRes.Message).toContain("No object with id"); + }); + }); +}); diff --git a/src/chaincode/dex/collect.ts b/src/chaincode/dex/collect.ts index 641e350..49af6c9 100644 --- a/src/chaincode/dex/collect.ts +++ b/src/chaincode/dex/collect.ts @@ -12,9 +12,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { NotFoundError, TokenInstanceKey } from "@gala-chain/api"; +import { AllowanceType, NotFoundError, TokenInstanceKey, asValidUserAlias } from "@gala-chain/api"; import { GalaChainContext, + fetchAllowancesWithPagination, fetchOrCreateBalance, getObjectByKey, putChainObject, @@ -44,14 +45,59 @@ export async function collect(ctx: GalaChainContext, dto: CollectDto): Promise