Skip to content
Closed
79 changes: 79 additions & 0 deletions docs/cross-venue-pool-normalisation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Cross-venue pool normalisation: two traps

**Context:** ranking concentrated-liquidity pools from Meteora DLMM, Orca Whirlpools and
Raydium CLMM together, each read from the venue's own API. Nothing in Gateway normalises
across venues today, so neither of these is a bug report — they are the two things that
bite first when something does, and both are easy to mistake for "that venue has no good
pools."

---

## 1. Base and quote ordering is the venue's, and it decides which amount field is which

Gateway resolves `baseToken` and `quoteToken` **in the venue's own order**. It does not
impose a house convention, and the venues do not agree on one:

| Venue | Where SOL usually sits |
|---|---|
| Meteora DLMM | quote side |
| Orca Whirlpools | base side |
| Raydium CLMM | base side |

The temptation, when ranking or listing pools from several venues at once, is to require a
single orientation so the rows are comparable — "SOL is always the quote" — and drop what
does not match. Measured across ~250 SOL pools, that is not a small filter:

| | requiring one convention | matching either side |
|---|---|---|
| Orca | 1 | 14 |
| Raydium | 4 | 8 |

**This is not only a display concern.** Because base and quote resolve in the venue's
order, the orientation decides which request field an amount goes in. A caller that always
puts its SOL budget in `quoteTokenAmount` sends it to the wrong token on a pool whose SOL is
the base. The deposit is still expressible — the SOL amount goes in `baseTokenAmount`
instead — but only if the caller maps its amount to the pool's orientation.

So the useful shape is:

- **Rank on either orientation**, so a venue is not silently emptied by a convention it
never agreed to.
- **Map amounts to each pool's own base and quote** when filling, rather than assuming one
side.

If a future `fetch-pools` or `pool-info` normalisation imposes one convention across
venues, this is the failure it will produce: most of Orca's and Raydium's SOL pools
disappear, and the result looks indistinguishable from those venues having nothing worth
ranking. Nothing errors.

## 2. Orca publishes no pool age

Orca's API returns `tradeEnableTimestamp` as the **unix epoch on every pool** — not a
missing field, not null, but a valid-looking timestamp of 1970-01-01 that arithmetic
happily accepts.

Every Orca pool therefore looks decades old. A "pool must be older than N days" filter,
meant to screen out new pools, lets **all of Orca** through, new pools included. A "pool
must be newer than N days" filter excludes all of Orca, and the output is indistinguishable
from Orca having no qualifying pools. Neither result says that the age was unknown.

Where age matters, treat Orca as *age unknown* and decide explicitly — exempt it, or
exclude it with a stated reason. Do not let an epoch timestamp be compared as if it were
data.

---

## Why this lives in Gateway's docs

Both traps are about Gateway's model rather than any caller's. The ordering one is
Gateway's base/quote resolution showing through to whoever aggregates across venues; the
age one is the kind of per-venue field gap a unified `fetch-pools` would have to decide
about. Written down here so the next person building cross-venue ranking, a unified pool
list, or a venue-agnostic quote finds them before rediscovering them.

A related caution about yield: the venues' own yield fields are not the same quantity.
Meteora's `apr` is a daily percent, Raydium's `day.feeApr` is annualised, and Orca publishes
`yieldOverTvl`. Where Gateway passes these through — `apr` in the Meteora and Orca
`fetch-pools` results, `yieldOverTvl` in Orca's `pool-info` — it reports each venue's figure
as the venue does, without normalising it. Sorting on them directly compares different
quantities and calls the largest best; anything ranking on yield owns the normalisation.
280 changes: 280 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -982,6 +982,163 @@
"quoteId"
]
},
"TransactionsResponse": {
"type": "object",
"properties": {
"network": {
"type": "string"
},
"address": {
"type": "string"
},
"transactions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"signature": {
"type": "string"
},
"slot": {
"type": "number"
},
"blockTime": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
]
},
"ok": {
"type": "boolean"
},
"kind": {
"description": "close_position | remove_liquidity | collect_fees | add_liquidity | open_position | swap | withdraw | deposit | other — the most significant of what the transaction did",
"type": "string"
},
"protocol": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"actions": {
"description": "every venue instruction recognised, outer and inner",
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string"
},
"protocol": {
"description": "connector-style name: meteora/clmm, raydium/amm, jupiter/router …",
"type": "string"
},
"program": {
"type": "string"
},
"instruction": {
"type": "string"
}
},
"required": [
"kind",
"protocol",
"program",
"instruction"
]
}
},
"changes": {
"description": "what the wallet's own balances did, fee excluded",
"type": "array",
"items": {
"type": "object",
"properties": {
"mint": {
"description": "a mint, or 'SOL' for the chain's own money",
"type": "string"
},
"symbol": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"decimals": {
"type": "number"
},
"amount": {
"description": "signed, whole units",
"type": "string"
}
},
"required": [
"mint",
"symbol",
"decimals",
"amount"
]
}
},
"feeLamports": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
]
}
},
"required": [
"signature",
"slot",
"blockTime",
"ok",
"kind",
"protocol",
"actions",
"changes",
"feeLamports"
]
}
},
"scanned": {
"type": "number"
},
"cursor": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"required": [
"network",
"address",
"transactions",
"scanned",
"cursor"
]
},
"PoolListItem": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -8608,6 +8765,129 @@
}
}
},
"/chains/{chain}/transactions": {
"get": {
"operationId": "getWalletTransactions",
"tags": [
"/chains"
],
"description": "A wallet's recent transactions, newest first, each named as what it did — swap, open_position, add_liquidity, remove_liquidity, collect_fees, close_position, deposit, withdraw — with what the wallet's own balances did. Page with `before` = the last page's cursor. Solana mainnet-beta only. Reads the wallet address's own signature history, so a transfer into an existing token account that does not name the wallet is not listed.",
"parameters": [
{
"schema": {
"enum": [
"devnet",
"mainnet-beta",
"arbitrum",
"avalanche",
"base",
"bsc",
"celo",
"mainnet",
"optimism",
"polygon",
"robinhoodchain-testnet",
"robinhoodchain",
"sepolia",
"unichain"
],
"type": "string"
},
"example": "mainnet-beta",
"in": "query",
"name": "network",
"required": false,
"description": "Network to use. Defaults to the chain's configured default network."
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "address",
"required": true,
"description": "the wallet whose activity to read"
},
{
"schema": {
"minimum": 1,
"maximum": 100,
"default": 25,
"type": "integer"
},
"in": "query",
"name": "limit",
"required": false,
"description": "signatures to inspect, newest first"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "before",
"required": false,
"description": "a signature; the page ends before it (the previous page's cursor)"
},
{
"schema": {
"default": false,
"type": "boolean"
},
"in": "query",
"name": "includeOther",
"required": false,
"description": "also return transactions that are neither a known venue call nor a balance change"
},
{
"schema": {
"enum": [
"solana",
"ethereum"
],
"default": "solana",
"type": "string"
},
"in": "path",
"name": "chain",
"required": true,
"description": "Chain to operate on"
}
],
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TransactionsResponse"
}
}
}
},
"400": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/chains/{chain}/balances": {
"post": {
"operationId": "getBalances",
Expand Down
Loading
Loading