diff --git a/bin/indexer.rs b/bin/indexer.rs index b57f123..d0ee5d2 100644 --- a/bin/indexer.rs +++ b/bin/indexer.rs @@ -96,6 +96,17 @@ async fn main() -> anyhow::Result<()> { let pool = connect(&pool_cfg).await?; migrate(&pool).await?; + // One-time contract-history backfill (no-op once `contracts` is populated). + // Runs in the background so it never blocks the sync loops on a large chain. + { + let pool = pool.clone(); + tokio::spawn(async move { + if let Err(e) = indexer_sync::block_writer::backfill_contracts(&pool).await { + tracing::warn!(error = %e, "contracts history backfill failed"); + } + }); + } + let provider = ChainProvider::http(&cfg.rpc_url)?; // Native REST client for `/chain/blocks/` + `/tx/` — Sentrix's // EVM JSON-RPC ignores `full=true` on getBlockByNumber, so block + tx diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 08602a2..7cd48b6 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -112,6 +112,7 @@ pub fn make_router(state: AppState, cfg: RouterConfig) -> Router { .merge(routes::leaderboards::router()) .merge(routes::coinblast::router()) .merge(routes::stats::router()) + .merge(routes::contracts::router()) .merge(routes::etherscan::router()) .with_state(shared.clone()); let gql = graphql::router(schema).with_state(shared); diff --git a/crates/api/src/routes/contracts.rs b/crates/api/src/routes/contracts.rs new file mode 100644 index 0000000..4d3df25 --- /dev/null +++ b/crates/api/src/routes/contracts.rs @@ -0,0 +1,121 @@ +//! `/contracts/recent|pioneers|stats` — contract leaderboards from the +//! `contracts` table (migration 0004). Response shape +//! `{"contracts":[{rank, address, first_seen_block, last_seen_block, code_hash}]}`, +//! matching the legacy indexer / the explorer's expected contract. + +use crate::error::{ApiError, ApiResult}; +use crate::routes::clamp_limit; +use crate::{CacheTier, SharedState, cached}; +use axum::extract::{Query, State}; +use axum::routing::get; +use axum::{Json, Router}; +use indexer_db::contracts; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +struct ListQuery { + limit: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ContractEntry { + rank: i64, + address: String, + first_seen_block: i64, + last_seen_block: i64, + /// NULL until an eth_getCode pass lands; the frontend renders it as "—". + code_hash: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ContractsResponse { + contracts: Vec, +} + +async fn load( + state: &SharedState, + limit: i64, + ascending: bool, + key: &str, +) -> ApiResult> { + let resp: ContractsResponse = cached::get_or_load(state, key, CacheTier::Chain, || async { + let rows = contracts::list(&state.pool, limit, ascending).await?; + Ok::<_, ApiError>(ContractsResponse { + contracts: rows + .into_iter() + .enumerate() + .map(|(i, r)| ContractEntry { + rank: i as i64 + 1, + address: r.address, + first_seen_block: r.first_seen_block, + last_seen_block: r.last_seen_block, + code_hash: r.code_hash, + }) + .collect(), + }) + }) + .await?; + Ok(Json(resp)) +} + +/// `/contracts/recent` — newest contracts first. +async fn recent( + State(state): State, + Query(q): Query, +) -> ApiResult> { + let limit = clamp_limit(q.limit.as_deref()); + load(&state, limit, false, &format!("contracts:recent:{limit}")).await +} + +/// `/contracts/pioneers` — earliest contracts first. +async fn pioneers( + State(state): State, + Query(q): Query, +) -> ApiResult> { + let limit = clamp_limit(q.limit.as_deref()); + load(&state, limit, true, &format!("contracts:pioneers:{limit}")).await +} + +/// `/contracts/stats` — the explorer's sortable contracts list; defaults to +/// newest-created (same as recent), kept on the shared `{contracts:[…]}` shape. +async fn stats( + State(state): State, + Query(q): Query, +) -> ApiResult> { + let limit = clamp_limit(q.limit.as_deref()); + load(&state, limit, false, &format!("contracts:stats:{limit}")).await +} + +/// Router for `/contracts/{recent,pioneers,stats}`. +pub fn router() -> Router { + Router::new() + .route("/contracts/recent", get(recent)) + .route("/contracts/pioneers", get(pioneers)) + .route("/contracts/stats", get(stats)) +} + +#[cfg(test)] +mod tests { + use super::{ContractEntry, ContractsResponse}; + + #[test] + fn contracts_response_shape() { + let resp = ContractsResponse { + contracts: vec![ContractEntry { + rank: 1, + address: "0xc0ffee".into(), + first_seen_block: 100, + last_seen_block: 200, + code_hash: None, + }], + }; + let v = serde_json::to_value(&resp).unwrap(); + assert!(v["contracts"].is_array()); + let e = &v["contracts"][0]; + assert_eq!(e["rank"], 1); + assert_eq!(e["address"], "0xc0ffee"); + assert_eq!(e["first_seen_block"], 100); + assert_eq!(e["last_seen_block"], 200); + assert!(e["code_hash"].is_null(), "null code_hash → frontend '—'"); + } +} diff --git a/crates/api/src/routes/mod.rs b/crates/api/src/routes/mod.rs index 9e4fed8..34871ec 100644 --- a/crates/api/src/routes/mod.rs +++ b/crates/api/src/routes/mod.rs @@ -5,6 +5,7 @@ pub mod address; pub mod blocks; pub mod coinblast; +pub mod contracts; pub mod etherscan; pub mod health; pub mod leaderboards; diff --git a/crates/db/migrations/0004_contracts.sql b/crates/db/migrations/0004_contracts.sql new file mode 100644 index 0000000..2fbb5a1 --- /dev/null +++ b/crates/db/migrations/0004_contracts.sql @@ -0,0 +1,24 @@ +-- 0004_contracts.sql — contract leaderboards (/contracts/recent|pioneers|stats) +-- +-- Tracks contract-creation txs (transactions.to_address IS NULL). The created +-- address is the CREATE address keccak(rlp(sender, nonce))[12:], computed by the +-- indexer (Postgres can't keccak), so this migration only creates the table — +-- the indexer's block-writer populates it going forward, and a one-time startup +-- backfill fills history from existing `transactions WHERE to_address IS NULL`. +-- +-- `code_hash` is reserved for a later eth_getCode pass (the frontend already +-- renders NULL as "—"); leaving it NULL keeps this MVP free of receipt/getCode +-- round-trips. + +CREATE TABLE IF NOT EXISTS contracts ( + address varchar(42) PRIMARY KEY, + first_seen_block bigint NOT NULL, + last_seen_block bigint NOT NULL, + code_hash varchar(66), + tx_count bigint NOT NULL DEFAULT 1, + created_tx_hash varchar(66) NOT NULL +); + +-- pioneers = earliest created; recent = newest created. +CREATE INDEX IF NOT EXISTS contracts_first_seen_idx ON contracts (first_seen_block); +CREATE INDEX IF NOT EXISTS contracts_last_seen_idx ON contracts (last_seen_block DESC); diff --git a/crates/db/src/contracts.rs b/crates/db/src/contracts.rs new file mode 100644 index 0000000..6c32cbe --- /dev/null +++ b/crates/db/src/contracts.rs @@ -0,0 +1,115 @@ +//! `/contracts/recent|pioneers|stats` — contract leaderboards backed by the +//! `contracts` table (migration 0004). Rows come from contract-creation txs +//! (`transactions.to_address IS NULL`); the created address is computed by the +//! sync layer (Postgres can't keccak) and upserted here. + +use crate::{DbResult, PgPool}; +use sqlx::Row; + +/// One contract row for the leaderboard responses. Field names mirror the +/// legacy indexer / the explorer's expected shape. +#[derive(Debug, Clone)] +pub struct ContractRow { + /// Contract address (lowercase 0x-hex). + pub address: String, + /// Block the contract was created in. + pub first_seen_block: i64, + /// Most recent block the contract was seen in. + pub last_seen_block: i64, + /// Reserved for a later eth_getCode pass; NULL renders as "—" in the UI. + pub code_hash: Option, +} + +/// Upsert a contract creation. Keeps the earliest `first_seen_block` / +/// `created_tx_hash` and advances `last_seen_block` on replays — idempotent, so +/// reorg/backfill re-runs are safe. +pub async fn upsert_creation<'e, E>( + executor: E, + address: &str, + block: i64, + tx_hash: &str, +) -> DbResult<()> +where + E: sqlx::PgExecutor<'e>, +{ + sqlx::query( + "INSERT INTO contracts (address, first_seen_block, last_seen_block, created_tx_hash) \ + VALUES ($1, $2, $2, $3) \ + ON CONFLICT (address) DO UPDATE SET \ + first_seen_block = LEAST(contracts.first_seen_block, EXCLUDED.first_seen_block), \ + last_seen_block = GREATEST(contracts.last_seen_block, EXCLUDED.last_seen_block)", + ) + .bind(address) + .bind(block) + .bind(tx_hash) + .execute(executor) + .await?; + Ok(()) +} + +/// List contracts by creation height. `ascending` → pioneers (oldest first); +/// otherwise recent (newest first). +pub async fn list(pool: &PgPool, limit: i64, ascending: bool) -> DbResult> { + let sql = if ascending { + "SELECT address, first_seen_block, last_seen_block, code_hash \ + FROM contracts ORDER BY first_seen_block ASC, address ASC LIMIT $1" + } else { + "SELECT address, first_seen_block, last_seen_block, code_hash \ + FROM contracts ORDER BY first_seen_block DESC, address ASC LIMIT $1" + }; + let rows = sqlx::query(sql).bind(limit).fetch_all(pool).await?; + rows.into_iter() + .map(|r| { + Ok(ContractRow { + address: r.try_get("address")?, + first_seen_block: r.try_get("first_seen_block")?, + last_seen_block: r.try_get("last_seen_block")?, + code_hash: r.try_get("code_hash")?, + }) + }) + .collect::>() + .map_err(Into::into) +} + +/// Total contract count — gates the one-time history backfill. +pub async fn count(pool: &PgPool) -> DbResult { + let row = sqlx::query("SELECT COUNT(*) AS n FROM contracts") + .fetch_one(pool) + .await?; + Ok(row.try_get("n")?) +} + +/// Stream creation txs already in `transactions` (to_address IS NULL) so the +/// sync layer can compute their addresses + backfill `contracts` once. +pub struct CreationTx { + /// Creator (sender) address. + pub from_addr: String, + /// Sender nonce at creation time. + pub nonce: i64, + /// Block the creation tx landed in. + pub block_height: i64, + /// Creation tx hash. + pub hash: String, +} + +/// Read all historical contract-creation txs ordered by height — used by the +/// one-time backfill. Limited columns keep the scan light. +pub async fn creation_txs(pool: &PgPool) -> DbResult> { + let rows = sqlx::query( + "SELECT from_addr, nonce, block_height, hash \ + FROM transactions WHERE to_addr IS NULL ORDER BY block_height ASC", + ) + .fetch_all(pool) + .await?; + rows.into_iter() + .map(|r| { + Ok(CreationTx { + from_addr: r.try_get("from_addr")?, + nonce: r.try_get("nonce")?, + block_height: r.try_get("block_height")?, + hash: r.try_get("hash")?, + }) + }) + .collect::>() + .map_err(Into::into) +} diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 7cf8e72..e4d75d5 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -11,6 +11,7 @@ pub mod blocks; pub mod cb_queries; pub mod cb_tokens; pub mod cb_trades; +pub mod contracts; pub mod logs; pub mod meta; pub mod stats; diff --git a/crates/sync/src/block_writer.rs b/crates/sync/src/block_writer.rs index 688a223..13a09c4 100644 --- a/crates/sync/src/block_writer.rs +++ b/crates/sync/src/block_writer.rs @@ -13,10 +13,47 @@ use crate::cursor::write_cursor; use crate::{SyncError, SyncResult}; +use alloy_primitives::Address; use indexer_analytics::{AnalyticsHandle, RawTxRow}; -use indexer_db::{PgPool, blocks, logs, token_transfers, transactions}; +use indexer_db::{PgPool, blocks, contracts, logs, token_transfers, transactions}; use indexer_domain::{Block, Log, TokenTransfer, Transaction}; +/// CREATE-address of a contract-creation tx (`to_addr IS NULL`): +/// `keccak(rlp(sender, nonce))[12:]`. Lowercase 0x-hex to match the indexer's +/// address casing. `None` if the sender doesn't parse or the nonce is negative. +fn created_contract_address(from: &str, nonce: i64) -> Option { + let sender: Address = from.parse().ok()?; + let nonce = u64::try_from(nonce).ok()?; + Some(sender.create(nonce).to_string().to_lowercase()) +} + +/// One-time history backfill for `/contracts`: scan creation txs already in +/// `transactions` (`to_addr IS NULL`), compute their CREATE addresses, and +/// populate the `contracts` table. No-op once the table has rows — the +/// steady-state block writer maintains it thereafter. Safe to call on every +/// boot (the count guard makes repeats cheap). +pub async fn backfill_contracts(pool: &PgPool) -> SyncResult<()> { + if contracts::count(pool).await? > 0 { + return Ok(()); + } + let creations = contracts::creation_txs(pool).await?; + if creations.is_empty() { + return Ok(()); + } + let total = creations.len(); + let mut tx = pool.begin().await.map_err(SyncError::from)?; + let mut written = 0usize; + for c in &creations { + if let Some(addr) = created_contract_address(&c.from_addr, c.nonce) { + contracts::upsert_creation(&mut *tx, &addr, c.block_height, &c.hash).await?; + written += 1; + } + } + tx.commit().await.map_err(SyncError::from)?; + tracing::info!(total, written, "contracts: history backfill complete"); + Ok(()) +} + /// Page size for batch inserts. Postgres protocol caps bind params at /// ~65k per query; the widest table (transactions, 15 cols) tops out at /// ~4300 rows per statement. 500 leaves comfortable headroom and keeps @@ -57,6 +94,12 @@ pub async fn write_block( blocks::insert(&mut *tx, &b.block).await?; for t in &b.txs { transactions::insert(&mut *tx, t).await?; + // Contract creation (no recipient) → record for /contracts leaderboards. + if t.to_addr.is_none() + && let Some(addr) = created_contract_address(&t.from_addr, t.nonce) + { + contracts::upsert_creation(&mut *tx, &addr, t.block_height.0, &t.hash).await?; + } } for l in &b.logs { logs::insert(&mut *tx, l).await?; @@ -166,6 +209,14 @@ pub async fn batch_write_blocks( for chunk in all_txs.chunks(BATCH_INSERT_CHUNK) { transactions::insert_batch(&mut *tx, chunk).await?; } + // Contract creations (no recipient) → /contracts leaderboards. + for t in &all_txs { + if t.to_addr.is_none() + && let Some(addr) = created_contract_address(&t.from_addr, t.nonce) + { + contracts::upsert_creation(&mut *tx, &addr, t.block_height.0, &t.hash).await?; + } + } for chunk in all_logs.chunks(BATCH_INSERT_CHUNK) { logs::insert_batch(&mut *tx, chunk).await?; } @@ -204,3 +255,27 @@ pub async fn batch_write_blocks( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::created_contract_address; + + #[test] + fn create_address_matches_known_vector() { + // Canonical CREATE example (sender, nonce 0) → fixed contract address. + let got = created_contract_address("0x6ac7ea33f8831ea9dcc53393aaa88b25a785dbf0", 0); + assert_eq!( + got.as_deref(), + Some("0xcd234a471b72ba2f1ccf0a70fcaba648a5eecd8d") + ); + } + + #[test] + fn create_address_rejects_bad_sender_and_nonce() { + assert_eq!(created_contract_address("not-an-address", 0), None); + assert_eq!( + created_contract_address("0x6ac7ea33f8831ea9dcc53393aaa88b25a785dbf0", -1), + None + ); + } +} diff --git a/scripts/smoke-fixtures.sql b/scripts/smoke-fixtures.sql index b5745ff..0e53ada 100644 --- a/scripts/smoke-fixtures.sql +++ b/scripts/smoke-fixtures.sql @@ -102,6 +102,12 @@ INSERT INTO cb_trades (curve_address, token_address, type, trader_address, srx_a 500000000, 500000000000000000, 500000, 3, '0xtxdddd00000000000000000000000000000000000000000000000000000000dd', 0); +-- ── contracts (Phase 2 leaderboards) ────────────────────────────────── +-- One fixture contract (code_hash NULL → frontend renders "—"). +INSERT INTO contracts (address, first_seen_block, last_seen_block, code_hash, tx_count, created_tx_hash) VALUES + ('0xc0ffee0000000000000000000000000000000001', 2, 3, NULL, 1, + '0xtxcreate0000000000000000000000000000000000000000000000000000cc'); + -- ── refresh stats_daily_mv ───────────────────────────────────────────── -- Three blocks 86400s apart = three distinct day_buckets (19675, 19676, 19677). REFRESH MATERIALIZED VIEW stats_daily_mv; diff --git a/scripts/smoke.sh b/scripts/smoke.sh index ec2ebf5..73a1d39 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -221,6 +221,21 @@ v=$(curl -fsS "$API_BASE/stats/daily" | jq -r '.[0].blocks | type') [[ "$v" == "number" ]] || fail "/stats/daily blocks not numeric (got $v)" ok "/stats/daily (bare array, 3 day buckets, ordered DESC)" +# /contracts/recent -> 1 fixture contract; shape {contracts:[{address,first_seen_block,...}]} +v=$(curl -fsS "$API_BASE/contracts/recent" | jq -r '.contracts | length') +[[ "$v" == "1" ]] || fail "/contracts/recent len != 1 (got $v)" +v=$(curl -fsS "$API_BASE/contracts/recent" | jq -r '.contracts[0].address') +[[ "$v" == "0xc0ffee0000000000000000000000000000000001" ]] || fail "/contracts/recent addr (got '$v')" +v=$(curl -fsS "$API_BASE/contracts/recent" | jq -r '.contracts[0].code_hash') +[[ "$v" == "null" ]] || fail "/contracts/recent code_hash != null (got '$v')" +ok "/contracts/recent (shape + null code_hash)" +# /contracts/pioneers + /contracts/stats share the shape. +v=$(curl -fsS "$API_BASE/contracts/pioneers" | jq -r '.contracts[0].address') +[[ "$v" == "0xc0ffee0000000000000000000000000000000001" ]] || fail "/contracts/pioneers addr (got '$v')" +v=$(curl -fsS "$API_BASE/contracts/stats" | jq -r '.contracts | length') +[[ "$v" == "1" ]] || fail "/contracts/stats len != 1 (got $v)" +ok "/contracts/{pioneers,stats}" + # /api?module=account&action=txlist (etherscan compat) v=$(curl -fsS "$API_BASE/api?module=account&action=txlist&address=0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" | jq -r '.status') [[ "$v" == "1" ]] || fail "/api?module=account txlist status != 1 (got '$v')"