diff --git a/contracts/collections/tickmap.ral b/contracts/collections/tickmap.ral index 8ae29a01..cb883cfc 100644 --- a/contracts/collections/tickmap.ral +++ b/contracts/collections/tickmap.ral @@ -278,4 +278,18 @@ Abstract Contract Tickmap() extends Decimal(), BatchHelper() { let maxChunkIndex = toU256!(maxBitmapIndex) / ChunkSize return maxChunkIndex } + + pub fn countActiveBitsInChunk(chunk: U256, minBit: U256, maxBit: U256) -> U256 { + let range = (chunk >> minBit) & ((1 << (maxBit - minBit + 1)) - 1) + return countOnes(range) + } + + fn countOnes(mut v: U256) -> U256 { + let mut num = 0 + while(v > 0) { + num = num + (v & 1) + v = v >> 1 + } + return num + } } \ No newline at end of file diff --git a/contracts/invariant.ral b/contracts/invariant.ral index 209d916f..0e9ff1eb 100644 --- a/contracts/invariant.ral +++ b/contracts/invariant.ral @@ -25,6 +25,12 @@ struct FeeTiers { mut feeTiers: [FeeTier; 32] } +struct LiquidityTick { + mut index: I256, + mut liquidityChange: U256, + mut sign: Bool +} + Contract Invariant( mut config: InvariantConfig, clamm: CLAMM, @@ -592,4 +598,66 @@ Contract Invariant( pub fn getUserPositionCount(owner: Address) -> U256 { return positionCount(owner) } + + pub fn getLiquidityTicks(poolKey: PoolKey, indexes: ByteVec, length: U256) -> ByteVec { + let mut liquidityTicks = # + + let keyBytes = poolKeyBytes(poolKey) + + for (let mut i = 0; i < length; i = i + 1) { + let index = toI256!(u256From4Byte!(byteVecSlice!(indexes, i * 4, (i + 1) * 4))) - GlobalMaxTick + + let key = keyBytes ++ toByteVec!(index) + let tick = ticks[key] + liquidityTicks = liquidityTicks ++ toByteVec!(tick.index) ++ b`break` ++ toByteVec!(tick.liquidityChange) ++ b`break` ++ toByteVec!(tick.sign) ++ b`break` + } + + return liquidityTicks + } + + pub fn getLiquidityTicksAmount(poolKey: PoolKey, lowerTick: I256, upperTick: I256) -> U256 { + let tickSpacing = poolKey.feeTier.tickSpacing + clamm.checkTicks(lowerTick, upperTick, tickSpacing) + + let (minChunkIndex, minBit) = tickToPosition(lowerTick, tickSpacing) + let (maxChunkIndex, maxBit) = tickToPosition(upperTick, tickSpacing) + let minBatch = minChunkIndex / ChunksPerBatch + let maxBatch = maxChunkIndex / ChunksPerBatch + + let mut amount = 0 + + if(minBatch == maxBatch) { + let key = poolKeyBytes(poolKey) ++ toByteVec!(minBatch) + + if(!bitmap.contains!(key)) { + return 0 + } + + let batch = bitmap[key].chunks + let minIndexInBatch = minChunkIndex % ChunksPerBatch + let maxIndexInBatch = maxChunkIndex % ChunksPerBatch + + if(minIndexInBatch == maxIndexInBatch) { + amount = countActiveBitsInChunk(batch[minIndexInBatch], minBit, maxBit) + return amount + } + + amount = countActiveBitsInChunk(batch[minIndexInBatch], minBit, ChunkSize - 1) + for(let mut i = minIndexInBatch + 1; i < maxIndexInBatch; i = i + 1) { + amount = amount + countOnes(batch[i]) + } + + amount = amount + countActiveBitsInChunk(batch[maxIndexInBatch], 0, maxBit) + return amount + } + + amount = countActiveBitsInChunk(getChunk(minChunkIndex, poolKey), minBit, ChunkSize - 1) + for(i = minChunkIndex + 1; i < maxChunkIndex; i = i + 1) { + amount = amount + countOnes(getChunk(i, poolKey)) + } + + amount = amount + countActiveBitsInChunk(getChunk(maxChunkIndex, poolKey), 0, maxBit) + + return amount + } } diff --git a/contracts/math/utils.ral b/contracts/math/utils.ral index c093d0a2..dc4fa77b 100644 --- a/contracts/math/utils.ral +++ b/contracts/math/utils.ral @@ -206,4 +206,13 @@ Contract Utils() extends Uints(), Decimal(), Log(), PoolKeyHelper(), FeeTierHelp pub fn toFee(feeGrowth: U256, liquidity: U256) -> U256 { return toU256(bigMulDiv256(feeGrowth, liquidity, one(FeeGrowthScale + LiquidityScale))) } + + pub fn bitPositionToTick( + chunk: U256, + bit: U256, + tickSpacing: U256 + ) -> I256 { + let tickRangeLimit = GlobalMaxTick - (GlobalMaxTick % toI256!(tickSpacing)) + return toI256!(chunk * ChunkSize * tickSpacing + (bit * tickSpacing)) - tickRangeLimit + } } \ No newline at end of file diff --git a/src/consts.ts b/src/consts.ts index 9695458d..10ce8fa5 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -26,6 +26,7 @@ export const { ReserveError } = Reserve.consts export const MAX_BATCHES_QUERIED = 18n export const MAX_POOL_KEYS_QUERIED = 117n export const MAX_POSITIONS_QUERIED = 83n +export const MAX_LIQUIDITY_TICKS_QUERIED = 269n export enum VMError { ArithmeticError = 'ArithmeticError', diff --git a/src/invariant.ts b/src/invariant.ts index df140aa1..dfc60295 100644 --- a/src/invariant.ts +++ b/src/invariant.ts @@ -13,8 +13,16 @@ import { TransferPosition, WithdrawProtocolFee } from '../artifacts/ts' -import { FeeTier, Pool, PoolKey, Position, QuoteResult, Tick } from '../artifacts/ts/types' -import { calculateSqrtPriceAfterSlippage, calculateTick } from './math' +import { + FeeTier, + LiquidityTick, + Pool, + PoolKey, + Position, + QuoteResult, + Tick +} from '../artifacts/ts/types' +import { calculateSqrtPriceAfterSlippage, calculateTick, bitPositionToTick } from './math' import { Network } from './network' import { getReserveAddress } from './testUtils' import { @@ -33,9 +41,18 @@ import { getNodeUrl, signAndSend, decodePositions, - Page + Page, + toByteVecWithOffset, + decodeLiquidityTicks, + Tickmap } from './utils' -import { MAX_BATCHES_QUERIED, MAX_POOL_KEYS_QUERIED, MAX_POSITIONS_QUERIED } from './consts' +import { + ChunkSize, + MAX_BATCHES_QUERIED, + MAX_LIQUIDITY_TICKS_QUERIED, + MAX_POOL_KEYS_QUERIED, + MAX_POSITIONS_QUERIED +} from './consts' import { Address, ALPH_TOKEN_ID, @@ -584,7 +601,7 @@ export class Invariant { return constructTickmap(response.returns) } - async getFullTickmap(poolKey: PoolKey) { + async getFullTickmap(poolKey: PoolKey): Promise { const promises: Promise<[bigint, bigint][]>[] = [] const maxBatch = await getMaxBatch(poolKey.feeTier.tickSpacing) let currentBatch = 0n @@ -599,10 +616,41 @@ export class Invariant { const storedTickmap = new Map(fullResult) return { bitmap: storedTickmap } } - // async getLiquidityTicks() {} - // async getAllLiquidityTicks() {} + async getLiquidityTicks(poolKey: PoolKey, ticks: bigint[]) { + const indexes = toByteVecWithOffset(ticks) + const response = await this.instance.view.getLiquidityTicks({ + args: { poolKey, indexes, length: BigInt(ticks.length) } + }) + + return decodeLiquidityTicks(response.returns) + } + async getAllLiquidityTicks(poolKey: PoolKey, tickmap: Tickmap) { + const tickIndexes: bigint[] = [] + for (const [chunkIndex, chunk] of tickmap.bitmap.entries()) { + for (let bit = 0n; bit < ChunkSize; bit++) { + const checkedBit = chunk & (1n << bit) + if (checkedBit) { + const tickIndex = await bitPositionToTick(chunkIndex, bit, poolKey.feeTier.tickSpacing) + tickIndexes.push(tickIndex) + } + } + } + const limit = Number(MAX_LIQUIDITY_TICKS_QUERIED) + const promises: Promise[] = [] + for (let i = 0; i < tickIndexes.length; i += limit) { + promises.push(this.getLiquidityTicks(poolKey, tickIndexes.slice(i, i + limit))) + } + + const liquidityTicks = await Promise.all(promises) + return liquidityTicks.flat() + } // async getUserPositionAmount() {} - // async getLiquidityTicksAmount() {} + async getLiquidityTicksAmount(poolKey: PoolKey, lowerTick: bigint, upperTick: bigint) { + const response = await this.instance.view.getLiquidityTicksAmount({ + args: { poolKey, lowerTick, upperTick } + }) + return response.returns + } async getAllPoolsForPair(token0Id: string, token1Id: string) { return decodePools( ( diff --git a/src/math.ts b/src/math.ts index f216dbc7..27bcf140 100644 --- a/src/math.ts +++ b/src/math.ts @@ -206,6 +206,21 @@ export const calculateTokenAmounts = async ( ).returns } +export const bitPositionToTick = async ( + chunk: bigint, + bit: bigint, + tickSpacing: bigint +): Promise => { + return ( + await Utils.tests.bitPositionToTick({ + testArgs: { + chunk, + bit, + tickSpacing + } + }) + ).returns +} const sqrt = (value: bigint): bigint => { if (value < 0n) { throw 'square root of negative numbers is not supported' diff --git a/src/utils.ts b/src/utils.ts index ccb0bb7d..d0cc4a48 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,6 @@ import { NodeProvider, codec, - ONE_ALPH, SignerProvider, ZERO_ADDRESS, node, @@ -9,16 +8,30 @@ import { SignExecuteScriptTxResult, bs58, hexToBinUnsafe, - ALPH_TOKEN_ID + ALPH_TOKEN_ID, + decodeBool } from '@alephium/web3' import { CLAMM, Invariant, InvariantInstance, Reserve, Utils } from '../artifacts/ts' import { TokenFaucet } from '../artifacts/ts/TokenFaucet' -import { FeeTier, FeeTiers, Pool, PoolKey, Position, Tick } from '../artifacts/ts/types' -import { ChunkSize, ChunksPerBatch, MaxFeeTiers } from './consts' +import { + FeeTier, + FeeTiers, + LiquidityTick, + Pool, + PoolKey, + Position, + Tick +} from '../artifacts/ts/types' +import { ChunkSize, ChunksPerBatch, GlobalMaxTick, MaxFeeTiers } from './consts' import { getMaxTick, getMinTick } from './math' import { Network } from './network' const BREAK_BYTES = '627265616b' + +export interface Tickmap { + bitmap: Map +} + export const EMPTY_FEE_TIERS: FeeTiers = { feeTiers: new Array(Number(MaxFeeTiers)).fill({ fee: 0n, @@ -357,3 +370,27 @@ export const signAndSend = async ( }) return txId } + +export const toByteVecWithOffset = ( + values: bigint[], + offset: bigint = GlobalMaxTick, + radix: number = 16, + length: number = 8, + filler: string = '0' +): string => { + return values.map(value => (value + offset).toString(radix).padStart(length, filler)).join('') +} + +export const decodeLiquidityTicks = (string: string): LiquidityTick[] => { + const parts = string.split(BREAK_BYTES) + const ticks: LiquidityTick[] = [] + for (let i = 0; i < parts.length - 1; i += 3) { + const tick: LiquidityTick = { + index: decodeI256(parts[i]), + liquidityChange: decodeU256(parts[i + 1]), + sign: decodeBool(hexToBytes(parts[i + 2])) + } + ticks.push(tick) + } + return ticks +} diff --git a/test/sdk/e2e/get-liquidity-ticks.test.ts b/test/sdk/e2e/get-liquidity-ticks.test.ts new file mode 100644 index 00000000..a5273ea7 --- /dev/null +++ b/test/sdk/e2e/get-liquidity-ticks.test.ts @@ -0,0 +1,300 @@ +import { ONE_ALPH, web3 } from '@alephium/web3' +import { getSigner } from '@alephium/web3-test' +import { Invariant } from '../../../src/invariant' +import { Network } from '../../../src/network' +import { PrivateKeyWallet } from '@alephium/web3-wallet' +import { getBasicFeeTickSpacing } from '../../../src/snippets' +import { TokenFaucetInstance } from '../../../artifacts/ts' +import { expectVMError, initTokensXY, withdrawTokens } from '../../../src/testUtils' +import { FeeTier, PoolKey } from '../../../artifacts/ts/types' +import { balanceOf, newFeeTier, newPoolKey } from '../../../src/utils' +import { MAX_LIQUIDITY_TICKS_QUERIED, VMError } from '../../../src/consts' + +web3.setCurrentNodeProvider('http://127.0.0.1:22973') + +let invariant: Invariant +let deployer: PrivateKeyWallet +let positionOwner: PrivateKeyWallet +let tokenX: TokenFaucetInstance +let tokenY: TokenFaucetInstance +let feeTier: FeeTier +let poolKey: PoolKey + +describe('query liquidity ticks tests', () => { + const initialFee = 0n + const [fee] = getBasicFeeTickSpacing() + const tickSpacing = 1n + const initSqrtPrice = 10n ** 24n + const supply = 10n ** 10n + + beforeEach(async () => { + deployer = await getSigner(ONE_ALPH * 1000n, 0) + positionOwner = await getSigner(ONE_ALPH * 1000n, 0) + invariant = await Invariant.deploy(deployer, Network.Local, initialFee) + ;[tokenX, tokenY] = await initTokensXY(deployer, supply) + + feeTier = await newFeeTier(fee, tickSpacing) + poolKey = await newPoolKey(tokenX.contractId, tokenY.contractId, feeTier) + + await invariant.addFeeTier(deployer, feeTier) + await invariant.createPool( + deployer, + tokenX.contractId, + tokenY.contractId, + feeTier, + initSqrtPrice + ) + await withdrawTokens(positionOwner, [tokenX, supply], [tokenY, supply]) + }) + test('get liquidity ticks', async () => { + const { sqrtPrice } = await invariant.getPool(poolKey) + const [lowerTickIndex, upperTickIndex] = [-20n, -10n] + const approveX = await balanceOf(tokenX.contractId, positionOwner.address) + const approveY = await balanceOf(tokenY.contractId, positionOwner.address) + await invariant.createPosition( + positionOwner, + poolKey, + lowerTickIndex, + upperTickIndex, + 10n, + approveX, + approveY, + sqrtPrice, + sqrtPrice + ) + const ticksAmount = await invariant.getLiquidityTicksAmount( + poolKey, + lowerTickIndex, + upperTickIndex + ) + + expect(ticksAmount).toBe(2n) + const liquidityTicks = await invariant.getLiquidityTicks(poolKey, [ + lowerTickIndex, + upperTickIndex + ]) + const lowerTick = await invariant.getTick(poolKey, lowerTickIndex) + const upperTick = await invariant.getTick(poolKey, upperTickIndex) + + expect(liquidityTicks.length).toBe(2) + expect(lowerTick).toMatchObject(liquidityTicks[0]) + expect(upperTick).toMatchObject(liquidityTicks[1]) + }) + + test('different tick spacing', async () => { + const feeTier2TS = await newFeeTier(fee, 2n) + const feeTier10TS = await newFeeTier(fee, 10n) + + const poolKey2TS = await newPoolKey(tokenX.contractId, tokenY.contractId, feeTier2TS) + const poolKey10TS = await newPoolKey(tokenX.contractId, tokenY.contractId, feeTier10TS) + + await invariant.addFeeTier(deployer, feeTier2TS) + await invariant.addFeeTier(deployer, feeTier10TS) + + await invariant.createPool( + deployer, + tokenX.contractId, + tokenY.contractId, + feeTier2TS, + initSqrtPrice + ) + await invariant.createPool( + deployer, + tokenX.contractId, + tokenY.contractId, + feeTier10TS, + initSqrtPrice + ) + + const { sqrtPrice } = await invariant.getPool(poolKey) + + { + const [lowerTickIndex, upperTickIndex] = [-10n, 30n] + const approveX = await balanceOf(tokenX.contractId, positionOwner.address) + const approveY = await balanceOf(tokenY.contractId, positionOwner.address) + await invariant.createPosition( + positionOwner, + poolKey2TS, + lowerTickIndex, + upperTickIndex, + 10n, + approveX, + approveY, + sqrtPrice, + sqrtPrice + ) + const ticksAmount = await invariant.getLiquidityTicksAmount( + poolKey2TS, + lowerTickIndex, + upperTickIndex + ) + + expect(ticksAmount).toBe(2n) + const liquidityTicks = await invariant.getLiquidityTicks(poolKey2TS, [ + lowerTickIndex, + upperTickIndex + ]) + const lowerTick = await invariant.getTick(poolKey2TS, lowerTickIndex) + const upperTick = await invariant.getTick(poolKey2TS, upperTickIndex) + + expect(liquidityTicks.length).toBe(2) + expect(lowerTick).toMatchObject(liquidityTicks[0]) + expect(upperTick).toMatchObject(liquidityTicks[1]) + } + { + const [lowerTickIndex, upperTickIndex] = [-20n, 40n] + const approveX = await balanceOf(tokenX.contractId, positionOwner.address) + const approveY = await balanceOf(tokenY.contractId, positionOwner.address) + await invariant.createPosition( + positionOwner, + poolKey10TS, + lowerTickIndex, + upperTickIndex, + 10n, + approveX, + approveY, + sqrtPrice, + sqrtPrice + ) + const ticksAmount = await invariant.getLiquidityTicksAmount( + poolKey10TS, + lowerTickIndex, + upperTickIndex + ) + + expect(ticksAmount).toBe(2n) + const liquidityTicks = await invariant.getLiquidityTicks(poolKey10TS, [ + lowerTickIndex, + upperTickIndex + ]) + const lowerTick = await invariant.getTick(poolKey10TS, lowerTickIndex) + const upperTick = await invariant.getTick(poolKey10TS, upperTickIndex) + + expect(liquidityTicks.length).toBe(2) + expect(lowerTick).toMatchObject(liquidityTicks[0]) + expect(upperTick).toMatchObject(liquidityTicks[1]) + } + }) + test('get limit with spread between ticks', async () => { + const { sqrtPrice } = await invariant.getPool(poolKey) + + const pairs = Number(MAX_LIQUIDITY_TICKS_QUERIED / 2n) + const ticks = new Array(pairs) + .fill(null) + .map((_, index) => [BigInt((-index - 1) * 64), BigInt((index + 1) * 64)]) + + for (const [lowerTick, upperTick] of ticks) { + const approveX = await balanceOf(tokenX.contractId, positionOwner.address) + const approveY = await balanceOf(tokenY.contractId, positionOwner.address) + await invariant.createPosition( + positionOwner, + poolKey, + lowerTick, + upperTick, + 10n, + approveX, + approveY, + sqrtPrice, + sqrtPrice + ) + } + + const [minInitializedTick, maxInitializedTick] = ticks[ticks.length - 1] + const ticksAmount = await invariant.getLiquidityTicksAmount( + poolKey, + minInitializedTick, + maxInitializedTick + ) + expect(ticksAmount).toBe(MAX_LIQUIDITY_TICKS_QUERIED - 1n) + + const flattenTicks = ticks.flat() + const liquidityTicks = await invariant.getLiquidityTicks(poolKey, flattenTicks) + expect(liquidityTicks.length).toBe(Number(MAX_LIQUIDITY_TICKS_QUERIED - 1n)) + }, 100000) + + test('find query limit', async () => { + const { sqrtPrice } = await invariant.getPool(poolKey) + + const pairs = Number(MAX_LIQUIDITY_TICKS_QUERIED / 2n) + const ticks = new Array(pairs) + .fill(null) + .map((_, index) => [BigInt(index * 2), BigInt(index * 2 + 1)]) + + for (const [lowerTick, upperTick] of ticks) { + const approveX = await balanceOf(tokenX.contractId, positionOwner.address) + const approveY = await balanceOf(tokenY.contractId, positionOwner.address) + await invariant.createPosition( + positionOwner, + poolKey, + lowerTick, + upperTick, + 10n, + approveX, + approveY, + sqrtPrice, + sqrtPrice + ) + } + + const flattenTicks = ticks.flat() + const upperTick = flattenTicks[flattenTicks.length - 1] + 1n + flattenTicks.push(upperTick) + + const approveX = await balanceOf(tokenX.contractId, positionOwner.address) + const approveY = await balanceOf(tokenY.contractId, positionOwner.address) + await invariant.createPosition( + positionOwner, + poolKey, + 0n, + upperTick, + 10n, + approveX, + approveY, + sqrtPrice, + sqrtPrice + ) + + const singleQueryLiquidityTicks = await invariant.getLiquidityTicks(poolKey, flattenTicks) + const tickmap = await invariant.getFullTickmap(poolKey) + const getAllLiquidityTicks = await invariant.getAllLiquidityTicks(poolKey, tickmap) + expect(singleQueryLiquidityTicks.length).toBe(getAllLiquidityTicks.length) + for (const [index, liquidityTick] of singleQueryLiquidityTicks.entries()) { + expect(liquidityTick).toMatchObject(getAllLiquidityTicks[index]) + } + }, 200000) + test('query over limit fails in single call - passes in multiple', async () => { + const { sqrtPrice } = await invariant.getPool(poolKey) + + const pairs = Number(MAX_LIQUIDITY_TICKS_QUERIED / 2n) + 1 + const ticks = new Array(pairs) + .fill(null) + .map((_, index) => [BigInt(index * 2), BigInt(index * 2 + 1)]) + + for (const [lowerTick, upperTick] of ticks) { + const approveX = await balanceOf(tokenX.contractId, positionOwner.address) + const approveY = await balanceOf(tokenY.contractId, positionOwner.address) + await invariant.createPosition( + positionOwner, + poolKey, + lowerTick, + upperTick, + 10n, + approveX, + approveY, + sqrtPrice, + sqrtPrice + ) + } + + const flattenTicks = ticks.flat() + + expectVMError(VMError.OutOfGas, invariant.getLiquidityTicks(poolKey, flattenTicks)) + const tickmap = await invariant.getFullTickmap(poolKey) + const getAllLiquidityTicks = await invariant.getAllLiquidityTicks(poolKey, tickmap) + expect(getAllLiquidityTicks.length).toBe(Number(MAX_LIQUIDITY_TICKS_QUERIED + 1n)) + for (const liquidityTick of getAllLiquidityTicks) { + const tick = await invariant.getTick(poolKey, liquidityTick.index) + expect(tick).toMatchObject(liquidityTick) + } + }, 200000) +})