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),
});