diff --git a/bin/indexer.rs b/bin/indexer.rs index d0ee5d2..32d14d5 100644 --- a/bin/indexer.rs +++ b/bin/indexer.rs @@ -52,6 +52,10 @@ struct IndexerConfig { indexer_analytics_flush_secs: u64, #[serde(default = "default_stats_refresh_secs")] indexer_stats_refresh_secs: u64, + #[serde(default = "default_contract_detect_interval_secs")] + indexer_contract_detect_interval_secs: u64, + #[serde(default = "default_contract_detect_batch")] + indexer_contract_detect_batch: i64, } fn default_network() -> String { @@ -72,6 +76,12 @@ fn default_analytics_flush_secs() -> u64 { fn default_stats_refresh_secs() -> u64 { 300 } +fn default_contract_detect_interval_secs() -> u64 { + 4 +} +fn default_contract_detect_batch() -> i64 { + 10 +} #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -96,13 +106,20 @@ 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. + // One-time address-history backfill: seed `addresses` from every from/to + // address already in `transactions` so the contract detector can classify + // historical addresses too. No-op once `addresses` is populated. Runs in the + // background — the GROUP BY over all txs is heavy 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"); + match indexer_db::addresses::count(&pool).await { + Ok(0) => match indexer_db::addresses::backfill_from_transactions(&pool).await { + Ok(n) => tracing::info!(inserted = n, "addresses: history backfill complete"), + Err(e) => tracing::warn!(error = %e, "addresses history backfill failed"), + }, + Ok(_) => {} + Err(e) => tracing::warn!(error = %e, "addresses backfill: count failed"), } }); } @@ -271,11 +288,27 @@ async fn main() -> anyhow::Result<()> { }) }; + // Contract detector: lazily classify `addresses` (is_contract + code_hash) + // via eth_getCode, rate-limited, so /contracts/* fills over time. + let detector_handle = { + let pool = pool.clone(); + let provider = provider.clone(); + let cancel = cancel.clone(); + let interval = Duration::from_secs(cfg.indexer_contract_detect_interval_secs); + let batch = cfg.indexer_contract_detect_batch; + tokio::spawn(async move { + indexer_sync::run_contract_detector(&pool, &provider, interval, batch, cancel) + .await + .map_err(anyhow::Error::from) + }) + }; + shutdown_signal().await; tracing::info!("indexer: shutdown signal received; cancelling workers"); cancel.cancel(); let _ = stats_refresh_handle.await?; + let _ = detector_handle.await?; let _ = backfill_handle.await?; let _ = coinblast_handle.await?; if let Some(t) = tail_handle { diff --git a/crates/api/src/routes/contracts.rs b/crates/api/src/routes/contracts.rs index 4d3df25..3ac7b94 100644 --- a/crates/api/src/routes/contracts.rs +++ b/crates/api/src/routes/contracts.rs @@ -1,5 +1,5 @@ //! `/contracts/recent|pioneers|stats` — contract leaderboards from the -//! `contracts` table (migration 0004). Response shape +//! `addresses` table (migration 0005), `WHERE is_contract = true`. Response shape //! `{"contracts":[{rank, address, first_seen_block, last_seen_block, code_hash}]}`, //! matching the legacy indexer / the explorer's expected contract. @@ -9,7 +9,7 @@ use crate::{CacheTier, SharedState, cached}; use axum::extract::{Query, State}; use axum::routing::get; use axum::{Json, Router}; -use indexer_db::contracts; +use indexer_db::addresses; use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize)] @@ -39,7 +39,7 @@ async fn load( key: &str, ) -> ApiResult> { let resp: ContractsResponse = cached::get_or_load(state, key, CacheTier::Chain, || async { - let rows = contracts::list(&state.pool, limit, ascending).await?; + let rows = addresses::list_contracts(&state.pool, limit, ascending).await?; Ok::<_, ApiError>(ContractsResponse { contracts: rows .into_iter() diff --git a/crates/chain/src/provider.rs b/crates/chain/src/provider.rs index d1477f8..68c6ac3 100644 --- a/crates/chain/src/provider.rs +++ b/crates/chain/src/provider.rs @@ -123,6 +123,13 @@ impl ChainProvider { self.next().get_logs(&filter).await.map_err(rpc_err) } + /// `eth_getCode(address, latest)` — the account's deployed bytecode. Empty + /// (`0x`) for an EOA, non-empty for a contract. Used by the contract + /// detector to classify `addresses.is_contract` + `code_hash`. + pub async fn get_code(&self, address: Address) -> ChainResult { + self.next().get_code_at(address).await.map_err(rpc_err) + } + /// `eth_call` against `to` with abi-encoded `data`. Returns the raw /// return bytes; caller decodes via `alloy_sol_types`. Used by the /// CoinBlast worker to validate orphan curves (probe `token()` etc). diff --git a/crates/db/migrations/0005_addresses.sql b/crates/db/migrations/0005_addresses.sql new file mode 100644 index 0000000..6d722f9 --- /dev/null +++ b/crates/db/migrations/0005_addresses.sql @@ -0,0 +1,30 @@ +-- 0005_addresses.sql — address registry powering /contracts leaderboards. +-- +-- Every `from`/`to` address seen in a tx is upserted here by the block writer +-- (is_contract=false, code_hash NULL = "not yet classified"). A background +-- detector lazily runs eth_getCode on unclassified rows and sets is_contract + +-- code_hash ("0x" for EOAs, keccak(code) for contracts). `/contracts/*` then +-- serves `WHERE is_contract = true ORDER BY first_seen_block`. Mirrors the +-- legacy TS indexer's `addresses` table + contract-detector worker. +-- +-- Supersedes migration 0004's `contracts` table (which detected creations via +-- `to_addr IS NULL` — wrong for Sentrix, which records to_addr = the contract +-- address). The 0004 table is left in place (append-only migrations) but +-- unused; the API now reads `addresses`. + +CREATE TABLE IF NOT EXISTS addresses ( + address varchar(42) PRIMARY KEY, + first_seen_block bigint NOT NULL, + last_seen_block bigint NOT NULL, + is_contract boolean NOT NULL DEFAULT false, + code_hash varchar(66) +); + +-- /contracts/recent (DESC) + /contracts/pioneers (ASC): is_contract narrows, +-- first_seen_block sorts within the narrowed slice. +CREATE INDEX IF NOT EXISTS addresses_contract_recent_idx + ON addresses (is_contract, first_seen_block); + +-- Detector candidate scan stays cheap: only unclassified rows. +CREATE INDEX IF NOT EXISTS addresses_unclassified_idx + ON addresses (address) WHERE code_hash IS NULL; diff --git a/crates/db/src/addresses.rs b/crates/db/src/addresses.rs new file mode 100644 index 0000000..7850d8c --- /dev/null +++ b/crates/db/src/addresses.rs @@ -0,0 +1,135 @@ +//! `addresses` registry (migration 0005) — every from/to address seen in a tx, +//! lazily classified as contract/EOA by the detector worker. Powers +//! `/contracts/recent|pioneers|stats` (`WHERE is_contract = true`). + +use crate::{DbResult, PgPool}; +use sqlx::Row; + +/// One contract row for the `/contracts` leaderboards. +#[derive(Debug, Clone)] +pub struct ContractRow { + /// Contract address (lowercase 0x-hex). + pub address: String, + /// Block the address was first seen. + pub first_seen_block: i64, + /// Block the address was most recently seen. + pub last_seen_block: i64, + /// `keccak(code)` for the contract; never NULL once classified. + pub code_hash: Option, +} + +/// Upsert a batch of `(address, block)` sightings. Keeps the earliest +/// `first_seen_block` (ON CONFLICT leaves it) and advances `last_seen_block`. +/// New rows default to `is_contract=false, code_hash=NULL` (unclassified) so +/// the detector picks them up. Idempotent — safe on reorg replay. +pub async fn upsert_batch<'e, E>(executor: E, seen: &[(String, i64)]) -> DbResult<()> +where + E: sqlx::PgExecutor<'e>, +{ + if seen.is_empty() { + return Ok(()); + } + let mut qb = sqlx::QueryBuilder::new( + "INSERT INTO addresses (address, first_seen_block, last_seen_block) ", + ); + qb.push_values(seen.iter(), |mut row, (addr, block)| { + row.push_bind(addr).push_bind(*block).push_bind(*block); + }); + qb.push( + " ON CONFLICT (address) DO UPDATE SET \ + last_seen_block = GREATEST(addresses.last_seen_block, EXCLUDED.last_seen_block)", + ); + qb.build().execute(executor).await?; + Ok(()) +} + +/// Up to `limit` not-yet-classified addresses (code_hash IS NULL) for the +/// detector to run eth_getCode against. Uses the partial unclassified index. +pub async fn unclassified_batch(pool: &PgPool, limit: i64) -> DbResult> { + let rows = sqlx::query("SELECT address FROM addresses WHERE code_hash IS NULL LIMIT $1") + .bind(limit) + .fetch_all(pool) + .await?; + rows.into_iter() + .map(|r| r.try_get::("address")) + .collect::>() + .map_err(Into::into) +} + +/// Record a detector classification. `code_hash` is always set after a probe +/// ("0x" for an EOA, keccak(code) for a contract) so the row leaves the +/// unclassified set. +pub async fn classify<'e, E>( + executor: E, + address: &str, + is_contract: bool, + code_hash: &str, +) -> DbResult<()> +where + E: sqlx::PgExecutor<'e>, +{ + sqlx::query("UPDATE addresses SET is_contract = $2, code_hash = $3 WHERE address = $1") + .bind(address) + .bind(is_contract) + .bind(code_hash) + .execute(executor) + .await?; + Ok(()) +} + +/// List contracts by first-seen height. `ascending` → pioneers (oldest first); +/// otherwise recent (newest first). +pub async fn list_contracts( + pool: &PgPool, + limit: i64, + ascending: bool, +) -> DbResult> { + let sql = if ascending { + "SELECT address, first_seen_block, last_seen_block, code_hash \ + FROM addresses WHERE is_contract = true \ + ORDER BY first_seen_block ASC, address ASC LIMIT $1" + } else { + "SELECT address, first_seen_block, last_seen_block, code_hash \ + FROM addresses WHERE is_contract = true \ + 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 address rows — gates the one-time history backfill. +pub async fn count(pool: &PgPool) -> DbResult { + let row = sqlx::query("SELECT COUNT(*) AS n FROM addresses") + .fetch_one(pool) + .await?; + Ok(row.try_get("n")?) +} + +/// One-time history backfill: seed `addresses` from every from/to address +/// already in `transactions`, with min/max block as first/last seen. Rows land +/// unclassified (code_hash NULL) for the detector to process. Returns rows +/// inserted. No-op-safe via ON CONFLICT. +pub async fn backfill_from_transactions(pool: &PgPool) -> DbResult { + let res = sqlx::query( + "INSERT INTO addresses (address, first_seen_block, last_seen_block) \ + SELECT addr, MIN(block_height), MAX(block_height) FROM ( \ + SELECT from_addr AS addr, block_height FROM transactions WHERE from_addr IS NOT NULL \ + UNION ALL \ + SELECT to_addr AS addr, block_height FROM transactions WHERE to_addr IS NOT NULL \ + ) u GROUP BY addr \ + ON CONFLICT (address) DO NOTHING", + ) + .execute(pool) + .await?; + Ok(res.rows_affected()) +} diff --git a/crates/db/src/contracts.rs b/crates/db/src/contracts.rs deleted file mode 100644 index 6c32cbe..0000000 --- a/crates/db/src/contracts.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! `/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 e4d75d5..3c5aaa6 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -7,11 +7,11 @@ #![cfg_attr(not(test), warn(missing_docs))] +pub mod addresses; 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 13a09c4..52a3ae6 100644 --- a/crates/sync/src/block_writer.rs +++ b/crates/sync/src/block_writer.rs @@ -13,45 +13,29 @@ use crate::cursor::write_cursor; use crate::{SyncError, SyncResult}; -use alloy_primitives::Address; use indexer_analytics::{AnalyticsHandle, RawTxRow}; -use indexer_db::{PgPool, blocks, contracts, logs, token_transfers, transactions}; +use indexer_db::{PgPool, addresses, blocks, 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; +use std::collections::HashMap; + +/// Collect every from/to address in `txs` for the `addresses` registry, deduped +/// to one `(address, earliest-block)` per address so the batch upsert never hits +/// the same ON CONFLICT target twice. New rows seed `first_seen_block`; the +/// contract detector classifies them (is_contract/code_hash) afterwards. +fn tx_addresses(txs: &[Transaction]) -> Vec<(String, i64)> { + let mut seen: HashMap = HashMap::new(); + for t in txs { + let h = t.block_height.0; + seen.entry(t.from_addr.clone()) + .and_modify(|b| *b = (*b).min(h)) + .or_insert(h); + if let Some(to) = &t.to_addr { + seen.entry(to.clone()) + .and_modify(|b| *b = (*b).min(h)) + .or_insert(h); } } - tx.commit().await.map_err(SyncError::from)?; - tracing::info!(total, written, "contracts: history backfill complete"); - Ok(()) + seen.into_iter().collect() } /// Page size for batch inserts. Postgres protocol caps bind params at @@ -94,13 +78,9 @@ 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?; - } } + // Register from/to addresses for the contract detector (/contracts). + addresses::upsert_batch(&mut *tx, &tx_addresses(&b.txs)).await?; for l in &b.logs { logs::insert(&mut *tx, l).await?; } @@ -209,13 +189,9 @@ 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?; - } + // Register from/to addresses for the contract detector (/contracts). + for chunk in tx_addresses(&all_txs).chunks(BATCH_INSERT_CHUNK) { + addresses::upsert_batch(&mut *tx, chunk).await?; } for chunk in all_logs.chunks(BATCH_INSERT_CHUNK) { logs::insert_batch(&mut *tx, chunk).await?; @@ -255,27 +231,3 @@ 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/crates/sync/src/contract_detect.rs b/crates/sync/src/contract_detect.rs new file mode 100644 index 0000000..140be16 --- /dev/null +++ b/crates/sync/src/contract_detect.rs @@ -0,0 +1,67 @@ +//! Lazy contract detector — classifies `addresses` rows (`is_contract` + +//! `code_hash`) by running `eth_getCode`, rate-limited so a cold start doesn't +//! flood the RPC. Mirrors the legacy TS `contract-detect.ts` worker. +//! `/contracts/*` then serves `WHERE is_contract = true`. + +use crate::SyncResult; +use alloy_primitives::{Address, keccak256}; +use indexer_chain::ChainProvider; +use indexer_db::{PgPool, addresses}; +use std::str::FromStr; +use std::time::Duration; +use tokio_util::sync::CancellationToken; + +/// What `eth_getCode` returns for an account with no code (an EOA). +const NO_CODE: &str = "0x"; + +/// Run the detector until cancelled. Each tick classifies up to `batch` +/// not-yet-classified addresses, then waits `interval` before the next sweep. +/// A `getCode` failure leaves the row unclassified (retried next sweep); an +/// unparseable address is marked EOA so it never blocks the queue. +pub async fn run_contract_detector( + pool: &PgPool, + provider: &ChainProvider, + interval: Duration, + batch: i64, + cancel: CancellationToken, +) -> SyncResult<()> { + let mut tick = tokio::time::interval(interval); + loop { + tokio::select! { + _ = cancel.cancelled() => return Ok(()), + _ = tick.tick() => { + let candidates = addresses::unclassified_batch(pool, batch).await?; + for addr in candidates { + if cancel.is_cancelled() { + return Ok(()); + } + if let Err(e) = classify_one(pool, provider, &addr).await { + tracing::warn!( + address = %addr, error = %e, + "contract detector: classify failed; will retry next sweep" + ); + } + } + } + } + } +} + +/// Probe one address with `eth_getCode` and record the result. +async fn classify_one(pool: &PgPool, provider: &ChainProvider, addr: &str) -> SyncResult<()> { + let Ok(parsed) = Address::from_str(addr) else { + // Unparseable (shouldn't happen — addresses come from indexed txs). + // Mark EOA so it leaves the candidate set permanently. + addresses::classify(pool, addr, false, NO_CODE).await?; + return Ok(()); + }; + let code = provider.get_code(parsed).await?; + let is_contract = !code.is_empty(); + let code_hash = if is_contract { + format!("0x{:x}", keccak256(&code)) + } else { + NO_CODE.to_string() + }; + addresses::classify(pool, addr, is_contract, &code_hash).await?; + Ok(()) +} diff --git a/crates/sync/src/lib.rs b/crates/sync/src/lib.rs index a1b35bc..8523520 100644 --- a/crates/sync/src/lib.rs +++ b/crates/sync/src/lib.rs @@ -24,6 +24,7 @@ pub mod backfill; pub mod block_writer; +pub mod contract_detect; pub mod cursor; pub mod reorg; pub mod single_flight; @@ -33,6 +34,7 @@ pub mod token_decode; mod convert; pub use backfill::run_backfill; +pub use contract_detect::run_contract_detector; pub use cursor::{LAST_SYNCED_HEIGHT_KEY, read_cursor, write_cursor}; pub use single_flight::SingleFlight; pub use tail::{TailExit, run_tail}; diff --git a/scripts/smoke-fixtures.sql b/scripts/smoke-fixtures.sql index 0e53ada..ac88d32 100644 --- a/scripts/smoke-fixtures.sql +++ b/scripts/smoke-fixtures.sql @@ -102,11 +102,13 @@ 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'); +-- ── addresses (contract leaderboards) ───────────────────────────────── +-- One classified contract + one classified EOA. /contracts/* must return only +-- the contract (is_contract = true), proving the filter works. +INSERT INTO addresses (address, first_seen_block, last_seen_block, is_contract, code_hash) VALUES + ('0xc0ffee0000000000000000000000000000000001', 2, 3, true, + '0xc0dec0dec0dec0dec0dec0dec0dec0dec0dec0dec0dec0dec0dec0dec0dec0de'), + ('0xead0ead0ead0ead0ead0ead0ead0ead0ead0ead0', 1, 1, false, '0x'); -- ── refresh stats_daily_mv ───────────────────────────────────────────── -- Three blocks 86400s apart = three distinct day_buckets (19675, 19676, 19677). diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 73a1d39..048ae77 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -221,14 +221,15 @@ 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,...}]} +# /contracts/recent -> only the is_contract=true row (EOA fixture excluded); +# shape {contracts:[{address,first_seen_block,last_seen_block,code_hash}]}. v=$(curl -fsS "$API_BASE/contracts/recent" | jq -r '.contracts | length') -[[ "$v" == "1" ]] || fail "/contracts/recent len != 1 (got $v)" +[[ "$v" == "1" ]] || fail "/contracts/recent len != 1 — EOA leaked? (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)" +[[ "$v" != "null" && -n "$v" ]] || fail "/contracts/recent code_hash missing (got '$v')" +ok "/contracts/recent (is_contract filter + 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')"