diff --git a/sources/contracts/core/amm-pool.tact b/sources/contracts/core/amm-pool.tact index 081cc13..6d13c0a 100644 --- a/sources/contracts/core/amm-pool.tact +++ b/sources/contracts/core/amm-pool.tact @@ -78,8 +78,6 @@ contract AmmPool( otherVault: self.leftVault, amount: msg.rightAmount - expectedRightAmount, receiver: msg.depositor, - // TODO: Maybe we don't need to forward any payload here? - // Or we can even forward both payloads? payloadToForward: msg.rightAdditionalParams.payloadOnSuccess, }.toCell(), }); @@ -99,8 +97,6 @@ contract AmmPool( to: self.leftVault, bounce: false, body: PayoutFromPool { - // TODO: Maybe we don't need to forward any payload here? - // Or we can even forward both payloads? payloadToForward: msg.leftAdditionalParams.payloadOnSuccess, otherVault: self.rightVault, amount: msg.leftAmount - expectedLeftAmount, @@ -151,6 +147,7 @@ contract AmmPool( responseDestination: msg.depositor, // Thanks to this flag, we can send all TONs in notify sendAllTonsInNotifyFlag: true, + // due to the flag above, we can use 0 here forwardTonAmount: 0, forwardPayload, }.toCell(), @@ -421,6 +418,9 @@ contract AmmPool( require(false, "Pool: vaultAddress must be one of the vaults"); return 0; } + + require(inBalance != 0 && outBalance != 0, "Pool: No liquidity in pool"); + let amountInWithFee = muldiv(amountIn, 1000 - self.PoolFee, 1000); let newAmountIn = inBalance + amountInWithFee; let newAmountOut = muldiv(outBalance, inBalance, newAmountIn); @@ -447,6 +447,9 @@ contract AmmPool( require(false, "Pool: vaultOut must be one of the vaults"); return 0; } + + require(inBalance != 0 && outBalance != 0, "Pool: No liquidity in pool"); + let newAmountOut = outBalance - amountOut; if (newAmountOut <= 0) { require(false, "Pool: Desired amount out is greater than pool reserves"); diff --git a/sources/contracts/core/liquidity-deposit.tact b/sources/contracts/core/liquidity-deposit.tact index ad0d962..f60007c 100644 --- a/sources/contracts/core/liquidity-deposit.tact +++ b/sources/contracts/core/liquidity-deposit.tact @@ -1,9 +1,19 @@ // SPDX-License-Identifier: MIT // Copyright © 2025 TON Studio -import "./amm-pool"; import "../utils/math"; import "./messages"; +import "../vaults/vault-interface"; + +// TODO This is here until Tact will have something like `dataOf Contract()` +struct AmmPoolData { + leftVault: Address; + rightVault: Address; // To be deterministic, rightVault address must be greater than leftVault address + leftSideReserve: Int as coins; + rightSideReserve: Int as coins; + totalSupply: Int as coins; + jettonContent: Cell?; +} contract LiquidityDepositContract( leftVault: Address, // To be deterministic, leftVault address must be less than rightVault address @@ -24,19 +34,43 @@ contract LiquidityDepositContract( receive(msg: PartHasBeenDeposited) { let sender = sender(); if (sender == self.leftVault) { - // TODO maybe here we should check that it is not already filled and revert on errors. - require(msg.amount == self.leftSideAmount, "LP Deposit: Amount must be equal to leftSide"); require(msg.depositor == self.depositor, "LP Deposit: Depositor must be the same"); + if ((self.status & 1) != 0 || msg.amount != self.leftSideAmount) { + message(MessageParameters { + mode: SendRemainingValue, + body: RejectLiquidityPart { + depositor: msg.depositor, + amountToReturn: msg.amount, + }.toCell(), + value: 0, + to: sender(), + bounce: false, + }); + commit(); + require(false, "LP Deposit: Left side cannot be filled again or with different amount"); + } self.leftAdditionalParams = msg.additionalParams; self.status |= 1; - } - if (sender == self.rightVault) { - // TODO maybe here we should check that it is not already filled and revert on errors. - require(msg.amount == self.rightSideAmount, "LP Deposit: Amount must be equal to rightSide"); + } else if (sender == self.rightVault) { require(msg.depositor == self.depositor, "LP Deposit: Depositor must be the same"); + if ((self.status & 2) != 0 || msg.amount != self.rightSideAmount) { + message(MessageParameters { + mode: SendRemainingValue, + body: RejectLiquidityPart { + depositor: msg.depositor, + amountToReturn: msg.amount, + }.toCell(), + value: 0, + to: sender(), + bounce: false, + }); + commit(); + require(false, "LP Deposit: Right side cannot be filled again or with different amount"); + } self.rightAdditionalParams = msg.additionalParams; self.status |= 2; } + require(self.leftVault != self.rightVault, "LP Deposit: Vaults must be different"); // Both sides are filled, we can deposit now. if (self.status == 3) { // We must check, that this account was deployed with sorted vault, otherwise it could be a security issue @@ -46,7 +80,17 @@ contract LiquidityDepositContract( value: 0, bounce: false, // 1. To pay storage fees of AmmPool. 2. We will destroy this contract, so bounce does not have sense. mode: SendRemainingBalance + SendDestroyIfZero, // We don't need to keep this contract alive - init: initOf AmmPool(self.leftVault, self.rightVault, 0, 0, 0, null), + init: StateInit { + code: msg.ammPoolCode, + data: AmmPoolData { + leftVault: self.leftVault, + rightVault: self.rightVault, + leftSideReserve: 0, + rightSideReserve: 0, + totalSupply: 0, + jettonContent: null, + }.toCell(), + }, body: LiquidityDeposit { depositor: self.depositor, contractId: self.contractId, diff --git a/sources/contracts/core/messages.tact b/sources/contracts/core/messages.tact index 0c6dba8..cba0723 100644 --- a/sources/contracts/core/messages.tact +++ b/sources/contracts/core/messages.tact @@ -71,12 +71,6 @@ message(0x178d4519) MintViaJettonTransferInternal { forwardPayload: Slice as remaining; } -message(0xe7a3475f) PartHasBeenDeposited { - depositor: Address; - amount: Int as coins; - additionalParams: AdditionalParams; -} - struct AdditionalParams { minAmountToDeposit: Int as coins; lpTimeout: Int as uint32; diff --git a/sources/contracts/vaults/jetton-vault.tact b/sources/contracts/vaults/jetton-vault.tact index 018c771..19591f6 100644 --- a/sources/contracts/vaults/jetton-vault.tact +++ b/sources/contracts/vaults/jetton-vault.tact @@ -160,6 +160,27 @@ contract JettonVault( }); } + override fun handleRejectLP(msg: RejectLiquidityPart) { + let ctx = context(); + // Actually ctx.value - priceOfJettonForward() is always >= 0 because we require ctx.value >= priceOfJettonForward() in LPDepositPart + let maxFwdAmount = max(0, ctx.value - priceOfJettonForward(ctx.readForwardFee(), 1)); + message(MessageParameters { + mode: SendRemainingValue, + body: SendViaJettonTransfer { + queryId: 0, + amount: msg.amountToReturn, + destination: msg.depositor, + responseDestination: msg.depositor, + customPayload: null, + forwardTonAmount: maxFwdAmount, + forwardPayload: sliceWithOneZeroBit(), + }.toCell(), + value: 0, + to: self.jettonWallet!!, + bounce: true, + }); + } + get fun inited(): Bool { return self.jettonWallet != null; } @@ -247,6 +268,7 @@ inline fun actionHandler(msg: JettonNotifyWithActionRequest): Bool { depositor: msg.sender, amount: msg.amount, additionalParams: action.additionalParams, + ammPoolCode: codeOf AmmPool, }.toCell(), }); } else { @@ -279,6 +301,7 @@ inline fun actionHandler(msg: JettonNotifyWithActionRequest): Bool { depositor: msg.sender, amount: msg.amount, additionalParams: action.additionalParams, + ammPoolCode: codeOf AmmPool, }.toCell(), }); } diff --git a/sources/contracts/vaults/ton-vault.tact b/sources/contracts/vaults/ton-vault.tact index ecb113f..15cb005 100644 --- a/sources/contracts/vaults/ton-vault.tact +++ b/sources/contracts/vaults/ton-vault.tact @@ -8,6 +8,7 @@ import "../core/amm-pool"; import "../utils/utils"; import "../utils/gas-constants"; import "../core/lp-jettons/constants"; +import "../core/liquidity-deposit"; message(0xf8a7ea5) ReturnJettonsViaJettonTransfer { queryId: Int as uint64; @@ -145,6 +146,7 @@ contract TonVault( additionalParams: msg.additionalParams, amount: msg.amountIn, depositor: sender(), + ammPoolCode: codeOf AmmPool, }.toCell(), }); } else { @@ -177,11 +179,22 @@ contract TonVault( depositor: sender(), amount: msg.amountIn, additionalParams: msg.additionalParams, + ammPoolCode: codeOf AmmPool, }.toCell(), }); } } + override fun handleRejectLP(msg: RejectLiquidityPart) { + message(MessageParameters { + mode: SendRemainingValue, + body: null, + value: msg.amountToReturn, + to: msg.depositor, + bounce: true, + }); + } + receive() { cashback(sender()); } diff --git a/sources/contracts/vaults/vault-interface.tact b/sources/contracts/vaults/vault-interface.tact index becf389..ac4b9d3 100644 --- a/sources/contracts/vaults/vault-interface.tact +++ b/sources/contracts/vaults/vault-interface.tact @@ -80,10 +80,32 @@ message(0x1b434676) AddLiquidityPartTon { additionalParams: AdditionalParams; } +message(0xe7a3475f) PartHasBeenDeposited { + depositor: Address; + amount: Int as coins; + additionalParams: AdditionalParams; + // This field is needed to not add a (LP-Deposit -> AmmPool) dependency + ammPoolCode: Cell; +} + +// This message is used when PartHasBeenDeposited is rejected +// We don't forward any payload in this message because PartHasBeenDeposited may occur only as the result of incorrect +// user actions. +message(0xe3b122ab) RejectLiquidityPart { + depositor: Address; + amountToReturn: Int as coins; +} + trait VaultInterface { receive(msg: PayoutFromPool) { self.handlePayout(msg); } - abstract fun handlePayout(msg: PayoutFromPool); + abstract inline fun handlePayout(msg: PayoutFromPool); + + receive(msg: RejectLiquidityPart) { + self.handleRejectLP(msg); + } + + abstract inline fun handleRejectLP(msg: RejectLiquidityPart); } diff --git a/sources/tests/amm-pool.spec.ts b/sources/tests/amm-pool.spec.ts index c3c6dd2..6b5cc7f 100644 --- a/sources/tests/amm-pool.spec.ts +++ b/sources/tests/amm-pool.spec.ts @@ -731,4 +731,52 @@ describe("Amm pool", () => { expect(parsedRefundTx.payloadToForward).toEqualCell(payloadOnFailure) }) }) + test("Amm pool get-methods throw, when there are no reserves", async () => { + const blockchain = await Blockchain.create() + + const randomDeployer = await blockchain.treasury(randomAddress().toString()) + const firstVault = randomAddress() + const secondVault = randomAddress() + const ammPool = blockchain.openContract( + await AmmPool.fromInit(firstVault, secondVault, 0n, 0n, 0n, null), + ) + + await ammPool.send(randomDeployer.getSender(), {value: toNano(0.01)}, null) + + try { + await ammPool.getExpectedOut(firstVault, BigInt(randomInt(0, 100))) + } catch (e) { + if (!(e instanceof GetMethodError)) { + throw e + } + expect(e.exitCode).toEqual(AmmPool.errors["Pool: No liquidity in pool"]) + } + + try { + await ammPool.getExpectedOut(secondVault, BigInt(randomInt(0, 100))) + } catch (e) { + if (!(e instanceof GetMethodError)) { + throw e + } + expect(e.exitCode).toEqual(AmmPool.errors["Pool: No liquidity in pool"]) + } + + try { + await ammPool.getNeededInToGetX(firstVault, BigInt(randomInt(0, 100))) + } catch (e) { + if (!(e instanceof GetMethodError)) { + throw e + } + expect(e.exitCode).toEqual(AmmPool.errors["Pool: No liquidity in pool"]) + } + + try { + await ammPool.getNeededInToGetX(secondVault, BigInt(randomInt(0, 100))) + } catch (e) { + if (!(e instanceof GetMethodError)) { + throw e + } + expect(e.exitCode).toEqual(AmmPool.errors["Pool: No liquidity in pool"]) + } + }) }) diff --git a/sources/tests/liquidity-deposit.spec.ts b/sources/tests/liquidity-deposit.spec.ts index c6359d5..6ff5a42 100644 --- a/sources/tests/liquidity-deposit.spec.ts +++ b/sources/tests/liquidity-deposit.spec.ts @@ -16,6 +16,7 @@ import {sortAddresses} from "../utils/deployUtils" // eslint-disable-next-line import {SendDumpToDevWallet} from "@tondevwallet/traces" import {ExtendedLPJettonWallet} from "../wrappers/ExtendedLPJettonWallet" +import {randomInt} from "crypto" describe("Liquidity deposit", () => { test("Jetton vault should deploy correctly", async () => { @@ -561,4 +562,106 @@ describe("Liquidity deposit", () => { expect(lpBalance).toBeGreaterThan(0n) }, ) + test.each([ + { + name: "Jetton->Jetton", + createPool: createJettonAmmPool, + }, + { + name: "TON->Jetton", + createPool: createTonJettonAmmPool, + }, + ])( + "incorrect liquidity provision on same size should revert. Pool is $name", + async ({createPool}) => { + const blockchain = await Blockchain.create() + + const {vaultA, liquidityDepositSetup, isSwapped, vaultB} = await createPool(blockchain) + + // deploy liquidity deposit contract + const amountA = toNano(1) + const amountB = toNano(2) // 1 a == 2 b ratio + + const depositor = vaultB.treasury + + const liqSetup = await liquidityDepositSetup(depositor.walletOwner, amountA, amountB) + await liqSetup.deploy() + + await vaultA.deploy() + + const error = + `LP Deposit: ${isSwapped ? "Right" : "Left"} side cannot be filled again or with different amount` as const + const unsuccessfulLiqProvision = await vaultA.addLiquidity( + liqSetup.liquidityDeposit.address, + BigInt(randomInt(0, 2000000000)), // random amount to trigger error + ) + + expect(unsuccessfulLiqProvision.transactions).toHaveTransaction({ + on: liqSetup.liquidityDeposit.address, + op: LiquidityDepositContract.opcodes.PartHasBeenDeposited, + success: true, + }) + + expect(await liqSetup.liquidityDeposit.getStatus()).toEqual(0n) + + expect(unsuccessfulLiqProvision.transactions).toHaveTransaction({ + from: liqSetup.liquidityDeposit.address, + op: LiquidityDepositContract.opcodes.RejectLiquidityPart, + success: true, + }) + + // add liquidity to vaultA + const firstLiqProvision = await vaultA.addLiquidity( + liqSetup.liquidityDeposit.address, + isSwapped ? amountB : amountA, + ) + + expect(firstLiqProvision.transactions).toHaveTransaction({ + from: vaultA.vault.address, + to: liqSetup.liquidityDeposit.address, + op: LiquidityDepositContract.opcodes.PartHasBeenDeposited, + success: true, + }) + + const lpState = await liqSetup.liquidityDeposit.getStatus() + expect(lpState).toBeGreaterThan(0n) + + // Add the same liquidity again + const secondLiqProvision = await vaultA.addLiquidity( + liqSetup.liquidityDeposit.address, + isSwapped ? amountB : amountA, + ) + expect(secondLiqProvision.transactions).toHaveTransaction({ + on: liqSetup.liquidityDeposit.address, + success: true, // Commit happened + exitCode: LiquidityDepositContract.errors[error], + }) + expect(secondLiqProvision.transactions).toHaveTransaction({ + from: liqSetup.liquidityDeposit.address, + op: LiquidityDepositContract.opcodes.RejectLiquidityPart, + success: true, + }) + expect(await liqSetup.liquidityDeposit.getStatus()).toEqual(lpState) + + // And one more time with an incorrect amount + const thirdLiqProvision = await vaultA.addLiquidity( + liqSetup.liquidityDeposit.address, + isSwapped ? amountB : amountA, + ) + + expect(thirdLiqProvision.transactions).toHaveTransaction({ + on: liqSetup.liquidityDeposit.address, + success: true, // Commit happened + exitCode: LiquidityDepositContract.errors[error], + }) + + expect(await liqSetup.liquidityDeposit.getStatus()).toEqual(lpState) + + expect(thirdLiqProvision.transactions).toHaveTransaction({ + from: liqSetup.liquidityDeposit.address, + op: LiquidityDepositContract.opcodes.RejectLiquidityPart, + success: true, + }) + }, + ) })