diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b9f1883a..9b60ae178 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ -lockfileVersion: '6.0' +lockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false + +importers: + + .: {} diff --git a/src/typescript/frontend/src/app/dexscreener/README.md b/src/typescript/frontend/src/app/dexscreener/README.md new file mode 100644 index 000000000..8d039a8aa --- /dev/null +++ b/src/typescript/frontend/src/app/dexscreener/README.md @@ -0,0 +1,38 @@ +# DEX Screener Adapter Specs + + + +Taken from https://dexscreener.notion.site/DEX-Screener-Adapter-Specs-cc1223cdf6e74a7799599106b65dcd0e + + + +v1.1 / Dec 2023 + +The DEX Screener Adapter is a set of HTTP endpoints that allows DEX Screener to +track historical and real-time data for any Partner Decentralized Exchange. +Adapters are responsible for supplying accurate and up-to-date data, whereas DEX +Screener handles all data ingestion, processing and serving. + +## Overview + +- The DEX Screener Indexer queries Adapter endpoints to continuously index + events as they become available, in chunks of one or many blocks at a time. + Each block is only queried once, so caution must be taken to ensure that all + returned data is accurate at the time of indexing. +- Adapters are to be deployed, served and maintained by the Partner +- If adapter endpoints become unreachable indexing will halt and automatically + resume once they become available again +- The Indexer allows for customizable rate limits and block chunk size to ensure + Adapter endpoints are not overloaded + +## Endpoints + +- An in-depth explanation of the schemas expected for each endpoint is described + on the `Schemas` section below + +- Numbers for amounts and price can be both `number` and `string`. Strings are + more suitable when dealing with extremely small or extremely large numbers + that can't be accurately serialized into JSON numbers. + +- Indexing will halt if schemas are invalid or contain unexpected values + (i.e.: `swapEvent.priceNative=0` or `pair.name=""`) diff --git a/src/typescript/frontend/src/app/dexscreener/asset.ts b/src/typescript/frontend/src/app/dexscreener/asset.ts new file mode 100644 index 000000000..de92d9554 --- /dev/null +++ b/src/typescript/frontend/src/app/dexscreener/asset.ts @@ -0,0 +1,90 @@ +/*** + Request: GET /asset?id=:string + + Response Schema: + // interface AssetResponse { + // asset: Asset; + // } + + Example Response: + // { + // "asset": { + // "id": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + // "name": "Wrapped Ether", + // "symbol": "WETH", + // "totalSupply": 10000000, + // "circulatingSupply": 900000, + // "coinGeckoId": "ether", + // "coinMarketCapId": "ether" + // } + // } + **/ + +import { type NextRequest, NextResponse } from "next/server"; +import { toMarketEmojiData } from "@sdk/emoji_data"; +import { EMOJICOIN_SUPPLY } from "@sdk/const"; +import { calculateCirculatingSupply } from "@sdk/markets"; +import { symbolEmojiStringToArray } from "./util"; +import { fetchMarketState } from "@/queries/market"; + +/** + * - In most cases, asset ids will correspond to contract addresses. Ids are case-sensitive. + * - All `Asset` props aside from `id` may be mutable. The Indexer will periodically query assets for their most + * up-to-date info + * - `totalSupply` is optional but DEX Screener cannot calculate FDV/Market Cap if not available + * - `circulatingSupply` is optional but DEX Screener may not be able to show accurate market cap if not available + * - `coinGeckoId` and `coinMarketCapId` are optional but may be used for displaying additional token information such + * as image, description and self-reported/off-chain circulating supply + * - `metadata` includes any optional auxiliary info not covered in the default schema and not required in most cases + */ +export interface Asset { + id: string; + name: string; + symbol: string; + totalSupply: number | string; + circulatingSupply?: number | string; + coinGeckoId?: string; + coinMarketCapId?: string; + metadata?: Record; +} + +export interface AssetResponse { + asset: Asset; +} + +/** + * Fetches an asset by a string of the emojis that represent the asset + * @param assetId + */ +export async function getAsset(assetId: string): Promise { + const marketEmojiData = toMarketEmojiData(assetId); + const symbolEmojis = symbolEmojiStringToArray(assetId); + const marketState = await fetchMarketState({ searchEmojis: symbolEmojis }); + + const circulatingSupply: { circulatingSupply?: number | string } = {}; + if (marketState && marketState.state) { + circulatingSupply.circulatingSupply = calculateCirculatingSupply(marketState.state).toString(); + } + + return { + id: assetId, + name: marketEmojiData.symbolData.name, + symbol: marketEmojiData.symbolData.symbol, + totalSupply: Number(EMOJICOIN_SUPPLY), + ...circulatingSupply, + // coinGeckoId: assetId, + // coinMarketCapId: assetId, + }; +} + +// NextJS JSON response handler +export async function GET(request: NextRequest): Promise> { + const searchParams = request.nextUrl.searchParams; + const assetId = searchParams.get("id"); + if (!assetId) { + // This is a required field, and is an error otherwise + return new NextResponse("id is a parameter", { status: 400 }); + } + const asset = await getAsset(assetId); + return NextResponse.json({ asset }); +} diff --git a/src/typescript/frontend/src/app/dexscreener/events.ts b/src/typescript/frontend/src/app/dexscreener/events.ts new file mode 100644 index 000000000..b738fe39f --- /dev/null +++ b/src/typescript/frontend/src/app/dexscreener/events.ts @@ -0,0 +1,312 @@ +// cspell:word dexscreener +/*** + Request: GET /events?fromBlock=:number&toBlock=:number + + - fromBlock and toBlock are both inclusive: a request to /events?fromBlock=10&toBlock=15 should + include all available events from block 10, 11, 12, 13, 14 and 15. + + Response Schema: + // interface EventsResponse { + // events: Array<{ block: Block } & (SwapEvent | JoinExitEvent)>; + // } + + Example Response: + // { + // "events": [ + // { + // "block": { + // "blockNumber": 10, + // "blockTimestamp": 1673319600 + // }, + // "eventType": "swap", + // "txnId": "0xe9e91f1ee4b56c0df2e9f06c2b8c27c6076195a88a7b8537ba8313d80e6f124e", + // "txnIndex": 4, + // "eventIndex": 3, + // "maker": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + // "pairId": "0x123", + // "asset0In": 10000, + // "asset1Out": 20000, + // "priceNative": 2, + // "reserves": { + // "asset0": 500, + // "asset1": 1000 + // } + // }, + // { + // "block": { + // "blockNumber": 10, + // "blockTimestamp": 1673319600 + // }, + // "eventType": "join", + // "txnId": "0xea1093d492a1dcb1bef708f771a99a96ff05dcab81ca76c31940300177fcf49f", + // "txnIndex": 0, + // "eventIndex": 0, + // "maker": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + // "pairId": "0x456", + // "amount0": 10, + // "amount1": 5, + // "reserves": { + // "asset0": 100, + // "asset1": 50 + // } + // }, + // { + // "block": { + // "blockNumber": 11, + // "blockTimestamp": 1673406000 + // }, + // "eventType": "swap", + // "txnId": "0xea1093d492a1dcb1bef708f771a99a96ff05dcab81ca76c31940300177fcf49f", + // "txnIndex": 1, + // "eventIndex": 20, + // "maker": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + // "pairId": "0x456", + // "asset0In": 0.0123456789, + // "asset1Out": 0.000123456789, + // "priceNative": 0.00000012345, + // "reserves": { + // "asset0": 0.0001, + // "asset1": 0.000000000000001 + // } + // } + // ] + // } + **/ + +import { type NextRequest, NextResponse } from "next/server"; +import { type Block } from "./latest-block"; +import { + fetchLiquidityEventsByBlock, + fetchSwapEventsByBlock, +} from "@sdk/indexer-v2/queries/app/dexscreener"; +import { + isLiquidityEventModel, + type toLiquidityEventModel, + type toSwapEventModel, +} from "@sdk/indexer-v2/types"; +import { calculateCurvePrice, calculateRealReserves } from "@sdk/markets"; +import { toCoinDecimalString } from "../../lib/utils/decimals"; +import { DECIMALS } from "@sdk/const"; +import { symbolEmojisToPairId } from "./util"; +import { compareBigInt } from "@econia-labs/emojicoin-sdk"; + +/** + * - `txnId` is a transaction identifier such as a transaction hash + * - `txnIndex` refers to the order of a transaction within a block, the higher the index the later + * in the block the transaction was processed + * - `eventIndex` refer to the order of the event within a transaction, the higher the index the + * later in the transaction the event was processed + * - The combination of `txnIndex` + `eventIndex` must be unique to any given event within a block, + * including for different event types + * - `maker` is an identifier for the account responsible for submitting the transaction. In most + * cases the transaction is submitted by the same account that sent/received tokens, but in cases + * where this doesn't apply (i.e.: transaction submitted by an aggregator) an effort should be made + * to identify the underlying account + * - All amounts (`assetIn`/`assetOut`/`reserve`) should be decimalized + * (`amount / (10 ** assetDecimals)`) + * - Reserves refer to the pooled amount of each asset *after* a swap event has occurred. If there + * are multiple swap events on the same block and reserves cannot be determined after each + * individual event then it's acceptable for only the last event to contain the `reserves` prop + * - While `reserves` are technically optional, the Indexer relies on it for accurately calculating + * derived USD pricing (i.e. USD price for `FOO/BAR` derived from `BAR/USDC`) from the most suitable + * reference pair. Pairs with no known reserves, from both `SwapEvent` or `JoinExitEvent`, will + * **not** be used for derived pricing and this may result in pairs with no known USD prices + * - `reserves` are also required to calculate total liquidity for each pair: if that's not + * available then DEX Screener will show liquidity as `N/A` and a warning for β€œUnknown liquidity” + * - A combination of either `asset0In + asset1Out` or `asset1In + asset0Out` is expected. If there + * are multiple assets in or multiple assets out then the swap event is considered invalid and + * indexing will halt + * - `priceNative` refers to the price of `asset0` quoted in `asset1` in that event + * - For example, in a theoretical `BTC/USD` pair, if 1 BTC = 30000 USD `priceNative` == `30000` + * - Similarly, in a theoretical `USD/BTC` pair, `priceNative` would be `0.00003333333333` + * (1 BTC = 0.00003333333333 USD) + * - `metadata` includes any optional auxiliary info not covered in the default schema and not + * required in most cases + * - The Indexer will use up to 50 decimal places for amounts/prices/reserves and all subsequent + * decimal places will be ignored. Using all 50 decimal places is highly encouraged to ensure + * accurate prices + * - The Indexer automatically handles calculations for USD pricing (`priceUsd` as opposed to + * `priceNative`) + */ +export interface SwapEvent { + eventType: "swap"; + txnId: string; + txnIndex: number; + eventIndex: number; + maker: string; + pairId: string; + asset0In?: number | string; + asset1In?: number | string; + asset0Out?: number | string; + asset1Out?: number | string; + priceNative: number | string; + reserves?: { + asset0: number | string; + asset1: number | string; + }; + metadata?: Record; +} + +/** + * - `txnId` is a transaction identifier such as a transaction hash + * - `txnIndex` refers to the order of a transaction within a block, the higher the index the later + * in the block the transaction was processed + * - `eventIndex` refer to the order of the event within a transaction, the higher the index the + * later in the transaction the event was processed + * - The combination of `txnIndex` + `eventIndex` must be unique to any given event within a block, + * including for different event types + * - `maker` is an identifier for the account responsible for submitting the transaction. In most + * cases the transaction is submitted by the same account that sent/received tokens, but in cases + * where this doesn't apply (i.e.: transaction submitted by an aggregator) an effort should be made + * to identify the underlying account. + * - All amounts (`assetIn`/`assetOut`/`reserve`) should be decimalized: + * (`amount / (10 ** assetDecimals)`) + * - Reserves refer to the pooled amount of each asset *after* a join/exit event has occurred. + * If there are multiple join/exit events on the same block and reserves cannot be determined after + * each individual event then it's acceptable for only the last event to contain the `reserves` prop + * - `metadata` includes any optional auxiliary info not covered in the default schema and not + * required in most cases + */ +export interface JoinExitEvent { + eventType: "join" | "exit"; + txnId: string; + txnIndex: number; + eventIndex: number; + maker: string; + pairId: string; + amount0: number | string; + amount1: number | string; + reserves?: { + asset0: number | string; + asset1: number | string; + }; + metadata?: Record; +} + +export type BlockInfo = { block: Block }; +export type Event = (SwapEvent | JoinExitEvent) & BlockInfo; + +export interface EventsResponse { + events: Event[]; +} + +export function toDexscreenerSwapEvent( + event: ReturnType +): SwapEvent & BlockInfo { + let assetInOut; + + if (event.swap.isSell) { + // We are selling to APT + assetInOut = { + asset0In: toCoinDecimalString(event.swap.inputAmount, DECIMALS), + asset0Out: 0, + asset1In: 0, + asset1Out: toCoinDecimalString(event.swap.baseVolume, DECIMALS), + }; + } else { + // We are buying with APT + assetInOut = { + asset0In: 0, + asset0Out: toCoinDecimalString(event.swap.quoteVolume, DECIMALS), + asset1In: toCoinDecimalString(event.swap.inputAmount, DECIMALS), + asset1Out: 0, + }; + } + + const { base, quote } = calculateRealReserves(event.state); + const reserves = { + asset0: toCoinDecimalString(base, DECIMALS), + asset1: toCoinDecimalString(quote, DECIMALS), + }; + + const priceNative = calculateCurvePrice(event.state).toFixed(50).toString(); + + if (!event.blockAndEvent) throw new Error("blockAndEvent is undefined"); + + return { + block: { + blockNumber: Number(event.blockAndEvent.blockNumber), + blockTimestamp: event.transaction.timestamp.getTime() / 1000, + }, + eventType: "swap", + txnId: event.transaction.version.toString(), + + txnIndex: Number(event.transaction.version), + eventIndex: Number(event.blockAndEvent.eventIndex), + + maker: event.swap.swapper, + pairId: symbolEmojisToPairId(event.market.symbolEmojis), + + ...assetInOut, + + asset0In: event.swap.inputAmount.toString(), + asset1Out: event.swap.quoteVolume.toString(), + priceNative, + ...reserves, + }; +} + +export function toDexscreenerJoinExitEvent( + event: ReturnType +): JoinExitEvent & BlockInfo { + const { base, quote } = calculateRealReserves(event.state); + const reserves = { + asset0: toCoinDecimalString(base, DECIMALS), + asset1: toCoinDecimalString(quote, DECIMALS), + }; + + if (!event.blockAndEvent) throw new Error("blockAndEvent is undefined"); + + return { + block: { + blockNumber: Number(event.blockAndEvent.blockNumber), + blockTimestamp: event.transaction.timestamp.getTime() / 1000, + }, + eventType: event.liquidity.liquidityProvided ? "join" : "exit", + + txnId: event.transaction.version.toString(), + + txnIndex: Number(event.transaction.version), + eventIndex: Number(event.blockAndEvent.eventIndex), + + maker: event.liquidity.provider, + pairId: symbolEmojisToPairId(event.market.symbolEmojis), + + amount0: toCoinDecimalString(event.liquidity.baseAmount, DECIMALS), + amount1: toCoinDecimalString(event.liquidity.quoteAmount, DECIMALS), + reserves, + }; +} + +export async function getEventsByVersion(fromBlock: number, toBlock: number): Promise { + const swapEvents = await fetchSwapEventsByBlock({ fromBlock, toBlock }); + const liquidityEvents = await fetchLiquidityEventsByBlock({ fromBlock, toBlock }); + + // Merge these two arrays by their `transaction.version` + return [...swapEvents, ...liquidityEvents] + .sort((a, b) => compareBigInt(a.transaction.version, b.transaction.version)) + .map((event) => + isLiquidityEventModel(event) + ? toDexscreenerJoinExitEvent(event) + : toDexscreenerSwapEvent(event) + ); +} + +// NextJS JSON response handler +/** + * We treat our versions as "blocks" because it's faster to implement given our current architecture + * This requires dexscreener to have relatively large `fromBlock - toBlock` ranges to keep up + * */ +export async function GET(request: NextRequest): Promise> { + const searchParams = request.nextUrl.searchParams; + const fromBlock = searchParams.get("fromBlock"); + const toBlock = searchParams.get("toBlock"); + if (fromBlock === null || toBlock === null) { + // This should never happen, and is an invalid call + return new NextResponse("fromBlock and toBlock are required parameters", { status: 400 }); + } + + const events = await getEventsByVersion(parseInt(fromBlock, 10), parseInt(toBlock, 10)); + + return NextResponse.json({ events }); +} diff --git a/src/typescript/frontend/src/app/dexscreener/latest-block.ts b/src/typescript/frontend/src/app/dexscreener/latest-block.ts new file mode 100644 index 000000000..0b4bff762 --- /dev/null +++ b/src/typescript/frontend/src/app/dexscreener/latest-block.ts @@ -0,0 +1,54 @@ +// cspell:word dexscreener +/*** + - The `/latest-block` endpoint should be in sync with the `/events` endpoint, meaning it should only return the latest block where data from `/events` will be available. This doesn't mean it should return the latest block with an event, but it should not return a block for which `/events` has no data available yet. + - If `/events` fetches data on-demand this isn't an issue, but if it relies on data indexed and persisted in the backend then `/latest-block` should be aware of the latest persisted block + - During live indexing, the Indexer will continuously poll `/latest-block` and use its data to then query `/events` + Response Schema: + // interface LatestBlockResponse { + // block: Block; + // } + Example Response: + // { + // "block": { + // "blockNumber": 100, + // "blockTimestamp": 1698126147 + // } + // } + **/ + +import { type NextRequest, NextResponse } from "next/server"; +import { getProcessorStatus } from "@sdk/indexer-v2/queries"; +import { getAptosClient } from "@sdk/utils/aptos-client"; + +/** + * - `blockTimestamp` should be a UNIX timestamp, **not** including milliseconds + * - `metadata` includes any optional auxiliary info not covered in the default schema and not required in most cases + */ +export interface Block { + blockNumber: number; + blockTimestamp: number; + metadata?: Record; +} + +export interface LatestBlockResponse { + block: Block; +} + +// NextJS JSON response handler +export async function GET(_request: NextRequest): Promise> { + const status = await getProcessorStatus(); + const aptos = getAptosClient(); + const latestBlock = await aptos.getBlockByVersion({ ledgerVersion: status.lastSuccessVersion }); + // Because we may not have finished processing the entire block yet, we return block number - 1 + // here. This adds ~1s (block time) of latency, but ensures completeness. We set it to 0 if below. + const blockHeight = parseInt(latestBlock.block_height, 10); + const blockNumber = blockHeight > 0 ? blockHeight - 1 : 0; + + return NextResponse.json({ + block: { + blockNumber, + // Convert to seconds + blockTimestamp: status.lastTransactionTimestamp.getTime() / 1000, + }, + }); +} diff --git a/src/typescript/frontend/src/app/dexscreener/pair.ts b/src/typescript/frontend/src/app/dexscreener/pair.ts new file mode 100644 index 000000000..1f61a33ed --- /dev/null +++ b/src/typescript/frontend/src/app/dexscreener/pair.ts @@ -0,0 +1,125 @@ +// cspell:word dexscreener +/*** + Request: GET /pair?id=:string + + Response Schema: + // interface PairResponse { + // pair: Pair; + // } + + Example Response: + // { + // "pair": { + // "id": "0x11b815efB8f581194ae79006d24E0d814B7697F6", + // "dexKey": "uniswap", + // "asset0Id": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + // "asset1Id": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + // "createdAtBlockNumber": 100, + // "createdAtBlockTimestamp": 1698126147, + // "createdAtTxnId": "0xe9e91f1ee4b56c0df2e9f06c2b8c27c6076195a88a7b8537ba8313d80e6f124e", + // "feeBps": 100 + // } + // } + **/ + +import { type NextRequest, NextResponse } from "next/server"; +import { fetchMarketRegistrationEventBySymbolEmojis } from "@sdk/indexer-v2/queries/app/dexscreener"; +import { getAptosClient } from "@sdk/utils/aptos-client"; +import { INTEGRATOR_FEE_RATE_BPS } from "@sdk/const"; +import { pairIdToSymbolEmojis, symbolEmojisToString } from "./util"; + +/** + * - All `Pair` props are immutable - Indexer will not query a given pair more than once + * - In most cases, pair ids will correspond to contract addresses. Ids are case-sensitive. + * - `dexKey` is an identifier for the DEX that hosts this pair. For most adapters this will be a static value such as + * `uniswap`, but if multiple DEXes are tracked an id such as a factory address may be used. + * - `asset0` and `asset1` order should **never** change. `amount0/reserve0` will always refer to the same `asset0`, + * and `amount1/reserve1` will always refer to the same `asset1`. If asset order mutates then all data after the change + * will be invalid and a re-index will be required. + * - A simple strategy to keep this in check is to simply order assets alphabetically. For example, in a pair + * containing assets `0xAAA` and `0xZZZ`, `asset0=0xAAA` and `asset1=0xZZZ` + * - DEX Screener UI will automatically invert pairs as needed and default to their most logical order (i.e.: + * `BTC/USD` as opposed to `USD/BTC`) + * - `createdAtBlockNumber`, `createdAtBlockTimestamp` and `createdAtTxnId` are optional but encouraged. If unavailable + * DEX Screener can bet set to assume pair creation date is the same date as its first ever event. + * - `feeBps` corresponds to swap fees in bps. For instance, a fee of 1% maps to `feeBps=100` + * - `pool` is only recommended for DEXes that support multi-asset pools and allows the DEX Screener UI to correlate + * multiple pairs in the same multi-asset pool + * - `metadata` includes any optional auxiliary info not covered in the default schema and not required in most cases + */ +export interface Pair { + id: string; + dexKey: string; + asset0Id: string; + asset1Id: string; + createdAtBlockNumber?: number; + createdAtBlockTimestamp?: number; + createdAtTxnId?: string; + creator?: string; + feeBps?: number; + pool?: { + id: string; + name: string; + assetIds: string[]; + pairIds: string[]; + metadata?: Record; + }; + metadata?: Record; +} + +export interface PairResponse { + pair: Pair; +} + +/** + * + * @param pairId is the pair ID. Generally it's `event.market.symbolEmojis.join("") + "-APT"` + */ +export async function getPair( + pairId: string +): Promise<{ pair: Pair; error?: never } | { pair?: never; error: NextResponse }> { + const symbolEmojis = pairIdToSymbolEmojis(pairId); + + const marketRegistrations = await fetchMarketRegistrationEventBySymbolEmojis({ + searchEmojis: symbolEmojis, + }); + const marketRegistration = marketRegistrations.pop(); + if (!marketRegistration) { + return { + error: new NextResponse(`Market registration not found for pairId: ${pairId}`, { + status: 404, + }), + }; + } + + const aptos = getAptosClient(); + const block = await aptos.getBlockByVersion({ + ledgerVersion: marketRegistration.transaction.version, + }); + + return { + pair: { + id: pairId, + dexKey: "emojicoin.fun", + asset0Id: symbolEmojisToString(symbolEmojis), + asset1Id: "APT", + createdAtBlockNumber: parseInt(block.block_height), + createdAtBlockTimestamp: marketRegistration.transaction.timestamp.getTime() / 1000, + createdAtTxnId: String(marketRegistration.transaction.version), + feeBps: INTEGRATOR_FEE_RATE_BPS, + }, + }; +} + +// NextJS JSON response handler +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const pairId = searchParams.get("id"); + if (!pairId) { + return new NextResponse("id is a required parameter", { status: 400 }); + } + const { pair, error } = await getPair(pairId); + if (error) return error; + + return NextResponse.json({ pair }); +} diff --git a/src/typescript/frontend/src/app/dexscreener/util.test.ts b/src/typescript/frontend/src/app/dexscreener/util.test.ts new file mode 100644 index 000000000..203c1a88d --- /dev/null +++ b/src/typescript/frontend/src/app/dexscreener/util.test.ts @@ -0,0 +1,27 @@ +// cspell:word dexscreener +import type { SymbolEmoji } from "@sdk/emoji_data"; +import { describe, it } from "node:test"; +import { pairIdToSymbolEmojis, symbolEmojisToPairId, symbolEmojisToString } from "./util"; +import { expect } from "@playwright/test"; + +describe("dexscreener utilities", () => { + const marketSymbols: SymbolEmoji[][] = [["🀌"], ["🀌🏻", "🀌🏽"], ["🀌🏼", "🀌🏾", "🀌🏿"]]; + + it("ensures ID stability across different endpoints", async () => { + const expectedJoinedStrings = ["🀌", "🀌🏻🀌🏽", "🀌🏼🀌🏾🀌🏿"]; + const expectedPairIds = ["🀌-APT", "🀌🏻🀌🏽-APT", "🀌🏼🀌🏾🀌🏿-APT"]; + + for (let i = 0; i < marketSymbols.length; i++) { + const symbolEmojis = marketSymbols[i]; + + const joinedSymbolEmojis = symbolEmojisToString(symbolEmojis); + expect(joinedSymbolEmojis).toEqual(expectedJoinedStrings[i]); + + const pairId = symbolEmojisToPairId(symbolEmojis); + expect(pairId).toEqual(expectedPairIds[i]); + + const deserializedSymbolEmojis = pairIdToSymbolEmojis(pairId); + expect(deserializedSymbolEmojis).toEqual(symbolEmojis); + } + }); +}); diff --git a/src/typescript/frontend/src/app/dexscreener/util.ts b/src/typescript/frontend/src/app/dexscreener/util.ts new file mode 100644 index 000000000..4cc12bb1f --- /dev/null +++ b/src/typescript/frontend/src/app/dexscreener/util.ts @@ -0,0 +1,23 @@ +import { type SymbolEmoji, toMarketEmojiData } from "@sdk/emoji_data"; + +export function pairIdToSymbolEmojiString(pairId: string): string { + return pairId.split("-")[0]; +} + +export function symbolEmojisToString(symbolEmojis: Array): string { + return symbolEmojis.join(""); +} + +export function symbolEmojiStringToArray(symbolEmojiString: string): SymbolEmoji[] { + const marketEmojiData = toMarketEmojiData(symbolEmojiString); + return marketEmojiData.emojis.map((emojiData) => emojiData.emoji); +} + +export function pairIdToSymbolEmojis(pairId: string): SymbolEmoji[] { + const emojiString = pairIdToSymbolEmojiString(pairId); + return symbolEmojiStringToArray(emojiString); +} + +export function symbolEmojisToPairId(symbolEmojis: Array): string { + return symbolEmojisToString(symbolEmojis) + "-APT"; +} diff --git a/src/typescript/frontend/src/middleware.ts b/src/typescript/frontend/src/middleware.ts index 7c246f79d..a4b413201 100644 --- a/src/typescript/frontend/src/middleware.ts +++ b/src/typescript/frontend/src/middleware.ts @@ -1,3 +1,4 @@ +// cspell:word dexscreener import { COOKIE_FOR_ACCOUNT_ADDRESS, COOKIE_FOR_HASHED_ADDRESS, @@ -11,16 +12,16 @@ import { normalizePossibleMarketPath } from "utils/pathname-helpers"; export default async function middleware(request: NextRequest) { const pathname = new URL(request.url).pathname; - if (pathname === "/launching") { + if (pathname === ROUTES.launching) { return NextResponse.next(); } - if (PRE_LAUNCH_TEASER && pathname !== "/launching") { + if (PRE_LAUNCH_TEASER && pathname !== ROUTES.launching) { return NextResponse.redirect(new URL(ROUTES.launching, request.url)); } - if (MAINTENANCE_MODE && pathname !== "/maintenance") { + if (MAINTENANCE_MODE && pathname !== ROUTES.maintenance) { return NextResponse.redirect(new URL(ROUTES.maintenance, request.url)); } - if (pathname === "/test" || pathname === "/verify_status") { + if (pathname === "/test" || pathname === "/verify_status" || pathname === ROUTES.dexscreener) { return NextResponse.next(); } diff --git a/src/typescript/frontend/src/router/routes.ts b/src/typescript/frontend/src/router/routes.ts index eb96d0cda..b4fe25e16 100644 --- a/src/typescript/frontend/src/router/routes.ts +++ b/src/typescript/frontend/src/router/routes.ts @@ -1,3 +1,4 @@ +// cspell:word dexscreener export const ROUTES = { root: "/", api: "/api", @@ -10,4 +11,5 @@ export const ROUTES = { notFound: "/not-found", maintenance: "/maintenance", launching: "/launching", + dexscreener: "/dexscreener", } as const; diff --git a/src/typescript/sdk/src/indexer-v2/mini-processor/event-groups/utils.ts b/src/typescript/sdk/src/indexer-v2/mini-processor/event-groups/utils.ts index 1e7100f28..0cb53243c 100644 --- a/src/typescript/sdk/src/indexer-v2/mini-processor/event-groups/utils.ts +++ b/src/typescript/sdk/src/indexer-v2/mini-processor/event-groups/utils.ts @@ -95,7 +95,8 @@ export const addModelsForBumpEvent = (args: { }); } else if (isLiquidityEvent(event)) { const liquidity = toLiquidityEventData(event); - const liquidityEventModel: DatabaseModels["liquidity_events"] = { + const liquidityEventModel = { + blockAndEvent: undefined, transaction, market, state, @@ -126,6 +127,7 @@ export const addModelsForBumpEvent = (args: { } } else if (isSwapEvent(event)) { rows.swapEvents.push({ + blockAndEvent: undefined, transaction, market, state, diff --git a/src/typescript/sdk/src/indexer-v2/queries/app/dexscreener.ts b/src/typescript/sdk/src/indexer-v2/queries/app/dexscreener.ts new file mode 100644 index 000000000..65f8268b3 --- /dev/null +++ b/src/typescript/sdk/src/indexer-v2/queries/app/dexscreener.ts @@ -0,0 +1,59 @@ +import { queryHelper } from "../utils"; +import { + toLiquidityEventModel, + toMarketRegistrationEventModel, + toSwapEventModel, +} from "../../types"; +import { LIMIT } from "../../../queries"; +import type { MarketStateQueryArgs } from "../../types/common"; +import { postgrest, toQueryArray } from "../client"; +import { TableName } from "../../types/json-types"; +import { type SymbolEmoji } from "../../../emoji_data"; + +const selectMarketRegistrationEventBySymbolEmojis = ({ + searchEmojis, + page = 1, + pageSize = LIMIT, +}: { searchEmojis: SymbolEmoji[] } & MarketStateQueryArgs) => + postgrest + .from(TableName.MarketRegistrationEvents) + .select("*") + .eq("symbol_emojis", toQueryArray(searchEmojis)) + .range((page - 1) * pageSize, page * pageSize - 1); + +const selectSwapEventsByBlock = ({ + fromBlock, + toBlock, + page = 1, + pageSize = LIMIT, +}: { fromBlock: number; toBlock: number } & MarketStateQueryArgs) => + postgrest + .from(TableName.SwapEvents) + .select("*") + .gte("block_number", fromBlock) + .lte("block_number", toBlock) + .range((page - 1) * pageSize, page * pageSize - 1); + +const selectLiquidityEventsByBlock = ({ + fromBlock, + toBlock, + page = 1, + pageSize = LIMIT, +}: { fromBlock: number; toBlock: number } & MarketStateQueryArgs) => + postgrest + .from(TableName.LiquidityEvents) + .select("*") + .gte("block_number", fromBlock) + .lte("block_number", toBlock) + .range((page - 1) * pageSize, page * pageSize - 1); + +export const fetchMarketRegistrationEventBySymbolEmojis = queryHelper( + selectMarketRegistrationEventBySymbolEmojis, + toMarketRegistrationEventModel +); + +export const fetchSwapEventsByBlock = queryHelper(selectSwapEventsByBlock, toSwapEventModel); +export const fetchLiquidityEventsByBlock = queryHelper( + selectLiquidityEventsByBlock, + toLiquidityEventModel +); diff --git a/src/typescript/sdk/src/indexer-v2/queries/app/index.ts b/src/typescript/sdk/src/indexer-v2/queries/app/index.ts index e3dc8266e..0a4b81230 100644 --- a/src/typescript/sdk/src/indexer-v2/queries/app/index.ts +++ b/src/typescript/sdk/src/indexer-v2/queries/app/index.ts @@ -1,3 +1,5 @@ +// cspell:word dexscreener export * from "./home"; export * from "./market"; export * from "./pools"; +export * from "./dexscreener"; diff --git a/src/typescript/sdk/src/indexer-v2/types/index.ts b/src/typescript/sdk/src/indexer-v2/types/index.ts index cfb50140a..16e7e11d9 100644 --- a/src/typescript/sdk/src/indexer-v2/types/index.ts +++ b/src/typescript/sdk/src/indexer-v2/types/index.ts @@ -21,6 +21,7 @@ import { TableName, type ProcessedFields, DatabaseRpc, + type BlockAndEventIndexMetadata, } from "./json-types"; import { type MarketEmojiData, type SymbolEmoji, toMarketEmojiData } from "../../emoji_data"; import { toPeriod, toTrigger, type Period, type Trigger } from "../../const"; @@ -51,6 +52,14 @@ const toTransactionMetadata = ( insertedAt: data.inserted_at ? postgresTimestampToDate(data.inserted_at) : new Date(0), }); +const toBlockAndEventIndex = (data: BlockAndEventIndexMetadata) => + data && data.block_number + ? { + blockNumber: BigInt(data.block_number), + eventIndex: BigInt(data.event_index), + } + : undefined; + /// If received from postgres, symbol bytes come in as a hex string in the format "\\xabcd" where /// "abcd" is the hex string. /// If received from the broker, the symbolBytes will be deserialized as an array of values. @@ -351,6 +360,8 @@ export const withMarketAndStateMetadataAndEmitTime = curryToNamedType( toMarketMetadataModel, "market" ); +/// The `blockAndEvent` field is only set when fetched from the DB- otherwise it's `undefined`. +export const withBlockAndEventIndex = curryToNamedType(toBlockAndEventIndex, "blockAndEvent"); export const withLastSwap = curryToNamedType(toLastSwapFromDatabase, "lastSwap"); export const withGlobalStateEventData = curryToNamedType(toGlobalStateEventData, "globalState"); export const withPeriodicStateMetadata = curryToNamedType( @@ -476,6 +487,7 @@ export const toSwapEventModel = (data: DatabaseJsonType["swap_events"]) => ({ ...withMarketAndStateMetadataAndBumpTime(data), ...withSwapEventData(data), ...withStateEventData(data), + ...withBlockAndEventIndex(data), ...GuidGetters.swapEvent(data), }); @@ -494,6 +506,7 @@ export const toLiquidityEventModel = (data: DatabaseJsonType["liquidity_events"] ...withLiquidityEventData(data), ...withStateEventData(data), ...withLastSwapData(data), + ...withBlockAndEventIndex(data), ...GuidGetters.liquidityEvent(data), });