Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .devcontainer/setup-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,17 @@ docker run --rm --network=host \
-url="jdbc:postgresql://127.0.0.1:${PGPORT}/${PGDATABASE}?user=${PGUSER}&password=" \
migrate

# The pool-indexer has its own database migrated from its own directory (mirrors
# the per-network prod DB), separate from the autopilot/orderbook set above.
psql -h 127.0.0.1 -U postgres -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname='pool_indexer'" | grep -q 1 \
|| psql -h 127.0.0.1 -U postgres -d postgres -c \
"CREATE DATABASE pool_indexer OWNER \"${PGUSER}\";"
docker run --rm --network=host \
-v "$REPO_ROOT/database/sql-pool-indexer:/flyway/sql:ro" \
-v "$REPO_ROOT/database/conf:/flyway/conf:ro" \
flyway/flyway:10.7.1 \
-url="jdbc:postgresql://127.0.0.1:${PGPORT}/pool_indexer?user=${PGUSER}&password=" \
migrate

echo "==> e2e environment ready (postgres)"
1 change: 1 addition & 0 deletions .github/workflows/pull-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ jobs:
tool: nextest
- run: docker compose -f docker-compose.yaml up -d db
- run: docker compose -f docker-compose.yaml up migrations --abort-on-container-failure
- run: docker compose -f docker-compose.yaml up migrations-pool-indexer --abort-on-container-failure
- uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4.0.0
with:
just-version: 1.39.0
Expand Down
9 changes: 2 additions & 7 deletions crates/database/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ pub const TABLES: &[&str] = &[
"last_indexed_blocks",
"onchain_order_invalidations",
"onchain_placed_orders",
"pool_indexer_checkpoints",
"presignature_events",
"proposed_jit_orders",
"quotes",
Expand All @@ -72,8 +71,6 @@ pub const TABLES: &[&str] = &[
"solver_competitions",
"surplus_capturing_jit_order_owners",
"trades",
"uniswap_v3_pool_states",
"uniswap_v3_pools",
];

/// The names of potentially big volume tables we use in the db.
Expand All @@ -88,7 +85,6 @@ pub const LARGE_TABLES: &[&str] = &[
"order_quotes",
"proposed_solutions",
"proposed_trade_executions",
"uniswap_v3_ticks",
];

pub fn all_tables() -> impl Iterator<Item = &'static str> {
Expand All @@ -98,9 +94,8 @@ pub fn all_tables() -> impl Iterator<Item = &'static str> {
/// Delete all data in the database. Only used by tests.
///
/// Truncates all tables in a single statement so Postgres accepts foreign-key
/// cycles between listed tables (e.g. `uniswap_v3_pool_states` →
/// `uniswap_v3_pools`). Individual per-table `TRUNCATE`s error out when any
/// other listed table references the one being truncated.
/// cycles between listed tables. Individual per-table `TRUNCATE`s error out
/// when any other listed table references the one being truncated.
#[expect(non_snake_case)]
pub async fn clear_DANGER_(ex: &mut PgTransaction<'_>) -> sqlx::Result<()> {
let tables = all_tables().collect::<Vec<_>>().join(", ");
Expand Down
17 changes: 11 additions & 6 deletions crates/e2e/tests/e2e/pool_indexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,10 @@ sol! {
const POOL_INDEXER_PORT: u16 = 7778;
const POOL_INDEXER_HOST: &str = "http://127.0.0.1:7778";
const POOL_INDEXER_METRICS_PORT: u16 = 7779;
const LOCAL_DB_URL: &str = "postgresql://";
// The indexer has its own database (mirrors the per-network prod DB), migrated
// from `database/sql-pool-indexer` by the `migrations-pool-indexer` flyway step
// (docker-compose / setup-e2e.sh), separate from the shared autopilot DB.
const POOL_INDEXER_DB_URL: &str = "postgresql:///pool_indexer";
Comment thread
AryanGodara marked this conversation as resolved.

// sqrt(1) * 2^96 — valid starting price
const INITIAL_SQRT_PRICE: u128 = 1u128 << 96;
Expand Down Expand Up @@ -162,6 +165,8 @@ struct TicksResponse {
#[derive(Deserialize)]
struct TickEntry {}

/// Truncates the indexer's tables between tests. The schema itself is
/// provisioned by flyway (`migrations-pool-indexer`), so this just clears rows.
async fn clear_pool_indexer_tables(db: &PgPool) {
sqlx::query(
"TRUNCATE uniswap_v3_ticks, uniswap_v3_pool_states, uniswap_v3_pools, \
Expand Down Expand Up @@ -190,7 +195,7 @@ async fn seed_checkpoint(db: &PgPool, factory: Address, block: u64) {
async fn spawn_pool_indexer(factory: Address, metrics_port: u16) -> tokio::task::JoinHandle<()> {
let config = Configuration {
database: DatabaseConfig {
url: LOCAL_DB_URL.parse().unwrap(),
url: POOL_INDEXER_DB_URL.parse().unwrap(),
max_connections: NonZeroU32::new(5).unwrap(),
},
network: NetworkConfig {
Expand Down Expand Up @@ -390,7 +395,7 @@ async fn driver_integration(web3: Web3) {
const POOLS_BY_IDS_ROUTE: &str = "/api/v1/{network}/uniswap/v3/pools/by-ids";
const TICKS_ROUTE: &str = "/api/v1/{network}/uniswap/v3/pools/ticks";

let db = PgPool::connect(LOCAL_DB_URL).await.unwrap();
let db = PgPool::connect(POOL_INDEXER_DB_URL).await.unwrap();
clear_pool_indexer_tables(&db).await;

let mut onchain = OnchainComponents::deploy(web3.clone()).await;
Expand Down Expand Up @@ -482,7 +487,7 @@ async fn local_node_pool_indexer_checkpoint_resume() {
/// count, sqrt_price / tick / liquidity, and the checkpoint all survive a
/// stop+start.
async fn checkpoint_resume(web3: Web3) {
let db = PgPool::connect(LOCAL_DB_URL).await.unwrap();
let db = PgPool::connect(POOL_INDEXER_DB_URL).await.unwrap();
clear_pool_indexer_tables(&db).await;

let (factory, pool_addr) = deploy_univ3(&web3).await;
Expand Down Expand Up @@ -533,7 +538,7 @@ async fn local_node_pool_indexer_api_errors() {
/// 400, a valid-but-unknown address must come back as 200 with empty ticks.
/// Lets callers distinguish "garbage input" from "no data yet".
async fn api_errors(web3: Web3) {
let db = PgPool::connect(LOCAL_DB_URL).await.unwrap();
let db = PgPool::connect(POOL_INDEXER_DB_URL).await.unwrap();
clear_pool_indexer_tables(&db).await;

let (factory, _pool) = deploy_univ3(&web3).await;
Expand Down Expand Up @@ -585,7 +590,7 @@ async fn local_node_pool_indexer_pagination() {
/// every pool exactly once. Three pools is the smallest set that exercises
/// a mid-stream cursor and the `next_cursor = null` terminator.
async fn pagination(web3: Web3) {
let db = PgPool::connect(LOCAL_DB_URL).await.unwrap();
let db = PgPool::connect(POOL_INDEXER_DB_URL).await.unwrap();
clear_pool_indexer_tables(&db).await;

let (factory, _pool1) = deploy_univ3(&web3).await;
Expand Down
78 changes: 1 addition & 77 deletions database/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,83 +532,7 @@ Indexes:
- jit\_user\_order\_creation\_timestamp: btree(`owner`, `creation_timestamp` DESC)
- jit\_event\_id: btree(`block_number`, `log_index`)

### pool\_indexer\_checkpoints
Comment thread
AryanGodara marked this conversation as resolved.

Highest finalized block processed per `contract_address` by `pool-indexer`. `contract_address` is the factory address. The indexer runs one process per network against its own DB, so there's no `chain_id` column.

Column | Type | Nullable | Details
--------------------|--------|----------|--------
contract\_address | bytea | not null | Factory address (20 bytes)
block\_number | bigint | not null |

Indexes:
- PRIMARY KEY: btree (`contract_address`)

### uniswap\_v3\_pools

One row per pool discovered from a `PoolCreated` event. `token{0,1}_{decimals,symbol}` are nullable and filled in by the backfill task. `factory` partitions the table when multiple V3-compatible factories run on the same network so each indexer touches only its own rows.

Column | Type | Nullable | Details
-------------------|----------|----------|--------
address | bytea | not null | Pool address (20 bytes)
factory | bytea | not null | Address of the V3 factory that emitted `PoolCreated`
token0 | bytea | not null |
token1 | bytea | not null |
fee | int | not null | Hundredths of a basis point (500 = 0.05%, 3000 = 0.3%, 10000 = 1%). `CHECK (fee > 0)`.
token0\_decimals | smallint | nullable | `NULL` = not yet fetched. `-1` = sentinel for "fetched but call failed"
token1\_decimals | smallint | nullable |
token0\_symbol | text | nullable | `NULL` = not yet fetched. `""` = sentinel for "fetched but call failed"
token1\_symbol | text | nullable |
created\_block | bigint | not null | Block in which the pool was created on-chain

Indexes:
- PRIMARY KEY: btree (`address`)
- Four partial indexes on `(token{0,1})` with predicate `token{0,1}_{symbol,decimals} IS NULL` to power the backfill scan.

### uniswap\_v3\_pool\_states

Current state per pool: `sqrt_price_x96` and `tick` come from the latest `Swap`/`Initialize`; `liquidity` and `block_number` also update on in-range `Mint`/`Burn`. FK → `uniswap_v3_pools`.

**Uniswap V3 pool-state primer.** Three values capture a pool's instantaneous state:

- `sqrt_price_x96` — `sqrt(price) * 2^96` where `price = token1/token0`, stored in Q64.96 fixed-point. The square-root form keeps swap math additive and bounds precision loss over the uint160 range. Mirrors on-chain `slot0.sqrtPriceX96`.
- `tick` — `floor(log_{1.0001}(price))`. Each tick is a ~0.01% price step; the current tick is the bucket the live price falls into. Routers use it to decide which positions are in-range.
- `liquidity` — sum of every position's liquidity whose `tickLower <= current_tick < tickUpper`. This is the `L` in V3's invariant `Δsqrt_price = Δamount / L`. Updates on `Swap` (the event carries the new value) and on `Mint`/`Burn` whose range spans the current tick.

The per-tick deltas that move `liquidity` when the price crosses a tick boundary live in [`uniswap_v3_ticks`](#uniswap_v3_ticks).

Column | Type | Nullable | Details
-------------------|---------|----------|--------
pool\_address | bytea | not null | FK → `uniswap_v3_pools(address)`
block\_number | bigint | not null | Block of the most recent state-changing event (`Swap`, `Initialize`, or in-range `Mint`/`Burn`).
sqrt\_price\_x96 | numeric | not null | uint160 — see primer above
liquidity | numeric | not null | uint128 — see primer above
tick | int | not null | signed int24 — see primer above

Indexes:
- PRIMARY KEY: btree (`pool_address`)

### uniswap\_v3\_ticks

Per-tick liquidity deltas. Rows with `liquidity_net = 0` are pruned. FK → `uniswap_v3_pools`.

**Why deltas instead of per-tick totals.** A V3 position covers `[tickLower, tickUpper)` and contributes to pool liquidity only when the current tick is in that range. We store the entering / exiting deltas at the bounds:

- At `tickLower`: `liquidity_net += position.liquidity` (entering)
- At `tickUpper`: `liquidity_net -= position.liquidity` (exiting)

When a swap crosses a tick boundary, the pool's `liquidity` shifts by `± tick.liquidity_net`. This encoding makes the per-tick aggregate O(1) at swap time — no per-position iteration.

Quoters consult these to predict liquidity changes at tick crossings during swap simulation. Without them, large swaps would be priced as if the liquidity stayed flat, producing wildy wrong quotes

Column | Type | Nullable | Details
----------------|---------|----------|--------
pool\_address | bytea | not null | FK → `uniswap_v3_pools(address)`
tick\_idx | int | not null | Tick coordinate (signed int24); same domain as [`uniswap_v3_pool_states.tick`](#uniswap_v3_pool_states)
liquidity\_net | numeric | not null | int128, signed — net liquidity entering (+) / exiting (-) at this tick

Indexes:
- PRIMARY KEY: btree (`pool_address`, `tick_idx`)
The `pool-indexer` service uses its own per-network database, not these shared DBs. Its tables (`pool_indexer_checkpoints`, `uniswap_v3_pools`, `uniswap_v3_pool_states`, `uniswap_v3_ticks`) and migrations live in [`sql-pool-indexer/`](sql-pool-indexer/).

### cow\_amms

Expand Down
99 changes: 99 additions & 0 deletions database/sql-pool-indexer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Pool-indexer migrations

Flyway migrations for the pool-indexer's own per-network database (e.g.
`ink_pool_indexer`), kept out of the shared `../sql/` set so they don't run
against the autopilot/orderbook main DBs.

The migration image ships both dirs; init containers pick one via `-locations`:

| DB | location |
|---------------------|-----------------------------------------------------|
| autopilot/orderbook | `/flyway/sql` (default) |
| pool-indexer | `-locations=filesystem:/flyway/sql-pool-indexer` |
Comment thread
AryanGodara marked this conversation as resolved.

New pool-indexer migrations go here, never in `../sql/`. `V110` is duplicated
from `../sql/` on purpose: the shared copy can't be deleted (Flyway checksums
applied migrations) so it's cancelled there by `../sql/V111`.

## Schema

The tables below live in the indexer's own per-network database (e.g.
`ink_pool_indexer`), created by the migrations in this directory.

### pool\_indexer\_checkpoints

Highest finalized block processed per `contract_address` by `pool-indexer`. `contract_address` is the factory address. The indexer runs one process per network against its own DB, so there's no `chain_id` column.

Column | Type | Nullable | Details
--------------------|--------|----------|--------
contract\_address | bytea | not null | Factory address (20 bytes)
block\_number | bigint | not null |

Indexes:
- PRIMARY KEY: btree (`contract_address`)

### uniswap\_v3\_pools

One row per pool discovered from a `PoolCreated` event. `token{0,1}_{decimals,symbol}` are nullable and filled in by the backfill task. `factory` partitions the table when multiple V3-compatible factories run on the same network so each indexer touches only its own rows.

Column | Type | Nullable | Details
-------------------|----------|----------|--------
address | bytea | not null | Pool address (20 bytes)
factory | bytea | not null | Address of the V3 factory that emitted `PoolCreated`
token0 | bytea | not null |
token1 | bytea | not null |
fee | int | not null | Hundredths of a basis point (500 = 0.05%, 3000 = 0.3%, 10000 = 1%). `CHECK (fee > 0)`.
token0\_decimals | smallint | nullable | `NULL` = not yet fetched. `-1` = sentinel for "fetched but call failed"
token1\_decimals | smallint | nullable |
token0\_symbol | text | nullable | `NULL` = not yet fetched. `""` = sentinel for "fetched but call failed"
token1\_symbol | text | nullable |
created\_block | bigint | not null | Block in which the pool was created on-chain

Indexes:
- PRIMARY KEY: btree (`address`)
- Four partial indexes on `(token{0,1})` with predicate `token{0,1}_{symbol,decimals} IS NULL` to power the backfill scan.

### uniswap\_v3\_pool\_states

Current state per pool: `sqrt_price_x96` and `tick` come from the latest `Swap`/`Initialize`; `liquidity` and `block_number` also update on in-range `Mint`/`Burn`. FK → `uniswap_v3_pools`.

**Uniswap V3 pool-state primer.** Three values capture a pool's instantaneous state:

- `sqrt_price_x96` — `sqrt(price) * 2^96` where `price = token1/token0`, stored in Q64.96 fixed-point. The square-root form keeps swap math additive and bounds precision loss over the uint160 range. Mirrors on-chain `slot0.sqrtPriceX96`.
- `tick` — `floor(log_{1.0001}(price))`. Each tick is a ~0.01% price step; the current tick is the bucket the live price falls into. Routers use it to decide which positions are in-range.
- `liquidity` — sum of every position's liquidity whose `tickLower <= current_tick < tickUpper`. This is the `L` in V3's invariant `Δsqrt_price = Δamount / L`. Updates on `Swap` (the event carries the new value) and on `Mint`/`Burn` whose range spans the current tick.

The per-tick deltas that move `liquidity` when the price crosses a tick boundary live in [`uniswap_v3_ticks`](#uniswap_v3_ticks).

Column | Type | Nullable | Details
-------------------|---------|----------|--------
pool\_address | bytea | not null | FK → `uniswap_v3_pools(address)`
block\_number | bigint | not null | Block of the most recent state-changing event (`Swap`, `Initialize`, or in-range `Mint`/`Burn`).
sqrt\_price\_x96 | numeric | not null | uint160 — see primer above
liquidity | numeric | not null | uint128 — see primer above
tick | int | not null | signed int24 — see primer above

Indexes:
- PRIMARY KEY: btree (`pool_address`)

### uniswap\_v3\_ticks

Per-tick liquidity deltas. Rows with `liquidity_net = 0` are pruned. FK → `uniswap_v3_pools`.

**Why deltas instead of per-tick totals.** A V3 position covers `[tickLower, tickUpper)` and contributes to pool liquidity only when the current tick is in that range. We store the entering / exiting deltas at the bounds:

- At `tickLower`: `liquidity_net += position.liquidity` (entering)
- At `tickUpper`: `liquidity_net -= position.liquidity` (exiting)

When a swap crosses a tick boundary, the pool's `liquidity` shifts by `± tick.liquidity_net`. This encoding makes the per-tick aggregate O(1) at swap time — no per-position iteration.

Quoters consult these to predict liquidity changes at tick crossings during swap simulation. Without them, large swaps would be priced as if the liquidity stayed flat, producing wildy wrong quotes

Column | Type | Nullable | Details
----------------|---------|----------|--------
pool\_address | bytea | not null | FK → `uniswap_v3_pools(address)`
tick\_idx | int | not null | Tick coordinate (signed int24); same domain as [`uniswap_v3_pool_states.tick`](#uniswap_v3_pool_states)
liquidity\_net | numeric | not null | int128, signed — net liquidity entering (+) / exiting (-) at this tick

Indexes:
- PRIMARY KEY: btree (`pool_address`, `tick_idx`)
59 changes: 59 additions & 0 deletions database/sql-pool-indexer/V110__pool_indexer_uniswap_v3.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
-- Tracks the highest finalized block fully processed per factory contract.
-- A DB instance hosts a single network, so no `chain_id` column is needed.
CREATE TABLE pool_indexer_checkpoints (
contract_address BYTEA NOT NULL, -- factory address
block_number BIGINT NOT NULL,
PRIMARY KEY (contract_address)
);

-- One row per pool, discovered from `PoolCreated` events. `factory`
-- partitions the table so multiple V3-compatible factories on the same
-- network can coexist (logs are fetched chain-wide, then partitioned at
-- the write boundary).
CREATE TABLE uniswap_v3_pools (
address BYTEA NOT NULL, -- pool address
factory BYTEA NOT NULL,
token0 BYTEA NOT NULL,
token1 BYTEA NOT NULL,
fee INT NOT NULL CHECK (fee > 0), -- hundredths of a basis point (500 = 0.05%, 3000 = 0.3%, 10000 = 1%)
token0_decimals SMALLINT,
token1_decimals SMALLINT,
token0_symbol TEXT,
token1_symbol TEXT,
created_block BIGINT NOT NULL,
PRIMARY KEY (address)
);

-- Current state per pool. `sqrt_price_x96` + `tick` come from the latest
-- Swap/Initialize; `liquidity` + `block_number` also update on in-range
-- Mint/Burn events.
CREATE TABLE uniswap_v3_pool_states (
pool_address BYTEA NOT NULL,
block_number BIGINT NOT NULL,
sqrt_price_x96 NUMERIC NOT NULL, -- uint160
liquidity NUMERIC NOT NULL, -- uint128
-- `tick` here means the Uniswap V3 *price tick index* (signed
-- int24), not a database index. See `uniswap_v3_ticks.tick_idx`.
tick INT NOT NULL,
PRIMARY KEY (pool_address),
FOREIGN KEY (pool_address) REFERENCES uniswap_v3_pools(address)
);

-- Active ticks per pool. Rows with `liquidity_net = 0` are pruned.
-- `tick_idx` is the price tick coordinate (signed int24) — same domain
-- as `uniswap_v3_pool_states.tick`, one row per active tick boundary.
CREATE TABLE uniswap_v3_ticks (
pool_address BYTEA NOT NULL,
tick_idx INT NOT NULL,
liquidity_net NUMERIC NOT NULL, -- int128 (can be negative)
PRIMARY KEY (pool_address, tick_idx),
FOREIGN KEY (pool_address) REFERENCES uniswap_v3_pools(address)
);

-- Symbol/decimals backfill hot paths. Partial on `IS NULL` so each
-- index shrinks to near-empty once most rows are populated (real value
-- or the `""` / `-1` "tried, failed" sentinel).
CREATE INDEX ON uniswap_v3_pools (token0) WHERE token0_symbol IS NULL;
CREATE INDEX ON uniswap_v3_pools (token1) WHERE token1_symbol IS NULL;
CREATE INDEX ON uniswap_v3_pools (token0) WHERE token0_decimals IS NULL;
CREATE INDEX ON uniswap_v3_pools (token1) WHERE token1_decimals IS NULL;
Loading
Loading