diff --git a/.changeset/spotty-islands-admire.md b/.changeset/spotty-islands-admire.md new file mode 100644 index 0000000..81e8cd9 --- /dev/null +++ b/.changeset/spotty-islands-admire.md @@ -0,0 +1,5 @@ +--- +"@mangrovedao/mgv": patch +--- + +Added getTokens and getOpenMarkets diff --git a/bun.lockb b/bun.lockb index 37431af..d8c5c63 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index d05bf7d..61e00bf 100644 --- a/package.json +++ b/package.json @@ -22,13 +22,12 @@ "devDependencies": { "@biomejs/biome": "1.8.3", "@types/bun": "^1.1.8", - "@viem/anvil": "^0.0.10", "@vitest/coverage-v8": "^2.0.5", "globby": "^14.0.2", "rimraf": "^6.0.1", - "simple-git-hooks": "^2.11.1", "viem": "^2.21.2", - "vitest": "^2.0.5" + "vitest": "^2.0.5", + "prool": "^0.0.16" }, "peerDependencies": { "typescript": "^5.5.4" diff --git a/src/actions/index.ts b/src/actions/index.ts index 0f23c7d..5ec73c4 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -119,3 +119,19 @@ export type { GetKandelStateResult, KandelStatus, } from './kandel/view.js' + +export { getTokens, GetTokenInfoError } from './tokens.js' + +export type { + GetTokensParams, + GetTokensResult, +} from './tokens.js' + +export { getRawOpenMarkets, getOpenMarkets } from './reader.js' + +export type { + GetOpenMarketArgs, + GetOpenMarketRawArgs, + GetOpenMarketRawResult, + GetOpenMarketResult, +} from './reader.js' diff --git a/src/actions/reader.test.ts b/src/actions/reader.test.ts new file mode 100644 index 0000000..f89c53b --- /dev/null +++ b/src/actions/reader.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, inject, it } from 'vitest' +import { getClient } from '~test/src/client.js' +import { getOpenMarkets } from './reader.js' + +const client = getClient() +const params = inject('mangrove') +const { WETH, USDC, DAI } = inject('tokens') +const { wethDAI, wethUSDC } = inject('markets') + +describe('getOpenMarkets', () => { + it('should return the open markets', async () => { + const markets = await getOpenMarkets(client, params, { + cashnesses: { + WETH: 1, + USDC: 1000, + DAI: 1000, + }, + }) + + expect(markets[0]?.base.address.toLowerCase()).toEqual( + wethUSDC.base.address.toLowerCase(), + ) + expect(markets[0]?.quote.address.toLowerCase()).toEqual( + wethUSDC.quote.address.toLowerCase(), + ) + expect(markets[1]?.base.address.toLowerCase()).toEqual( + wethDAI.base.address.toLowerCase(), + ) + expect(markets[1]?.quote.address.toLowerCase()).toEqual( + wethDAI.quote.address.toLowerCase(), + ) + + expect(markets[0]?.base.symbol).toEqual(WETH.symbol) + expect(markets[0]?.quote.symbol).toEqual(USDC.symbol) + expect(markets[1]?.base.symbol).toEqual(WETH.symbol) + expect(markets[1]?.quote.symbol).toEqual(DAI.symbol) + + expect(markets[0]?.base.decimals).toEqual(WETH.decimals) + expect(markets[0]?.quote.decimals).toEqual(USDC.decimals) + expect(markets[1]?.base.decimals).toEqual(WETH.decimals) + expect(markets[1]?.quote.decimals).toEqual(DAI.decimals) + }) + + it('should return the open markets with inverted cashnesses', async () => { + const markets = await getOpenMarkets(client, params, { + cashnesses: { + WETH: 100000, + USDC: 1000, + DAI: 1000, + }, + }) + + expect(markets[0]?.base.address.toLowerCase()).toEqual( + wethUSDC.quote.address.toLowerCase(), + ) + expect(markets[0]?.quote.address.toLowerCase()).toEqual( + wethUSDC.base.address.toLowerCase(), + ) + expect(markets[1]?.base.address.toLowerCase()).toEqual( + wethDAI.quote.address.toLowerCase(), + ) + expect(markets[1]?.quote.address.toLowerCase()).toEqual( + wethDAI.base.address.toLowerCase(), + ) + }) +}) diff --git a/src/actions/reader.ts b/src/actions/reader.ts new file mode 100644 index 0000000..4461801 --- /dev/null +++ b/src/actions/reader.ts @@ -0,0 +1,80 @@ +import type { Address, Client, ContractFunctionParameters } from 'viem' +import { readContract } from 'viem/actions' +import { getOpenMarketsParams, type mgvReaderABI } from '../builder/reader.js' +import type { + BuiltArgs, + MangroveActionsDefaultParams, + MarketParams, +} from '../types/index.js' +import { getAction } from '../utils/getAction.js' +import { type GetTokensParams, getTokens } from './tokens.js' + +export type GetOpenMarketRawArgs = Omit< + ContractFunctionParameters, + BuiltArgs +> + +export type GetOpenMarketRawResult = { + tkn0: Address + tkn1: Address + tickSpacing: bigint +}[] + +export async function getRawOpenMarkets( + client: Client, + params: MangroveActionsDefaultParams, + args?: GetOpenMarketRawArgs, +): Promise { + const result = await getAction( + client, + readContract, + 'readContract', + )({ + ...args, + address: params.mgvReader, + ...getOpenMarketsParams, + }) + + return result[0] as GetOpenMarketRawResult +} + +export type GetOpenMarketArgs = Omit & + GetOpenMarketRawArgs & { + // symbol -> cashness + cashnesses: Record + } + +export type GetOpenMarketResult = MarketParams[] + +export async function getOpenMarkets( + client: Client, + params: MangroveActionsDefaultParams, + args: GetOpenMarketArgs, +): Promise { + const raw = await getRawOpenMarkets(client, params, args) + const tokens = await getTokens(client, { + ...args, + tokens: raw.flatMap((market) => [market.tkn0, market.tkn1]), + }) + + return raw.map((market): MarketParams => { + // we don't use isAddressEqual because both are supposedly checksummed from viem + const tkn0 = tokens.find((token) => token.address === market.tkn0) + const tkn1 = tokens.find((token) => token.address === market.tkn1) + + if (!tkn0 || !tkn1) { + throw new Error( + 'Token not found, this is a bug, please report at https://github.com/mangrovedao/mgv/issues', + ) + } + + const tkn0Cashness = args.cashnesses[tkn0.symbol] ?? 0 + const tkn1Cashness = args.cashnesses[tkn1.symbol] ?? 0 + + return { + base: tkn0Cashness > tkn1Cashness ? tkn1 : tkn0, + quote: tkn0Cashness > tkn1Cashness ? tkn0 : tkn1, + tickSpacing: market.tickSpacing, + } + }) +} diff --git a/src/actions/tokens.test.ts b/src/actions/tokens.test.ts new file mode 100644 index 0000000..e3bdf29 --- /dev/null +++ b/src/actions/tokens.test.ts @@ -0,0 +1,86 @@ +import { ContractFunctionExecutionError, zeroAddress } from 'viem' +import { describe, expect, inject, it } from 'vitest' +import { getClient } from '~test/src/client.js' +import { GetTokenInfoError, getTokens } from './tokens.js' + +const { WETH, USDC, DAI } = inject('tokens') +const client = getClient() + +describe('tokens', () => { + it('should get tokens', async () => { + const tokens = await getTokens(client, { tokens: [WETH.address] as const }) + expect(tokens).toEqual([WETH]) + + const foundWETH = tokens[0] + + expect(foundWETH.mgvTestToken).toBe(false) + expect(foundWETH.address).toBe(WETH.address) + expect(foundWETH.symbol).toBe(WETH.symbol) + expect(foundWETH.decimals).toBe(WETH.decimals) + }) + + it('should get multiple tokens', async () => { + const tokens = await getTokens(client, { + tokens: [WETH.address, USDC.address, DAI.address] as const, + }) + expect(tokens).toEqual([WETH, USDC, DAI]) + + const foundWETH = tokens[0] + expect(foundWETH.mgvTestToken).toBe(false) + expect(foundWETH.address).toBe(WETH.address) + expect(foundWETH.symbol).toBe(WETH.symbol) + expect(foundWETH.decimals).toBe(WETH.decimals) + + const foundUSDC = tokens[1] + expect(foundUSDC.mgvTestToken).toBe(false) + expect(foundUSDC.address).toBe(USDC.address) + expect(foundUSDC.symbol).toBe(USDC.symbol) + expect(foundUSDC.decimals).toBe(USDC.decimals) + + const foundDAI = tokens[2] + expect(foundDAI.mgvTestToken).toBe(false) + expect(foundDAI.address).toBe(DAI.address) + expect(foundDAI.symbol).toBe(DAI.symbol) + expect(foundDAI.decimals).toBe(DAI.decimals) + }) + + it('should get test tokens', async () => { + const tokens = await getTokens(client, { + tokens: [WETH.address] as const, + testTokens: [WETH.address], + }) + + const foundWETH = tokens[0] + expect(foundWETH.mgvTestToken).toBe(true) + expect(foundWETH.address).toBe(WETH.address) + expect(foundWETH.symbol).toBe(WETH.symbol) + expect(foundWETH.decimals).toBe(WETH.decimals) + }) + + it('should have display decimals', async () => { + const tokens = await getTokens(client, { + tokens: [WETH.address, USDC.address, DAI.address] as const, + displayDecimals: { [WETH.symbol]: 1000 }, + priceDisplayDecimals: { [WETH.symbol]: 1000 }, + }) + + const foundWETH = tokens[0] + expect(foundWETH.displayDecimals).toBe(1000) + expect(foundWETH.priceDisplayDecimals).toBe(1000) + }) + + it('should fail on unknown token', async () => { + try { + await getTokens(client, { tokens: [zeroAddress] as const }) + } catch (error) { + expect(error).toBeInstanceOf(GetTokenInfoError) + const typedError = error as GetTokenInfoError + expect(typedError.shortMessage).toBe( + `No decimals found for token ${zeroAddress}`, + ) + expect(typedError.cause).toBeInstanceOf(ContractFunctionExecutionError) + const typedCause = typedError.cause as ContractFunctionExecutionError + expect(typedCause.contractAddress).toBe(zeroAddress) + } + }) +}) diff --git a/src/actions/tokens.ts b/src/actions/tokens.ts new file mode 100644 index 0000000..3ed2b3b --- /dev/null +++ b/src/actions/tokens.ts @@ -0,0 +1,111 @@ +import { + type Address, + BaseError, + type Client, + type MulticallParameters, + parseAbi, +} from 'viem' +import { multicall } from 'viem/actions' +import { type Token, buildToken } from '../addresses/index.js' +import { getAction } from '../utils/getAction.js' + +export type GetTokensParams = + { + tokens: T + displayDecimals?: Record + priceDisplayDecimals?: Record + testTokens?: T[number][] + } & Omit + +export type GetTokensResult = + { + [K in keyof T]: Token + } + +const tokenABI = parseAbi([ + 'function decimals() external view returns (uint8)', + 'function symbol() external view returns (string)', +]) + +export class GetTokenInfoError extends BaseError { + constructor( + tokenAddress: Address, + param: 'decimals' | 'symbol', + cause: Error, + ) { + super(`No ${param} found for token ${tokenAddress}`, { cause }) + } +} + +export async function getTokens< + T extends readonly Address[] = readonly Address[], +>( + client: Client, + { + tokens, + displayDecimals = {}, + priceDisplayDecimals = {}, + testTokens = [], + }: GetTokensParams, +): Promise> { + const tokenInfos = await getAction( + client, + multicall, + 'multicall', + )({ + contracts: tokens.flatMap( + (token) => + [ + { + address: token, + abi: tokenABI, + functionName: 'decimals', + }, + { + address: token, + abi: tokenABI, + functionName: 'symbol', + }, + ] as const, + ), + }) + + return tokens.map((token: T[number], i) => { + const decimalsResult = tokenInfos[i * 2] + const symbolResult = tokenInfos[i * 2 + 1] + + if (!decimalsResult || !symbolResult) + throw new Error( + 'Error while getting token infos, This is a bug, please report at https://github.com/mangrovedao/mgv/issues', + ) + + if (decimalsResult.status === 'failure') + throw new GetTokenInfoError(token, 'decimals', decimalsResult.error) + if (symbolResult.status === 'failure') + throw new GetTokenInfoError(token, 'symbol', symbolResult.error) + + const decimals = decimalsResult.result + const symbol = symbolResult.result + + if (typeof symbol !== 'string') + throw new Error( + 'Error while getting token infos, This is a bug, please report at https://github.com/mangrovedao/mgv/issues', + ) + if (typeof decimals !== 'number') + throw new Error( + 'Error while getting token infos, This is a bug, please report at https://github.com/mangrovedao/mgv/issues', + ) + + const display = displayDecimals[symbol] + const priceDisplay = priceDisplayDecimals[symbol] + + return buildToken({ + address: token, + symbol, + decimals, + displayDecimals: display, + priceDisplayDecimals: priceDisplay, + mgvTestToken: testTokens.includes(token), + }) + }) as GetTokensResult +} diff --git a/src/builder/index.ts b/src/builder/index.ts index 0186b31..13345cf 100644 --- a/src/builder/index.ts +++ b/src/builder/index.ts @@ -112,3 +112,7 @@ export { deployRouterParams, bindParams, } from './smart-router.js' + +// reader + +export { getOpenMarketsParams, mgvReaderABI } from './reader.js' diff --git a/src/builder/reader.ts b/src/builder/reader.ts new file mode 100644 index 0000000..6793069 --- /dev/null +++ b/src/builder/reader.ts @@ -0,0 +1,16 @@ +import { type ContractFunctionParameters, parseAbi } from 'viem' + +export const mgvReaderABI = parseAbi([ + 'struct Market { address tkn0; address tkn1; uint tickSpacing;}', + 'struct MarketConfig {LocalUnpacked config01;LocalUnpacked config10;}', + 'struct LocalUnpacked { bool active; uint fee; uint density; uint binPosInLeaf; uint level3; uint level2; uint level1; uint root; uint kilo_offer_gasbase; bool lock; uint last;}', + 'function openMarkets() external view returns (Market[] memory, MarketConfig[] memory)', +]) + +export const getOpenMarketsParams = { + abi: mgvReaderABI, + functionName: 'openMarkets', +} satisfies Omit< + ContractFunctionParameters, + 'address' +> diff --git a/src/bundle/public/general-actions.ts b/src/bundle/public/general-actions.ts index 418acf7..b3d1f23 100644 --- a/src/bundle/public/general-actions.ts +++ b/src/bundle/public/general-actions.ts @@ -1,9 +1,14 @@ -import type { Client } from 'viem' +import type { Address, Client } from 'viem' import { type GetBalanceResult, type GetBalancesArgs, getBalances, } from '../../actions/balances.js' +import { + type GetTokensParams, + type GetTokensResult, + getTokens, +} from '../../actions/tokens.js' import type { Logic } from '../../addresses/logics/utils.js' export type GeneralActions = { @@ -17,8 +22,35 @@ export type GeneralActions = { getBalances: ( args: GetBalancesArgs, ) => Promise> + + /** + * + * @param args.tokens the tokens to get info for + * @param args.displayDecimals the decimals to display for each token + * @param args.priceDisplayDecimals the decimals to display for each token's price + * @param args.testTokens the tokens that are mangrove test tokens + * @returns all tokens and their info + * @example + * ```ts + * const tokens = await client.getTokens({ + * tokens: [WETHaddress, USDCaddress], + * displayDecimals: { WETH: 4, USDC: 2 }, // optional + * priceDisplayDecimals: { WETH: 2, USDC: 4 }, // optional + * testTokens: [WETH.address] // optional + * }) + * // Returns: + * // { + * // WETH: { address: "0x...", symbol: "WETH", decimals: 18, displayDecimals: 4, priceDisplayDecimals: 2, isTestToken: true }, + * // USDC: { address: "0x...", symbol: "USDC", decimals: 6, displayDecimals: 2, priceDisplayDecimals: 4, isTestToken: false } + * // } + * ``` + */ + getTokens: ( + args: GetTokensParams, + ) => Promise> } export const generalActions = (client: Client): GeneralActions => ({ getBalances: (args) => getBalances(client, args), + getTokens: (args) => getTokens(client, args), }) diff --git a/src/bundle/public/mangrove-actions.ts b/src/bundle/public/mangrove-actions.ts index d6bb63a..b3b44c2 100644 --- a/src/bundle/public/mangrove-actions.ts +++ b/src/bundle/public/mangrove-actions.ts @@ -1,6 +1,10 @@ import type { Address, Client } from 'viem' -import type { GetUserRouterArgs } from '../../actions/index.js' -import { getUserRouter } from '../../actions/index.js' +import type { + GetOpenMarketArgs, + GetOpenMarketResult, + GetUserRouterArgs, +} from '../../actions/index.js' +import { getOpenMarkets, getUserRouter } from '../../actions/index.js' import { type GetOrdersArgs, type GetSingleOrderArgs, @@ -34,6 +38,44 @@ export type MangroveActions = { /** Gets multiple orders details given their markets, sides, and ids */ getOrders: (args: GetOrdersArgs) => Promise + + /** + * Gets all open markets on Mangrove + * @param args.cashnesses The cashness values for each token symbol (e.g. { "WETH": 10, "USDC": 100 }). + * Tokens with higher cashness will be quote tokens, lower cashness will be base tokens. + * For example, in the WETH/USDC market, WETH has higher cashness so it's the quote token. + * @param args.displayDecimals The number of decimals to display for each token symbol + * @param args.priceDisplayDecimals The number of decimals to display for prices in each token symbol + * @param args.testTokens Array of token addresses that are test tokens + * @returns Array of market parameters containing token pairs and tick spacing + * @example + * ```ts + * const markets = await client.getOpenMarkets({ + * cashnesses: { + * "WETH": 10, // Lower cashness -> WETH will be base token + * "USDC": 100 // Higher cashness -> USDC will be quote token + * }, + * displayDecimals: { + * "WETH": 4, + * "USDC": 2 + * }, + * priceDisplayDecimals: { + * "WETH": 2, + * "USDC": 4 + * } + * }); + * // Returns: + * // [ + * // { + * // base: { address: "0x...", symbol: "WETH", decimals: 18, ... }, + * // quote: { address: "0x...", symbol: "USDC", decimals: 6, ... }, + * // tickSpacing: 1n + * // }, + * // ... + * // ] + * ``` + */ + getOpenMarkets: (args: GetOpenMarketArgs) => Promise } export function mangroveActions(actionsParams: MangroveActionsDefaultParams) { @@ -43,5 +85,6 @@ export function mangroveActions(actionsParams: MangroveActionsDefaultParams) { simulateDeployRouter(client, actionsParams, args), getOrder: (args) => getOrder(client, actionsParams, args), getOrders: (args) => getOrders(client, actionsParams, args), + getOpenMarkets: (args) => getOpenMarkets(client, actionsParams, args), }) } diff --git a/src/package.json b/src/package.json index 55303e0..caa9c9a 100644 --- a/src/package.json +++ b/src/package.json @@ -55,7 +55,7 @@ }, "peerDependencies": { "typescript": ">=5.0.4", - "viem": "^2.9.1" + "viem": ">=2.9.1" }, "peerDependenciesMeta": { "typescript": { diff --git a/test/globalSetup.ts b/test/globalSetup.ts index e3babf1..b0364e6 100644 --- a/test/globalSetup.ts +++ b/test/globalSetup.ts @@ -1,4 +1,6 @@ -import { createAnvil, startProxy } from '@viem/anvil' +import { createServer, defineInstance } from 'prool' +import { anvil } from 'prool/instances' +// import { createAnvil, startProxy } from '@viem/anvil' import { type Address, parseAbi, parseEther, parseUnits } from 'viem' import { foundry } from 'viem/chains' import type { GlobalSetupContext } from 'vitest/node' @@ -26,12 +28,11 @@ export const multicall: Address = '0xcA11bde05977b3631167028862bE2a173976CA11' export default async function ({ provide }: GlobalSetupContext) { // create an anvil instance - const anvil = createAnvil({ + const globalInstance = anvil({ port: Number(process.env.MAIN_PORT || 8546), - chainId: foundry.id, ipc: '/tmp/anvil.ipc', }) - await anvil.start() + await globalInstance.start() // setting initial balances of accounts for (const account of accounts) { @@ -130,16 +131,18 @@ export default async function ({ provide }: GlobalSetupContext) { }) // starts a proxy pool from there - const shutdown = await startProxy({ - port: Number(process.env.PROXY_PORT || 8545), - options: { + const server = createServer({ + instance: anvil({ forkUrl: '/tmp/anvil.ipc', - }, + }), + port: Number(process.env.PROXY_PORT || 8545), }) + await server.start() + return async () => { - await shutdown() - await anvil.stop() + await server.stop() + await globalInstance.stop() } }