diff --git a/README.md b/README.md index bf07dd6..b8a5190 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Solver implementations are standalone contracts that inherits from `SolverCompon scarb build # Run the tests -snforge test +snforge test --max-n-steps 4294967295 ``` ## Version control diff --git a/Scarb.lock b/Scarb.lock index a8484f6..ca0260a 100644 --- a/Scarb.lock +++ b/Scarb.lock @@ -29,6 +29,16 @@ dependencies = [ "snforge_std", ] +[[package]] +name = "haiko_solver_reversion" +version = "1.0.0" +dependencies = [ + "haiko_lib", + "haiko_solver_core", + "openzeppelin", + "snforge_std", +] + [[package]] name = "openzeppelin" version = "0.11.0" diff --git a/Scarb.toml b/Scarb.toml index bd78937..8d8038c 100644 --- a/Scarb.toml +++ b/Scarb.toml @@ -2,4 +2,5 @@ members = [ "packages/core", "packages/replicating", + "packages/reversion", ] \ No newline at end of file diff --git a/models/src/config.ts b/models/src/common/config.ts similarity index 100% rename from models/src/config.ts rename to models/src/common/config.ts diff --git a/models/src/constants.ts b/models/src/common/constants.ts similarity index 100% rename from models/src/constants.ts rename to models/src/common/constants.ts diff --git a/models/src/common/math/feeMath.ts b/models/src/common/math/feeMath.ts new file mode 100644 index 0000000..a65321a --- /dev/null +++ b/models/src/common/math/feeMath.ts @@ -0,0 +1,79 @@ +import Decimal from "decimal.js"; +import { PRECISION, ROUNDING } from "../config"; + +export const calcFee = (grossAmount: Decimal.Value, feeRate: Decimal.Value) => { + Decimal.set({ precision: PRECISION, rounding: ROUNDING }); + let grossAmountDec = new Decimal(grossAmount); + let fee = grossAmountDec.mul(feeRate); + return fee.toFixed(); +}; + +export const netToFee = (netAmount: Decimal.Value, feeRate: Decimal.Value) => { + Decimal.set({ precision: PRECISION, rounding: ROUNDING }); + let netAmountBN = new Decimal(netAmount); + let one = new Decimal(1); + let fee = netAmountBN.mul(feeRate).div(one.sub(feeRate)); + return fee.toFixed(); +}; + +export const netToGross = ( + netAmount: Decimal.Value, + feeRate: Decimal.Value +) => { + Decimal.set({ precision: PRECISION, rounding: ROUNDING }); + let netAmountBN = new Decimal(netAmount); + let one = new Decimal(1); + let fee = netAmountBN.div(one.sub(feeRate)); + return fee.toFixed(); +}; + +export const grossToNet = ( + grossAmount: Decimal.Value, + feeRate: Decimal.Value +) => { + Decimal.set({ precision: PRECISION, rounding: ROUNDING }); + let grossAmountDec = new Decimal(grossAmount); + let one = new Decimal(1); + let netAmount = grossAmountDec.mul(one.sub(feeRate)); + return netAmount.toFixed(); +}; + +export const getFeeInside = ( + lowerBaseFeeFactor: Decimal.Value, + lowerQuoteFeeFactor: Decimal.Value, + upperBaseFeeFactor: Decimal.Value, + upperQuoteFeeFactor: Decimal.Value, + lowerLimit: Decimal.Value, + upperLimit: Decimal.Value, + currLimit: Decimal.Value, + marketBaseFeeFactor: Decimal.Value, + marketQuoteFeeFactor: Decimal.Value +) => { + const currLimitDec = new Decimal(currLimit); + const marketBaseFeeFactorDec = new Decimal(marketBaseFeeFactor); + const marketQuoteFeeFactorDec = new Decimal(marketQuoteFeeFactor); + + const baseFeesBelow = currLimitDec.gte(lowerLimit) + ? lowerBaseFeeFactor + : marketBaseFeeFactorDec.sub(lowerBaseFeeFactor); + const baseFeesAbove = currLimitDec.lt(upperLimit) + ? upperBaseFeeFactor + : marketBaseFeeFactorDec.sub(upperBaseFeeFactor); + const quoteFeesBelow = currLimitDec.gte(lowerLimit) + ? lowerQuoteFeeFactor + : marketQuoteFeeFactorDec.sub(lowerQuoteFeeFactor); + const quoteFeesAbove = currLimitDec.lt(upperLimit) + ? upperQuoteFeeFactor + : marketQuoteFeeFactorDec.sub(upperQuoteFeeFactor); + + const baseFeeFactor = marketBaseFeeFactorDec + .sub(baseFeesBelow) + .sub(baseFeesAbove) + .toFixed(); + const quoteFeeFactor = marketQuoteFeeFactorDec + .sub(quoteFeesBelow) + .sub(quoteFeesAbove) + .toFixed(); + + return { baseFeeFactor, quoteFeeFactor }; +}; diff --git a/models/src/math/liquidityMath.ts b/models/src/common/math/liquidityMath.ts similarity index 100% rename from models/src/math/liquidityMath.ts rename to models/src/common/math/liquidityMath.ts diff --git a/models/src/math/math.ts b/models/src/common/math/math.ts similarity index 100% rename from models/src/math/math.ts rename to models/src/common/math/math.ts diff --git a/models/src/math/priceMath.ts b/models/src/common/math/priceMath.ts similarity index 100% rename from models/src/math/priceMath.ts rename to models/src/common/math/priceMath.ts diff --git a/models/src/math/feeMath.ts b/models/src/math/feeMath.ts deleted file mode 100644 index e0bceb5..0000000 --- a/models/src/math/feeMath.ts +++ /dev/null @@ -1,21 +0,0 @@ -import Decimal from "decimal.js"; -import { PRECISION, ROUNDING } from "../config"; - -export const netToFee = (netAmount: Decimal.Value, feeRate: Decimal.Value) => { - Decimal.set({ precision: PRECISION, rounding: ROUNDING }); - let netAmountBN = new Decimal(netAmount); - let one = new Decimal(1); - let fee = netAmountBN.mul(feeRate).div(one.sub(feeRate)); - return fee.toFixed(); -}; - -export const grossToNet = ( - grossAmount: Decimal.Value, - feeRate: Decimal.Value -) => { - Decimal.set({ precision: PRECISION, rounding: ROUNDING }); - let grossAmountDec = new Decimal(grossAmount); - let one = new Decimal(1); - let netAmount = grossAmountDec.mul(one.sub(feeRate)); - return netAmount.toFixed(); -}; diff --git a/models/src/libraries/SpreadMath.ts b/models/src/replicating/libraries/SpreadMath.ts similarity index 91% rename from models/src/libraries/SpreadMath.ts rename to models/src/replicating/libraries/SpreadMath.ts index a7b0cf2..c3eab74 100644 --- a/models/src/libraries/SpreadMath.ts +++ b/models/src/replicating/libraries/SpreadMath.ts @@ -1,7 +1,10 @@ import Decimal from "decimal.js"; -import { PRECISION, ROUNDING } from "../config"; -import { baseToLiquidity, quoteToLiquidity } from "../math/liquidityMath"; -import { limitToSqrtPrice, priceToLimit } from "../math/priceMath"; +import { PRECISION, ROUNDING } from "../../common/config"; +import { + baseToLiquidity, + quoteToLiquidity, +} from "../../common/math/liquidityMath"; +import { limitToSqrtPrice, priceToLimit } from "../../common/math/priceMath"; type PositionInfo = { lowerSqrtPrice: Decimal.Value; diff --git a/models/src/libraries/SwapLib.ts b/models/src/replicating/libraries/SwapLib.ts similarity index 96% rename from models/src/libraries/SwapLib.ts rename to models/src/replicating/libraries/SwapLib.ts index f06baaa..cb43a68 100644 --- a/models/src/libraries/SwapLib.ts +++ b/models/src/replicating/libraries/SwapLib.ts @@ -1,7 +1,10 @@ import Decimal from "decimal.js"; -import { PRECISION, ROUNDING } from "../config"; -import { liquidityToBase, liquidityToQuote } from "../math/liquidityMath"; -import { grossToNet, netToFee } from "../math/feeMath"; +import { PRECISION, ROUNDING } from "../../common/config"; +import { + liquidityToBase, + liquidityToQuote, +} from "../../common/math/liquidityMath"; +import { grossToNet, netToFee } from "../../common/math/feeMath"; export const getSwapAmounts = ({ isBuy, diff --git a/models/tests/solver/DebugSwap.test.ts b/models/src/replicating/tests/DebugSwap.test.ts similarity index 91% rename from models/tests/solver/DebugSwap.test.ts rename to models/src/replicating/tests/DebugSwap.test.ts index 6b09106..280c0b5 100644 --- a/models/tests/solver/DebugSwap.test.ts +++ b/models/src/replicating/tests/DebugSwap.test.ts @@ -2,8 +2,8 @@ import { getDelta, getVirtualPosition, getVirtualPositionRange, -} from "../../src/libraries/SpreadMath"; -import { getSwapAmounts } from "../../src/libraries/SwapLib"; +} from "../libraries/SpreadMath"; +import { getSwapAmounts } from "../libraries/SwapLib"; const isBuy = false; const exactInput = false; diff --git a/models/tests/libraries/SpreadMath.test.ts b/models/src/replicating/tests/SpreadMath.test.ts similarity index 98% rename from models/tests/libraries/SpreadMath.test.ts rename to models/src/replicating/tests/SpreadMath.test.ts index a462406..c794ef9 100644 --- a/models/tests/libraries/SpreadMath.test.ts +++ b/models/src/replicating/tests/SpreadMath.test.ts @@ -2,7 +2,7 @@ import Decimal from "decimal.js"; import { getVirtualPosition, getVirtualPositionRange, -} from "../../src/libraries/SpreadMath"; +} from "../../replicating/libraries/SpreadMath"; const testGetVirtualPositionCases = () => { const cases = [ diff --git a/models/tests/solver/Swap.test.ts b/models/src/replicating/tests/Swap.test.ts similarity index 97% rename from models/tests/solver/Swap.test.ts rename to models/src/replicating/tests/Swap.test.ts index 5fc2abd..7f7395b 100644 --- a/models/tests/solver/Swap.test.ts +++ b/models/src/replicating/tests/Swap.test.ts @@ -3,8 +3,8 @@ import { getDelta, getVirtualPosition, getVirtualPositionRange, -} from "../../src/libraries/SpreadMath"; -import { getSwapAmounts } from "../../src/libraries/SwapLib"; +} from "../libraries/SpreadMath"; +import { getSwapAmounts } from "../libraries/SwapLib"; type Case = { description: string; @@ -328,19 +328,19 @@ const testSwapCases = () => { upperLimit, isBuy ? baseReserves : quoteReserves ); - const { amountIn, amountOut, fees } = getSwapAmounts( + const { amountIn, amountOut, fees } = getSwapAmounts({ isBuy, exactInput, amount, - Number(feeRate) / 10000, + swapFeeRate: Number(feeRate) / 10000, thresholdSqrtPrice, thresholdAmount, lowerSqrtPrice, upperSqrtPrice, liquidity, baseDecimals, - quoteDecimals - ); + quoteDecimals, + }); console.log({ amountIn: new Decimal(amountIn).mul(1e18).toFixed(0), amountOut: new Decimal(amountOut).mul(1e18).toFixed(0), diff --git a/models/src/reversion/libraries/SpreadMath.ts b/models/src/reversion/libraries/SpreadMath.ts new file mode 100644 index 0000000..98773ab --- /dev/null +++ b/models/src/reversion/libraries/SpreadMath.ts @@ -0,0 +1,150 @@ +import Decimal from "decimal.js"; +import { + baseToLiquidity, + quoteToLiquidity, +} from "../../common/math/liquidityMath"; +import { PRECISION, ROUNDING } from "../../common/config"; +import { limitToSqrtPrice, priceToLimit } from "../../common/math/priceMath"; +import { Trend } from "../types"; + +type PositionInfo = { + lowerSqrtPrice: Decimal.Value; + upperSqrtPrice: Decimal.Value; + liquidity: Decimal.Value; +}; + +type PositionRange = { + bidLower: Decimal.Value; + bidUpper: Decimal.Value; + askLower: Decimal.Value; + askUpper: Decimal.Value; +}; + +export const getVirtualPosition = ( + isBid: boolean, + lowerLimit: Decimal.Value, + upperLimit: Decimal.Value, + amount: Decimal.Value +): PositionInfo => { + Decimal.set({ precision: PRECISION, rounding: ROUNDING }); + + const lowerSqrtPrice = limitToSqrtPrice(lowerLimit, 1); + const upperSqrtPrice = limitToSqrtPrice(upperLimit, 1); + if (isBid) { + const liquidity = quoteToLiquidity( + lowerSqrtPrice, + upperSqrtPrice, + amount + ).toFixed(); + return { lowerSqrtPrice, upperSqrtPrice, liquidity }; + } else { + const liquidity = baseToLiquidity( + lowerSqrtPrice, + upperSqrtPrice, + amount + ).toFixed(); + return { lowerSqrtPrice, upperSqrtPrice, liquidity }; + } +}; + +export const getVirtualPositionRange = ( + trend: Trend, + range: Decimal.Value, + cachedPrice: Decimal.Value, + oraclePrice: Decimal.Value +): PositionRange => { + Decimal.set({ precision: PRECISION, rounding: ROUNDING }); + + if (new Decimal(oraclePrice).isZero()) { + throw new Error("Oracle price is zero"); + } + + const oracleLimit = priceToLimit(oraclePrice, 1); + const newBidLower = oracleLimit.sub(range); + const newBidUpper = oracleLimit; + const newAskLower = oracleLimit; + const newAskUpper = oracleLimit.add(range); + + let cachedPriceSet = cachedPrice; + if (new Decimal(cachedPrice).isZero()) { + cachedPriceSet = oraclePrice; + } + + const cachedLimit = priceToLimit(cachedPriceSet, 1); + const bidLower = cachedLimit.sub(range); + const bidUpper = cachedLimit; + const askLower = cachedLimit; + const askUpper = cachedLimit.add(range); + + switch (trend) { + case Trend.Up: + if (newBidUpper.gt(bidUpper)) { + return { + bidLower: newBidLower, + bidUpper: newBidUpper, + askLower: 0, + askUpper: 0, + }; + } else if (newAskLower.lte(bidLower)) { + return { + bidLower: 0, + bidUpper: 0, + askLower: bidLower, + askUpper: bidUpper, + }; + } else { + if (newAskLower.gte(bidUpper)) { + return { + bidLower: bidLower, + bidUpper: newBidUpper, + askLower: 0, + askUpper: 0, + }; + } + return { + bidLower: bidLower, + bidUpper: newBidUpper, + askLower: newAskLower, + askUpper: bidUpper, + }; + } + case Trend.Down: + if (newAskLower.lt(askLower)) { + return { + bidLower: 0, + bidUpper: 0, + askLower: newAskLower, + askUpper: newAskUpper, + }; + } else if (newBidUpper.gte(askUpper)) { + return { + bidLower: askLower, + bidUpper: askUpper, + askLower: 0, + askUpper: 0, + }; + } else { + if (newBidUpper.lte(askLower)) { + return { + bidLower: 0, + bidUpper: 0, + askLower: newAskLower, + askUpper: askUpper, + }; + } + return { + bidLower: askLower, + bidUpper: newBidUpper, + askLower: newAskLower, + askUpper: askUpper, + }; + } + default: + return { + bidLower: newBidLower, + bidUpper: newBidUpper, + askLower: newAskLower, + askUpper: newAskUpper, + }; + } +}; diff --git a/models/src/reversion/libraries/SwapLib.ts b/models/src/reversion/libraries/SwapLib.ts new file mode 100644 index 0000000..cb43a68 --- /dev/null +++ b/models/src/reversion/libraries/SwapLib.ts @@ -0,0 +1,194 @@ +import Decimal from "decimal.js"; +import { PRECISION, ROUNDING } from "../../common/config"; +import { + liquidityToBase, + liquidityToQuote, +} from "../../common/math/liquidityMath"; +import { grossToNet, netToFee } from "../../common/math/feeMath"; + +export const getSwapAmounts = ({ + isBuy, + exactInput, + amount, + swapFeeRate, + thresholdSqrtPrice, + thresholdAmount, + lowerSqrtPrice, + upperSqrtPrice, + liquidity, + baseDecimals, + quoteDecimals, +}: { + isBuy: boolean; + exactInput: boolean; + amount: Decimal.Value; + swapFeeRate: Decimal.Value; + thresholdSqrtPrice: Decimal.Value | null; + thresholdAmount: Decimal.Value | null; + lowerSqrtPrice: Decimal.Value; + upperSqrtPrice: Decimal.Value; + liquidity: Decimal.Value; + baseDecimals: number; + quoteDecimals: number; +}): { + amountIn: Decimal.Value; + amountOut: Decimal.Value; + fees: Decimal.Value; +} => { + const scaledLowerSqrtPrice = new Decimal(lowerSqrtPrice).mul( + new Decimal(10).pow((baseDecimals - quoteDecimals) / 2) + ); + const scaledUpperSqrtPrice = new Decimal(upperSqrtPrice).mul( + new Decimal(10).pow((baseDecimals - quoteDecimals) / 2) + ); + + const startSqrtPrice = isBuy ? scaledLowerSqrtPrice : scaledUpperSqrtPrice; + const targetSqrtPrice = isBuy + ? thresholdSqrtPrice + ? Decimal.min(thresholdSqrtPrice, scaledUpperSqrtPrice) + : scaledUpperSqrtPrice + : thresholdSqrtPrice + ? Decimal.max(thresholdSqrtPrice, scaledLowerSqrtPrice) + : scaledLowerSqrtPrice; + + const { amountIn, amountOut, fees, nextSqrtPrice } = computeSwapAmount({ + currSqrtPrice: startSqrtPrice, + targetSqrtPrice, + liquidity, + amountRem: amount, + swapFeeRate, + exactInput, + }); + + const grossAmountIn = new Decimal(amountIn).add(fees); + + if (thresholdAmount) { + if (exactInput) { + if (amountOut < thresholdAmount) + throw new Error("Threshold amount not met"); + } else { + if (grossAmountIn > thresholdAmount) + throw new Error("Threshold amount exceeded"); + } + } + + return { amountIn: grossAmountIn, amountOut, fees }; +}; + +export const computeSwapAmount = ({ + currSqrtPrice, + targetSqrtPrice, + liquidity, + amountRem, + swapFeeRate, + exactInput, +}: { + currSqrtPrice: Decimal.Value; + targetSqrtPrice: Decimal.Value; + liquidity: Decimal.Value; + amountRem: Decimal.Value; + swapFeeRate: Decimal.Value; + exactInput: boolean; +}) => { + Decimal.set({ precision: PRECISION, rounding: ROUNDING }); + const isBuy = new Decimal(targetSqrtPrice).gt(currSqrtPrice); + let amountIn: Decimal.Value = "0"; + let amountOut: Decimal.Value = "0"; + let nextSqrtPrice: Decimal.Value = "0"; + let fees: Decimal.Value = "0"; + + if (exactInput) { + const amountRemainingLessFee = grossToNet(amountRem, swapFeeRate); + amountIn = isBuy + ? liquidityToQuote(currSqrtPrice, targetSqrtPrice, liquidity) + : liquidityToBase(targetSqrtPrice, currSqrtPrice, liquidity); + if (new Decimal(amountRemainingLessFee).gte(amountIn)) { + nextSqrtPrice = targetSqrtPrice; + } else { + nextSqrtPrice = nextSqrtPriceAmountIn( + currSqrtPrice, + liquidity, + amountRemainingLessFee, + isBuy + ); + } + } else { + amountOut = isBuy + ? liquidityToBase(currSqrtPrice, targetSqrtPrice, liquidity) + : liquidityToQuote(targetSqrtPrice, currSqrtPrice, liquidity); + if (new Decimal(amountRem).gte(amountOut)) { + nextSqrtPrice = targetSqrtPrice; + } else { + nextSqrtPrice = nextSqrtPriceAmountOut( + currSqrtPrice, + liquidity, + amountRem, + isBuy + ); + } + } + + const max = targetSqrtPrice === nextSqrtPrice; + + if (isBuy) { + if (!max || !exactInput) { + amountIn = liquidityToQuote(currSqrtPrice, nextSqrtPrice, liquidity); + } + if (!max || exactInput) { + amountOut = liquidityToBase(currSqrtPrice, nextSqrtPrice, liquidity); + } + } else { + if (!max || !exactInput) { + amountIn = liquidityToBase(nextSqrtPrice, currSqrtPrice, liquidity); + } + if (!max || exactInput) { + amountOut = liquidityToQuote(nextSqrtPrice, currSqrtPrice, liquidity); + } + } + + if (!exactInput && amountOut > amountRem) { + amountOut = amountRem; + } + + // In Uniswap, if target price is not reached, LP takes the remainder of the maximum input as fee. + // We don't do that here. + fees = netToFee(amountIn, swapFeeRate); + + return { amountIn, amountOut, fees, nextSqrtPrice }; +}; + +export const nextSqrtPriceAmountIn = ( + currSqrtPrice: Decimal.Value, + liquidity: Decimal.Value, + amountIn: Decimal.Value, + isBuy: boolean +) => { + Decimal.set({ precision: PRECISION, rounding: ROUNDING }); + const currSqrtPriceBN = new Decimal(currSqrtPrice); + const liquidityBN = new Decimal(liquidity); + const amountInBN = new Decimal(amountIn); + const nextSqrtPrice = isBuy + ? currSqrtPriceBN.add(amountInBN.div(liquidityBN)) + : liquidityBN + .mul(currSqrtPriceBN) + .div(liquidityBN.add(amountInBN.mul(currSqrtPriceBN))); + return nextSqrtPrice; +}; + +export const nextSqrtPriceAmountOut = ( + currSqrtPrice: Decimal.Value, + liquidity: Decimal.Value, + amountOut: Decimal.Value, + isBuy: boolean +) => { + Decimal.set({ precision: PRECISION, rounding: ROUNDING }); + const currSqrtPriceBN = new Decimal(currSqrtPrice); + const liquidityBN = new Decimal(liquidity); + const amountOutBN = new Decimal(amountOut); + const nextSqrtPrice = isBuy + ? liquidityBN + .mul(currSqrtPriceBN) + .div(liquidityBN.sub(amountOutBN.mul(currSqrtPriceBN))) + : currSqrtPriceBN.sub(amountOutBN.div(liquidityBN)); + return nextSqrtPrice; +}; diff --git a/models/src/reversion/tests/SpreadMath.test.ts b/models/src/reversion/tests/SpreadMath.test.ts new file mode 100644 index 0000000..bde03df --- /dev/null +++ b/models/src/reversion/tests/SpreadMath.test.ts @@ -0,0 +1,84 @@ +import Decimal from "decimal.js"; +import { + getVirtualPosition, + getVirtualPositionRange, +} from "../libraries/SpreadMath"; +import { Trend } from "../types"; + +const testGetVirtualPositionCases = () => { + const cases = [ + { lowerLimit: 7906624, upperLimit: 7906625, amount: 1000 }, + { lowerLimit: 7906625, upperLimit: 7907625, amount: 0 }, + { lowerLimit: 7906625 - 600000, upperLimit: 7906625 - 500000, amount: 1 }, + { lowerLimit: 7906625 - 100000, upperLimit: 7906625 - 90000, amount: 1 }, + { lowerLimit: 7906625 + 90000, upperLimit: 7906625 + 100000, amount: 1 }, + { lowerLimit: 7906625 + 500000, upperLimit: 7906625 + 600000, amount: 1 }, + { + lowerLimit: 7906625, + upperLimit: 7907625, + amount: "0.000000000000000001", + }, + { + lowerLimit: 7906625, + upperLimit: 7907625, + amount: "100000000000000000", + }, + ]; + + for (let i = 0; i < cases.length; i++) { + const params = cases[i]; + const bid = getVirtualPosition( + true, + params.lowerLimit, + params.upperLimit, + params.amount + ); + const ask = getVirtualPosition( + false, + params.lowerLimit, + params.upperLimit, + params.amount + ); + console.log(`Case ${i + 1}`); + console.log({ + lowerSqrtPrice: new Decimal(bid.lowerSqrtPrice).mul(1e28).toFixed(0), + upperSqrtPrice: new Decimal(bid.upperSqrtPrice).mul(1e28).toFixed(0), + bidLiquidity: new Decimal(bid.liquidity).mul(1e18).toFixed(0), + askLiquidity: new Decimal(ask.liquidity).mul(1e18).toFixed(0), + }); + } +}; + +const testGetVirtualPositionRangeCases = () => { + const cases = [ + getVirtualPositionRange(Trend.Up, 1000, 1, 1.1), + getVirtualPositionRange(Trend.Up, 1000, 1, 1), + getVirtualPositionRange(Trend.Up, 1000, 1, 0.995), + getVirtualPositionRange(Trend.Up, 1000, 1, 0.99005), + getVirtualPositionRange(Trend.Up, 1000, 1, 0.95), + getVirtualPositionRange(Trend.Up, 1000, 0, 0.9), + getVirtualPositionRange(Trend.Down, 1000, 1, 0.9), + getVirtualPositionRange(Trend.Down, 1000, 1, 1), + getVirtualPositionRange(Trend.Down, 1000, 1, 1.005), + getVirtualPositionRange(Trend.Down, 1000, 1, 1.01006), + getVirtualPositionRange(Trend.Down, 1000, 1, 1.05), + getVirtualPositionRange(Trend.Down, 1000, 0, 1.1), + getVirtualPositionRange(Trend.Range, 1000, 1, 1), + getVirtualPositionRange(Trend.Range, 1000, 1, 1.5), + getVirtualPositionRange(Trend.Range, 1000, 1, 0.5), + ]; + + for (let i = 0; i < cases.length; i++) { + const pos = cases[i]; + console.log(`Case ${i + 1}`); + console.log({ + bidLower: new Decimal(pos.bidLower).toFixed(0), + bidUpper: new Decimal(pos.bidUpper).toFixed(0), + askLower: new Decimal(pos.askLower).toFixed(0), + askUpper: new Decimal(pos.askUpper).toFixed(0), + }); + } +}; + +// testGetVirtualPositionCases(); +testGetVirtualPositionRangeCases(); diff --git a/models/src/reversion/tests/Swap.test.ts b/models/src/reversion/tests/Swap.test.ts new file mode 100644 index 0000000..c2d0712 --- /dev/null +++ b/models/src/reversion/tests/Swap.test.ts @@ -0,0 +1,303 @@ +import Decimal from "decimal.js"; +import { getSwapAmounts } from "../libraries/SwapLib"; +import { + getVirtualPosition, + getVirtualPositionRange, +} from "../libraries/SpreadMath"; +import { Trend } from "../types"; + +const reset = "\x1b[0m"; +const green = "\x1b[32m"; + +type Case = { + description: string; + oraclePrice: Decimal.Value; + baseReserves: Decimal.Value; + quoteReserves: Decimal.Value; + feeRate: Decimal.Value; + range: Decimal.Value; + amount: Decimal.Value; + thresholdSqrtPrice: Decimal.Value | null; + thresholdAmount: Decimal.Value | null; + swapCasesOverride?: SwapCase[]; + trendCasesOverride?: Trend[]; +}; + +type SwapCase = { + isBuy: boolean; + exactInput: boolean; +}; + +const getSwapCases = (): SwapCase[] => { + const swapCases: SwapCase[] = [ + { + isBuy: true, + exactInput: true, + }, + { + isBuy: false, + exactInput: true, + }, + { + isBuy: true, + exactInput: false, + }, + { + isBuy: false, + exactInput: false, + }, + ]; + return swapCases; +}; + +const testSwapCases = () => { + const cases: Case[] = [ + { + description: "1) Full range liq, price 1, 0% fee", + oraclePrice: 1, + baseReserves: 1000, + quoteReserves: 1000, + feeRate: 0, + range: 7906625, + amount: 100, + thresholdSqrtPrice: null, + thresholdAmount: null, + }, + { + description: "2) Full range liq, price 0.1, 0% fee", + oraclePrice: 0.1, + baseReserves: 100, + quoteReserves: 1000, + feeRate: 0, + range: 7676365, + amount: 10, + thresholdSqrtPrice: null, + thresholdAmount: null, + }, + { + description: "3) Full range liq, price 10, 0% fee", + oraclePrice: 10, + baseReserves: 1000, + quoteReserves: 100, + feeRate: 0, + range: 7676365, + amount: 100, + thresholdSqrtPrice: null, + thresholdAmount: null, + }, + { + description: "4) Concentrated liq, price 1, 0% fee", + oraclePrice: 1, + baseReserves: 1000, + quoteReserves: 1000, + feeRate: 0, + range: 5000, + amount: 100, + thresholdSqrtPrice: null, + thresholdAmount: null, + }, + { + description: "5) Concentrated liq, price 1, 1% fee", + oraclePrice: 1, + baseReserves: 1000, + quoteReserves: 1000, + feeRate: 0.01, + range: 5000, + amount: 100, + thresholdSqrtPrice: null, + thresholdAmount: null, + }, + { + description: "6) Concentrated liq, price 10, 50% fee", + oraclePrice: 10, + baseReserves: 1000, + quoteReserves: 1000, + feeRate: 0.5, + range: 5000, + amount: 100, + thresholdSqrtPrice: null, + thresholdAmount: null, + }, + { + description: "7) Swap with liquidity exhausted", + oraclePrice: 1, + baseReserves: 100, + quoteReserves: 100, + feeRate: 0.01, + range: 5000, + amount: 200, + thresholdSqrtPrice: null, + thresholdAmount: null, + }, + { + description: "8) Swap with high oracle price", + oraclePrice: 1000000000000000, + baseReserves: 1000, + quoteReserves: 1000, + feeRate: 0.01, + range: 5000, + amount: 100, + thresholdSqrtPrice: null, + thresholdAmount: null, + }, + { + description: "9) Swap with low oracle price", + oraclePrice: "0.00000001", + baseReserves: 1000, + quoteReserves: 1000, + feeRate: 0.01, + range: 5000, + amount: 100, + thresholdSqrtPrice: null, + thresholdAmount: null, + }, + { + description: "10) Swap buy capped at threshold price", + oraclePrice: 1, + baseReserves: 100, + quoteReserves: 100, + feeRate: 0.01, + range: 50000, + amount: 50, + thresholdSqrtPrice: "1.0488088481701515469914535136", + thresholdAmount: null, + swapCasesOverride: [ + { + isBuy: true, + exactInput: true, + }, + { + isBuy: true, + exactInput: false, + }, + ], + }, + { + description: "11) Swap sell capped at threshold price", + oraclePrice: 1, + baseReserves: 100, + quoteReserves: 100, + feeRate: 0.01, + range: 50000, + amount: 50, + thresholdSqrtPrice: "0.9486832980505137995996680633", + thresholdAmount: null, + swapCasesOverride: [ + { + isBuy: false, + exactInput: true, + }, + { + isBuy: false, + exactInput: false, + }, + ], + }, + { + description: "12) Swap exact in with threshold amount", + oraclePrice: 1, + baseReserves: 1000, + quoteReserves: 1000, + feeRate: 0.01, + range: 5000, + amount: 100, + thresholdSqrtPrice: null, + thresholdAmount: "0.99650000000000000000", + swapCasesOverride: [ + { + isBuy: true, + exactInput: true, + }, + { + isBuy: false, + exactInput: true, + }, + ], + }, + { + description: "13) Swap exact out with threshold amount", + oraclePrice: 1, + baseReserves: 1000, + quoteReserves: 1000, + feeRate: 0.01, + range: 5000, + amount: 100, + thresholdSqrtPrice: null, + thresholdAmount: "102", + swapCasesOverride: [ + { + isBuy: true, + exactInput: false, + }, + { + isBuy: false, + exactInput: false, + }, + ], + }, + ]; + + for (let i = 0; i < cases.length; i++) { + const { + description, + oraclePrice, + baseReserves, + quoteReserves, + feeRate, + range, + amount, + thresholdSqrtPrice, + thresholdAmount, + swapCasesOverride, + trendCasesOverride, + } = cases[i]; + + const baseDecimals = 18; + const quoteDecimals = 18; + + console.log(`Test Case ${description}`); + + // Loop through swap cases + const swapCases = swapCasesOverride ?? getSwapCases(); + let j = 0; + for (const { isBuy, exactInput } of swapCases) { + console.log( + ` Swap Case ${j + 1}) isBuy: ${isBuy}, exactInput: ${exactInput}` + ); + const { bidLower, bidUpper, askLower, askUpper } = + getVirtualPositionRange(Trend.Range, range, 0, oraclePrice); + const { lowerSqrtPrice, upperSqrtPrice, liquidity } = getVirtualPosition( + !isBuy, + isBuy ? askLower : bidLower, + isBuy ? askUpper : bidUpper, + isBuy ? baseReserves : quoteReserves + ); + const { amountIn, amountOut, fees } = getSwapAmounts({ + isBuy, + exactInput, + amount, + swapFeeRate: feeRate, + thresholdSqrtPrice, + thresholdAmount, + lowerSqrtPrice, + upperSqrtPrice, + liquidity, + baseDecimals, + quoteDecimals, + }); + console.log( + ` amountIn: ${green}${new Decimal(amountIn) + .mul(1e18) + .toFixed(0)}${reset}, amountOut: ${green}${new Decimal(amountOut) + .mul(1e18) + .toFixed(0)}${reset}, fees: ${green}${new Decimal(fees) + .mul(1e18) + .toFixed(0)}${reset}` + ); + j++; + } + } +}; + +console.log("testing"); +testSwapCases(); diff --git a/models/tests/libraries/SwapLib.test.ts b/models/src/reversion/tests/SwapLib.test.ts similarity index 95% rename from models/tests/libraries/SwapLib.test.ts rename to models/src/reversion/tests/SwapLib.test.ts index 775ed98..3cd05aa 100644 --- a/models/tests/libraries/SwapLib.test.ts +++ b/models/src/reversion/tests/SwapLib.test.ts @@ -1,5 +1,5 @@ import Decimal from "decimal.js"; -import { getSwapAmounts } from "../../src/libraries/SwapLib"; +import { getSwapAmounts } from "../libraries/SwapLib"; type SwapParams = { isBuy: boolean; @@ -18,7 +18,7 @@ const testGetSwapAmounts = () => { isBuy: true, exactInput: true, amount: 1, - swapFeeRate: 0.005, + swapFeeRate: 0, thresholdSqrtPrice: null, thresholdAmount: null, lowerSqrtPrice: 0.8 ** 0.5, diff --git a/models/src/reversion/types.ts b/models/src/reversion/types.ts new file mode 100644 index 0000000..28b980d --- /dev/null +++ b/models/src/reversion/types.ts @@ -0,0 +1,5 @@ +export enum Trend { + Up = "Up", + Down = "Down", + Range = "Range", +} diff --git a/packages/reversion/src/contracts/mocks.cairo b/packages/reversion/src/contracts/mocks.cairo index 7aa468f..54f052e 100644 --- a/packages/reversion/src/contracts/mocks.cairo +++ b/packages/reversion/src/contracts/mocks.cairo @@ -1,2 +1,3 @@ pub(crate) mod mock_pragma_oracle; -pub(crate) mod store_packing_contract; \ No newline at end of file +pub(crate) mod store_packing_contract; +pub(crate) mod upgraded_reversion_solver; diff --git a/packages/reversion/src/contracts/mocks/store_packing_contract.cairo b/packages/reversion/src/contracts/mocks/store_packing_contract.cairo index 402b33f..6a10d9e 100644 --- a/packages/reversion/src/contracts/mocks/store_packing_contract.cairo +++ b/packages/reversion/src/contracts/mocks/store_packing_contract.cairo @@ -1,18 +1,20 @@ -use haiko_solver_reversion::types::{MarketParams, TrendState}; +use haiko_solver_reversion::types::{MarketParams, ModelParams}; #[starknet::interface] pub trait IStorePackingContract { fn get_market_params(self: @TContractState, market_id: felt252) -> MarketParams; - fn get_trend_state(self: @TContractState, market_id: felt252) -> TrendState; + fn get_model_params(self: @TContractState, market_id: felt252) -> ModelParams; fn set_market_params(ref self: TContractState, market_id: felt252, market_params: MarketParams); - fn set_trend_state(ref self: TContractState, market_id: felt252, trend_state: TrendState); + fn set_model_params(ref self: TContractState, market_id: felt252, model_params: ModelParams); } #[starknet::contract] pub mod StorePackingContract { - use haiko_solver_reversion::types::{MarketParams, TrendState}; - use haiko_solver_reversion::libraries::store_packing::{MarketParamsStorePacking, TrendStateStorePacking}; + use haiko_solver_reversion::types::{MarketParams, ModelParams}; + use haiko_solver_reversion::libraries::store_packing::{ + MarketParamsStorePacking, ModelParamsStorePacking + }; use super::IStorePackingContract; //////////////////////////////// @@ -22,7 +24,7 @@ pub mod StorePackingContract { #[storage] struct Storage { market_params: LegacyMap::, - trend_state: LegacyMap::, + model_params: LegacyMap::, } #[constructor] @@ -38,8 +40,8 @@ pub mod StorePackingContract { self.market_params.read(market_id) } - fn get_trend_state(self: @ContractState, market_id: felt252) -> TrendState { - self.trend_state.read(market_id) + fn get_model_params(self: @ContractState, market_id: felt252) -> ModelParams { + self.model_params.read(market_id) } //////////////////////////////// @@ -52,10 +54,10 @@ pub mod StorePackingContract { self.market_params.write(market_id, market_params); } - fn set_trend_state( - ref self: ContractState, market_id: felt252, trend_state: TrendState + fn set_model_params( + ref self: ContractState, market_id: felt252, model_params: ModelParams ) { - self.trend_state.write(market_id, trend_state); + self.model_params.write(market_id, model_params); } } } diff --git a/packages/reversion/src/contracts/mocks/upgraded_reversion_solver.cairo b/packages/reversion/src/contracts/mocks/upgraded_reversion_solver.cairo new file mode 100644 index 0000000..990abe1 --- /dev/null +++ b/packages/reversion/src/contracts/mocks/upgraded_reversion_solver.cairo @@ -0,0 +1,35 @@ +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IUpgradedReversionSolver { + fn foo(self: @TContractState) -> u32; +} + +#[starknet::contract] +pub mod UpgradedReversionSolver { + use super::IUpgradedReversionSolver; + + //////////////////////////////// + // STORAGE + //////////////////////////////// + + #[storage] + struct Storage { + foo: u32, + } + + //////////////////////////////// + // CONSTRUCTOR + //////////////////////////////// + + #[abi(embed_v0)] + impl UpgradedReversionSolver of IUpgradedReversionSolver { + //////////////////////////////// + // VIEW FUNCTIONS + //////////////////////////////// + + fn foo(self: @ContractState) -> u32 { + self.foo.read() + } + } +} diff --git a/packages/reversion/src/contracts/reversion_solver.cairo b/packages/reversion/src/contracts/reversion_solver.cairo index d80824e..d9567cb 100644 --- a/packages/reversion/src/contracts/reversion_solver.cairo +++ b/packages/reversion/src/contracts/reversion_solver.cairo @@ -3,14 +3,14 @@ pub mod ReversionSolver { // Core lib imports. use starknet::ContractAddress; use starknet::contract_address::contract_address_const; - use starknet::get_block_timestamp; + use starknet::{get_caller_address, get_block_timestamp}; use starknet::class_hash::ClassHash; // Local imports. use haiko_solver_core::contracts::solver::SolverComponent; use haiko_solver_core::interfaces::ISolver::ISolverHooks; use haiko_solver_reversion::libraries::{ - swap_lib, spread_math, store_packing::{MarketParamsStorePacking, TrendStateStorePacking} + swap_lib, spread_math, store_packing::{MarketParamsStorePacking, ModelParamsStorePacking} }; use haiko_solver_reversion::interfaces::{ IReversionSolver::IReversionSolver, @@ -19,8 +19,8 @@ pub mod ReversionSolver { IOracleABIDispatcherTrait }, }; - use haiko_solver_core::types::{PositionInfo, MarketState, MarketInfo, SwapParams}; - use haiko_solver_reversion::types::{MarketParams, TrendState, Trend}; + use haiko_solver_core::types::{PositionInfo, MarketState, MarketInfo, SwapParams, SwapAmounts}; + use haiko_solver_reversion::types::{MarketParams, ModelParams, Trend}; // Haiko imports. use haiko_lib::math::math; @@ -50,10 +50,19 @@ pub mod ReversionSolver { solver: SolverComponent::Storage, // oracle for price and volatility feeds oracle: IOracleABIDispatcher, + // admin with permission to change model params + model_admin: ContractAddress, // Indexed by market id market_params: LegacyMap::, // Indexed by market id - trend_state: LegacyMap::, + queued_market_params: LegacyMap::, + // Indexed by market id + model_params: LegacyMap::, + // Indexed by market id + // timestamp when market params were queued + queued_at: LegacyMap::, + // delay in seconds for confirming queued market params + delay: u64, } //////////////////////////////// @@ -63,18 +72,33 @@ pub mod ReversionSolver { #[event] #[derive(Drop, starknet::Event)] pub(crate) enum Event { - SetTrend: SetTrend, + SetModelParams: SetModelParams, + QueueMarketParams: QueueMarketParams, SetMarketParams: SetMarketParams, + SetDelay: SetDelay, ChangeOracle: ChangeOracle, + ChangeModelAdmin: ChangeModelAdmin, #[flat] SolverEvent: SolverComponent::Event, } #[derive(Drop, starknet::Event)] - pub(crate) struct SetTrend { + pub(crate) struct SetModelParams { #[key] pub market_id: felt252, pub trend: Trend, + pub range: u32, + } + + #[derive(Drop, starknet::Event)] + pub(crate) struct QueueMarketParams { + #[key] + pub market_id: felt252, + pub fee_rate: u16, + pub base_currency_id: felt252, + pub quote_currency_id: felt252, + pub min_sources: u32, + pub max_age: u64, } #[derive(Drop, starknet::Event)] @@ -82,18 +106,27 @@ pub mod ReversionSolver { #[key] pub market_id: felt252, pub fee_rate: u16, - pub range: u32, pub base_currency_id: felt252, pub quote_currency_id: felt252, pub min_sources: u32, pub max_age: u64, } + #[derive(Drop, starknet::Event)] + pub(crate) struct SetDelay { + pub delay: u64, + } + #[derive(Drop, starknet::Event)] pub(crate) struct ChangeOracle { pub oracle: ContractAddress, } + #[derive(Drop, starknet::Event)] + pub(crate) struct ChangeModelAdmin { + pub admin: ContractAddress, + } + //////////////////////////////// // CONSTRUCTOR //////////////////////////////// @@ -128,7 +161,7 @@ pub mod ReversionSolver { // * `fees` - amount of fees fn quote( self: @ContractState, market_id: felt252, swap_params: SwapParams, - ) -> (u256, u256, u256) { + ) -> SwapAmounts { // Run validity checks. let state: MarketState = self.solver.market_state.read(market_id); let market_info: MarketInfo = self.solver.market_info.read(market_id); @@ -137,11 +170,19 @@ pub mod ReversionSolver { // Get virtual positions. let (bid, ask) = self.get_virtual_positions(market_id); - let position = if swap_params.is_buy { ask } else { bid }; + let position = if swap_params.is_buy { + ask + } else { + bid + }; // Calculate and return swap amounts. let market_params = self.market_params.read(market_id); - swap_lib::get_swap_amounts(swap_params, market_params.fee_rate, position) + let (amount_in, amount_out, fees) = swap_lib::get_swap_amounts( + swap_params, market_params.fee_rate, position + ); + + SwapAmounts { amount_in, amount_out, fees } } // Get the initial token supply to mint when first depositing to a market. @@ -171,24 +212,27 @@ pub mod ReversionSolver { fn after_swap(ref self: ContractState, market_id: felt252, swap_params: SwapParams) { // Run checks. assert(self.solver.unlocked.read(), 'NotSolver'); - + // Fetch state. - let mut trend_state: TrendState = self.trend_state.read(market_id); + let mut model_params: ModelParams = self.model_params.read(market_id); let oracle_output = self.get_unscaled_oracle_price(market_id); - + // Calculate conditions for updating cached price. // 1. if price trends up and price > cached price, update cached price // 2. if price trends down and price < cached price, update cached price // 3. otherwise, don't update - if trend_state.trend == Trend::Up && oracle_output.price > trend_state.cached_price || - trend_state.trend == Trend::Down && oracle_output.price < trend_state.cached_price { - trend_state.cached_price = oracle_output.price; - trend_state.cached_decimals = oracle_output.decimals; - self.trend_state.write(market_id, trend_state); + if model_params.trend == Trend::Up + && oracle_output.price > model_params.cached_price + || model_params.trend == Trend::Down + && oracle_output.price < model_params.cached_price + || model_params.cached_price == 0 { + model_params.cached_price = oracle_output.price; + model_params.cached_decimals = oracle_output.decimals; + self.model_params.write(market_id, model_params); }; // Commit state. - self.trend_state.write(market_id, trend_state); + self.model_params.write(market_id, model_params); } } @@ -199,11 +243,37 @@ pub mod ReversionSolver { self.market_params.read(market_id) } + // Queued market parameters + fn queued_market_params(self: @ContractState, market_id: felt252) -> MarketParams { + self.queued_market_params.read(market_id) + } + + // Delay (in seconds) for setting market parameters + fn delay(self: @ContractState) -> u64 { + self.delay.read() + } + // Pragma oracle contract address fn oracle(self: @ContractState) -> ContractAddress { self.oracle.read().contract_address } + // Model admin contract address + fn model_admin(self: @ContractState) -> ContractAddress { + self.model_admin.read() + } + + // Get model parameters of solver market. + // + // # Params + // * `market_id` - market id + // + // # Returns + // * `trend - market trend + fn model_params(self: @ContractState, market_id: felt252) -> ModelParams { + self.model_params.read(market_id) + } + // Get unscaled oracle price from oracle feed. // // # Arguments @@ -211,19 +281,22 @@ pub mod ReversionSolver { // // # Returns // * `output` - Pragma oracle price response - fn get_unscaled_oracle_price(self: @ContractState, market_id: felt252) -> PragmaPricesResponse { + fn get_unscaled_oracle_price( + self: @ContractState, market_id: felt252 + ) -> PragmaPricesResponse { // Fetch state. let oracle = self.oracle.read(); let params = self.market_params.read(market_id); // Fetch oracle price. - oracle.get_data_with_USD_hop( - params.base_currency_id, - params.quote_currency_id, - AggregationMode::Median(()), - SimpleDataType::SpotEntry(()), - Option::None(()) - ) + oracle + .get_data_with_USD_hop( + params.base_currency_id, + params.quote_currency_id, + AggregationMode::Median(()), + SimpleDataType::SpotEntry(()), + Option::None(()) + ) } // Get price from oracle feed. @@ -253,52 +326,105 @@ pub mod ReversionSolver { } // Change trend of the solver market. - // Only callable by market owner. + // Only callable by market owner or model admin. // // # Params // * `market_id` - market id // * `trend - market trend - fn set_trend(ref self: ContractState, market_id: felt252, trend: Trend) { + // * `range` - range of virtual liquidity position + fn set_model_params(ref self: ContractState, market_id: felt252, trend: Trend, range: u32) { + let caller = get_caller_address(); + let market_info: MarketInfo = self.solver.market_info.read(market_id); + let mut model_params = self.model_params.read(market_id); + let model_admin = self.model_admin.read(); + + // Run checks. + assert(market_info.base_token != contract_address_const::<0x0>(), 'MarketNull'); + assert(caller == market_info.owner || caller == model_admin, 'NotApproved'); + assert(model_params.trend != trend || model_params.range != range, 'Unchanged'); + assert(range != 0, 'RangeZero'); + + // Update state. + model_params.trend = trend; + model_params.range = range; + // Whenever we update trend, we also need to update the cached price in case it is stale. + // This update is skipped if cached price is uninitialised. + if model_params.cached_decimals != 0 && model_params.cached_price != 0 { + let oracle_output = self.get_unscaled_oracle_price(market_id); + model_params.cached_price = oracle_output.price; + model_params.cached_decimals = oracle_output.decimals; + } + self.model_params.write(market_id, model_params); + + // Emit event. + self.emit(Event::SetModelParams(SetModelParams { market_id, trend, range })); + } + + // Queue change to the parameters of the solver market. + // This must be accepted after the set delay in order for the change to be applied. + // Only callable by market owner. + // + // # Params + // * `market_id` - market id + // * `params` - market params + fn queue_market_params(ref self: ContractState, market_id: felt252, params: MarketParams) { // Run checks. self.solver.assert_market_owner(market_id); - let mut trend_state = self.trend_state.read(market_id); - assert(trend_state.trend != trend, 'TrendUnchanged'); + let old_params = self.market_params.read(market_id); + assert(old_params != params, 'ParamsUnchanged'); // Update state. - trend_state.trend = trend; - self.trend_state.write(market_id, trend_state); + let now = get_block_timestamp(); + self.queued_market_params.write(market_id, params); + self.queued_at.write(market_id, now); // Emit event. self .emit( - Event::SetTrend( - SetTrend { + Event::QueueMarketParams( + QueueMarketParams { market_id, - trend, + fee_rate: params.fee_rate, + base_currency_id: params.base_currency_id, + quote_currency_id: params.quote_currency_id, + min_sources: params.min_sources, + max_age: params.max_age, } ) ); } - // Change parameters of the solver market. + // Confirm and set queued market parameters. + // Must have been queued for at least the set delay. // Only callable by market owner. // // # Params // * `market_id` - market id // * `params` - market params - fn set_market_params(ref self: ContractState, market_id: felt252, params: MarketParams) { + fn set_market_params(ref self: ContractState, market_id: felt252) { + // Fetch queued params and delay. + let params = self.market_params.read(market_id); + let queued_params = self.queued_market_params.read(market_id); + let queued_at = self.queued_at.read(market_id); + let delay = self.delay.read(); + // Run checks. self.solver.assert_market_owner(market_id); - let old_params = self.market_params.read(market_id); - assert(old_params != params, 'ParamsUnchanged'); - assert(params.range != 0, 'RangeZero'); - assert(params.min_sources != 0, 'MinSourcesZero'); - assert(params.max_age != 0, 'MaxAgeZero'); - assert(params.base_currency_id != 0, 'BaseIdZero'); - assert(params.quote_currency_id != 0, 'QuoteIdZero'); + assert(params != queued_params, 'ParamsUnchanged'); + if params != Default::default() { + // Skip this check if we are initialising the market for first time. + assert(queued_at + delay <= get_block_timestamp(), 'DelayNotPassed'); + } + assert(queued_at != 0 && queued_params != Default::default(), 'NotQueued'); + assert(queued_params.min_sources != 0, 'MinSourcesZero'); + assert(queued_params.max_age != 0, 'MaxAgeZero'); + assert(queued_params.base_currency_id != 0, 'BaseIdZero'); + assert(queued_params.quote_currency_id != 0, 'QuoteIdZero'); // Update state. - self.market_params.write(market_id, params); + self.queued_market_params.write(market_id, Default::default()); + self.queued_at.write(market_id, 0); + self.market_params.write(market_id, queued_params); // Emit event. self @@ -306,17 +432,34 @@ pub mod ReversionSolver { Event::SetMarketParams( SetMarketParams { market_id, - fee_rate: params.fee_rate, - range: params.range, - base_currency_id: params.base_currency_id, - quote_currency_id: params.quote_currency_id, - min_sources: params.min_sources, - max_age: params.max_age, + fee_rate: queued_params.fee_rate, + base_currency_id: queued_params.base_currency_id, + quote_currency_id: queued_params.quote_currency_id, + min_sources: queued_params.min_sources, + max_age: queued_params.max_age, } ) ); } + // Set delay (in seconds) for changing market parameters + // Only callable by owner. + // + // # Params + // * `delay` - delay in blocks + fn set_delay(ref self: ContractState, delay: u64) { + // Run checks. + self.solver.assert_owner(); + let old_delay = self.delay.read(); + assert(delay != old_delay, 'DelayUnchanged'); + + // Update state. + self.delay.write(delay); + + // Emit event. + self.emit(Event::SetDelay(SetDelay { delay })); + } + // Change the oracle contract address. // // # Arguments @@ -330,6 +473,18 @@ pub mod ReversionSolver { self.emit(Event::ChangeOracle(ChangeOracle { oracle })); } + // Change the trend setter. + // + // # Arguments + // * `admin` - contract address of model admin + fn change_model_admin(ref self: ContractState, admin: ContractAddress) { + self.solver.assert_owner(); + let old_admin = self.model_admin.read(); + assert(admin != old_admin, 'TrendSetterUnchanged'); + self.model_admin.write(admin); + self.emit(Event::ChangeModelAdmin(ChangeModelAdmin { admin })); + } + // Get virtual liquidity positions against which swaps are executed. // // # Arguments @@ -344,25 +499,26 @@ pub mod ReversionSolver { // Fetch state. let market_info: MarketInfo = self.solver.market_info.read(market_id); let state: MarketState = self.solver.market_state.read(market_id); - let params = self.market_params.read(market_id); - let trend_state: TrendState = self.trend_state.read(market_id); + let model_params: ModelParams = self.model_params.read(market_id); // Fetch oracle price and cached oracle price. let (oracle_price, is_valid) = self.get_oracle_price(market_id); assert(is_valid, 'InvalidOraclePrice'); - let cached_price = if trend_state.cached_price == 0 { - 0 + let cached_price = if model_params.cached_price == 0 { + 0 } else { - self.scale_oracle_price( - @market_info, trend_state.cached_price, trend_state.cached_decimals - ) + self + .scale_oracle_price( + @market_info, model_params.cached_price, model_params.cached_decimals + ) }; // Calculate and return positions. let mut bid: PositionInfo = Default::default(); let mut ask: PositionInfo = Default::default(); - let (bid_lower, bid_upper, ask_lower, ask_upper) = spread_math::get_virtual_position_range( - trend_state.trend, params.range, cached_price, oracle_price + let (bid_lower, bid_upper, ask_lower, ask_upper) = + spread_math::get_virtual_position_range( + model_params.trend, model_params.range, cached_price, oracle_price ); if state.quote_reserves != 0 { bid = @@ -392,10 +548,7 @@ pub mod ReversionSolver { // * `oracle_price` - oracle price // * `decimals` - oracle price decimals fn scale_oracle_price( - self: @ContractState, - market_info: @MarketInfo, - oracle_price: u128, - decimals: u32 + self: @ContractState, market_info: @MarketInfo, oracle_price: u128, decimals: u32 ) -> u256 { // Get token decimals. let base_token = ERC20ABIDispatcher { contract_address: *market_info.base_token }; diff --git a/packages/reversion/src/interfaces/IReversionSolver.cairo b/packages/reversion/src/interfaces/IReversionSolver.cairo index 3d77660..760f83d 100644 --- a/packages/reversion/src/interfaces/IReversionSolver.cairo +++ b/packages/reversion/src/interfaces/IReversionSolver.cairo @@ -4,17 +4,29 @@ use starknet::class_hash::ClassHash; // Local imports. use haiko_solver_core::types::PositionInfo; -use haiko_solver_reversion::types::{Trend, MarketParams}; +use haiko_solver_reversion::types::{Trend, ModelParams, MarketParams}; use haiko_solver_reversion::interfaces::pragma::PragmaPricesResponse; #[starknet::interface] pub trait IReversionSolver { - // Configurable market parameters + // Market parameters fn market_params(self: @TContractState, market_id: felt252) -> MarketParams; + // Queued market parameters + fn queued_market_params(self: @TContractState, market_id: felt252) -> MarketParams; + + // Delay (in seconds) for setting market parameters + fn delay(self: @TContractState) -> u64; + // Pragma oracle contract address fn oracle(self: @TContractState) -> ContractAddress; + // Model admin contract address + fn model_admin(self: @TContractState) -> ContractAddress; + + // Get model parameters of solver market. + fn model_params(self: @TContractState, market_id: felt252) -> ModelParams; + // Get unscaled oracle price from oracle feed. // // # Arguments @@ -31,13 +43,30 @@ pub trait IReversionSolver { // * `is_valid` - whether oracle price passes validity checks re number of sources and age fn get_oracle_price(self: @TContractState, market_id: felt252) -> (u256, bool); - // Change parameters of the solver market. + // Queue change to the parameters of the solver market. + // This must be accepted after the set delay in order for the change to be applied. + // Only callable by market owner. + // + // # Params + // * `market_id` - market id + // * `params` - market params + fn queue_market_params(ref self: TContractState, market_id: felt252, params: MarketParams); + + // Confirm and set queued market parameters. + // Must have been queued for at least the set delay. // Only callable by market owner. // // # Params // * `market_id` - market id // * `params` - market params - fn set_market_params(ref self: TContractState, market_id: felt252, params: MarketParams); + fn set_market_params(ref self: TContractState, market_id: felt252); + + // Set delay (in seconds) for changing market parameters + // Only callable by owner. + // + // # Params + // * `delay` - delay in blocks + fn set_delay(ref self: TContractState, delay: u64); // Change trend of the solver market. // Only callable by market owner. @@ -45,7 +74,8 @@ pub trait IReversionSolver { // # Params // * `market_id` - market id // * `trend - market trend - fn set_trend(ref self: TContractState, market_id: felt252, trend: Trend); + // * `range` - range of virtual liquidity position + fn set_model_params(ref self: TContractState, market_id: felt252, trend: Trend, range: u32); // Change the oracle contract address. // @@ -53,6 +83,12 @@ pub trait IReversionSolver { // * `oracle` - contract address of oracle feed fn change_oracle(ref self: TContractState, oracle: ContractAddress); + // Change the model admin. + // + // # Arguments + // * `admin` - contract address of trend setter admin + fn change_model_admin(ref self: TContractState, admin: ContractAddress); + // Query virtual liquidity positions against which swaps are executed. // // # Arguments diff --git a/packages/reversion/src/lib.cairo b/packages/reversion/src/lib.cairo index 5ae5333..b92f20c 100644 --- a/packages/reversion/src/lib.cairo +++ b/packages/reversion/src/lib.cairo @@ -3,5 +3,5 @@ pub mod libraries; pub mod interfaces; pub mod types; -// #[cfg(test)] -// pub(crate) mod tests; +#[cfg(test)] +pub(crate) mod tests; diff --git a/packages/reversion/src/libraries.cairo b/packages/reversion/src/libraries.cairo index e6bc0d2..78ceb1c 100644 --- a/packages/reversion/src/libraries.cairo +++ b/packages/reversion/src/libraries.cairo @@ -1,3 +1,3 @@ pub mod swap_lib; pub mod spread_math; -pub mod store_packing; \ No newline at end of file +pub mod store_packing; diff --git a/packages/reversion/src/libraries/spread_math.cairo b/packages/reversion/src/libraries/spread_math.cairo index d5145f7..db47e0f 100644 --- a/packages/reversion/src/libraries/spread_math.cairo +++ b/packages/reversion/src/libraries/spread_math.cairo @@ -106,33 +106,50 @@ pub fn get_virtual_position_range( // Note that if a position should be disabled, ranges are returned as 0. match trend { Trend::Up => { - if new_bid_upper > bid_upper { + if new_bid_upper >= bid_upper { + // println!("[case U1] new_bid_upper: {}, new_bid_upper: {}", new_bid_upper, new_bid_upper); (new_bid_lower, new_bid_upper, 0, 0) } else if new_bid_upper <= bid_lower { + // println!("[case U2] new_bid_upper: {}, bid_lower: {}", new_bid_upper, bid_lower); (0, 0, bid_lower, bid_upper) } else { - // Handle special case: oracle limit + spread can exceed bid upper, so disable ask - if new_ask_lower >= bid_upper { - (bid_lower, new_bid_upper, 0, 0) - } else { - (bid_lower, new_bid_upper, new_ask_lower, bid_upper) - } + // println!( + // "[case U3] bid_lower: {}, new_bid_upper: {}, new_ask_lower: {}, bid_upper: {}", + // bid_lower, + // new_bid_upper, + // new_ask_lower, + // bid_upper + // ); + (bid_lower, new_bid_upper, new_ask_lower, bid_upper) } }, Trend::Down => { - if new_ask_lower < ask_lower { + if new_ask_lower <= ask_lower { + // println!("[case D1] new_ask_lower: {}, new_ask_upper: {}", new_ask_lower, new_ask_upper); (0, 0, new_ask_lower, new_ask_upper) } else if new_ask_lower >= ask_upper { + // println!("[case D2] new_ask_lower: {}, ask_upper: {}", new_ask_lower, ask_upper); (ask_lower, ask_upper, 0, 0) } else { - // Handle special case: oracle limit - spread can be less than ask lower, disable bid - if new_bid_upper <= ask_lower { - (0, 0, new_ask_lower, ask_upper) - } else { - (ask_lower, new_bid_upper, new_ask_lower, ask_upper) - } + // println!( + // "[case D3] ask_lower: {}, new_bid_upper: {}, new_ask_lower: {}, ask_upper: {}", + // ask_lower, + // new_bid_upper, + // new_ask_lower, + // ask_upper + // ); + (ask_lower, new_bid_upper, new_ask_lower, ask_upper) } }, - Trend::Range => (new_bid_lower, new_bid_upper, new_ask_lower, new_ask_upper), + Trend::Range => { + // println!( + // "[case R] new_bid_lower: {}, new_bid_upper: {}, new_ask_lower: {}, new_ask_upper: {}", + // new_bid_lower, + // new_bid_upper, + // new_ask_lower, + // new_ask_upper + // ); + (new_bid_lower, new_bid_upper, new_ask_lower, new_ask_upper) + }, } -} \ No newline at end of file +} diff --git a/packages/reversion/src/libraries/store_packing.cairo b/packages/reversion/src/libraries/store_packing.cairo index 39560bc..b95f00c 100644 --- a/packages/reversion/src/libraries/store_packing.cairo +++ b/packages/reversion/src/libraries/store_packing.cairo @@ -2,17 +2,19 @@ use starknet::storage_access::StorePacking; // Local imports. -use haiko_solver_reversion::types::{Trend, MarketParams, TrendState, PackedMarketParams, PackedTrendState}; +use haiko_solver_reversion::types::{ + Trend, MarketParams, ModelParams, PackedMarketParams, PackedModelParams +}; //////////////////////////////// // CONSTANTS //////////////////////////////// -const TWO_POW_32: felt252 = 0x100000000; -const TWO_POW_64: felt252 = 0x10000000000000000; -const TWO_POW_96: felt252 = 0x1000000000000000000000000; +const TWO_POW_16: felt252 = 0x10000; +const TWO_POW_48: felt252 = 0x1000000000000; const TWO_POW_128: felt252 = 0x100000000000000000000000000000000; const TWO_POW_160: u256 = 0x10000000000000000000000000000000000000000; +const TWO_POW_192: u256 = 0x1000000000000000000000000000000000000000000000000; const MASK_1: u256 = 0x1; @@ -29,9 +31,8 @@ const MASK_128: u256 = 0xffffffffffffffffffffffffffffffff; pub impl MarketParamsStorePacking of StorePacking { fn pack(value: MarketParams) -> PackedMarketParams { let mut slab2: u256 = value.fee_rate.into(); - slab2 += value.range.into() * TWO_POW_32.into(); - slab2 += value.min_sources.into() * TWO_POW_64.into(); - slab2 += value.max_age.into() * TWO_POW_96.into(); + slab2 += value.min_sources.into() * TWO_POW_16.into(); + slab2 += value.max_age.into() * TWO_POW_48.into(); PackedMarketParams { slab0: value.base_currency_id, @@ -43,13 +44,11 @@ pub impl MarketParamsStorePacking of StorePacking MarketParams { let slab2: u256 = value.slab2.into(); let fee_rate: u16 = (slab2 & MASK_16).try_into().unwrap(); - let range: u32 = ((slab2 / TWO_POW_32.into()) & MASK_32).try_into().unwrap(); - let min_sources: u32 = ((slab2 / TWO_POW_64.into()) & MASK_32).try_into().unwrap(); - let max_age: u64 = ((slab2 / TWO_POW_96.into()) & MASK_32).try_into().unwrap(); + let min_sources: u32 = ((slab2 / TWO_POW_16.into()) & MASK_32).try_into().unwrap(); + let max_age: u64 = ((slab2 / TWO_POW_48.into()) & MASK_32).try_into().unwrap(); MarketParams { fee_rate, - range, base_currency_id: value.slab0, quote_currency_id: value.slab1, min_sources, @@ -58,26 +57,24 @@ pub impl MarketParamsStorePacking of StorePacking { - fn pack(value: TrendState) -> PackedTrendState { +pub impl ModelParamsStorePacking of StorePacking { + fn pack(value: ModelParams) -> PackedModelParams { let mut slab0: u256 = value.cached_price.into(); slab0 += value.cached_decimals.into() * TWO_POW_128.into(); - slab0 += trend_to_u256(value.trend) * TWO_POW_160.into(); + slab0 += value.range.into() * TWO_POW_160.into(); + slab0 += trend_to_u256(value.trend) * TWO_POW_192.into(); - PackedTrendState { slab0: slab0.try_into().unwrap() } + PackedModelParams { slab0: slab0.try_into().unwrap() } } - fn unpack(value: PackedTrendState) -> TrendState { + fn unpack(value: PackedModelParams) -> ModelParams { let slab0: u256 = value.slab0.into(); let cached_price: u128 = (slab0 & MASK_128).try_into().unwrap(); let cached_decimals: u32 = ((slab0 / TWO_POW_128.into()) & MASK_32).try_into().unwrap(); - let trend: Trend = u256_to_trend((value.slab0.into() / TWO_POW_160.into()) & MASK_2); + let range: u32 = ((slab0 / TWO_POW_160.into()) & MASK_32).try_into().unwrap(); + let trend: Trend = u256_to_trend((value.slab0.into() / TWO_POW_192.into()) & MASK_2); - TrendState { - trend, - cached_price, - cached_decimals - } + ModelParams { cached_price, cached_decimals, range, trend } } } diff --git a/packages/reversion/src/tests.cairo b/packages/reversion/src/tests.cairo new file mode 100644 index 0000000..cc015e7 --- /dev/null +++ b/packages/reversion/src/tests.cairo @@ -0,0 +1,3 @@ +pub mod helpers; +pub mod libraries; +pub mod solver; diff --git a/packages/reversion/src/tests/cases.md b/packages/reversion/src/tests/cases.md new file mode 100644 index 0000000..98a3b00 --- /dev/null +++ b/packages/reversion/src/tests/cases.md @@ -0,0 +1,130 @@ +# Test Cases + +## Libraries + +### `test_spread_math.cairo` + +- `Success` Get virtual position (bid and ask for each) + - Copy cases from replicating solver + - Add case for `lower_limit = upper_limit = 0` +- `Success` Get virtual position range + - Uptrend + - Oracle price is outside (above) bid position + - Oracle price is equal to cached price + - Oracle price is inside bid position + - Oracle price is at bid lower + - Oracle price is outside (below) bid position + - Cache price unset + - Downtrend + - Oracle price is outside (below) ask position + - Oracle price is equal to cached price + - Oracle price is inside ask position + - Oracle price is at ask upper + - Oracle price is outside (above) ask position + - Cache price unset + - Ranging (always quoted at latest price, ignore cached) + - Oracle price is equal to cached price + - Oracle price is greater than cached price + - Oracle price is less than cached price + +### `test_swap_lib.cairo` + +- Copy from `replicating_solver` + +### `test_store_packing.cairo` + +- Base on `replicating_solver` + +## Solver + +### `test_deploy.cairo` + +- Test deploy reversion solver initialises immutables + +### `test_set_trend.cairo` + +- `Success` Test new solver market has ranging trend by default +- `Success` Test set trend for solver market updates state +- `Success` Test set trend is callable by market owner +- `Success` Test set trend is callable by trend setter +- `Event` Test set trend emits event +- `Fail` Test set trend fails if market does not exist +- `Fail` Test set trend fails if trend unchanged +- `Fail` Test set trend fails if caller is not market owner + +## `test_oracle.cairo` + +- Copy from `replicating_solver` + +## `test_trend_setter.cairo` + +- `Success` Test changing trend setter works +- `Event` Test changing trend setter emits event +- `Fail` Test changing trend setter fails if not owner +- `Fail` Test changing trend setter fails if unchanged + +## `test_market_params.cairo` + +- `Success` Test setting market params updates immutables +- `Event` Test set market params emits event +- `Fail` Test set market params fails if not market owner +- `Fail` Test set market params fails if params unchanged +- `Fail` Test set market params fails if zero range +- `Fail` Test set market params fails if zero min sources +- `Fail` Test set market params fails if zero max age +- `Fail` Test set market params fails if zero base currency id +- `Fail` Test set market params fails if zero quote currency id + +## `test_swap.cairo` + +Static cases + +- `Success` Swap over full range liquidity, no spread, price at 1 +- `Success` Swap over full range liquidity, no spread, price at 0.1 +- `Success` Swap over full range liquidity, no spread, price at 10 +- `Success` Swap over concentrated liquidity, no spread, price at 1 +- `Success` Swap over concentrated liquidity, 100 spread, price at 1 +- `Success` Swap over concentrated liquidity, 50000 spread, price at 10 +- `Success` Swap with liquidity exhausted +- `Success` Swap with very high oracle price +- `Success` Swap with very low oracle price +- `Success` Swap buy with capped at threshold sqrt price +- `Success` Swap sell with capped at threshold sqrt price +- `Success` Swap buy with capped at threshold amount +- `Success` Swap sell with capped at threshold amount +- `Event` Swap should emit event +- `Fail` Test swap fails if market uninitialised +- `Fail` Test swap fails if market paused +- `Fail` Test swap fails if not approved +- `Fail` Test swap fails if invalid oracle price +- `Fail` Test swap fails if zero amount +- `Fail` Test swap fails if zero min amount out +- `Fail` Swap buy with zero liquidity +- `Fail` Swap sell with zero liquidity +- `Fail` Swap buy below threshold amount +- `Fail` Swap sell below threshold amount +- `Fail` Swap buy in uptrend at cached price +- `Fail` Swap sell in downtrend at cached price +- `Fail` Swap sell when price is at virtual bid lower +- `Fail` Swap buy when price is at virutal ask upper +- `Fail` Test swap fails if limit overflows +- `Fail` Test swap fails if limit underflows +- `Fail` Test swap fails for non solver caller + +Sequential cases + +- `Success` In an uptrend, if price rises above last cached price, cached price is updated and price is quoted at latest oracle price +- `Success` In an uptrend, if price falls below last cached price and rises back to the same level, cached price is unchanged +- `Success` In an uptrend, if price falls below bid position, cached price is unchanged and no quote is available for sells +- `Success` In a downtrend, if price falls below last cached price, cached price is updated and price is quoted at latest oracle price +- `Success` In a downtrend, if price falls above last cached price and falls back to the same level, cached price is unchanged +- `Success` In a downtrend, if price rises above ask position, cached price is unchanged and no quote is available for buys +- `Success` In a ranging market, buying then selling is always quoted at latest oracle price +- `Success` If trend changes from up to ranging, price is quoted at latest oracle price rather even if cached price is stale +- `Success` If trend changes from down to ranging, price is quoted at latest oracle price even if cached price is stale +- `Success` Swap after trend change from uptrend to downtrend +- `Success` Swap after trend change from downtrend to uptrend +- `Success` Swap after trend change from ranging to uptrend +- `Success` Swap after trend change from ranging to downtrend +- `Fail` Swap sell exhausts bid liquidity and prevents further sell swaps (TODO) +- `Fail` Swap buy exhausts ask liquidity and prevents further buy swaps (TODO) diff --git a/packages/reversion/src/tests/helpers.cairo b/packages/reversion/src/tests/helpers.cairo new file mode 100644 index 0000000..593253d --- /dev/null +++ b/packages/reversion/src/tests/helpers.cairo @@ -0,0 +1,3 @@ +pub mod actions; +pub mod params; +pub mod utils; diff --git a/packages/reversion/src/tests/helpers/actions.cairo b/packages/reversion/src/tests/helpers/actions.cairo new file mode 100644 index 0000000..5b05fbf --- /dev/null +++ b/packages/reversion/src/tests/helpers/actions.cairo @@ -0,0 +1,31 @@ +// Core lib imports. +use starknet::syscalls::deploy_syscall; +use starknet::ContractAddress; +use starknet::class_hash::ClassHash; + +// Local imports. +use haiko_solver_reversion::contracts::mocks::mock_pragma_oracle::{ + IMockPragmaOracleDispatcher, IMockPragmaOracleDispatcherTrait +}; +use haiko_solver_core::interfaces::ISolver::ISolverDispatcher; + +// External imports. +use snforge_std::{declare, ContractClass, ContractClassTrait, start_prank, stop_prank}; + +pub fn deploy_reversion_solver( + solver_class: ContractClass, + owner: ContractAddress, + oracle: ContractAddress, + vault_token_class: ClassHash, +) -> ISolverDispatcher { + let calldata = array![owner.into(), oracle.into(), vault_token_class.into()]; + let contract_address = solver_class.deploy(@calldata).unwrap(); + ISolverDispatcher { contract_address } +} + +pub fn deploy_mock_pragma_oracle( + oracle_class: ContractClass, owner: ContractAddress, +) -> IMockPragmaOracleDispatcher { + let contract_address = oracle_class.deploy(@array![]).unwrap(); + IMockPragmaOracleDispatcher { contract_address } +} diff --git a/packages/reversion/src/tests/helpers/params.cairo b/packages/reversion/src/tests/helpers/params.cairo new file mode 100644 index 0000000..3fcacb7 --- /dev/null +++ b/packages/reversion/src/tests/helpers/params.cairo @@ -0,0 +1,25 @@ +// Local imports. +use haiko_solver_reversion::types::{MarketParams, ModelParams, Trend}; + +pub fn default_market_params() -> MarketParams { + MarketParams { + fee_rate: 50, + base_currency_id: 4543560, // ETH + quote_currency_id: 1431520323, // USDC + min_sources: 3, + max_age: 600, + } +} +pub fn new_market_params() -> MarketParams { + MarketParams { + fee_rate: 987, + base_currency_id: 123456, + quote_currency_id: 789012, + min_sources: 10, + max_age: 200, + } +} + +pub fn default_model_params() -> ModelParams { + ModelParams { cached_price: 0, cached_decimals: 0, trend: Trend::Range, range: 1000, } +} diff --git a/packages/reversion/src/tests/helpers/utils.cairo b/packages/reversion/src/tests/helpers/utils.cairo new file mode 100644 index 0000000..6e4c935 --- /dev/null +++ b/packages/reversion/src/tests/helpers/utils.cairo @@ -0,0 +1,299 @@ +// Core lib imports. +use starknet::ContractAddress; +use starknet::contract_address_const; +use starknet::class_hash::ClassHash; + +// Local imports. +use haiko_solver_reversion::{ + contracts::reversion_solver::ReversionSolver, + contracts::mocks::{ + upgraded_reversion_solver::{ + UpgradedReversionSolver, IUpgradedReversionSolverDispatcher, + IUpgradedReversionSolverDispatcherTrait + }, + mock_pragma_oracle::{IMockPragmaOracleDispatcher, IMockPragmaOracleDispatcherTrait}, + }, + interfaces::{ + IReversionSolver::{IReversionSolverDispatcher, IReversionSolverDispatcherTrait}, + pragma::{DataType, PragmaPricesResponse}, + }, + types::MarketParams, + tests::helpers::{ + actions::{deploy_reversion_solver, deploy_mock_pragma_oracle}, + params::{default_market_params, default_model_params}, + }, +}; +use haiko_solver_core::{ + interfaces::{ + ISolver::{ISolverDispatcher, ISolverDispatcherTrait}, + IVaultToken::{IVaultTokenDispatcher, IVaultTokenDispatcherTrait}, + }, + types::{MarketInfo, MarketState, PositionInfo, SwapParams}, +}; + +// Haiko imports. +use haiko_lib::helpers::{ + params::{owner, alice, bob, treasury, default_token_params}, + actions::{ + market_manager::{create_market, modify_position, swap}, + token::{deploy_token, fund, approve}, + }, + utils::{to_e18, to_e18_u128, to_e28, approx_eq, approx_eq_pct}, +}; + +// External imports. +use openzeppelin::token::erc20::interface::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait}; +use snforge_std::{ + declare, start_warp, start_prank, stop_prank, CheatTarget, spy_events, SpyOn, EventSpy, + EventAssertions, EventFetcher, ContractClass, ContractClassTrait +}; + +//////////////////////////////// +// SETUP +//////////////////////////////// + +const BASE_DECIMALS: u8 = 18; +const QUOTE_DECIMALS: u8 = 18; + +//////////////////////////////// +// TYPES +//////////////////////////////// + +#[derive(Drop, Copy, Serde)] +struct Snapshot { + pub lp_base_bal: u256, + pub lp_quote_bal: u256, + pub vault_lp_bal: u256, + pub vault_total_bal: u256, + pub solver_base_bal: u256, + pub solver_quote_bal: u256, + pub market_state: MarketState, + pub bid: PositionInfo, + pub ask: PositionInfo, +} + +//////////////////////////////// +// HELPERS +//////////////////////////////// + +pub fn declare_classes() -> (ContractClass, ContractClass, ContractClass, ContractClass) { + let erc20_class = declare("ERC20"); + let vault_token_class = declare("VaultToken"); + let solver_class = declare("ReversionSolver"); + let oracle_class = declare("MockPragmaOracle"); + + (erc20_class, vault_token_class, solver_class, oracle_class) +} + +pub fn before( + is_market_public: bool +) -> ( + ERC20ABIDispatcher, + ERC20ABIDispatcher, + IMockPragmaOracleDispatcher, + ClassHash, + ISolverDispatcher, + felt252, + Option, +) { + _before(is_market_public, BASE_DECIMALS, QUOTE_DECIMALS, true, 0, Option::None(())) +} + +pub fn before_custom_decimals( + is_market_public: bool, base_decimals: u8, quote_decimals: u8, +) -> ( + ERC20ABIDispatcher, + ERC20ABIDispatcher, + IMockPragmaOracleDispatcher, + ClassHash, + ISolverDispatcher, + felt252, + Option, +) { + _before(is_market_public, base_decimals, quote_decimals, true, 0, Option::None(())) +} + +pub fn before_skip_approve( + is_market_public: bool, +) -> ( + ERC20ABIDispatcher, + ERC20ABIDispatcher, + IMockPragmaOracleDispatcher, + ClassHash, + ISolverDispatcher, + felt252, + Option, +) { + _before(is_market_public, BASE_DECIMALS, QUOTE_DECIMALS, false, 0, Option::None(())) +} + +pub fn before_with_salt( + is_market_public: bool, + salt: felt252, + classes: (ContractClass, ContractClass, ContractClass, ContractClass), +) -> ( + ERC20ABIDispatcher, + ERC20ABIDispatcher, + IMockPragmaOracleDispatcher, + ClassHash, + ISolverDispatcher, + felt252, + Option, +) { + _before(is_market_public, BASE_DECIMALS, QUOTE_DECIMALS, true, salt, Option::Some(classes)) +} + +fn _before( + is_market_public: bool, + base_decimals: u8, + quote_decimals: u8, + approve_solver: bool, + salt: felt252, + classes: Option<(ContractClass, ContractClass, ContractClass, ContractClass)>, +) -> ( + ERC20ABIDispatcher, + ERC20ABIDispatcher, + IMockPragmaOracleDispatcher, + ClassHash, + ISolverDispatcher, + felt252, + Option, +) { + // Declare or unwrap classes. + let (erc20_class, vault_token_class, solver_class, oracle_class) = if classes.is_some() { + classes.unwrap() + } else { + declare_classes() + }; + + // Get default owner. + let owner = owner(); + + // Deploy tokens. + let (_treasury, mut base_token_params, mut quote_token_params) = default_token_params(); + base_token_params.decimals = base_decimals; + quote_token_params.decimals = quote_decimals; + let base_token = deploy_token(erc20_class, @base_token_params); + let quote_token = deploy_token(erc20_class, @quote_token_params); + + // Deploy oracle contract. + let oracle = deploy_mock_pragma_oracle(oracle_class, owner); + + // Deploy reversion solver. + let solver = deploy_reversion_solver( + solver_class, owner, oracle.contract_address, vault_token_class.class_hash + ); + + // Create market. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let market_info = MarketInfo { + base_token: base_token.contract_address, + quote_token: quote_token.contract_address, + owner, + is_public: is_market_public, + }; + let (market_id, vault_token_opt) = solver.create_market(market_info); + + // Set params. + start_warp(CheatTarget::One(solver.contract_address), 1000); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let market_params = default_market_params(); + rev_solver.queue_market_params(market_id, market_params); + rev_solver.set_market_params(market_id); + + // Set model params. + let model_params = default_model_params(); + rev_solver.set_model_params(market_id, model_params.trend, model_params.range); + + // Set oracle price. + start_warp(CheatTarget::One(oracle.contract_address), 1000); + oracle.set_data_with_USD_hop('ETH', 'USDC', 1000000000, 8, 999, 5); // 10 + + // Fund owner with initial token balances and approve strategy and market manager as spenders. + let base_amount = to_e18(10000000000000000000000); + let quote_amount = to_e18(10000000000000000000000); + fund(base_token, owner(), base_amount); + fund(quote_token, owner(), quote_amount); + if approve_solver { + approve(base_token, owner(), solver.contract_address, base_amount); + approve(quote_token, owner(), solver.contract_address, quote_amount); + } + + // Fund LP with initial token balances and approve strategy and market manager as spenders. + fund(base_token, alice(), base_amount); + fund(quote_token, alice(), quote_amount); + if approve_solver { + approve(base_token, alice(), solver.contract_address, base_amount); + approve(quote_token, alice(), solver.contract_address, quote_amount); + } + + ( + base_token, + quote_token, + oracle, + vault_token_class.class_hash, + solver, + market_id, + vault_token_opt + ) +} + +pub fn snapshot( + solver: ISolverDispatcher, + market_id: felt252, + base_token: ERC20ABIDispatcher, + quote_token: ERC20ABIDispatcher, + vault_token_addr: ContractAddress, + lp: ContractAddress, +) -> Snapshot { + let lp_base_bal = base_token.balanceOf(lp); + let lp_quote_bal = quote_token.balanceOf(lp); + let mut vault_lp_bal = 0; + let mut vault_total_bal = 0; + if vault_token_addr != contract_address_const::<0x0>() { + let vault_token = ERC20ABIDispatcher { contract_address: vault_token_addr }; + vault_lp_bal = vault_token.balanceOf(lp); + vault_total_bal = vault_token.totalSupply(); + } + let solver_base_bal = base_token.balanceOf(solver.contract_address); + let solver_quote_bal = quote_token.balanceOf(solver.contract_address); + let market_state: MarketState = solver.market_state(market_id); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let (bid, ask) = rev_solver.get_virtual_positions(market_id); + + Snapshot { + lp_base_bal, + lp_quote_bal, + vault_lp_bal, + vault_total_bal, + solver_base_bal, + solver_quote_bal, + market_state, + bid, + ask, + } +} +// Print foundry events. +// Doesn't work as a fn - copy code body into test. +// fn print_events(ref spy: EventSpy) { +// spy.fetch_events(); +// let mut i = 0; +// loop { +// if i == spy.events.len() { +// break; +// } +// let (_, event) = spy.events.at(i); +// let mut j = 0; +// loop { +// if j == event.data.len() { +// break; +// } +// let data = event.data.at(j); +// println!("[event {}] index {}: {}", i, j, data); +// j += 1; +// }; +// i += 1; +// }; +// } + + diff --git a/packages/reversion/src/tests/libraries.cairo b/packages/reversion/src/tests/libraries.cairo new file mode 100644 index 0000000..2fc6902 --- /dev/null +++ b/packages/reversion/src/tests/libraries.cairo @@ -0,0 +1,4 @@ +pub mod test_spread_math; +pub mod test_store_packing; +pub mod test_swap_lib; +pub mod test_swap_lib_invariants; diff --git a/packages/reversion/src/tests/libraries/test_spread_math.cairo b/packages/reversion/src/tests/libraries/test_spread_math.cairo new file mode 100644 index 0000000..6a7add2 --- /dev/null +++ b/packages/reversion/src/tests/libraries/test_spread_math.cairo @@ -0,0 +1,401 @@ +// Core lib imports. +use core::integer::BoundedInt; + +// Local imports. +use haiko_solver_reversion::types::Trend; +use haiko_solver_reversion::libraries::{ + spread_math::{get_virtual_position, get_virtual_position_range}, swap_lib::get_swap_amounts +}; + +// Haiko imports. +use haiko_lib::math::price_math; +use haiko_lib::types::i32::{i32, I32Trait}; +use haiko_lib::helpers::utils::{approx_eq, approx_eq_pct, to_e28, to_e18}; +use haiko_lib::constants::{MAX_LIMIT_SHIFTED}; + +//////////////////////////////// +// TYPES +//////////////////////////////// + +#[derive(Drop, Copy)] +struct PositionTestCase { + lower_limit: u32, + upper_limit: u32, + amount: u256, + lower_sqrt_price_exp: u256, + upper_sqrt_price_exp: u256, + bid_liquidity_exp: u128, + ask_liquidity_exp: u128, +} + +#[derive(Drop, Copy)] +struct PositionRangeTestCase { + trend: Trend, + range: u32, + cached_price: u256, + oracle_price: u256, + bid_lower_exp: u32, + bid_upper_exp: u32, + ask_lower_exp: u32, + ask_upper_exp: u32, +} + +//////////////////////////////// +// CONSTANTS +//////////////////////////////// + +const ONE: u128 = 10000000000000000000000000000; + +//////////////////////////////// +// TESTS +//////////////////////////////// + +#[test] +fn test_get_virtual_position_cases() { + // Define test cases. + let cases: Span = array![ + // Case 1 + PositionTestCase { + lower_limit: 7906624, + upper_limit: 7906625, + amount: to_e18(1000), + lower_sqrt_price_exp: 9999950000374996875027343503, + upper_sqrt_price_exp: to_e28(1), + bid_liquidity_exp: 200001499998750006249960937, + ask_liquidity_exp: 200000499998750006249960937, + }, + // Case 2 + PositionTestCase { + lower_limit: 7906625, + upper_limit: 7907625, + amount: 0, + lower_sqrt_price_exp: 10000000000000000000000000000, + upper_sqrt_price_exp: 10050124957342558567913166002, + bid_liquidity_exp: 0, + ask_liquidity_exp: 0, + }, + // Case 3 + PositionTestCase { + lower_limit: 7906625 - 600000, + upper_limit: 7906625 - 500000, + amount: to_e18(1), + lower_sqrt_price_exp: 497878151745117899580653183, + upper_sqrt_price_exp: 820860246859540603883281915, + bid_liquidity_exp: 30961468611618563661, + ask_liquidity_exp: 126535925281766305, + }, + // Case 4 + PositionTestCase { + lower_limit: 7906625 - 100000, + upper_limit: 7906625 - 90000, + amount: to_e18(1), + lower_sqrt_price_exp: 6065321760310693212937818528, + upper_sqrt_price_exp: 6376295862771640675414689014, + bid_liquidity_exp: 32157018609791833633, + ask_liquidity_exp: 12436497361224684536, + }, + // Case 5 + PositionTestCase { + lower_limit: 7906625 + 90000, + upper_limit: 7906625 + 100000, + amount: to_e18(1), + lower_sqrt_price_exp: 15683086568152456989606954482, + upper_sqrt_price_exp: 16487171489295820592662817441, + bid_liquidity_exp: 12436497361224684536, + ask_liquidity_exp: 32157018609791833633, + }, + // Case 6 + PositionTestCase { + lower_limit: 7906625 + 500000, + upper_limit: 7906625 + 600000, + amount: to_e18(1), + lower_sqrt_price_exp: 121823416814959055458686853108, + upper_sqrt_price_exp: 200852356444019400322324232963, + bid_liquidity_exp: 126535925281766305, + ask_liquidity_exp: 30961468611618563661, + }, + // Case 7 + PositionTestCase { + lower_limit: 7906625, + upper_limit: 7907625, + amount: 1, + lower_sqrt_price_exp: 10000000000000000000000000000, + upper_sqrt_price_exp: 10050124957342558567913166002, + bid_liquidity_exp: 199, + ask_liquidity_exp: 200, + }, + // Case 8 + PositionTestCase { + lower_limit: 7906625, + upper_limit: 7907625, + amount: to_e18(100000000000000000), + lower_sqrt_price_exp: 10000000000000000000000000000, + upper_sqrt_price_exp: 10050124957342558567913166002, + bid_liquidity_exp: 19950141666274308048509441863855847152, + ask_liquidity_exp: 20050141666274308048509441863855847152, + }, + ] + .span(); + + // Loop through test cases and perform checks. + let mut i = 0; + loop { + if i >= cases.len() { + break; + } + let case = *cases.at(i); + let bid = get_virtual_position(true, case.lower_limit, case.upper_limit, case.amount); + let ask = get_virtual_position(false, case.lower_limit, case.upper_limit, case.amount); + if (!approx_eq_pct(bid.lower_sqrt_price, case.lower_sqrt_price_exp, 22)) { + panic!( + "Lower sqrt p {}: {} (act), {} (exp)", + i + 1, + bid.lower_sqrt_price, + case.lower_sqrt_price_exp + ); + } + if (!approx_eq_pct(bid.upper_sqrt_price, case.upper_sqrt_price_exp, 22)) { + panic!( + "Upper sqrt p {}: {} (act), {} (exp)", + i + 1, + bid.upper_sqrt_price, + case.upper_sqrt_price_exp + ); + } + if (if bid.liquidity < ONE { + !approx_eq(bid.liquidity.into(), case.bid_liquidity_exp.into(), 10000) + } else { + !approx_eq_pct(bid.liquidity.into(), case.bid_liquidity_exp.into(), 22) + }) { + panic!( + "Bid liquidity {}: {} (act), {} (exp)", i + 1, bid.liquidity, case.bid_liquidity_exp + ); + } + if (if ask.liquidity < ONE { + !approx_eq(ask.liquidity.into(), case.ask_liquidity_exp.into(), 10000) + } else { + !approx_eq_pct(ask.liquidity.into(), case.ask_liquidity_exp.into(), 22) + }) { + panic!( + "Ask liquidity {}: {} (act), {} (exp)", i + 1, ask.liquidity, case.ask_liquidity_exp + ); + } + i += 1; + }; +} + +#[test] +fn test_get_virtual_position_range() { + // Define test cases. + let cases: Span = array![ + // Case 1: Uptrend, oracle price is outside (above) bid position + PositionRangeTestCase { + trend: Trend::Up, + range: 1000, + cached_price: to_e28(1), + oracle_price: to_e28(11) / 10, // 1.1 + bid_lower_exp: 7915156, + bid_upper_exp: 7916156, + ask_lower_exp: 0, + ask_upper_exp: 0, + }, + // Case 2: Uptrend, oracle price is equal to cached price + PositionRangeTestCase { + trend: Trend::Up, + range: 1000, + cached_price: to_e28(1), + oracle_price: to_e28(1), + bid_lower_exp: 7905625, + bid_upper_exp: 7906625, + ask_lower_exp: 0, + ask_upper_exp: 0, + }, + // Case 3: Uptrend, oracle price is inside virtual bid position + PositionRangeTestCase { + trend: Trend::Up, + range: 1000, + cached_price: to_e28(1), + oracle_price: to_e28(995) / 1000, // 0.995 + bid_lower_exp: 7905625, + bid_upper_exp: 7906123, + ask_lower_exp: 7906123, + ask_upper_exp: 7906625, + }, + // Case 4: Uptrend, oracle price at bid lower + PositionRangeTestCase { + trend: Trend::Up, + range: 1000, + cached_price: to_e28(1), + oracle_price: to_e28(99005) / 100000, // 0.99005 + bid_lower_exp: 0, + bid_upper_exp: 0, + ask_lower_exp: 7905625, + ask_upper_exp: 7906625, + }, + // Case 5: Uptrend, oracle price is outside (above) ask position + PositionRangeTestCase { + trend: Trend::Up, + range: 1000, + cached_price: to_e28(1), + oracle_price: to_e28(95) / 100, // 0.95 + bid_lower_exp: 0, + bid_upper_exp: 0, + ask_lower_exp: 7905625, + ask_upper_exp: 7906625, + }, + // Case 6: Uptrend, cached price unset + PositionRangeTestCase { + trend: Trend::Up, + range: 1000, + cached_price: 0, + oracle_price: to_e28(9) / 10, // 0.9 + bid_lower_exp: 7895088, + bid_upper_exp: 7896088, + ask_lower_exp: 0, + ask_upper_exp: 0, + }, + // Case 7: Downtrend, oracle price is outside (below) bid position + PositionRangeTestCase { + trend: Trend::Down, + range: 1000, + cached_price: to_e28(1), + oracle_price: to_e28(9) / 10, // 0.9 + bid_lower_exp: 0, + bid_upper_exp: 0, + ask_lower_exp: 7896088, + ask_upper_exp: 7897088, + }, + // Case 8: Downtrend, oracle price is equal to cached price + PositionRangeTestCase { + trend: Trend::Down, + range: 1000, + cached_price: to_e28(1), + oracle_price: to_e28(1), + bid_lower_exp: 0, + bid_upper_exp: 0, + ask_lower_exp: 7906625, + ask_upper_exp: 7907625, + }, + // Case 9: Downtrend, oracle price is inside ask position + PositionRangeTestCase { + trend: Trend::Down, + range: 1000, + cached_price: to_e28(1), + oracle_price: to_e28(1005) / 1000, // 1.005 + bid_lower_exp: 7906625, + bid_upper_exp: 7907124, + ask_lower_exp: 7907124, + ask_upper_exp: 7907625, + }, + // Case 10: Downtrend, oracle price is at ask upper + PositionRangeTestCase { + trend: Trend::Down, + range: 1000, + cached_price: to_e28(1), + oracle_price: to_e28(101006) / 100000, // 1.01006 + bid_lower_exp: 7906625, + bid_upper_exp: 7907625, + ask_lower_exp: 0, + ask_upper_exp: 0, + }, + // Case 11: Downtrend, oracle price is outside (above) ask upper + PositionRangeTestCase { + trend: Trend::Down, + range: 1000, + cached_price: to_e28(1), + oracle_price: to_e28(105) / 100, // 1.05 + bid_lower_exp: 7906625, + bid_upper_exp: 7907625, + ask_lower_exp: 0, + ask_upper_exp: 0, + }, + // Case 12: Downtrend, cached price unset + PositionRangeTestCase { + trend: Trend::Down, + range: 1000, + cached_price: 0, + oracle_price: to_e28(11) / 10, // 1.1 + bid_lower_exp: 0, + bid_upper_exp: 0, + ask_lower_exp: 7916156, + ask_upper_exp: 7917156, + }, + // Case 13: Ranging, oracle price is equal to cached + PositionRangeTestCase { + trend: Trend::Range, + range: 1000, + cached_price: to_e28(1), + oracle_price: to_e28(1), + bid_lower_exp: 7905625, + bid_upper_exp: 7906625, + ask_lower_exp: 7906625, + ask_upper_exp: 7907625, + }, + // Case 14: Ranging, oracle price is above cached + PositionRangeTestCase { + trend: Trend::Range, + range: 1000, + cached_price: to_e28(1), + oracle_price: to_e28(15) / 10, // 1.5 + bid_lower_exp: 7946171, + bid_upper_exp: 7947171, + ask_lower_exp: 7947171, + ask_upper_exp: 7948171, + }, + // Case 15: Ranging, oracle price is above cached + PositionRangeTestCase { + trend: Trend::Range, + range: 1000, + cached_price: to_e28(1), + oracle_price: to_e28(5) / 10, // 0.5 + bid_lower_exp: 7836309, + bid_upper_exp: 7837309, + ask_lower_exp: 7837309, + ask_upper_exp: 7838309, + }, + ] + .span(); + + // Loop through test cases and perform checks. + let mut i = 0; + loop { + if i >= cases.len() { + break; + } + let case = *cases.at(i); + let (bid_lower, bid_upper, ask_lower, ask_upper) = get_virtual_position_range( + case.trend, case.range, case.cached_price, case.oracle_price + ); + if (!approx_eq(bid_lower.into(), case.bid_lower_exp.into(), 1)) { + panic!("Bid lower {}: {} (act), {} (exp)", i + 1, bid_lower, case.bid_lower_exp); + } + if (!approx_eq(bid_upper.into(), case.bid_upper_exp.into(), 1)) { + panic!("Bid upper {}: {} (act), {} (exp)", i + 1, bid_upper, case.bid_upper_exp); + } + if (!approx_eq(ask_lower.into(), case.ask_lower_exp.into(), 1)) { + panic!("Ask lower {}: {} (act), {} (exp)", i + 1, ask_lower, case.ask_lower_exp); + } + if (!approx_eq(ask_upper.into(), case.ask_upper_exp.into(), 1)) { + panic!("Ask upper {}: {} (act), {} (exp)", i + 1, ask_upper, case.ask_upper_exp); + } + i += 1; + }; +} + +#[test] +#[should_panic(expected: ('CachedLimitUF',))] +fn test_get_virtual_position_range_cached_limit_underflow() { + get_virtual_position_range(Trend::Range, 2000000, 10, to_e28(1)); +} + +#[test] +#[should_panic(expected: ('OracleLimitUF',))] +fn test_get_virtual_position_range_oracle_limit_underflow() { + get_virtual_position_range(Trend::Range, 2000000, to_e28(1), 10); +} + +#[test] +#[should_panic(expected: ('OraclePriceZero',))] +fn test_get_virtual_position_range_oracle_price_zero() { + get_virtual_position_range(Trend::Range, 1000, to_e28(1), 0); +} diff --git a/packages/reversion/src/tests/libraries/test_store_packing.cairo b/packages/reversion/src/tests/libraries/test_store_packing.cairo new file mode 100644 index 0000000..7588311 --- /dev/null +++ b/packages/reversion/src/tests/libraries/test_store_packing.cairo @@ -0,0 +1,71 @@ +// Core lib imports. +use starknet::syscalls::deploy_syscall; +use starknet::contract_address::contract_address_const; + +// Local imports. +use haiko_solver_reversion::types::{MarketParams, ModelParams, Trend}; +use haiko_solver_reversion::contracts::mocks::store_packing_contract::{ + StorePackingContract, IStorePackingContractDispatcher, IStorePackingContractDispatcherTrait +}; + +// External imports. +use snforge_std::{declare, ContractClass, ContractClassTrait}; + +//////////////////////////////// +// SETUP +//////////////////////////////// + +fn before() -> IStorePackingContractDispatcher { + // Deploy store packing contract. + let class = declare("StorePackingContract"); + let contract_address = class.deploy(@array![]).unwrap(); + IStorePackingContractDispatcher { contract_address } +} + +//////////////////////////////// +// TESTS +//////////////////////////////// + +#[test] +fn test_store_packing_market_params() { + let store_packing_contract = before(); + + let market_params = MarketParams { + fee_rate: 15, + base_currency_id: 12893128793123, + quote_currency_id: 128931287, + min_sources: 12, + max_age: 3123712, + }; + + store_packing_contract.set_market_params(1, market_params); + let unpacked = store_packing_contract.get_market_params(1); + + assert(unpacked.fee_rate == market_params.fee_rate, 'Market params: fee rate'); + assert( + unpacked.base_currency_id == market_params.base_currency_id, 'Market params: base curr id' + ); + assert( + unpacked.quote_currency_id == market_params.quote_currency_id, + 'Market params: quote curr id' + ); + assert(unpacked.min_sources == market_params.min_sources, 'Market params: min sources'); + assert(unpacked.max_age == market_params.max_age, 'Market params: max age'); +} + +#[test] +fn test_store_packing_model_params() { + let store_packing_contract = before(); + + let model_params = ModelParams { + cached_price: 1000, cached_decimals: 8, range: 15000, trend: Trend::Up + }; + + store_packing_contract.set_model_params(1, model_params); + let unpacked = store_packing_contract.get_model_params(1); + + assert(unpacked.cached_price == model_params.cached_price, 'Trend state: range'); + assert(unpacked.cached_decimals == model_params.cached_decimals, 'Trend state: base curr id'); + assert(unpacked.range == model_params.range, 'Trend state: range'); + assert(unpacked.trend == model_params.trend, 'Trend state: spread'); +} diff --git a/packages/reversion/src/tests/libraries/test_swap_lib.cairo b/packages/reversion/src/tests/libraries/test_swap_lib.cairo new file mode 100644 index 0000000..29c4d9f --- /dev/null +++ b/packages/reversion/src/tests/libraries/test_swap_lib.cairo @@ -0,0 +1,427 @@ +// Core lib imports. +use core::integer::BoundedInt; + +// Local imports. +use haiko_solver_core::types::{SwapParams, PositionInfo}; +use haiko_solver_reversion::libraries::swap_lib::{ + get_swap_amounts, compute_swap_amounts, next_sqrt_price_input, next_sqrt_price_output +}; + +// Haiko imports. +use haiko_lib::math::fee_math::gross_to_net; +use haiko_lib::constants::{ONE, MAX}; +use haiko_lib::helpers::utils::{ + approx_eq, approx_eq_pct, encode_sqrt_price, to_e18, to_e28, to_e18_u128, to_e28_u128 +}; + +//////////////////////////////// +// TESTS - get_swap_amounts +//////////////////////////////// + +#[test] +fn test_get_swap_amounts_succeeds() { + let swap_params = SwapParams { + is_buy: true, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let position = PositionInfo { + lower_sqrt_price: encode_sqrt_price(8, 10), + upper_sqrt_price: encode_sqrt_price(1, 1), + liquidity: to_e18_u128(10000), + }; + let (amount_in, amount_out, fees) = get_swap_amounts(swap_params, 0, position); + assert(approx_eq(amount_in, 1000000000000000000, 1000), 'Swap amts: amt in'); + assert(approx_eq(amount_out, 1249860261374659470, 1000), 'Swap amts: amt out'); + assert(fees == 0, 'Swap amts: fees'); // TODO: fix amount +} + +#[test] +fn test_get_swap_amounts_over_zero_liquidity() { + let swap_params = SwapParams { + is_buy: true, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let position = PositionInfo { + lower_sqrt_price: encode_sqrt_price(1, 1), + upper_sqrt_price: encode_sqrt_price(12, 10), + liquidity: 0, + }; + let (amount_in, amount_out, fees) = get_swap_amounts(swap_params, 50, position); + assert(amount_in == 0 && amount_out == 0 && fees == 0, 'Swap amts: 0 liq'); +} + +#[test] +fn test_get_swap_amounts_bid_threshold_sqrt_price() { + let swap_params = SwapParams { + is_buy: false, + amount: to_e18(10), + exact_input: true, + threshold_sqrt_price: Option::Some(encode_sqrt_price(95, 100)), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let position = PositionInfo { + lower_sqrt_price: encode_sqrt_price(8, 10), + upper_sqrt_price: encode_sqrt_price(1, 1), + liquidity: to_e18_u128(200), + }; + let swap_fee_rate = 50; + let (amount_in, amount_out, fees) = get_swap_amounts(swap_params, swap_fee_rate, position); + assert( + amount_in < to_e18(10) && amount_out < 9478447249345082162 && fees < 50000000000000000, + 'Swap amts: bid threshold' + ); +} + +#[test] +fn test_get_swap_amounts_ask_threshold_sqrt_price() { + let swap_params = SwapParams { + is_buy: true, + amount: to_e18(10), + exact_input: true, + threshold_sqrt_price: Option::Some(encode_sqrt_price(105, 100)), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let position = PositionInfo { + lower_sqrt_price: encode_sqrt_price(1, 1), + upper_sqrt_price: encode_sqrt_price(12, 10), + liquidity: to_e18_u128(200), + }; + let swap_fee_rate = 50; + let (amount_in, amount_out, fees) = get_swap_amounts(swap_params, swap_fee_rate, position); + assert( + amount_in < to_e18(10) && amount_out < 9478447249345082162 && fees < 50000000000000000, + 'Swap amts: ask threshold' + ); +} + +//////////////////////////////// +// TESTS - compute_swap_amounts +//////////////////////////////// + +#[test] +fn test_compute_swap_amounts_buy_exact_input_reaches_price_target() { + let curr_sqrt_price = encode_sqrt_price(1, 1); + let target_sqrt_price = encode_sqrt_price(101, 100); + let liquidity = to_e28_u128(2); + let amount_rem = to_e28(1); + let fee_rate = 0; + + let (amount_in, amount_out, fees, next_sqrt_price) = compute_swap_amounts( + curr_sqrt_price, target_sqrt_price, liquidity, amount_rem, fee_rate, true + ); + let next_sqrt_price_full_amount = next_sqrt_price_input( + curr_sqrt_price, liquidity, amount_rem, true + ); + + assert(amount_in == 99751242241780540438529824, 'comp_swap buy/in/cap in'); + assert(amount_out == 99256195800217286694524923, 'comp_swap buy/in/cap out'); + assert(fees == 0, 'comp_swap buy/in/cap fees'); // TODO: fix amount + assert(next_sqrt_price == 10049875621120890270219264912, 'comp_swap buy/in/cap price'); + assert(next_sqrt_price == target_sqrt_price, 'comp_swap buy/in/cap target P'); + assert(next_sqrt_price < next_sqrt_price_full_amount, 'comp_swap buy/in/cap target Q'); + assert(amount_rem > amount_in, 'comp_swap buy/in/cap amount_rem'); +} + +#[test] +fn test_compute_swap_amounts_buy_exact_output_reaches_price_target() { + let curr_sqrt_price = encode_sqrt_price(1, 1); + let target_sqrt_price = encode_sqrt_price(101, 100); + let liquidity = to_e28_u128(2); + let amount_rem = to_e28(1); + let fee_rate = 0; + + let (amount_in, amount_out, fees, next_sqrt_price) = compute_swap_amounts( + curr_sqrt_price, target_sqrt_price, liquidity, amount_rem, fee_rate, false + ); + let next_sqrt_price_full_amount = next_sqrt_price_output( + curr_sqrt_price, liquidity, amount_rem, true + ); + + assert(amount_in == 99751242241780540438529824, 'comp_swap buy/out/cap in'); + assert(amount_out == 99256195800217286694524923, 'comp_swap buy/out/cap out'); + assert(fees == 0, 'comp_swap buy/out/cap fees'); // TODO: fix amount + assert(next_sqrt_price == 10049875621120890270219264912, 'comp_swap buy/in/cap price'); + assert(next_sqrt_price == target_sqrt_price, 'comp_swap buy/out/cap target P'); + assert(next_sqrt_price < next_sqrt_price_full_amount, 'comp_swap buy/out/cap target Q'); + assert(amount_rem > amount_out, 'comp_swap buy/out/cap rem'); +} + +#[test] +fn test_compute_swap_amounts_buy_exact_input_filled_max() { + let curr_sqrt_price = encode_sqrt_price(1, 1); + let target_sqrt_price = encode_sqrt_price(1000, 100); + let liquidity = to_e28_u128(2); + let amount_rem = to_e28(1); + let fee_rate = 0; + + let (amount_in, amount_out, fees, next_sqrt_price) = compute_swap_amounts( + curr_sqrt_price, target_sqrt_price, liquidity, amount_rem, fee_rate, true + ); + let next_sqrt_price_full_amount = next_sqrt_price_input( + curr_sqrt_price, liquidity, amount_rem, true + ); + + assert(amount_in == 10000000000000000000000000000, 'comp_swap buy/in/full in'); + assert(amount_out == 6666666666666666666666666666, 'comp_swap buy/in/full out'); + assert(fees == 0, 'comp_swap buy/in/full fees'); // TODO: fix amount + assert(next_sqrt_price == 15000000000000000000000000000, 'comp_swap buy/in/cap price'); + assert(next_sqrt_price < target_sqrt_price, 'comp_swap buy/in/full target P'); + assert(next_sqrt_price == next_sqrt_price_full_amount, 'comp_swap buy/in/full target Q'); +} + +#[test] +fn test_compute_swap_amounts_buy_exact_output_filled_max() { + let curr_sqrt_price = encode_sqrt_price(1, 1); + let target_sqrt_price = encode_sqrt_price(10000, 100); + let liquidity = to_e28_u128(2); + let amount_rem = to_e28(1); + let fee_rate = 0; + let (amount_in, amount_out, fees, next_sqrt_price) = compute_swap_amounts( + curr_sqrt_price, target_sqrt_price, liquidity, amount_rem, fee_rate, false + ); + let next_sqrt_price_full_amount = next_sqrt_price_output( + curr_sqrt_price, liquidity, amount_rem, true + ); + + assert(amount_in == 20000000000000000000000000000, 'comp_swap buy/out/full in'); + assert(amount_out == 10000000000000000000000000000, 'comp_swap buy/out/full out'); + assert(fees == 0, 'comp_swap buy/out/full fees'); // TODO: fix amount + assert(next_sqrt_price == 20000000000000000000000000000, 'comp_swap buy/out/full price'); + assert(next_sqrt_price < target_sqrt_price, 'comp_swap buy/out/full target P'); + assert(next_sqrt_price == next_sqrt_price_full_amount, 'comp_swap buy/out/full target Q'); + assert(amount_rem == amount_out, 'comp_swap buy/out/full rem'); +} + +#[test] +fn test_compute_swap_amounts_sell_exact_input_reached_price_target() { + let curr_sqrt_price = 15000000000000000000000000000; + let target_sqrt_price = to_e28(1); + let liquidity = to_e28_u128(2); + let amount_rem = to_e28(1); + let fee_rate = 0; + + let (amount_in, amount_out, fees, next_sqrt_price) = compute_swap_amounts( + curr_sqrt_price, target_sqrt_price, liquidity, amount_rem, fee_rate, true + ); + assert(amount_in == 6666666666666666666666666667, 'comp_swap sell/in/cap in'); + assert(amount_out == 10000000000000000000000000000, 'comp_swap sell/in/cap out'); + assert(fees == 0, 'comp_swap sell/in/cap fees'); // TODO: fix amount + assert(next_sqrt_price == 10000000000000000000000000000, 'comp_swap sell/in/cap price'); +} + +#[test] +fn test_compute_swap_amounts_sell_exact_output_reached_price_target() { + let curr_sqrt_price = to_e28(12) / 10; + let target_sqrt_price = to_e28(1); + let liquidity = to_e28_u128(2); + let amount_rem = to_e28(1); + let fee_rate = 0; + + let (amount_in, amount_out, fees, next_sqrt_price) = compute_swap_amounts( + curr_sqrt_price, target_sqrt_price, liquidity, amount_rem, fee_rate, false + ); + assert(amount_in == 3333333333333333333333333334, 'comp_swap sell/out/cap in'); + assert(amount_out == 4000000000000000000000000000, 'comp_swap sell/out/cap out'); + assert(fees == 0, 'comp_swap sell/out/cap fees'); // TODO: fix amount + assert(next_sqrt_price == 10000000000000000000000000000, 'comp_swap sell/out/cap price'); +} + +#[test] +fn test_compute_swap_amounts_sell_exact_input_filled_max() { + let curr_sqrt_price = encode_sqrt_price(1000, 100); + let target_sqrt_price = encode_sqrt_price(1, 1); + let liquidity = to_e28_u128(2); + let amount_rem = to_e28(1); + let fee_rate = 0; + + let (amount_in, amount_out, fees, next_sqrt_price) = compute_swap_amounts( + curr_sqrt_price, target_sqrt_price, liquidity, amount_rem, fee_rate, true + ); + assert(amount_in == 10000000000000000000000000000, 'comp_swap sell/in/full in'); + assert(amount_out == 38742588672279311066629784812, 'comp_swap sell/in/full out'); + assert(fees == 0, 'comp_swap sell/in/full fees'); // TODO: fix amount + assert(next_sqrt_price == 12251482265544137786674043038, 'comp_swap sell/in/full price'); +} + +#[test] +fn test_compute_swap_amounts_sell_exact_output_filled_max() { + let curr_sqrt_price = to_e28(3); + let target_sqrt_price = to_e28(1); + let liquidity = to_e28_u128(2); + let amount_rem = to_e28(1); + let fee_rate = 0; + + let (amount_in, amount_out, fees, next_sqrt_price) = compute_swap_amounts( + curr_sqrt_price, target_sqrt_price, liquidity, amount_rem, fee_rate, false + ); + assert(amount_in == 1333333333333333333333333334, 'comp_swap sell/out/full in'); + assert(amount_out == 10000000000000000000000000000, 'comp_swap sell/out/full out'); + assert(fees == 0, 'comp_swap sell/out/full fees'); // TODO: fix amount + assert(next_sqrt_price == 25000000000000000000000000000, 'comp_swap sell/out/full price'); +} + +#[test] +fn test_compute_swap_amounts_buy_exact_output_intermediate_insufficient_liquidity() { + let curr_sqrt_price = 2560000000000000000000000000000; + let target_sqrt_price = 2816000000000000000000000000000; + let liquidity = 1024; + let amount_rem = 4; + let fee_rate = 0; + + let (amount_in, amount_out, fees, next_sqrt_price) = compute_swap_amounts( + curr_sqrt_price, target_sqrt_price, liquidity, amount_rem, fee_rate, false + ); + + assert(amount_in == 26215, 'comp_swap buy/out/iil in'); + assert(amount_out == 0, 'comp_swap buy/out/iil out'); + assert(fees == 0, 'comp_swap buy/out/iil fees'); // TODO: fix amount + assert(next_sqrt_price == 2816000000000000000000000000000, 'comp_swap buy/out/iil price'); +} + +#[test] +fn test_compute_swap_amounts_sell_exact_output_intermediate_insufficient_liquidity() { + let curr_sqrt_price = 2560000000000000000000000000000; + let target_sqrt_price = 2304000000000000000000000000000; + let liquidity = 1024; + let amount_rem = 263000; + let fee_rate = 0; + + let (amount_in, amount_out, fees, next_sqrt_price) = compute_swap_amounts( + curr_sqrt_price, target_sqrt_price, liquidity, amount_rem, fee_rate, false + ); + + assert(amount_in == 1, 'comp_swap sell/out/iil in'); + assert(amount_out == 26214, 'comp_swap sell/out/iil out'); + assert(fees == 0, 'comp_swap sell/out/iil fees'); // TODO: fix amount + assert(next_sqrt_price == target_sqrt_price, 'comp_swap sell/out/iil price'); +} + +///////////////////////////////////// +// TESTS - next_sqrt_price_input +///////////////////////////////////// + +#[test] +fn test_next_sqrt_price_in_cases() { + let one: u128 = 10000000000000000000000000000; + assert( + next_sqrt_price_input(1, 1, BoundedInt::max(), false) == 1, 'next_sqrt_price_in amt max' + ); + assert(next_sqrt_price_input(256, 100, 0, false) == 256, 'next_sqrt_price_in buy amt 0'); + assert(next_sqrt_price_input(256, 100, 0, true) == 256, 'next_sqrt_price_in sell amt_0'); + assert( + next_sqrt_price_input(MAX, BoundedInt::max(), BoundedInt::max(), false) == 1, + 'next_sqrt_price_in all MAX' + ); + assert( + next_sqrt_price_input(ONE, one, ONE / 10, true) == 11000000000000000000000000000, + 'next_sqrt_price_in buy amt 0.1' + ); + assert( + next_sqrt_price_input(ONE, one, ONE / 10, false) == 9090909090909090909090909091, + 'next_sqrt_price_in sell amt 0.1' + ); + assert( + next_sqrt_price_input(ONE, 1, BoundedInt::max() / 2, false) == 1, + 'next_sqrt_price_in sell rtns 1' + ); +} + +#[test] +#[should_panic(expected: ('PriceZero',))] +fn test_next_sqrt_price_in_price_0() { + next_sqrt_price_input(0, 100, 1, true); +} + +#[test] +#[should_panic(expected: ('LiqZero',))] +fn test_next_sqrt_price_in_liq_0() { + next_sqrt_price_input(100, 0, 1, true); +} + +#[test] +#[should_panic(expected: ('PriceOF',))] +fn test_next_sqrt_price_in_price_overflow() { + next_sqrt_price_input(MAX, 1, 1, true); +} + +///////////////////////////////////// +// TESTS - next_sqrt_price_output +///////////////////////////////////// + +#[test] +fn test_next_sqrt_price_out_cases() { + let one: u128 = 10000000000000000000000000000; + assert( + next_sqrt_price_output(to_e28(256), 1024, 262143, false) == 9765625000000000000000000, + 'next_sqrt_price_in_amt_max_1' + ); + assert(next_sqrt_price_output(256, 100, 0, false) == 256, 'next_sqrt_price_out_buy_amt_0'); + assert(next_sqrt_price_output(256, 100, 0, true) == 256, 'next_sqrt_price_out_sell_amt_0'); + assert( + next_sqrt_price_output(ONE, one, ONE / 10, true) == 11111111111111111111111111112, + 'next_sqrt_price_out_buy_0.1' + ); + assert( + next_sqrt_price_output(ONE, one, ONE / 10, false) == 9000000000000000000000000000, + 'next_sqrt_price_out_sell_0.1' + ); +} + +#[test] +#[should_panic(expected: ('PriceZero',))] +fn test_next_sqrt_price_out_price_0() { + next_sqrt_price_output(0, 100, 1, true); +} + +#[test] +#[should_panic(expected: ('LiqZero',))] +fn test_next_sqrt_price_out_liq_0() { + next_sqrt_price_output(100, 0, 1, true); +} + +#[test] +#[should_panic(expected: ('PriceOF',))] +fn test_next_sqrt_price_out_buy_price_overflow() { + next_sqrt_price_output(ONE, 1, BoundedInt::max(), true); +} + +#[test] +#[should_panic(expected: ('MulDivOF',))] +fn test_next_sqrt_price_out_sell_price_overflow() { + next_sqrt_price_output(ONE, 1, BoundedInt::max(), false); +} + +#[test] +#[should_panic(expected: ('u256_sub Overflow',))] +fn test_next_sqrt_price_out_output_eq_quote_reserves() { + next_sqrt_price_output(256, 1024, 262144, false); +} + +#[test] +#[should_panic(expected: ('u256_sub Overflow',))] +fn test_next_sqrt_price_out_output_gt_quote_reserves() { + next_sqrt_price_output(256, 1024, 262145, false); +} + +#[test] +#[should_panic(expected: ('u256_sub Overflow',))] +fn test_next_sqrt_price_out_output_eq_base_reserves() { + next_sqrt_price_output(256, 1024, 4, false); +} + +#[test] +#[should_panic(expected: ('u256_sub Overflow',))] +fn test_next_sqrt_price_out_output_gt_base_reserves() { + next_sqrt_price_output(256, 1024, 5, false); +} + diff --git a/packages/reversion/src/tests/libraries/test_swap_lib_invariants.cairo b/packages/reversion/src/tests/libraries/test_swap_lib_invariants.cairo new file mode 100644 index 0000000..55acf0e --- /dev/null +++ b/packages/reversion/src/tests/libraries/test_swap_lib_invariants.cairo @@ -0,0 +1,192 @@ +// Core lib imports. +use core::integer::{BoundedInt, u256_wide_mul, u512}; + +// Local imports. +use haiko_solver_reversion::libraries::swap_lib::{ + compute_swap_amounts, next_sqrt_price_input, next_sqrt_price_output +}; + +// Haiko imports. +use haiko_lib::constants::{MIN_SQRT_PRICE, MAX_SQRT_PRICE}; +use haiko_lib::math::math; +use haiko_lib::math::liquidity_math::{liquidity_to_base, liquidity_to_quote}; +use haiko_lib::constants::ONE; +use haiko_lib::types::i128::I128Trait; +use haiko_lib::helpers::utils::approx_eq; + +// Check following invariants: +// 1. Amount in + fee <= u256 max +// 2. If exact input, amount out <= amount remaining +// If exact output, amount in <= amount remaining +// 3. If current price = target price, amount in = amount out = fee = 0 +// 4. If next price != target price and: +// - Exact input, amount in == amount remaining +// - Exact output, amount out == amount remaining +// 5. If target price <= curr price, target price <= next price <= curr price +// Else if target price > curr price, curr price <= next price <= target price +#[test] +fn test_compute_swap_amounts_invariants( + curr_sqrt_price: u128, + target_sqrt_price: u128, + liquidity: u128, + amount_rem: u128, + fee_rate: u8, + width: u16, +) { + // Return if invalid + if curr_sqrt_price.into() < MIN_SQRT_PRICE + || target_sqrt_price.into() < MIN_SQRT_PRICE + || width == 0 + || liquidity == 0 + || amount_rem == 0 { + return; + } + + // Compute swap amounts + let exact_input = amount_rem % 2 == 0; // bool fuzzing not supported, so use even/odd for rng + let (amount_in, amount_out, fees, next_sqrt_price) = compute_swap_amounts( + curr_sqrt_price.into(), + target_sqrt_price.into(), + liquidity.into(), + amount_rem.into(), + fee_rate.into(), + exact_input, + ); + + // Invariant 1 + assert(amount_in + fees <= BoundedInt::max(), 'Invariant 1'); + + // Invariant 2 + if exact_input { + assert(amount_in <= amount_rem.into(), 'Invariant 2a'); + } else { + assert(amount_out <= amount_rem.into(), 'Invariant 2b'); + } + + // Invariant 3 + if curr_sqrt_price == target_sqrt_price { + assert(amount_in == 0 && amount_out == 0 && fees == 0, 'Invariant 3'); + } + + // Invariant 4 + if next_sqrt_price != target_sqrt_price.into() { + if exact_input { + // Rounding error due to fee calculation which rounds down `amount_rem` + assert(approx_eq(amount_in + fees, amount_rem.into(), 10), 'Invariant 4a'); + } else { + assert(approx_eq(amount_out, amount_rem.into(), 10), 'Invariant 4b'); + } + } + + // Invariant 5 + if target_sqrt_price <= curr_sqrt_price { + assert( + target_sqrt_price.into() <= next_sqrt_price + && next_sqrt_price <= curr_sqrt_price.into(), + 'Invariant 5a' + ); + } else { + assert( + curr_sqrt_price.into() <= next_sqrt_price + && next_sqrt_price <= target_sqrt_price.into(), + 'Invariant 5b' + ); + } +} + +// Check following invariants: +// 1. If buying, next price >= curr price +// If selling, next price <= curr price +// 2. If buying, amount in >= liquidity to quote (rounding up) +// If selling, amount in >= liquidity to base (rounding up) +#[test] +fn test_next_sqrt_price_input_invariants(curr_sqrt_price: u128, liquidity: u128, amount_in: u128,) { + let is_buy = liquidity % 2 == 0; // bool fuzzing not supported, so use even/odd for rng + + // Return if invalid + if !is_buy + && curr_sqrt_price == 0 || curr_sqrt_price.into() < MIN_SQRT_PRICE || liquidity == 0 { + return; + } + + // Compute next sqrt price + let next_sqrt_price = next_sqrt_price_input( + curr_sqrt_price.into(), liquidity.into(), amount_in.into(), is_buy + ); + + // Invariant 1 + if is_buy { + assert(curr_sqrt_price.into() <= next_sqrt_price, 'Invariant 1a'); + } else { + assert(next_sqrt_price <= curr_sqrt_price.into(), 'Invariant 1b'); + } + + // Invariant 2 + let liquidity_i128 = I128Trait::new(liquidity.into(), false); + if is_buy { + let quote = liquidity_to_quote( + curr_sqrt_price.into(), next_sqrt_price, liquidity_i128, true + ); + assert(amount_in.into() >= quote.val, 'Invariant 2b'); + } else { + let base = liquidity_to_base(next_sqrt_price, curr_sqrt_price.into(), liquidity_i128, true); + assert(amount_in.into() >= base.val, 'Invariant 2a'); + } +} + +// Check following invariants: +// 1. If buying, next price >= curr price +// If selling, next price <= curr price +// 2. If buying, amount out <= liquidity to base (rounding down) +// If selling, amount out <= liquidity to quote (rounding down) +// 3. If selling, next price > 0 +#[test] +fn test_next_sqrt_price_output_invariants( + curr_sqrt_price: u128, liquidity: u128, amount_out: u128, +) { + let is_buy = liquidity % 2 == 0; // bool fuzzing not supported, so use even/odd for rng + + // Return if invalid + if !is_buy + && curr_sqrt_price == 0 || curr_sqrt_price.into() < MIN_SQRT_PRICE || liquidity == 0 { + return; + } + if is_buy { + let product_wide: u512 = u256_wide_mul(amount_out.into(), curr_sqrt_price.into()); + let product = math::mul_div(amount_out.into(), curr_sqrt_price.into(), ONE, true); + if product_wide.limb2 != 0 || product_wide.limb3 != 0 || product >= liquidity.into() { + return; + } + } + + // Compute next sqrt price + let next_sqrt_price = next_sqrt_price_output( + curr_sqrt_price.into(), liquidity.into(), amount_out.into(), is_buy + ); + + // Invariant 1 + if is_buy { + assert(curr_sqrt_price.into() <= next_sqrt_price, 'Invariant 1a'); + } else { + assert(next_sqrt_price <= curr_sqrt_price.into(), 'Invariant 1b'); + } + + // Invariant 2 + let liquidity_i128 = I128Trait::new(liquidity.into(), false); + if is_buy { + let base = liquidity_to_base( + curr_sqrt_price.into(), next_sqrt_price, liquidity_i128, false + ); + assert(amount_out.into() <= base.val, 'Invariant 2a'); + } else { + let quote = liquidity_to_quote( + next_sqrt_price, curr_sqrt_price.into(), liquidity_i128, false + ); + assert(amount_out.into() <= quote.val, 'Invariant 2b'); + } + + // Invariant 3 + if !is_buy { + assert(next_sqrt_price > 0, 'Invariant 3'); + } +} diff --git a/packages/reversion/src/tests/solver.cairo b/packages/reversion/src/tests/solver.cairo new file mode 100644 index 0000000..d71a051 --- /dev/null +++ b/packages/reversion/src/tests/solver.cairo @@ -0,0 +1,7 @@ +pub mod test_e2e; +pub mod test_deploy; +pub mod test_market_params; +pub mod test_model_params; +pub mod test_oracle; +pub mod test_model_admin; +pub mod test_swap; diff --git a/packages/reversion/src/tests/solver/test_deploy.cairo b/packages/reversion/src/tests/solver/test_deploy.cairo new file mode 100644 index 0000000..ca7698e --- /dev/null +++ b/packages/reversion/src/tests/solver/test_deploy.cairo @@ -0,0 +1,40 @@ +// Core lib imports. +use starknet::contract_address_const; + +// Local imports. +use haiko_solver_core::interfaces::ISolver::{ISolverDispatcher, ISolverDispatcherTrait}; +use haiko_solver_reversion::{ + interfaces::{IReversionSolver::{IReversionSolverDispatcher, IReversionSolverDispatcherTrait},}, + types::MarketParams, + tests::{ + helpers::{actions::{deploy_reversion_solver, deploy_mock_pragma_oracle}, utils::before,}, + }, +}; + +// Haiko imports. +use haiko_lib::helpers::params::owner; + +// External imports. +use snforge_std::{declare, start_prank, CheatTarget}; +use openzeppelin::token::erc20::interface::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait}; + +//////////////////////////////// +// TESTS +//////////////////////////////// + +#[test] +fn test_deploy_reversion_solver_initialises_immutables() { + let ( + _base_token, _quote_token, oracle, vault_token_class, solver, _market_id, _vault_token_opt + ) = + before( + true + ); + + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + + assert(solver.owner() == owner(), 'Owner'); + assert(solver.queued_owner() == contract_address_const::<0x0>(), 'Queued owner'); + assert(solver.vault_token_class() == vault_token_class, 'Vault token class'); + assert(rev_solver.oracle() == oracle.contract_address, 'Oracle'); +} diff --git a/packages/reversion/src/tests/solver/test_e2e.cairo b/packages/reversion/src/tests/solver/test_e2e.cairo new file mode 100644 index 0000000..961e944 --- /dev/null +++ b/packages/reversion/src/tests/solver/test_e2e.cairo @@ -0,0 +1,192 @@ +// Core lib imports. +use starknet::ContractAddress; +use starknet::contract_address_const; +use starknet::class_hash::ClassHash; + +// Local imports. +use haiko_solver_core::{ + interfaces::ISolver::{ISolverDispatcher, ISolverDispatcherTrait}, types::SwapParams, +}; +use haiko_solver_reversion::{ + contracts::mocks::{ + upgraded_reversion_solver::{ + UpgradedReversionSolver, IUpgradedReversionSolverDispatcher, + IUpgradedReversionSolverDispatcherTrait + }, + mock_pragma_oracle::{IMockPragmaOracleDispatcher, IMockPragmaOracleDispatcherTrait}, + }, + interfaces::{ + IReversionSolver::{IReversionSolverDispatcher, IReversionSolverDispatcherTrait}, + pragma::{DataType, PragmaPricesResponse}, + }, + types::{MarketParams, Trend}, + tests::{ + helpers::{actions::{deploy_reversion_solver, deploy_mock_pragma_oracle}, utils::before,}, + }, +}; + +// Haiko imports. +use haiko_lib::helpers::{ + params::{owner, alice, bob, treasury, default_token_params}, + actions::{ + market_manager::{create_market, modify_position, swap}, + token::{deploy_token, fund, approve}, + }, + utils::{to_e18, to_e18_u128, to_e28, approx_eq, approx_eq_pct}, +}; + +// External imports. +use openzeppelin::token::erc20::interface::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait}; +use snforge_std::{ + declare, start_warp, start_prank, stop_prank, CheatTarget, spy_events, SpyOn, EventSpy, + EventAssertions, EventFetcher, ContractClass, ContractClassTrait +}; + +//////////////////////////////// +// TESTS +//////////////////////////////// + +#[test] +fn test_solver_e2e_private_market() { + let ( + _base_token, _quote_token, oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Set oracle price. + start_warp(CheatTarget::One(oracle.contract_address), 1000); + oracle.set_data_with_USD_hop('ETH', 'USDC', 1000000000, 8, 999, 5); // 10 + + // Set trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let model_params = rev_solver.model_params(market_id); + rev_solver.set_model_params(market_id, Trend::Down, model_params.range); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let dep_init = solver.deposit_initial(market_id, to_e18(100), to_e18(1000)); + + // Deposit. + let dep = solver.deposit(market_id, to_e18(100), to_e18(500)); + + // Swap. + let params = SwapParams { + is_buy: true, + amount: to_e18(10), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let swap = solver.swap(market_id, params); + + // Withdraw. + let wd = solver.withdraw_private(market_id, to_e18(50), to_e18(300)); + + // Run checks. + let res = solver.get_balances(market_id); + assert( + res.base_amount == dep.base_amount + + dep_init.base_amount + - swap.amount_out + - wd.base_amount, + 'Base reserves' + ); + assert( + res.quote_amount == dep.quote_amount + + dep_init.quote_amount + + swap.amount_in + - swap.fees + - wd.quote_amount, + 'Quote reserves' + ); + assert(dep_init.shares == 0, 'Shares init'); + assert(dep.shares == 0, 'Shares'); +} + +#[test] +fn test_solver_e2e_public_market() { + let (base_token, quote_token, oracle, _vault_token_class, solver, market_id, _vault_token_opt) = + before( + true + ); + + // Set oracle price. + start_warp(CheatTarget::One(oracle.contract_address), 1000); + oracle.set_data_with_USD_hop('ETH', 'USDC', 1000000000, 8, 999, 5); // 10 + + // Set trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let model_params = rev_solver.model_params(market_id); + rev_solver.set_model_params(market_id, Trend::Down, model_params.range); + + // Set withdraw fee. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.set_withdraw_fee(market_id, 50); + + // Deposit initial. + let dep_init = solver.deposit_initial(market_id, to_e18(100), to_e18(1000)); + + // Deposit as LP. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let dep = solver.deposit(market_id, to_e18(50), to_e18(600)); // Contains extra, should coerce. + println!( + "base_deposit: {}, quote_deposit: {}, shares: {}", + dep.base_amount, + dep.quote_amount, + dep.shares + ); + + // Swap. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let params = SwapParams { + is_buy: true, + amount: to_e18(10), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let swap = solver.swap(market_id, params); + println!("amount_in: {}, amount_out: {}", swap.amount_in, swap.amount_out); + + // Withdraw. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let wd = solver.withdraw_public(market_id, dep.shares); + println!("base_withdraw: {}, quote_withdraw: {}", wd.base_amount, wd.quote_amount); + + // Collect withdraw fees. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let base_fees = solver + .collect_withdraw_fees(solver.contract_address, base_token.contract_address); + let quote_fees = solver + .collect_withdraw_fees(solver.contract_address, quote_token.contract_address); + println!("base_fees: {}, quote_fees: {}", base_fees, quote_fees); + + // Run checks. + let res = solver.get_balances(market_id); + let base_deposit_exp = to_e18(50); + let quote_deposit_exp = to_e18(500); + assert(dep.base_amount == base_deposit_exp, 'Base deposit'); + assert(dep.quote_amount == quote_deposit_exp, 'Quote deposit'); + assert(dep.shares == dep_init.shares / 2, 'Shares'); + assert( + res.base_amount == dep_init.base_amount + + base_deposit_exp + - swap.amount_out + - wd.base_amount, + 'Base reserves' + ); + assert( + res.quote_amount == dep_init.quote_amount + + quote_deposit_exp + + swap.amount_in + - swap.fees + - wd.quote_amount, + 'Quote reserves' + ); +} diff --git a/packages/reversion/src/tests/solver/test_market_params.cairo b/packages/reversion/src/tests/solver/test_market_params.cairo new file mode 100644 index 0000000..f1d45b6 --- /dev/null +++ b/packages/reversion/src/tests/solver/test_market_params.cairo @@ -0,0 +1,611 @@ +// Core lib imports. +use starknet::contract_address_const; + +// Local imports. +use haiko_solver_core::interfaces::ISolver::{ISolverDispatcher, ISolverDispatcherTrait}; +use haiko_solver_reversion::{ + contracts::reversion_solver::ReversionSolver, + contracts::mocks::mock_pragma_oracle::{ + IMockPragmaOracleDispatcher, IMockPragmaOracleDispatcherTrait + }, + interfaces::IReversionSolver::{IReversionSolverDispatcher, IReversionSolverDispatcherTrait}, + types::MarketParams, + tests::{ + helpers::{ + actions::{deploy_reversion_solver, deploy_mock_pragma_oracle}, + params::{default_market_params, new_market_params}, + utils::{before, before_custom_decimals, before_skip_approve, snapshot}, + }, + }, +}; + +// Haiko imports. +use haiko_lib::helpers::params::{owner, alice}; +use haiko_lib::helpers::utils::{to_e18, approx_eq, approx_eq_pct}; +use haiko_lib::helpers::actions::token::{fund, approve}; + +// External imports. +use snforge_std::{ + start_prank, stop_prank, start_warp, declare, spy_events, SpyOn, EventSpy, EventAssertions, + CheatTarget +}; +use openzeppelin::token::erc20::interface::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait}; + +//////////////////////////////// +// TESTS - Success cases +//////////////////////////////// + +#[test] +fn test_queue_and_set_market_params_no_delay() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Set market params. + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let params = new_market_params(); + rev_solver.queue_market_params(market_id, params); + rev_solver.set_market_params(market_id); + + // Get market params. + let market_params = rev_solver.market_params(market_id); + + // Run checks. + assert(market_params.fee_rate == params.fee_rate, 'Min spread'); + assert(market_params.base_currency_id == params.base_currency_id, 'Base currency ID'); + assert(market_params.quote_currency_id == params.quote_currency_id, 'Quote currency ID'); + assert(market_params.min_sources == params.min_sources, 'Min sources'); + assert(market_params.max_age == params.max_age, 'Max age'); +} + +#[test] +fn test_queue_and_set_market_params_with_delay() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Set delay. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let delay = 86400; // 24 hours + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_delay(delay); + + // Queue market params. + start_warp(CheatTarget::One(solver.contract_address), 1000); + let params = new_market_params(); + rev_solver.queue_market_params(market_id, params); + + // Set market params. + start_warp(CheatTarget::One(solver.contract_address), 100000); + rev_solver.set_market_params(market_id); + + // Get market params. + let market_params = rev_solver.market_params(market_id); + let queued_params = rev_solver.queued_market_params(market_id); + + // Run checks. + assert(market_params.fee_rate == params.fee_rate, 'Min spread'); + assert(market_params.base_currency_id == params.base_currency_id, 'Base currency ID'); + assert(market_params.quote_currency_id == params.quote_currency_id, 'Quote currency ID'); + assert(market_params.min_sources == params.min_sources, 'Min sources'); + assert(market_params.max_age == params.max_age, 'Max age'); + assert(queued_params == Default::default(), 'Queued params'); +} + +#[test] +fn test_queue_and_set_market_params_with_delay_first_initialisation() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Set delay. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_delay(86400); + + // Get market params. + // Note market params are already set in the `before` fn. + let market_params = rev_solver.market_params(market_id); + let queued_params = rev_solver.queued_market_params(market_id); + + // Run checks. + let params = default_market_params(); + assert(market_params.fee_rate == params.fee_rate, 'Fee rate'); + assert(market_params.base_currency_id == params.base_currency_id, 'Base currency ID'); + assert(market_params.quote_currency_id == params.quote_currency_id, 'Quote currency ID'); + assert(market_params.min_sources == params.min_sources, 'Min sources'); + assert(market_params.max_age == params.max_age, 'Max age'); + assert(queued_params == Default::default(), 'Queued params'); +} + +#[test] +fn test_set_delay() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, _market_id, _vault_token_opt + ) = + before( + true + ); + + // Set delay. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let delay = 86400; // 24 hours + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_delay(delay); + + // Run checks. + let fetched_delay = rev_solver.delay(); + assert(fetched_delay == delay, 'Delay'); +} + +#[test] +fn test_unset_delay() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, _market_id, _vault_token_opt + ) = + before( + true + ); + + // Set delay. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let delay = 86400; // 24 hours + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_delay(delay); + + // Unset delay. + let delay_null = 0; + rev_solver.set_delay(delay_null); + + // Run checks. + let fetched_delay = rev_solver.delay(); + assert(fetched_delay == delay_null, 'Delay'); +} + +#[test] +fn test_update_queued_market_params() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Queue market params. + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let params = new_market_params(); + rev_solver.queue_market_params(market_id, params); + + // Update queued market params. + let updated_params = MarketParams { + fee_rate: 123, + base_currency_id: 654321, + quote_currency_id: 210987, + min_sources: 20, + max_age: 400, + }; + rev_solver.queue_market_params(market_id, updated_params); + + // Run checks. + let queued_params = rev_solver.queued_market_params(market_id); + assert(queued_params.fee_rate == updated_params.fee_rate, 'Min spread'); + assert(queued_params.base_currency_id == updated_params.base_currency_id, 'Base currency ID'); + assert( + queued_params.quote_currency_id == updated_params.quote_currency_id, 'Quote currency ID' + ); + assert(queued_params.min_sources == updated_params.min_sources, 'Min sources'); + assert(queued_params.max_age == updated_params.max_age, 'Max age'); +} + +#[test] +fn test_cancel_queued_market_params() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Queue market params. + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let params = new_market_params(); + rev_solver.queue_market_params(market_id, params); + + // Cancel queued market params. + rev_solver.queue_market_params(market_id, Default::default()); + + // Run checks. + let queued_params = rev_solver.queued_market_params(market_id); + assert(queued_params.fee_rate == 0, 'Min spread'); + assert(queued_params.base_currency_id == 0, 'Base currency ID'); + assert(queued_params.quote_currency_id == 0, 'Quote currency ID'); + assert(queued_params.min_sources == 0, 'Min sources'); + assert(queued_params.max_age == 0, 'Max age'); +} + +//////////////////////////////// +// TESTS - Events +//////////////////////////////// + +#[test] +fn test_queue_market_params_emits_event() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Spy on events. + let mut spy = spy_events(SpyOn::One(solver.contract_address)); + + // Queue market params. + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let params = new_market_params(); + rev_solver.queue_market_params(market_id, params); + + // Check events emitted. + spy + .assert_emitted( + @array![ + ( + solver.contract_address, + ReversionSolver::Event::QueueMarketParams( + ReversionSolver::QueueMarketParams { + market_id, + fee_rate: params.fee_rate, + base_currency_id: params.base_currency_id, + quote_currency_id: params.quote_currency_id, + min_sources: params.min_sources, + max_age: params.max_age + } + ) + ) + ] + ); +} + +#[test] +fn test_set_delay_emits_event() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, _market_id, _vault_token_opt + ) = + before( + true + ); + + // Spy on events. + let mut spy = spy_events(SpyOn::One(solver.contract_address)); + + // Set delay. + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let delay = 86400; // 24 hours + rev_solver.set_delay(delay); + + // Check events emitted. + spy + .assert_emitted( + @array![ + ( + solver.contract_address, + ReversionSolver::Event::SetDelay(ReversionSolver::SetDelay { delay }) + ) + ] + ); +} + +#[test] +fn test_set_market_params_emits_event() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Spy on events. + let mut spy = spy_events(SpyOn::One(solver.contract_address)); + + // Set market params. + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let params = new_market_params(); + rev_solver.queue_market_params(market_id, params); + rev_solver.set_market_params(market_id); + + // Check events emitted. + spy + .assert_emitted( + @array![ + ( + solver.contract_address, + ReversionSolver::Event::QueueMarketParams( + ReversionSolver::QueueMarketParams { + market_id, + fee_rate: params.fee_rate, + base_currency_id: params.base_currency_id, + quote_currency_id: params.quote_currency_id, + min_sources: params.min_sources, + max_age: params.max_age + } + ) + ), + ( + solver.contract_address, + ReversionSolver::Event::SetMarketParams( + ReversionSolver::SetMarketParams { + market_id, + fee_rate: params.fee_rate, + base_currency_id: params.base_currency_id, + quote_currency_id: params.quote_currency_id, + min_sources: params.min_sources, + max_age: params.max_age + } + ) + ) + ] + ); +} + +//////////////////////////////// +// TESTS - Failure cases +//////////////////////////////// + +#[test] +#[should_panic(expected: ('OnlyMarketOwner',))] +fn test_queue_market_params_fails_if_not_market_owner() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Queue market params. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let params = rev_solver.market_params(market_id); + rev_solver.queue_market_params(market_id, params); +} + +#[test] +#[should_panic(expected: ('ParamsUnchanged',))] +fn test_queue_market_params_fails_if_params_unchanged() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Queue market params. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let params = rev_solver.market_params(market_id); + rev_solver.queue_market_params(market_id, params); +} + +#[test] +#[should_panic(expected: ('MinSourcesZero',))] +fn test_set_market_params_fails_if_min_sources_zero() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Queue market params. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let mut params = rev_solver.market_params(market_id); + params.min_sources = 0; + rev_solver.queue_market_params(market_id, params); + + // Set market params. + rev_solver.set_market_params(market_id); +} + +#[test] +#[should_panic(expected: ('MaxAgeZero',))] +fn test_set_market_params_fails_if_max_age_zero() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Queue market params. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let mut params = rev_solver.market_params(market_id); + params.max_age = 0; + rev_solver.queue_market_params(market_id, params); + + // Set market params. + rev_solver.set_market_params(market_id); +} + +#[test] +#[should_panic(expected: ('BaseIdZero',))] +fn test_set_market_params_fails_if_base_currency_id_zero() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Queue market params. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let mut params = rev_solver.market_params(market_id); + params.base_currency_id = 0; + rev_solver.queue_market_params(market_id, params); + + // Set market params. + rev_solver.set_market_params(market_id); +} + +#[test] +#[should_panic(expected: ('QuoteIdZero',))] +fn test_set_market_params_fails_if_quote_currency_id_zero() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Queue market params. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let mut params = rev_solver.market_params(market_id); + params.quote_currency_id = 0; + rev_solver.queue_market_params(market_id, params); + + // Set market params. + rev_solver.set_market_params(market_id); +} + +#[test] +#[should_panic(expected: ('OnlyMarketOwner',))] +fn test_set_market_params_fails_if_not_owner() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Queue market params. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let mut params = rev_solver.market_params(market_id); + params.fee_rate = 987; + rev_solver.queue_market_params(market_id, params); + + // Set market params. + start_prank(CheatTarget::One(solver.contract_address), alice()); + rev_solver.set_market_params(market_id); +} + +#[test] +#[should_panic(expected: ('DelayNotPassed',))] +fn test_set_market_params_fails_before_delay_complete() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Set delay. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let delay = 86400; // 24 hours + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_delay(delay); + + // Queue new market params. + start_warp(CheatTarget::One(solver.contract_address), 1000); + let mut params = new_market_params(); + params.fee_rate = 987; + rev_solver.queue_market_params(market_id, params); + + // Set new market params. + start_warp(CheatTarget::One(solver.contract_address), 2000); + rev_solver.set_market_params(market_id); +} + +#[test] +#[should_panic(expected: ('NotQueued',))] +fn test_set_market_params_fails_none_queued() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Set delay. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let delay = 86400; // 24 hours + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_delay(delay); + + // Set market params without queuing. + start_warp(CheatTarget::One(solver.contract_address), 100000); + rev_solver.set_market_params(market_id); +} + +#[test] +#[should_panic(expected: ('NotQueued',))] +fn test_set_market_params_fails_none_queued_null_params() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Set delay. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let delay = 86400; // 24 hours + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_delay(delay); + + // Queue market params. + start_warp(CheatTarget::One(solver.contract_address), 1000); + let mut params = new_market_params(); + params.fee_rate = 987; + rev_solver.queue_market_params(market_id, params); + + // Update queued market params to zero. + rev_solver.queue_market_params(market_id, Default::default()); + + // Set market params. + start_warp(CheatTarget::One(solver.contract_address), 100000); + rev_solver.set_market_params(market_id); +} + +#[test] +#[should_panic(expected: ('ParamsUnchanged',))] +fn test_set_market_params_fails_if_unchanged() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Set delay. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let delay = 86400; // 24 hours + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_delay(delay); + + // Queue market params. + start_warp(CheatTarget::One(solver.contract_address), 1000); + let mut params = rev_solver.market_params(market_id); + rev_solver.queue_market_params(market_id, params); + + // Set market params. + start_warp(CheatTarget::One(solver.contract_address), 100000); + rev_solver.set_market_params(market_id); + + // Queue market params again. + rev_solver.queue_market_params(market_id, params); + + // Set market params again. + start_warp(CheatTarget::One(solver.contract_address), 200000); + rev_solver.set_market_params(market_id); +} diff --git a/packages/reversion/src/tests/solver/test_model_admin.cairo b/packages/reversion/src/tests/solver/test_model_admin.cairo new file mode 100644 index 0000000..c279dc0 --- /dev/null +++ b/packages/reversion/src/tests/solver/test_model_admin.cairo @@ -0,0 +1,130 @@ +// Core lib imports. +use starknet::contract_address_const; + +// Local imports. +use haiko_solver_core::interfaces::ISolver::{ISolverDispatcher, ISolverDispatcherTrait}; +use haiko_solver_reversion::{ + contracts::reversion_solver::ReversionSolver, + contracts::mocks::mock_pragma_oracle::{ + IMockPragmaOracleDispatcher, IMockPragmaOracleDispatcherTrait + }, + interfaces::IReversionSolver::{IReversionSolverDispatcher, IReversionSolverDispatcherTrait}, + types::{Trend, MarketParams}, + tests::{ + helpers::{ + actions::{deploy_reversion_solver, deploy_mock_pragma_oracle}, + params::default_market_params, + utils::{before, before_custom_decimals, before_skip_approve, snapshot}, + }, + }, +}; + +// Haiko imports. +use haiko_lib::helpers::params::{owner, alice}; +use haiko_lib::helpers::utils::{to_e18, approx_eq, approx_eq_pct}; +use haiko_lib::helpers::actions::token::{fund, approve}; + +// External imports. +use snforge_std::{ + start_prank, stop_prank, start_warp, declare, spy_events, SpyOn, EventSpy, EventAssertions, + CheatTarget +}; +use openzeppelin::token::erc20::interface::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait}; + +//////////////////////////////// +// TESTS - Success cases +//////////////////////////////// + +#[test] +fn test_change_model_admin_works() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, _market_id, _vault_token_opt + ) = + before( + true + ); + + // Set model admin. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.change_model_admin(alice()); + + // Get model admin. + let model_admin = rev_solver.model_admin(); + + // Run checks. + assert(model_admin == alice(), 'Trend setter'); +} + +//////////////////////////////// +// TESTS - Events +//////////////////////////////// + +#[test] +fn test_change_model_admin_emits_event() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, _market_id, _vault_token_opt + ) = + before( + true + ); + + // Spy on events. + let mut spy = spy_events(SpyOn::One(solver.contract_address)); + + // Set trend. + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.change_model_admin(alice()); + + // Check events emitted. + spy + .assert_emitted( + @array![ + ( + solver.contract_address, + ReversionSolver::Event::ChangeModelAdmin( + ReversionSolver::ChangeModelAdmin { admin: alice() } + ) + ) + ] + ); +} + +//////////////////////////////// +// TESTS - Failure cases +//////////////////////////////// + +#[test] +#[should_panic(expected: ('OnlyOwner',))] +fn test_change_model_admin_fails_if_not_owner() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, _market_id, _vault_token_opt + ) = + before( + true + ); + + // Change model admin. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.change_model_admin(alice()); +} + +#[test] +#[should_panic(expected: ('TrendSetterUnchanged',))] +fn test_change_model_admin_fails_if_model_admin_unchanged() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, _market_id, _vault_token_opt + ) = + before( + true + ); + + // Change model admin. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.change_model_admin(alice()); + + // Change again to same setter. + rev_solver.change_model_admin(alice()); +} diff --git a/packages/reversion/src/tests/solver/test_model_params.cairo b/packages/reversion/src/tests/solver/test_model_params.cairo new file mode 100644 index 0000000..513fd41 --- /dev/null +++ b/packages/reversion/src/tests/solver/test_model_params.cairo @@ -0,0 +1,210 @@ +// Core lib imports. +use starknet::contract_address_const; + +// Local imports. +use haiko_solver_core::interfaces::ISolver::{ISolverDispatcher, ISolverDispatcherTrait}; +use haiko_solver_reversion::{ + contracts::reversion_solver::ReversionSolver, + contracts::mocks::mock_pragma_oracle::{ + IMockPragmaOracleDispatcher, IMockPragmaOracleDispatcherTrait + }, + interfaces::IReversionSolver::{IReversionSolverDispatcher, IReversionSolverDispatcherTrait}, + types::{Trend, MarketParams}, + tests::{ + helpers::{ + actions::{deploy_reversion_solver, deploy_mock_pragma_oracle}, + params::default_market_params, + utils::{before, before_custom_decimals, before_skip_approve, snapshot}, + }, + }, +}; + +// Haiko imports. +use haiko_lib::helpers::params::{owner, alice}; +use haiko_lib::helpers::utils::{to_e18, approx_eq, approx_eq_pct}; +use haiko_lib::helpers::actions::token::{fund, approve}; + +// External imports. +use snforge_std::{ + start_prank, stop_prank, start_warp, declare, spy_events, SpyOn, EventSpy, EventAssertions, + CheatTarget +}; +use openzeppelin::token::erc20::interface::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait}; + +//////////////////////////////// +// TESTS - Success cases +//////////////////////////////// + +#[test] +fn test_new_solver_market_has_ranging_trend_by_default() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Get trend. + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let model_params = rev_solver.model_params(market_id); + + // Run checks. + assert(model_params.trend == Trend::Range, 'Default trend'); +} + +#[test] +fn test_set_model_params_for_solver_market_updates_state() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Set trend. + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_model_params(market_id, Trend::Up, 2569); + + // Get trend and run check. + let model_params = rev_solver.model_params(market_id); + assert(model_params.trend == Trend::Up, 'Trend'); + assert(model_params.range == 2569, 'Range'); +} + +#[test] +fn test_set_model_params_is_callable_by_market_owner() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Set trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_model_params(market_id, Trend::Up, 2569); +} + +#[test] +fn test_set_model_params_is_callable_by_model_admin() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Change model admin. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.change_model_admin(alice()); + + // Set trend. + start_prank(CheatTarget::One(solver.contract_address), alice()); + rev_solver.set_model_params(market_id, Trend::Up, 2569); + let model_params = rev_solver.model_params(market_id); + assert(model_params.trend == Trend::Up, 'Trend'); + assert(model_params.range == 2569, 'Range'); +} + +//////////////////////////////// +// TESTS - Events +//////////////////////////////// + +#[test] +fn test_set_model_params_emits_event() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Spy on events. + let mut spy = spy_events(SpyOn::One(solver.contract_address)); + + // Set trend. + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_model_params(market_id, Trend::Up, 2569); + + // Check events emitted. + spy + .assert_emitted( + @array![ + ( + solver.contract_address, + ReversionSolver::Event::SetModelParams( + ReversionSolver::SetModelParams { market_id, trend: Trend::Up, range: 2569 } + ) + ) + ] + ); +} + +//////////////////////////////// +// TESTS - Failure cases +//////////////////////////////// + +#[test] +#[should_panic(expected: ('MarketNull',))] +fn test_set_model_params_fails_if_market_does_not_exist() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, _market_id, _vault_token_opt + ) = + before( + true + ); + + // Set model params. + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_model_params(1, Trend::Up, 2569); +} + +#[test] +#[should_panic(expected: ('Unchanged',))] +fn test_set_model_params_fails_if_unchanged() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Set model params. + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let model_params = rev_solver.model_params(market_id); + rev_solver.set_model_params(market_id, model_params.trend, model_params.range); +} + +#[test] +#[should_panic(expected: ('RangeZero',))] +fn test_set_model_params_fails_if_range_zero() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let params = rev_solver.model_params(market_id); + rev_solver.set_model_params(market_id, params.trend, 0); +} + +#[test] +#[should_panic(expected: ('NotApproved',))] +fn test_set_model_params_fails_if_caller_is_not_market_owner() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + true + ); + + // Set trend. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_model_params(market_id, Trend::Range, 2569); +} diff --git a/packages/reversion/src/tests/solver/test_oracle.cairo b/packages/reversion/src/tests/solver/test_oracle.cairo new file mode 100644 index 0000000..103967f --- /dev/null +++ b/packages/reversion/src/tests/solver/test_oracle.cairo @@ -0,0 +1,132 @@ +// Core lib imports. +use starknet::contract_address_const; + +// Local imports. +use haiko_solver_reversion::{ + contracts::reversion_solver::ReversionSolver, + contracts::mocks::mock_pragma_oracle::{ + IMockPragmaOracleDispatcher, IMockPragmaOracleDispatcherTrait + }, + interfaces::{IReversionSolver::{IReversionSolverDispatcher, IReversionSolverDispatcherTrait},}, + tests::{ + helpers::{ + actions::{deploy_reversion_solver, deploy_mock_pragma_oracle}, + params::default_market_params, + utils::{before, before_custom_decimals, before_skip_approve, snapshot}, + }, + }, +}; + +// Haiko imports. +use haiko_lib::helpers::params::{owner, alice}; +use haiko_lib::helpers::utils::{to_e18, approx_eq, approx_eq_pct}; +use haiko_lib::helpers::actions::token::{fund, approve}; + +// External imports. +use snforge_std::{ + start_prank, stop_prank, start_warp, declare, spy_events, SpyOn, EventSpy, EventAssertions, + CheatTarget +}; +use openzeppelin::token::erc20::interface::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait}; + +//////////////////////////////// +// TESTS - Success cases +//////////////////////////////// + +#[test] +fn test_change_oracle() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, _market_id, _vault_token_opt + ) = + before( + true + ); + + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + + // Change oracle. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let new_oracle = contract_address_const::<0x123>(); + rev_solver.change_oracle(new_oracle); + + // Get oracle and run check. + let oracle = rev_solver.oracle(); + assert(new_oracle == oracle, 'Oracle'); +} + +//////////////////////////////// +// TESTS - Events +//////////////////////////////// + +#[test] +fn test_change_oracle_emits_event() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, _market_id, _vault_token_opt + ) = + before( + true + ); + + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + + // Spy on events. + let mut spy = spy_events(SpyOn::One(solver.contract_address)); + + // Change oracle. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let new_oracle = contract_address_const::<0x123>(); + rev_solver.change_oracle(new_oracle); + + // Check events emitted. + spy + .assert_emitted( + @array![ + ( + solver.contract_address, + ReversionSolver::Event::ChangeOracle( + ReversionSolver::ChangeOracle { oracle: new_oracle } + ) + ) + ] + ); +} + +//////////////////////////////// +// TESTS - Failure cases +//////////////////////////////// + +#[test] +#[should_panic(expected: ('OnlyOwner',))] +fn test_change_oracle_not_owner() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, _market_id, _vault_token_opt + ) = + before( + true + ); + + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + + // Change oracle. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let new_oracle = contract_address_const::<0x123>(); + rev_solver.change_oracle(new_oracle); +} + +#[test] +#[should_panic(expected: ('OracleUnchanged',))] +fn test_change_oracle_unchanged() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, _market_id, _vault_token_opt + ) = + before( + true + ); + + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + + // Change oracle. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let oracle = rev_solver.oracle(); + rev_solver.change_oracle(oracle); +} diff --git a/packages/reversion/src/tests/solver/test_swap.cairo b/packages/reversion/src/tests/solver/test_swap.cairo new file mode 100644 index 0000000..17306f4 --- /dev/null +++ b/packages/reversion/src/tests/solver/test_swap.cairo @@ -0,0 +1,1945 @@ +// Core lib imports. +use starknet::contract_address_const; + +// Local imports. +use haiko_solver_core::{ + contracts::solver::SolverComponent, + interfaces::ISolver::{ + ISolverDispatcher, ISolverDispatcherTrait, ISolverHooksDispatcher, + ISolverHooksDispatcherTrait + }, + types::SwapParams, +}; +use haiko_solver_reversion::{ + contracts::mocks::mock_pragma_oracle::{ + IMockPragmaOracleDispatcher, IMockPragmaOracleDispatcherTrait + }, + interfaces::IReversionSolver::{IReversionSolverDispatcher, IReversionSolverDispatcherTrait}, + types::{Trend, MarketParams}, + tests::{ + helpers::{ + actions::{deploy_reversion_solver, deploy_mock_pragma_oracle}, + params::default_market_params, + utils::{ + before, before_custom_decimals, before_skip_approve, before_with_salt, snapshot, + declare_classes + }, + }, + }, +}; + +// Haiko imports. +use haiko_lib::helpers::params::{owner, alice}; +use haiko_lib::helpers::utils::{to_e18, to_e28_u128, approx_eq, approx_eq_pct}; +use haiko_lib::helpers::actions::token::{fund, approve}; + +// External imports. +use snforge_std::{ + start_prank, start_warp, declare, spy_events, SpyOn, EventSpy, EventAssertions, CheatTarget +}; +use openzeppelin::token::erc20::interface::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait}; + +//////////////////////////////// +// HELPERS +//////////////////////////////// + +#[derive(Drop, Clone)] +struct TestCase { + pub description: ByteArray, + // ORACLE + pub oracle_price: u128, // note this is e8 not e28 + // LIQUIDITY + pub base_reserves: u256, + pub quote_reserves: u256, + pub fee_rate: u16, + pub range: u32, + // SWAP + pub amount: u256, + pub threshold_sqrt_price: Option, + pub threshold_amount: Option, + pub exp: Span, +} + +#[derive(Drop, Clone)] +struct SwapCase { + pub is_buy: bool, + pub exact_input: bool, + pub amount_in: u256, + pub amount_out: u256, + pub fees: u256, +} + +fn get_test_cases_1() -> Span { + let cases: Array = array![ + TestCase { + description: "1) Full range liq, price 1, no fees", + oracle_price: 1_00000000, + base_reserves: to_e18(1000), + quote_reserves: to_e18(1000), + fee_rate: 0, + range: 7906625, + amount: to_e18(100), + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + exp: array![ + SwapCase { + is_buy: true, + exact_input: true, + amount_in: to_e18(100), + amount_out: 90909090909090909146, + fees: 0, + }, + SwapCase { + is_buy: false, + exact_input: true, + amount_in: to_e18(100), + amount_out: 90909090909090909146, + fees: 0, + }, + SwapCase { + is_buy: true, + exact_input: false, + amount_in: 111111111111111111027, + amount_out: to_e18(100), + fees: 0, + }, + SwapCase { + is_buy: false, + exact_input: false, + amount_in: 111111111111111111027, + amount_out: to_e18(100), + fees: 0, + }, + ] + .span(), + }, + TestCase { + description: "2) Full range liq, price 0.1, no fees", + oracle_price: 0_10000000, + base_reserves: to_e18(100), + quote_reserves: to_e18(1000), + fee_rate: 0, + range: 7676365, + amount: to_e18(10), + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + exp: array![ + SwapCase { + is_buy: true, + exact_input: true, + amount_in: to_e18(10), + amount_out: 50000084852067677296, + fees: 0, + }, + SwapCase { + is_buy: false, + exact_input: true, + amount_in: to_e18(10), + amount_out: 998997611702025557, + fees: 0, + }, + SwapCase { + is_buy: true, + exact_input: false, + amount_in: 1111107339914503129, + amount_out: to_e18(10), + fees: 0, + }, + SwapCase { + is_buy: false, + exact_input: false, + amount_in: 101010443847319896836, + amount_out: to_e18(10), + fees: 0, + }, + ] + .span(), + }, + TestCase { + description: "3) Full range liq, price 10, no fees", + oracle_price: 10_00000000, + base_reserves: to_e18(1000), + quote_reserves: to_e18(100), + fee_rate: 0, + range: 7676365, + amount: to_e18(100), + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + exp: array![ + SwapCase { + is_buy: true, + exact_input: true, + amount_in: to_e18(100), + amount_out: 9901054856275659172, + fees: 0, + }, + SwapCase { + is_buy: false, + exact_input: true, + amount_in: to_e18(100), + amount_out: 90909036314998803578, + fees: 0, + }, + SwapCase { + is_buy: true, + exact_input: false, + amount_in: 1111103771282806034735, + amount_out: to_e18(100), + fees: 0, + }, + SwapCase { + is_buy: false, + exact_input: false, + amount_in: 466588818773133962045136853193659825, + amount_out: to_e18(100), + fees: 0, + }, + ] + .span(), + }, + TestCase { + description: "4) Concentrated liq, price 1, no fees", + oracle_price: 1_00000000, + base_reserves: to_e18(1000), + quote_reserves: to_e18(1000), + fee_rate: 0, + range: 5000, + amount: to_e18(100), + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + exp: array![ + SwapCase { + is_buy: true, + exact_input: true, + amount_in: to_e18(100), + amount_out: 99753708432456984326, + fees: 0, + }, + SwapCase { + is_buy: false, + exact_input: true, + amount_in: to_e18(100), + amount_out: 99753708432456984326, + fees: 0, + }, + SwapCase { + is_buy: true, + exact_input: false, + amount_in: 100247510763823131034, + amount_out: to_e18(100), + fees: 0, + }, + SwapCase { + is_buy: false, + exact_input: false, + amount_in: 100247510763823131034, + amount_out: to_e18(100), + fees: 0, + }, + ] + .span(), + }, + ]; + cases.span() +} + +fn get_test_cases_2() -> Span { + let cases: Array = array![ + TestCase { + description: "5) Concentrated liq, price 1, 1% fees", + oracle_price: 1_00000000, + base_reserves: to_e18(1000), + quote_reserves: to_e18(1000), + fee_rate: 100, + range: 5000, + amount: to_e18(100), + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + exp: array![ + SwapCase { + is_buy: true, + exact_input: true, + amount_in: to_e18(100), + amount_out: 98758603689263513299, + fees: 999999999999999999 + }, + SwapCase { + is_buy: false, + exact_input: true, + amount_in: to_e18(100), + amount_out: 98758603689263513299, + fees: 1000000000000000000, + }, + SwapCase { + is_buy: true, + exact_input: false, + amount_in: 101260111882649627307, + amount_out: to_e18(100), + fees: 1012601118826496273, + }, + SwapCase { + is_buy: false, + exact_input: false, + amount_in: 101260111882649627307, + amount_out: to_e18(100), + fees: 1012601118826496273, + }, + ] + .span(), + }, + TestCase { + description: "6) Concentrated liq, price 1, 50% fees", + oracle_price: 10_00000000, + base_reserves: to_e18(1000), + quote_reserves: to_e18(1000), + fee_rate: 5000, + range: 5000, + amount: to_e18(100), + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + exp: array![ + SwapCase { + is_buy: true, + exact_input: true, + amount_in: to_e18(100), + amount_out: 4999415848330513296, + fees: 49999999999999999999, + }, + SwapCase { + is_buy: false, + exact_input: true, + amount_in: to_e18(100), + amount_out: 493899555720718382442, + fees: 50000000000000000000, + }, + SwapCase { + is_buy: true, + exact_input: false, + amount_in: 2004936970885156305832, + amount_out: to_e18(100), + fees: 1002468485442578152916, + }, + SwapCase { + is_buy: false, + exact_input: false, + amount_in: 20049634597552599158, + amount_out: to_e18(100), + fees: 10024817298776299579, + }, + ] + .span(), + }, + TestCase { + description: "7) Swap with liquidity exhausted", + oracle_price: 1_00000000, + base_reserves: to_e18(100), + quote_reserves: to_e18(100), + fee_rate: 100, + range: 5000, + amount: to_e18(200), + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + exp: array![ + SwapCase { + is_buy: true, + exact_input: true, + amount_in: 103567170945545576580, + amount_out: to_e18(100), + fees: 1035671709455455765, + }, + SwapCase { + is_buy: true, + exact_input: false, + amount_in: 103567170945545576580, + amount_out: to_e18(100), + fees: 1035671709455455765, + }, + SwapCase { + is_buy: false, + exact_input: true, + amount_in: 103567170945545576580, + amount_out: to_e18(100), + fees: 1035671709455455765, + }, + SwapCase { + is_buy: false, + exact_input: false, + amount_in: 103567170945545576580, + amount_out: to_e18(100), + fees: 1035671709455455765, + }, + ] + .span(), + }, + TestCase { + description: "8) Swap with high oracle price", + oracle_price: 1_000_000_000_000_000_00000000, + base_reserves: to_e18(1000), + quote_reserves: to_e18(1000), + fee_rate: 100, + range: 5000, + amount: to_e18(100), + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + exp: array![ + SwapCase { + is_buy: true, + exact_input: true, + amount_in: to_e18(100), + amount_out: 99000, + fees: 999999999999999999, + }, + SwapCase { + is_buy: false, + exact_input: true, + amount_in: 1035681, + amount_out: 999999999999999999999, + fees: 10356, + }, + SwapCase { + is_buy: true, + exact_input: false, + amount_in: 101259191588416392788627371401827001, + amount_out: 100000000000000000000, + fees: 1012591915884163927886273714018270, + }, + SwapCase { + is_buy: false, + exact_input: false, + amount_in: 101261, + amount_out: 100000000000000000000, + fees: 1012, + }, + ] + .span(), + }, + ]; + cases.span() +} + +fn get_test_cases_3() -> Span { + let cases: Array = array![ + TestCase { + description: "9) Swap with low oracle price", + oracle_price: 1, + base_reserves: to_e18(1000), + quote_reserves: to_e18(1000), + fee_rate: 100, + range: 5000, + amount: to_e18(100), + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + exp: array![ + SwapCase { + is_buy: true, + exact_input: true, + amount_in: 10356643015690, + amount_out: 999999999999999999999, + fees: 103566430156, + }, + SwapCase { + is_buy: false, + exact_input: true, + amount_in: 100000000000000000000, + amount_out: 989992918767, + fees: 1000000000000000000, + }, + SwapCase { + is_buy: true, + exact_input: false, + amount_in: 1012593875957, + amount_out: 99999999999999999999, + fees: 10125938759, + }, + SwapCase { + is_buy: false, + exact_input: false, + amount_in: 10126083617468551702944516926, + amount_out: 100000000000000000000, + fees: 101260836174685517029445169, + }, + ] + .span(), + }, + TestCase { + description: "10) Swap buy capped at threshold price", + oracle_price: 1_00000000, + base_reserves: to_e18(100), + quote_reserves: to_e18(100), + fee_rate: 100, + range: 50000, + amount: to_e18(50), + threshold_sqrt_price: Option::Some(10488088481701515469914535136), + threshold_amount: Option::None(()), + exp: array![ + SwapCase { + is_buy: true, + exact_input: true, + amount_in: 22288543558601668321, + amount_out: 21038779527378539768, + fees: 222885435586016683, + }, + SwapCase { + is_buy: true, + exact_input: false, + amount_in: 22288543558601668321, + amount_out: 21038779527378539768, + fees: 222885435586016683, + }, + ] + .span(), + }, + TestCase { + description: "11) Swap sell capped at threshold price", + oracle_price: 1_00000000, + base_reserves: to_e18(100), + quote_reserves: to_e18(100), + fee_rate: 100, + range: 50000, + amount: to_e18(50), + threshold_sqrt_price: Option::Some(9486832980505137995996680633), + threshold_amount: Option::None(()), + exp: array![ + SwapCase { + is_buy: false, + exact_input: true, + amount_in: 24701345711211794538, + amount_out: 23199416574442336449, + fees: 247013457112117945, + }, + SwapCase { + is_buy: false, + exact_input: false, + amount_in: 24701345711211794538, + amount_out: 23199416574442336449, + fees: 247013457112117945, + }, + ] + .span(), + }, + TestCase { + description: "12) Swap capped at threshold amount, exact input", + oracle_price: 1_00000000, + base_reserves: to_e18(1000), + quote_reserves: to_e18(1000), + fee_rate: 100, + range: 5000, + amount: to_e18(100), + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::Some(98750000000000000000), + exp: array![ + SwapCase { + is_buy: true, + exact_input: true, + amount_in: 99999999999999999999, + amount_out: 98758603689263513299, + fees: 999999999999999999, + }, + SwapCase { + is_buy: false, + exact_input: true, + amount_in: 100000000000000000000, + amount_out: 98758603689263513299, + fees: 1000000000000000000, + }, + ] + .span(), + }, + TestCase { + description: "13) Swap capped at threshold amount, exact output", + oracle_price: 1_00000000, + base_reserves: to_e18(1000), + quote_reserves: to_e18(1000), + fee_rate: 100, + range: 5000, + amount: to_e18(100), + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::Some(101500000000000000000), + exp: array![ + SwapCase { + is_buy: true, + exact_input: false, + amount_in: 101260111882649627307, + amount_out: 99999999999999999999, + fees: 1012601118826496273, + }, + SwapCase { + is_buy: false, + exact_input: false, + amount_in: 101260111882649627307, + amount_out: 100000000000000000000, + fees: 1012601118826496273, + }, + ] + .span(), + }, + ]; + cases.span() +} + +fn run_swap_cases(cases: Span) { + // Declare classes. + let (erc20_class, vault_token_class, solver_class, oracle_class) = declare_classes(); + + // Fetch, loop through and run test cases. + let mut i = 0; + loop { + if i == cases.len() { + break; + } + + // Extract test case. + let case = cases[i].clone(); + println!("Test Case {}", case.description); + + // Loop through swap cases. + let mut j = 0; + loop { + if j == case.exp.len() { + break; + } + + // Extract swap case. + let swap_case = case.exp[j].clone(); + + // Setup vm. + let salt: felt252 = (i.into() + 1) * 1000 + j.into() + 1; + let ( + _base_token, + _quote_token, + oracle, + _vault_token_class, + solver, + market_id, + _vault_token_opt + ) = + before_with_salt( + false, salt, (erc20_class, vault_token_class, solver_class, oracle_class) + ); + + // Print description. + println!( + " Swap Case {}) is_buy: {}, exact_input: {}", + j + 1, + swap_case.is_buy, + swap_case.exact_input + ); + + // Set params. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { + contract_address: solver.contract_address + }; + let mut market_params = rev_solver.market_params(market_id); + market_params.fee_rate = case.fee_rate; + rev_solver.queue_market_params(market_id, market_params); + rev_solver.set_market_params(market_id); + + // Set model params. + rev_solver.set_model_params(market_id, Trend::Range, case.range); + + // Set oracle price. + start_warp(CheatTarget::One(oracle.contract_address), 1000); + oracle + .set_data_with_USD_hop( + market_params.base_currency_id, + market_params.quote_currency_id, + case.oracle_price, + 8, + 999, + 5 + ); + + // Setup liquidity. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, case.base_reserves, case.quote_reserves); + + // Obtain quotes and execute swaps. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let solver_hooks = ISolverHooksDispatcher { contract_address: solver.contract_address }; + let quote = solver_hooks + .quote( + market_id, + SwapParams { + is_buy: swap_case.is_buy, + amount: case.amount, + exact_input: swap_case.exact_input, + threshold_sqrt_price: case.threshold_sqrt_price, + threshold_amount: case.threshold_amount, + deadline: Option::None(()), + } + ); + + // If exact input, additionally quote for uptrend and downtrend cases and compare quotes. + if swap_case.exact_input && case.threshold_sqrt_price.is_none() { + // Set uptrend and compare quotes. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { + contract_address: solver.contract_address + }; + rev_solver.set_model_params(market_id, Trend::Up, case.range); + let quote_up = solver_hooks + .quote( + market_id, + SwapParams { + is_buy: swap_case.is_buy, + amount: case.amount, + exact_input: swap_case.exact_input, + threshold_sqrt_price: case.threshold_sqrt_price, + threshold_amount: case.threshold_amount, + deadline: Option::None(()), + } + ); + if swap_case.is_buy { + assert( + quote_up.amount_in == 0 && quote_up.amount_out == 0 && quote_up.fees == 0, + 'Quote amounts: uptrend' + ); + } else { + assert(quote_up.amount_in == quote.amount_in, 'Quote in: uptrend'); + assert(quote_up.amount_out == quote.amount_out, 'Quote out: uptrend'); + assert(quote_up.fees == quote.fees, 'Quote fees: uptrend'); + } + // Set downtrend and compare quotes. + rev_solver.set_model_params(market_id, Trend::Down, case.range); + let quote_down = solver_hooks + .quote( + market_id, + SwapParams { + is_buy: swap_case.is_buy, + amount: case.amount, + exact_input: swap_case.exact_input, + threshold_sqrt_price: case.threshold_sqrt_price, + threshold_amount: case.threshold_amount, + deadline: Option::None(()), + } + ); + if !swap_case.is_buy { + assert( + quote_down.amount_in == 0 + && quote_down.amount_out == 0 + && quote_down.fees == 0, + 'Quote amounts: downtrend' + ); + } else { + assert(quote_down.amount_in == quote.amount_in, 'Quote in: downtrend'); + assert(quote_down.amount_out == quote.amount_out, 'Quote out: downtrend'); + assert(quote_down.fees == quote.fees, 'Quote fees: downtrend'); + } + // Reset trend. + rev_solver.set_model_params(market_id, Trend::Range, case.range); + } + + // Execute swap. + let swap = solver + .swap( + market_id, + SwapParams { + is_buy: swap_case.is_buy, + amount: case.amount, + exact_input: swap_case.exact_input, + threshold_sqrt_price: case.threshold_sqrt_price, + threshold_amount: case.threshold_amount, + deadline: Option::None(()), + } + ); + + // Check results. + println!( + " Amount in: {}, amount out: {}, fees: {}", + swap.amount_in, + swap.amount_out, + swap.fees + ); + assert( + approx_eq_pct(swap.amount_in, swap_case.amount_in, 10) + || approx_eq(swap.amount_in, swap_case.amount_in, 1000), + 'Amount in' + ); + assert( + approx_eq_pct(swap.amount_out, swap_case.amount_out, 10) + || approx_eq(swap.amount_out, swap_case.amount_out, 1000), + 'Amount out' + ); + assert( + approx_eq_pct(swap.fees, swap_case.fees, 10) + || approx_eq(swap.fees, swap_case.fees, 1000), + 'Fees' + ); + assert(swap.amount_in == quote.amount_in, 'Quote in'); + assert(swap.amount_out == quote.amount_out, 'Quote out'); + assert(swap.fees == quote.fees, 'Fees'); + + j += 1; + }; + + i += 1; + }; +} + +//////////////////////////////// +// TESTS - Success cases +//////////////////////////////// + +#[test] +fn test_swap_cases_1() { + run_swap_cases(get_test_cases_1()); +} + +#[test] +fn test_swap_cases_2() { + run_swap_cases(get_test_cases_2()); +} + +#[test] +fn test_swap_cases_3() { + run_swap_cases(get_test_cases_3()); +} + +#[test] +fn test_swap_price_rises_above_last_cached_price_in_uptrend() { + let ( + _base_token, _quote_token, oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Set trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let model_params = rev_solver.model_params(market_id); + rev_solver.set_model_params(market_id, Trend::Up, model_params.range); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, to_e18(100), to_e18(1000)); + + // Sell swap to cache price. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let params = SwapParams { + is_buy: false, + amount: to_e18(5), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + solver.swap(market_id, params); + + // Oracle price rises above last cached price. + start_warp(CheatTarget::One(oracle.contract_address), 1000); + oracle.set_data_with_USD_hop('ETH', 'USDC', 1050000000, 8, 999, 5); // 10.5 + + // Swap again to update cached price. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let swap = solver.swap(market_id, params); + + // Run checks. + let model_params = rev_solver.model_params(market_id); + assert(model_params.cached_price == 1050000000, 'Cached price'); + assert(swap.amount_in == to_e18(5), 'Amount in'); + assert(swap.amount_out > to_e18(50), 'Amount out'); + assert(swap.fees == 25000000000000000, 'Fees'); +} + +#[test] +fn test_swap_price_falls_below_last_cached_price_and_rises_again_in_uptrend() { + let ( + _base_token, _quote_token, oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Set trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let model_params = rev_solver.model_params(market_id); + rev_solver.set_model_params(market_id, Trend::Up, model_params.range); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, to_e18(100), to_e18(1000)); + + // Sell swap to cache price. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let params = SwapParams { + is_buy: false, + amount: to_e18(5), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + solver.swap(market_id, params); + + // Oracle price falls below last cached price. + start_prank(CheatTarget::One(solver.contract_address), owner()); + oracle.set_data_with_USD_hop('ETH', 'USDC', 950000000, 8, 999, 5); // 9.5 + + // Swap again to update cached price. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let params = SwapParams { + is_buy: true, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let swap_1 = solver.swap(market_id, params); + + // Oracle price recovers. + start_prank(CheatTarget::One(solver.contract_address), owner()); + oracle.set_data_with_USD_hop('ETH', 'USDC', 990000000, 8, 999, 5); // 9.9 + + // Swap again to update cached price. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let swap_2 = solver.swap(market_id, params); + + // Run checks. + let model_params = rev_solver.model_params(market_id); + assert(model_params.cached_price == 1000000000, 'Cached price'); + assert(swap_1.amount_in == to_e18(1), 'Amount in 1'); + assert( + swap_1.amount_out > to_e18(1) / 10 && swap_1.amount_out < to_e18(10) / 95, 'Amount out 1' + ); + assert(swap_1.fees == 5000000000000000, 'Fees 1'); + assert(swap_2.amount_in == to_e18(1), 'Amount in 2'); + assert( + swap_2.amount_out > to_e18(1) / 10 && swap_2.amount_out < to_e18(10) / 99, 'Amount out 2' + ); + assert(swap_2.fees == 5000000000000000, 'Fees 2'); +} + +#[test] +fn test_swap_price_falls_below_bid_position_in_uptrend() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Set trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let model_params = rev_solver.model_params(market_id); + rev_solver.set_model_params(market_id, Trend::Up, model_params.range); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let dep_init = solver.deposit_initial(market_id, to_e18(10), to_e18(100)); + + // Sell swap. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let mut params = SwapParams { + is_buy: false, + amount: to_e18(20), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let swap = solver.swap(market_id, params); + + // Get reserves. + let res = solver.get_balances(market_id); + + // Quote for buy and sell. + let solver_hooks = ISolverHooksDispatcher { contract_address: solver.contract_address }; + let quote_sell = solver_hooks.quote(market_id, params); + params.is_buy = true; + let quote_buy = solver_hooks.quote(market_id, params); + + // Run checks. + assert( + approx_eq(res.base_amount, dep_init.base_amount + swap.amount_in - swap.fees, 10), + 'Base amount' + ); + assert(approx_eq(res.quote_amount, 0, 10), 'Quote amount'); + assert(swap.amount_in > to_e18(10), 'Amount in'); + assert(approx_eq(swap.amount_out, to_e18(100), 10), 'Amount out'); + assert(swap.fees > 50000000000000000, 'Fees'); + assert(approx_eq(quote_sell.amount_in, 0, 10), 'Quote in sell'); + assert(approx_eq(quote_sell.amount_out, 0, 10), 'Quote out sell'); + assert(approx_eq(quote_sell.fees, 0, 10), 'Quote fees sell'); + assert(approx_eq(quote_buy.amount_in, 0, 10), 'Quote in buy'); + assert(approx_eq(quote_buy.amount_out, 0, 10), 'Quote out buy'); + assert(approx_eq(quote_buy.fees, 0, 10), 'Quote fees buy'); +} + +#[test] +fn test_swap_price_falls_below_last_cached_price_in_downtrend() { + let ( + _base_token, _quote_token, oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Set trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let mut model_params = rev_solver.model_params(market_id); + rev_solver.set_model_params(market_id, Trend::Down, model_params.range); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, to_e18(100), to_e18(1000)); + + // Sell buy to cache price. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let params = SwapParams { + is_buy: true, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + solver.swap(market_id, params); + + // Oracle price falls below last cached price. + start_prank(CheatTarget::One(solver.contract_address), owner()); + oracle.set_data_with_USD_hop('ETH', 'USDC', 950000000, 8, 999, 5); // 9.5 + + // Swap again to update cached price. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let swap = solver.swap(market_id, params); + + // Run checks. + model_params = rev_solver.model_params(market_id); + assert(model_params.cached_price == 950000000, 'Cached price'); + assert(swap.amount_in == to_e18(1), 'Amount in 1'); + assert( + swap.amount_out > to_e18(1) / 10 && swap.amount_out < to_e18(105) / 1000, 'Amount out 1' + ); + assert(swap.fees == 5000000000000000, 'Fees 1'); +} + +#[test] +fn test_swap_price_rises_above_last_cached_price_and_falls_again_in_downtrend() { + let ( + _base_token, _quote_token, oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Set trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let model_params = rev_solver.model_params(market_id); + rev_solver.set_model_params(market_id, Trend::Down, model_params.range); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, to_e18(100), to_e18(1000)); + + // Buy swap to cache price. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let params = SwapParams { + is_buy: true, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + solver.swap(market_id, params); + + // Oracle price rises above last cached price. + start_prank(CheatTarget::One(solver.contract_address), owner()); + oracle.set_data_with_USD_hop('ETH', 'USDC', 1050000000, 8, 999, 5); // 10.5 + + // Swap again to update cached price. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let params = SwapParams { + is_buy: false, + amount: to_e18(5), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let swap_1 = solver.swap(market_id, params); + + // Oracle price recovers. + start_prank(CheatTarget::One(solver.contract_address), owner()); + oracle.set_data_with_USD_hop('ETH', 'USDC', 1010000000, 8, 999, 5); // 10.1 + + // Swap again to update cached price. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let swap_2 = solver.swap(market_id, params); + + // Run checks. + let model_params = rev_solver.model_params(market_id); + assert(model_params.cached_price == 1000000000, 'Cached price'); + assert(swap_1.amount_in == to_e18(5), 'Amount in 1'); + assert( + swap_1.amount_out > to_e18(50) && swap_1.amount_out < to_e18(5 * 105) / 10, 'Amount out 1' + ); + assert(swap_1.fees == 25000000000000000, 'Fees 1'); + assert(swap_2.amount_in == to_e18(5), 'Amount in 2'); + assert( + swap_2.amount_out > to_e18(50) && swap_2.amount_out < to_e18(5 * 101) / 10, 'Amount out 2' + ); + assert(swap_2.fees == 25000000000000000, 'Fees 2'); +} + +#[test] +fn test_swap_price_rises_above_ask_position_in_downtrend() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Set trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let model_params = rev_solver.model_params(market_id); + rev_solver.set_model_params(market_id, Trend::Down, model_params.range); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let dep_init = solver.deposit_initial(market_id, to_e18(10), to_e18(100)); + + // Buy swap. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let mut params = SwapParams { + is_buy: true, + amount: to_e18(200), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let swap = solver.swap(market_id, params); + + // Get reserves. + let res = solver.get_balances(market_id); + + // Quote for buy and sell. + let solver_hooks = ISolverHooksDispatcher { contract_address: solver.contract_address }; + let quote_sell = solver_hooks.quote(market_id, params); + params.is_buy = false; + let quote_buy = solver_hooks.quote(market_id, params); + + // Run checks. + assert(approx_eq(res.base_amount, 0, 10), 'Base amount'); + assert( + approx_eq(res.quote_amount, dep_init.quote_amount + swap.amount_in - swap.fees, 10), + 'Quote amount' + ); + assert(swap.amount_in > to_e18(100), 'Amount in'); + assert(approx_eq(swap.amount_out, to_e18(10), 10), 'Amount out'); + assert(swap.fees > 500000000000000000, 'Fees'); + assert(approx_eq(quote_sell.amount_in, 0, 10), 'Quote in sell'); + assert(approx_eq(quote_sell.amount_out, 0, 10), 'Quote out sell'); + assert(approx_eq(quote_sell.fees, 0, 10), 'Quote fees sell'); + assert(approx_eq(quote_buy.amount_in, 0, 10), 'Quote in buy'); + assert(approx_eq(quote_buy.amount_out, 0, 10), 'Quote out buy'); + assert(approx_eq(quote_buy.fees, 0, 10), 'Quote fees buy'); +} + +#[test] +fn test_swap_buying_then_selling_in_ranging_market_quoted_at_oracle_price() { + let ( + _base_token, _quote_token, oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Disable swap fees. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let mut market_params = default_market_params(); + market_params.fee_rate = 0; + rev_solver.queue_market_params(market_id, market_params); + rev_solver.set_market_params(market_id); + + // Set range 1. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_model_params(market_id, Trend::Range, 1); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, to_e18(100), to_e18(1000)); + + // Oracle price falls. + start_prank(CheatTarget::One(solver.contract_address), owner()); + oracle.set_data_with_USD_hop('ETH', 'USDC', 950000000, 8, 999, 5); // 9.5 + + // Buy. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let buy_params = SwapParams { + is_buy: true, + amount: to_e18(10), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let swap_1 = solver.swap(market_id, buy_params); + + // Oracle price rises. + start_prank(CheatTarget::One(solver.contract_address), owner()); + oracle.set_data_with_USD_hop('ETH', 'USDC', 1050000000, 8, 999, 5); // 10.5 + + // Sell. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let sell_params = SwapParams { + is_buy: false, + amount: to_e18(2), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let swap_2 = solver.swap(market_id, sell_params); + + // Oracle price returns to original. + start_prank(CheatTarget::One(solver.contract_address), owner()); + oracle.set_data_with_USD_hop('ETH', 'USDC', 1000000000, 8, 999, 5); // 10 + + // Quote buy. + let solver_hooks = ISolverHooksDispatcher { contract_address: solver.contract_address }; + let quote_buy = solver_hooks.quote(market_id, buy_params); + let quote_sell = solver_hooks.quote(market_id, sell_params); + + // Run checks. + assert(approx_eq_pct(swap_1.amount_out, to_e18(10) * 10 / 95, 4), 'Amount out 1'); + assert(approx_eq_pct(swap_2.amount_out, to_e18(2) * 105 / 10, 4), 'Amount out 2'); + assert(approx_eq_pct(quote_buy.amount_out, to_e18(10) / 10, 4), 'Quote out buy'); + assert(approx_eq_pct(quote_sell.amount_out, to_e18(2) * 10, 4), 'Quote out sell'); +} + +#[test] +fn test_swap_trend_changes_from_uptrend_to_ranging() { + let ( + _base_token, _quote_token, oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Set trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_model_params(market_id, Trend::Up, 1); + + // Disable swap fees. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let mut market_params = default_market_params(); + market_params.fee_rate = 0; + rev_solver.queue_market_params(market_id, market_params); + rev_solver.set_market_params(market_id); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, to_e18(100), to_e18(1000)); + + // Oracle price falls. + start_prank(CheatTarget::One(solver.contract_address), owner()); + oracle.set_data_with_USD_hop('ETH', 'USDC', 950000000, 8, 999, 5); // 9.5 + + // Swap to cache price. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let sell_params = SwapParams { + is_buy: false, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + solver.swap(market_id, sell_params); + + // Reset oracle price. + start_prank(CheatTarget::One(solver.contract_address), owner()); + oracle.set_data_with_USD_hop('ETH', 'USDC', 1000000000, 8, 999, 5); // 10.5 + + // Snapshot cached price before. + let cached_price_bef = rev_solver.model_params(market_id).cached_price; + + // Update trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + rev_solver.set_model_params(market_id, Trend::Range, 1); + + // Snapshot cached price after. + let cached_price_aft = rev_solver.model_params(market_id).cached_price; + + // Quote sell. + let solver_hooks = ISolverHooksDispatcher { contract_address: solver.contract_address }; + let quote_sell = solver_hooks.quote(market_id, sell_params); + + // Quote buy. + let buy_params = SwapParams { + is_buy: true, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let quote_buy = solver_hooks.quote(market_id, buy_params); + + // Run checks. + assert(cached_price_bef == 950000000, 'Cached price before'); + assert(cached_price_aft == 1000000000, 'Cached price after'); + assert(approx_eq_pct(quote_buy.amount_out, to_e18(1) / 10, 4), 'Quote out buy'); + assert(approx_eq_pct(quote_sell.amount_out, to_e18(1) * 10, 4), 'Quote out sell'); +} + +#[test] +fn test_swap_trend_changes_from_downtrend_to_ranging() { + let ( + _base_token, _quote_token, oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Set trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_model_params(market_id, Trend::Down, 1); + + // Disable swap fees. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let mut market_params = default_market_params(); + market_params.fee_rate = 0; + rev_solver.queue_market_params(market_id, market_params); + rev_solver.set_market_params(market_id); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, to_e18(100), to_e18(1000)); + + // Oracle price rises. + start_prank(CheatTarget::One(solver.contract_address), owner()); + oracle.set_data_with_USD_hop('ETH', 'USDC', 1050000000, 8, 999, 5); // 10.5 + + // Swap to cache price. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let buy_params = SwapParams { + is_buy: true, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + solver.swap(market_id, buy_params); + + // Reset oracle price. + start_prank(CheatTarget::One(solver.contract_address), owner()); + oracle.set_data_with_USD_hop('ETH', 'USDC', 1000000000, 8, 999, 5); // 10 + + // Snapshot cached price before. + let cached_price_bef = rev_solver.model_params(market_id).cached_price; + + // Update trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + rev_solver.set_model_params(market_id, Trend::Range, 1); + + // Snapshot cached price after. + let cached_price_aft = rev_solver.model_params(market_id).cached_price; + + // Quote buy. + let solver_hooks = ISolverHooksDispatcher { contract_address: solver.contract_address }; + let quote_buy = solver_hooks.quote(market_id, buy_params); + + // Quote sell. + let sell_params = SwapParams { + is_buy: false, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let quote_sell = solver_hooks.quote(market_id, sell_params); + + // Run checks. + assert(cached_price_bef == 1050000000, 'Cached price before'); + assert(cached_price_aft == 1000000000, 'Cached price after'); + assert(approx_eq_pct(quote_buy.amount_out, to_e18(1) / 10, 4), 'Quote out buy'); + assert(approx_eq_pct(quote_sell.amount_out, to_e18(1) * 10, 4), 'Quote out sell'); +} + +#[test] +fn test_swap_trend_changes_from_uptrend_to_downtrend() { + let ( + _base_token, _quote_token, oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Set trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_model_params(market_id, Trend::Up, 1); + + // Disable swap fees. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let mut market_params = default_market_params(); + market_params.fee_rate = 0; + rev_solver.queue_market_params(market_id, market_params); + rev_solver.set_market_params(market_id); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, to_e18(100), to_e18(1000)); + + // Swap to cache price. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let sell_params = SwapParams { + is_buy: false, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + solver.swap(market_id, sell_params); + + // Lower oracle price. + start_prank(CheatTarget::One(solver.contract_address), owner()); + oracle.set_data_with_USD_hop('ETH', 'USDC', 950000000, 8, 999, 5); // 10 + + // Update trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + rev_solver.set_model_params(market_id, Trend::Down, 1); + + // Snapshot cached price after. + let cached_price = rev_solver.model_params(market_id).cached_price; + // Quote sell. + let solver_hooks = ISolverHooksDispatcher { contract_address: solver.contract_address }; + let quote_sell = solver_hooks.quote(market_id, sell_params); + + // Quote buy. + let buy_params = SwapParams { + is_buy: true, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let quote_buy = solver_hooks.quote(market_id, buy_params); + + // Run checks. + let model_params = rev_solver.model_params(market_id); + assert(model_params.trend == Trend::Down, 'Trend'); + assert(cached_price == 950000000, 'Cached price'); + assert(approx_eq_pct(quote_buy.amount_out, to_e18(1) * 10 / 95, 4), 'Quote out buy'); + assert(quote_sell.amount_out == 0, 'Quote out sell'); +} + +#[test] +fn test_swap_trend_changes_from_downtrend_to_uptrend() { + let ( + _base_token, _quote_token, oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Set trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.set_model_params(market_id, Trend::Down, 1); + + // Disable swap fees. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let mut market_params = default_market_params(); + market_params.fee_rate = 0; + rev_solver.queue_market_params(market_id, market_params); + rev_solver.set_market_params(market_id); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, to_e18(100), to_e18(1000)); + + // Swap to cache price. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let buy_params = SwapParams { + is_buy: true, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + solver.swap(market_id, buy_params); + + // Raise oracle price. + start_prank(CheatTarget::One(solver.contract_address), owner()); + oracle.set_data_with_USD_hop('ETH', 'USDC', 1050000000, 8, 999, 5); // 10 + + // Update trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + rev_solver.set_model_params(market_id, Trend::Up, 1); + + // Snapshot cached price after. + let cached_price = rev_solver.model_params(market_id).cached_price; + + // Quote buy. + let solver_hooks = ISolverHooksDispatcher { contract_address: solver.contract_address }; + let quote_buy = solver_hooks.quote(market_id, buy_params); + + // Quote sell. + let sell_params = SwapParams { + is_buy: false, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let quote_sell = solver_hooks.quote(market_id, sell_params); + + // Run checks. + let model_params = rev_solver.model_params(market_id); + assert(model_params.trend == Trend::Up, 'Trend'); + assert(cached_price == 1050000000, 'Cached price'); + assert(approx_eq_pct(quote_sell.amount_out, to_e18(1) * 105 / 10, 4), 'Quote out sell'); + assert(quote_buy.amount_out == 0, 'Quote out buy'); +} + +#[test] +fn test_swap_trend_changes_from_ranging_to_uptrend() { + let ( + _base_token, _quote_token, oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Disable swap fees. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let mut market_params = default_market_params(); + market_params.fee_rate = 0; + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.queue_market_params(market_id, market_params); + rev_solver.set_market_params(market_id); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, to_e18(100), to_e18(1000)); + + // Swap to cache price. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let buy_params = SwapParams { + is_buy: true, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + solver.swap(market_id, buy_params); + + // Raise oracle price. + start_prank(CheatTarget::One(solver.contract_address), owner()); + oracle.set_data_with_USD_hop('ETH', 'USDC', 1050000000, 8, 999, 5); // 10.5 + + // Update trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + rev_solver.set_model_params(market_id, Trend::Up, 1); + + // Snapshot cached price after. + let cached_price = rev_solver.model_params(market_id).cached_price; + + // Quote buy. + let solver_hooks = ISolverHooksDispatcher { contract_address: solver.contract_address }; + let quote_buy = solver_hooks.quote(market_id, buy_params); + + // Quote sell. + let sell_params = SwapParams { + is_buy: false, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let quote_sell = solver_hooks.quote(market_id, sell_params); + + // Run checks. + let model_params = rev_solver.model_params(market_id); + assert(model_params.trend == Trend::Up, 'Trend'); + assert(cached_price == 1050000000, 'Cached price'); + assert(approx_eq_pct(quote_sell.amount_out, to_e18(1) * 105 / 10, 4), 'Quote out sell'); + assert(quote_buy.amount_out == 0, 'Quote out buy'); +} + +#[test] +fn test_swap_trend_changes_from_ranging_to_downtrend() { + let ( + _base_token, _quote_token, oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Disable swap fees. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let mut market_params = default_market_params(); + market_params.fee_rate = 0; + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + rev_solver.queue_market_params(market_id, market_params); + rev_solver.set_market_params(market_id); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, to_e18(100), to_e18(1000)); + + // Swap to cache price. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let buy_params = SwapParams { + is_buy: true, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + solver.swap(market_id, buy_params); + + // Lower oracle price. + start_prank(CheatTarget::One(solver.contract_address), owner()); + oracle.set_data_with_USD_hop('ETH', 'USDC', 950000000, 8, 999, 5); // 9.5 + + // Update trend. + start_prank(CheatTarget::One(solver.contract_address), owner()); + rev_solver.set_model_params(market_id, Trend::Down, 1); + + // Snapshot cached price after. + let cached_price = rev_solver.model_params(market_id).cached_price; + + // Quote buy. + let solver_hooks = ISolverHooksDispatcher { contract_address: solver.contract_address }; + let quote_buy = solver_hooks.quote(market_id, buy_params); + + // Quote sell. + let sell_params = SwapParams { + is_buy: false, + amount: to_e18(1), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let quote_sell = solver_hooks.quote(market_id, sell_params); + + // Run checks. + let model_params = rev_solver.model_params(market_id); + assert(model_params.trend == Trend::Down, 'Trend'); + assert(cached_price == 950000000, 'Cached price'); + assert(approx_eq_pct(quote_buy.amount_out, to_e18(1) * 10 / 95, 4), 'Quote out sell'); + assert(quote_sell.amount_out == 0, 'Quote out buy'); +} + +//////////////////////////////// +// TESTS - Events +//////////////////////////////// + +#[test] +fn test_swap_emits_event() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, to_e18(100), to_e18(1000)); + + // Spy on events. + let mut spy = spy_events(SpyOn::One(solver.contract_address)); + + // Swap. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let params = SwapParams { + is_buy: true, + amount: to_e18(10), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + let swap = solver.swap(market_id, params); + + // Check events. + spy + .assert_emitted( + @array![ + ( + solver.contract_address, + SolverComponent::Event::Swap( + SolverComponent::Swap { + market_id, + caller: alice(), + is_buy: params.is_buy, + exact_input: params.exact_input, + amount_in: swap.amount_in, + amount_out: swap.amount_out, + fees: swap.fees, + } + ) + ) + ] + ); +} + +//////////////////////////////// +// TESTS - Fail cases +//////////////////////////////// + +#[test] +#[should_panic(expected: ('InvalidOraclePrice',))] +fn test_swap_fails_if_invalid_oracle_price() { + let ( + _base_token, _quote_token, oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Set oracle price. + start_warp(CheatTarget::One(solver.contract_address), 1000); + IMockPragmaOracleDispatcher { contract_address: oracle.contract_address } + .set_data_with_USD_hop('ETH', 'USDC', 1000000000, 8, 999, 1); // 10 + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, to_e18(100), to_e18(1000)); + + // Swap. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let params = SwapParams { + is_buy: true, + amount: to_e18(10), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + solver.swap(market_id, params); +} + +#[test] +#[should_panic(expected: ('ThresholdAmount', 995001635073273840, 0))] +fn test_swap_fails_if_swap_buy_below_threshold_amount() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, to_e18(1000), 0); + + // Swap. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let params = SwapParams { + is_buy: true, + amount: to_e18(10), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::Some(to_e18(2)), + deadline: Option::None(()), + }; + solver.swap(market_id, params); +} + +#[test] +#[should_panic(expected: ('ThresholdAmount', 99449990405342377223, 0))] +fn test_swap_fails_if_swap_sell_below_threshold_amount() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Deposit initial. + solver.deposit_initial(market_id, to_e18(1000), to_e18(1000)); + + // Swap. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let params = SwapParams { + is_buy: false, + amount: to_e18(10), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::Some(to_e18(100)), + deadline: Option::None(()), + }; + solver.swap(market_id, params); +} + +#[test] +#[should_panic(expected: ('LimitOF',))] +fn test_swap_fails_if_limit_overflows() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Set params. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let model_params = rev_solver.model_params(market_id); + rev_solver.set_model_params(market_id, model_params.trend, 8000000); + + // Deposit initial. + solver.deposit_initial(market_id, to_e18(1000), to_e18(1000)); + + // Swap. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let params = SwapParams { + is_buy: true, + amount: to_e18(10), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + solver.swap(market_id, params); +} + +#[test] +#[should_panic(expected: ('OracleLimitUF',))] +fn test_swap_fails_if_limit_underflows() { + let ( + _base_token, _quote_token, oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Set oracle. + start_warp(CheatTarget::One(oracle.contract_address), 1000); + oracle.set_data_with_USD_hop('ETH', 'USDC', 1, 8, 999, 5); + + // Set params. + start_prank(CheatTarget::One(solver.contract_address), owner()); + let rev_solver = IReversionSolverDispatcher { contract_address: solver.contract_address }; + let model_params = rev_solver.model_params(market_id); + rev_solver.set_model_params(market_id, model_params.trend, 7000000); + + // Deposit initial. + solver.deposit_initial(market_id, to_e18(1000), to_e18(1000)); + + // Swap. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let params = SwapParams { + is_buy: false, + amount: to_e18(10), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + solver.swap(market_id, params); +} + +#[test] +#[should_panic(expected: ('NotSolver',))] +fn test_after_swap_fails_for_non_solver_caller() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Call after swap. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let solver_hooks = ISolverHooksDispatcher { contract_address: solver.contract_address }; + let params = SwapParams { + is_buy: true, + amount: to_e18(10), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + solver_hooks.after_swap(market_id, params); +} + +#[test] +#[should_panic(expected: ('AmountZero',))] +fn test_swap_sell_exhausts_bid_liquidity_and_prevents_further_sell_swaps() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, to_e18(10), to_e18(100)); + + // Swap sell to exhaust bid liquidity. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let swap_params = SwapParams { + is_buy: false, + amount: to_e18(20), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + solver.swap(market_id, swap_params); + + // Try to swap again. + solver.swap(market_id, swap_params); +} + +#[test] +#[should_panic(expected: ('AmountZero',))] +fn test_swap_buy_exhausts_ask_liquidity_and_prevents_further_buy_swaps() { + let ( + _base_token, _quote_token, _oracle, _vault_token_class, solver, market_id, _vault_token_opt + ) = + before( + false + ); + + // Deposit initial. + start_prank(CheatTarget::One(solver.contract_address), owner()); + solver.deposit_initial(market_id, to_e18(10), to_e18(100)); + + // Swap buy to exhaust ask liquidity. + start_prank(CheatTarget::One(solver.contract_address), alice()); + let swap_params = SwapParams { + is_buy: true, + amount: to_e18(200), + exact_input: true, + threshold_sqrt_price: Option::None(()), + threshold_amount: Option::None(()), + deadline: Option::None(()), + }; + solver.swap(market_id, swap_params); + + // Try to swap again. + solver.swap(market_id, swap_params); +} diff --git a/packages/reversion/src/types.cairo b/packages/reversion/src/types.cairo index 1c6c574..66b63d3 100644 --- a/packages/reversion/src/types.cairo +++ b/packages/reversion/src/types.cairo @@ -1,5 +1,6 @@ // Core lib imports. use starknet::ContractAddress; +use core::fmt::{Display, Formatter, Error}; //////////////////////////////// // TYPES @@ -18,18 +19,28 @@ pub enum Trend { Down, } +pub impl TrendDisplay of Display { + fn fmt(self: @Trend, ref f: Formatter) -> Result<(), Error> { + let str: ByteArray = match self { + Trend::Range => "Range", + Trend::Up => "Up", + Trend::Down => "Down", + }; + f.buffer.append(@str); + Result::Ok(()) + } +} + // Solver market parameters. // // * `fee_rate` - swap fee rate applied to swap amounts -// * `range` - default range of spread applied on an imbalanced portfolio // * `base_currency_id` - Pragma oracle base currency id // * `quote_currency_id` - Pragma oracle quote currency id // * `min_sources` - minimum number of oracle data sources aggregated // * `max_age` - maximum age of quoted oracle price -#[derive(Drop, Copy, Serde, PartialEq)] +#[derive(Drop, Copy, Serde, PartialEq, Default)] pub struct MarketParams { pub fee_rate: u16, - pub range: u32, // Oracle params pub base_currency_id: felt252, pub quote_currency_id: felt252, @@ -39,17 +50,19 @@ pub struct MarketParams { // Trend state. // -// * `trend` - trend classification // * `cached_price` - last cached oracle price, used in combination with trend to decide whether to quote for bid, ask or both // 1. if price trends up and price > cached price, quote for bids only (and update cached price) // 2. if price trends down and price < cached price, quote for asks only (and update cached price) // 3. otherwise, quote for both // * `cached_decimals` - decimals of cached oracle price +// * `range` - range of virtual liquidity position +// * `trend` - trend classification #[derive(Drop, Copy, Serde, PartialEq)] -pub struct TrendState { - pub trend: Trend, +pub struct ModelParams { pub cached_price: u128, pub cached_decimals: u32, + pub range: u32, + pub trend: Trend, } //////////////////////////////// @@ -70,9 +83,9 @@ pub struct PackedMarketParams { // Packed trend state. // -// * `slab0` - `cached_price` (128) + `cached_decimals` (32) + `trend` (2) +// * `slab0` - `cached_price` (128) + `cached_decimals` (32) + range (32) + `trend` (2) // where `trend` is encoded as: `0` (Range), `1` (Up), `2` (Down) #[derive(starknet::Store)] -pub struct PackedTrendState { +pub struct PackedModelParams { pub slab0: felt252, }