From 828b8631ab29e6b803f54aa9e2d357cfc979b724 Mon Sep 17 00:00:00 2001 From: Maxence Raballand Date: Tue, 25 Jun 2024 12:37:44 +0200 Subject: [PATCH] feat: Add tests for viewing kandel state. (#100) * feat: Add tests for viewing kandel state. * chore: format --------- Co-authored-by: maxencerb --- .changeset/shaggy-emus-knock.md | 5 + src/actions/kandel/view.test.ts | 211 ++++++++++++++++++++++++++ src/actions/kandel/view.ts | 261 ++++++++++++++++++++++++++++---- src/builder/kandel/view.ts | 27 ++++ 4 files changed, 478 insertions(+), 26 deletions(-) create mode 100644 .changeset/shaggy-emus-knock.md create mode 100644 src/actions/kandel/view.test.ts diff --git a/.changeset/shaggy-emus-knock.md b/.changeset/shaggy-emus-knock.md new file mode 100644 index 0000000..7a31c95 --- /dev/null +++ b/.changeset/shaggy-emus-knock.md @@ -0,0 +1,5 @@ +--- +"@mangrovedao/mgv": patch +--- + +Add reverse market possibility on kandel view function diff --git a/src/actions/kandel/view.test.ts b/src/actions/kandel/view.test.ts new file mode 100644 index 0000000..31ebc75 --- /dev/null +++ b/src/actions/kandel/view.test.ts @@ -0,0 +1,211 @@ +import { type Address, parseEther, parseUnits } from 'viem' +import { describe, expect, inject, it } from 'vitest' +import { validateKandelParams } from '~mgv/index.js' +import type { KandelParams, MarketParams } from '~mgv/index.js' +import { BS, Order } from '~mgv/lib/enums.js' +import { getClient } from '~test/src/client.js' +import { getBook } from '../book.js' +import { simulateLimitOrder } from '../index.js' +import { simulateBind, simulateDeployRouter } from '../smart-router.js' +import { simulatePopulate } from './populate.js' +import { simulateSow } from './sow.js' +import { KandelStatus, getKandelState } from './view.js' + +const { smartKandelSeeder } = inject('kandel') +const { wethUSDC } = inject('markets') +const actionParams = inject('mangrove') +const client = getClient() + +async function sowAndPopulate( + params: KandelParams, + provision: bigint, + market: MarketParams = wethUSDC, +): Promise
{ + const { request: sowReq, result: kandel } = await simulateSow( + client, + market, + smartKandelSeeder, + { + account: client.account.address, + }, + ) + const hash = await client.writeContract(sowReq) + await client.waitForTransactionReceipt({ hash }) + + const { request: deployRouterReq, router } = await simulateDeployRouter( + client, + actionParams, + { + user: client.account.address, + }, + ) + const routerTx = await client.writeContract(deployRouterReq) + await client.waitForTransactionReceipt({ hash: routerTx }) + + const { request: bindReq } = await simulateBind(client, router, { + target: kandel, + }) + const bindTx = await client.writeContract(bindReq) + await client.waitForTransactionReceipt({ hash: bindTx }) + + const { request } = await simulatePopulate(client, kandel, { + ...params, + account: client.account.address, + value: provision, + }) + const hash2 = await client.writeContract(request) + await client.waitForTransactionReceipt({ hash: hash2 }) + + return kandel +} + +describe('view kandel', () => { + it('view kandel state', async () => { + const book = await getBook(client, actionParams, wethUSDC) + + const { params, isValid, minProvision } = validateKandelParams({ + minPrice: 2500, + midPrice: 3000, + maxPrice: 3500, + pricePoints: 5n, + market: wethUSDC, + baseAmount: parseEther('1'), + quoteAmount: parseUnits('3000', 18), + stepSize: 1n, + gasreq: 350_000n, + factor: 3, + asksLocalConfig: book.asksConfig, + bidsLocalConfig: book.bidsConfig, + marketConfig: book.marketConfig, + }) + + expect(isValid).toBe(true) + + const kandel = await sowAndPopulate(params, minProvision) + const kandelState = await getKandelState( + client, + actionParams, + wethUSDC, + kandel, + {}, + ) + + expect(kandelState.reversed).toBe(false) + + expect(kandelState.baseQuoteTickOffset).toBe(841n) + expect(kandelState.baseAmount).toBe(parseEther('1')) + expect(kandelState.quoteAmount).toBe(parseUnits('3000', 18)) + expect(kandelState.gasprice).toBe(0) + expect(kandelState.gasreq).toBe(350_000) + expect(kandelState.stepSize).toBe(1) + expect(kandelState.pricePoints).toBe(5) + expect(kandelState.asks.length).toBe(4) + expect(kandelState.bids.length).toBe(4) + expect(kandelState.unlockedProvision).toBe(0n) + expect(kandelState.kandelStatus).toBe(KandelStatus.Active) + }) + + it('creates a kandel out of range', async () => { + const book = await getBook(client, actionParams, wethUSDC) + + const { params, isValid, minProvision } = validateKandelParams({ + minPrice: 2500, + midPrice: 3500, + maxPrice: 3500, + pricePoints: 5n, + market: wethUSDC, + baseAmount: parseEther('1'), + quoteAmount: parseUnits('3000', 6), + stepSize: 1n, + gasreq: 350_000n, + factor: 3, + asksLocalConfig: book.asksConfig, + bidsLocalConfig: book.bidsConfig, + marketConfig: book.marketConfig, + }) + + expect(isValid).toBe(false) + + const kandel = await sowAndPopulate(params, minProvision) + + // creating a limit order to shift price out of range + + const { request } = await simulateLimitOrder( + client, + actionParams, + wethUSDC, + { + baseAmount: parseEther('1'), + quoteAmount: parseUnits('4000', 6), + restingOrderGasreq: 250_000n, + bs: BS.sell, + book, + orderType: Order.PO, + }, + ) + const tx = await client.writeContract(request) + await client.waitForTransactionReceipt({ hash: tx }) + + const kandelState = await getKandelState( + client, + actionParams, + wethUSDC, + kandel, + {}, + ) + + expect(kandelState.kandelStatus).toBe(KandelStatus.OutOfRange) + }) + + it('creates a reversed kandel', async () => { + const reversedMarket: MarketParams = { + base: wethUSDC.quote, + quote: wethUSDC.base, + tickSpacing: wethUSDC.tickSpacing, + } + + const book = await getBook(client, actionParams, reversedMarket) + + const { params, isValid, minProvision } = validateKandelParams({ + minPrice: 1 / 3500, + midPrice: 1 / 3000, + maxPrice: 1 / 2500, + pricePoints: 5n, + market: reversedMarket, + baseAmount: parseUnits('3000', 6), + quoteAmount: parseEther('1'), + stepSize: 1n, + gasreq: 350_000n, + factor: 3, + asksLocalConfig: book.asksConfig, + bidsLocalConfig: book.bidsConfig, + marketConfig: book.marketConfig, + }) + + const kandel = await sowAndPopulate(params, minProvision, reversedMarket) + + expect(isValid).toBe(true) + + const kandelState = await getKandelState( + client, + actionParams, + wethUSDC, + kandel, + {}, + ) + + expect(kandelState.reversed).toBe(true) + + expect(kandelState.baseQuoteTickOffset).toBe(841n) + expect(kandelState.baseAmount).toBe(parseEther('1')) + expect(kandelState.quoteAmount).toBe(parseUnits('3000', 6)) + expect(kandelState.gasprice).toBe(0) + expect(kandelState.gasreq).toBe(350_000) + expect(kandelState.stepSize).toBe(1) + expect(kandelState.pricePoints).toBe(5) + expect(kandelState.asks.length).toBe(4) + expect(kandelState.bids.length).toBe(4) + expect(kandelState.unlockedProvision).toBe(0n) + expect(kandelState.kandelStatus).toBe(KandelStatus.Active) + }) +}) diff --git a/src/actions/kandel/view.ts b/src/actions/kandel/view.ts index c64b2a3..62c2a06 100644 --- a/src/actions/kandel/view.ts +++ b/src/actions/kandel/view.ts @@ -3,15 +3,20 @@ import { type Client, type MulticallParameters, erc20Abi, + isAddressEqual, } from 'viem' import { multicall } from 'viem/actions' +import { getBookParams, parseBookResult } from '../../builder/book.js' import { + baseParams, baseQuoteTickOffsetParams, getOfferParams, kandelParamsParams, offerIdOfIndexParams, offeredVolumeParams, provisionOfParams, + quoteParams, + tickSpacingParams, } from '../../builder/kandel/view.js' import { type MangroveActionsDefaultParams, @@ -39,6 +44,13 @@ export type OfferParsed = { provision: bigint } +export enum KandelStatus { + Active = 'active', + OutOfRange = 'out-of-range', + Inactive = 'inactive', + Closed = 'closed', +} + export type GetKandelStateResult = { baseQuoteTickOffset: bigint gasprice: number @@ -48,22 +60,47 @@ export type GetKandelStateResult = { quoteAmount: bigint baseAmount: bigint unlockedProvision: bigint + kandelStatus: KandelStatus asks: OfferParsed[] bids: OfferParsed[] + reversed: boolean } -export async function getKandelState( +type KandelInitCallResult = { + baseQuoteTickOffset: bigint + params: { + gasprice: number + gasreq: number + stepSize: number + pricePoints: number + } + baseAmount: bigint + quoteAmount: bigint + unlockedProvision: bigint + // mid price from the book + midPrice: number + // whether the base quote is reversed + reversed: boolean +} + +async function kandelInitCall( client: Client, actionsParams: MangroveActionsDefaultParams, market: MarketParams, kandel: Address, args: GetKandelStateArgs, -): Promise { +): Promise { + const { asksMarket, bidsMarket } = getSemibooksOLKeys(market) const [ + bestAsk, + bestBid, baseQuoteTickOffset, params, - quoteAmount, - baseAmount, + _quoteAmount, + _baseAmount, + _base, + _quote, + _tickSpacing, unlockedProvision, ] = await getAction( client, @@ -72,6 +109,20 @@ export async function getKandelState( )({ ...args, contracts: [ + { + address: actionsParams.mgvReader, + ...getBookParams({ + olKey: asksMarket, + maxOffers: 1n, + }), + }, + { + address: actionsParams.mgvReader, + ...getBookParams({ + olKey: bidsMarket, + maxOffers: 1n, + }), + }, { address: kandel, ...baseQuoteTickOffsetParams, @@ -88,6 +139,18 @@ export async function getKandelState( address: kandel, ...offeredVolumeParams(BA.asks), }, + { + address: kandel, + ...baseParams, + }, + { + address: kandel, + ...quoteParams, + }, + { + address: kandel, + ...tickSpacingParams, + }, { address: actionsParams.mgv, abi: erc20Abi, @@ -98,11 +161,99 @@ export async function getKandelState( allowFailure: true, }) - const pricePoints = - params.status === 'success' ? params.result.pricePoints : 0 + let reversed = false + const [base, quote, tickSpacing] = [ + _base.result, + _quote.result, + _tickSpacing.result, + ] + if (!base || !quote || !tickSpacing) + throw new Error('Could not fetch base, quote or tickSpacing') + if ( + isAddressEqual(market.base.address, quote) && + isAddressEqual(market.quote.address, base) && + market.tickSpacing === tickSpacing + ) { + reversed = true + } else if ( + !isAddressEqual(market.base.address, base) || + !isAddressEqual(market.quote.address, quote) || + tickSpacing !== market.tickSpacing + ) { + throw new Error('Market does not match kandel') + } + + const baseAmount = _baseAmount.status === 'success' ? _baseAmount.result : 0n + const quoteAmount = + _quoteAmount.status === 'success' ? _quoteAmount.result : 0n + + const asks = + bestAsk.status === 'success' + ? parseBookResult({ + result: bestAsk.result, + ba: BA.asks, + baseDecimals: market.base.decimals, + quoteDecimals: market.quote.decimals, + }) + : [] + + const bids = + bestBid.status === 'success' + ? parseBookResult({ + result: bestBid.result, + ba: BA.bids, + baseDecimals: market.base.decimals, + quoteDecimals: market.quote.decimals, + }) + : [] + const minAskPrice = asks[0]?.price + const maxBidPrice = bids[0]?.price + + const midPrice = + minAskPrice && maxBidPrice + ? (minAskPrice + maxBidPrice) / 2 + : minAskPrice + ? minAskPrice + : maxBidPrice + ? maxBidPrice + : 1 + + return { + baseQuoteTickOffset: + baseQuoteTickOffset.status === 'success' + ? baseQuoteTickOffset.result + : 0n, + params: + params.status === 'success' + ? params.result + : { gasprice: 0, gasreq: 0, stepSize: 0, pricePoints: 0 }, + baseAmount: reversed ? quoteAmount : baseAmount, + quoteAmount: reversed ? baseAmount : quoteAmount, + reversed, + unlockedProvision: + unlockedProvision.status === 'success' ? unlockedProvision.result : 0n, + midPrice, + } +} + +type GetKandelBidsAndAskResult = { + asks: OfferParsed[] + bids: OfferParsed[] +} + +async function kandelBidsAndAsks( + client: Client, + market: MarketParams, + kandel: Address, + args: GetKandelStateArgs & { + pricePoints: number + reversed: boolean + }, +): Promise { const asks: OfferParsed[] = [] const bids: OfferParsed[] = [] + const pricePoints = args.pricePoints if (pricePoints > 0) { const offers = await getAction( client, @@ -143,13 +294,17 @@ export async function getKandelState( if (rawBid?.status === 'success' && rawBidId?.status === 'success') { const bid = unpackOffer(rawBid.result) const bidId = rawBidId.result + const reversedMultiplier = args.reversed ? 1n : -1n if (bidId > 0n) { bids.push({ ...bid, index: BigInt(index / 4), id: bidId, - price: rawPriceToHumanPrice(priceFromTick(-bid.tick), market), - ba: BA.bids, + price: rawPriceToHumanPrice( + priceFromTick(bid.tick * reversedMultiplier), + market, + ), + ba: args.reversed ? BA.asks : BA.bids, provision: 0n, }) } @@ -157,13 +312,17 @@ export async function getKandelState( if (rawAsk?.status === 'success' && rawAskId?.status === 'success') { const ask = unpackOffer(rawAsk.result) const askId = rawAskId.result + const reversedMultiplier = args.reversed ? -1n : 1n if (askId > 0n) { asks.push({ ...ask, index: BigInt(index / 4), id: askId, - price: rawPriceToHumanPrice(priceFromTick(ask.tick), market), - ba: BA.asks, + price: rawPriceToHumanPrice( + priceFromTick(ask.tick * reversedMultiplier), + market, + ), + ba: args.reversed ? BA.bids : BA.asks, provision: 0n, }) } @@ -182,11 +341,17 @@ export async function getKandelState( contracts: [ ...bids.map((bid) => ({ address: kandel, - ...provisionOfParams(bidsMarket, bid.id), + ...provisionOfParams( + args.reversed ? asksMarket : bidsMarket, + bid.id, + ), })), ...asks.map((ask) => ({ address: kandel, - ...provisionOfParams(asksMarket, ask.id), + ...provisionOfParams( + args.reversed ? bidsMarket : asksMarket, + ask.id, + ), })), ], }) @@ -209,20 +374,64 @@ export async function getKandelState( } } + if (args.reversed) return { asks: bids, bids: asks } + return { asks, bids } +} + +function kandelStatus( + { asks, bids }: GetKandelBidsAndAskResult, + unlockedProvision: bigint, + midPrice: number, +): KandelStatus { + // if no offers and no provision, closed + const hasAsks = asks.some((ask) => ask.gives > 0n) + const hasBids = bids.some((bid) => bid.gives > 0n) + if (!hasAsks && !hasBids && unlockedProvision === 0n) { + return KandelStatus.Closed + } + + // if no offers and provision, inactive + if (!hasAsks && !hasBids) { + return KandelStatus.Inactive + } + // if offers and midPrice in range, active + const minPrice = Math.min( + ...asks.map((ask) => ask.price), + ...bids.map((bid) => bid.price), + ) + const maxPrice = Math.max( + ...asks.map((ask) => ask.price), + ...bids.map((bid) => bid.price), + ) + if (midPrice >= minPrice && midPrice <= maxPrice) { + return KandelStatus.Active + } + // if offers and midPrice out of range, out of range + return KandelStatus.OutOfRange +} + +export async function getKandelState( + client: Client, + actionsParams: MangroveActionsDefaultParams, + market: MarketParams, + kandel: Address, + args: GetKandelStateArgs, +): Promise { + const { params, reversed, unlockedProvision, midPrice, ...rest } = + await kandelInitCall(client, actionsParams, market, kandel, args) + + const result = await kandelBidsAndAsks(client, market, kandel, { + ...args, + pricePoints: params.pricePoints, + reversed, + }) + return { - baseQuoteTickOffset: - baseQuoteTickOffset.status === 'success' - ? baseQuoteTickOffset.result - : 0n, - gasprice: params.status === 'success' ? params.result.gasprice : 0, - gasreq: params.status === 'success' ? params.result.gasreq : 0, - stepSize: params.status === 'success' ? params.result.stepSize : 0, - pricePoints, - unlockedProvision: - unlockedProvision.status === 'success' ? unlockedProvision.result : 0n, - quoteAmount: quoteAmount.status === 'success' ? quoteAmount.result : 0n, - baseAmount: baseAmount.status === 'success' ? baseAmount.result : 0n, - asks, - bids, + ...rest, + ...params, + ...result, + unlockedProvision, + kandelStatus: kandelStatus(result, unlockedProvision, midPrice), + reversed, } } diff --git a/src/builder/kandel/view.ts b/src/builder/kandel/view.ts index 862faee..39349c8 100644 --- a/src/builder/kandel/view.ts +++ b/src/builder/kandel/view.ts @@ -16,8 +16,35 @@ export const viewKandelABI = parseAbi([ 'function getOffer(uint8 ba, uint index) public view returns (uint offer)', 'function offerIdOfIndex(uint8 ba, uint index) public view returns (uint offerId)', 'function provisionOf(OLKey memory olKey, uint offerId) public view returns (uint provision)', + 'function BASE() public view returns (address)', + 'function QUOTE() public view returns (address)', + 'function TICK_SPACING() public view returns (uint)', ]) +export const baseParams = { + abi: viewKandelABI, + functionName: 'BASE', +} satisfies Omit< + ContractFunctionParameters, + 'address' +> + +export const quoteParams = { + abi: viewKandelABI, + functionName: 'QUOTE', +} satisfies Omit< + ContractFunctionParameters, + 'address' +> + +export const tickSpacingParams = { + abi: viewKandelABI, + functionName: 'TICK_SPACING', +} satisfies Omit< + ContractFunctionParameters, + 'address' +> + export const baseQuoteTickOffsetParams = { abi: viewKandelABI, functionName: 'baseQuoteTickOffset',