Skip to content
Merged
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
11 changes: 11 additions & 0 deletions bin/indexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<n>` + `/tx/<hash>` — Sentrix's
// EVM JSON-RPC ignores `full=true` on getBlockByNumber, so block + tx
Expand Down
1 change: 1 addition & 0 deletions crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
121 changes: 121 additions & 0 deletions crates/api/src/routes/contracts.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

#[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<String>,
}

#[derive(Debug, Serialize, Deserialize)]
struct ContractsResponse {
contracts: Vec<ContractEntry>,
}

async fn load(
state: &SharedState,
limit: i64,
ascending: bool,
key: &str,
) -> ApiResult<Json<ContractsResponse>> {
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<SharedState>,
Query(q): Query<ListQuery>,
) -> ApiResult<Json<ContractsResponse>> {
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<SharedState>,
Query(q): Query<ListQuery>,
) -> ApiResult<Json<ContractsResponse>> {
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<SharedState>,
Query(q): Query<ListQuery>,
) -> ApiResult<Json<ContractsResponse>> {
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<SharedState> {
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 '—'");
}
}
1 change: 1 addition & 0 deletions crates/api/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 24 additions & 0 deletions crates/db/migrations/0004_contracts.sql
Original file line number Diff line number Diff line change
@@ -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);
115 changes: 115 additions & 0 deletions crates/db/src/contracts.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

/// 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<Vec<ContractRow>> {
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::<Result<_, sqlx::Error>>()
.map_err(Into::into)
}

/// Total contract count — gates the one-time history backfill.
pub async fn count(pool: &PgPool) -> DbResult<i64> {
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<Vec<CreationTx>> {
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::<Result<_, sqlx::Error>>()
.map_err(Into::into)
}
1 change: 1 addition & 0 deletions crates/db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading