diff --git a/src/db/ido/mod.rs b/src/db/ido/mod.rs index d07b8d0..844f9ad 100644 --- a/src/db/ido/mod.rs +++ b/src/db/ido/mod.rs @@ -989,9 +989,12 @@ pub fn pad_minimally_encoded_vm_number(bin: &[u8], length: usize) -> Vec { /// PoolParams io#4 and the altPPOut fallback — the conf NFT is consumed once, /// at run() input#4, and is forbidden in init/collect), plus native-BCH /// xToken IDOs (nullable xTokenCategory, single-UTXO tokenbch permanent pool). +/// Version 5: `ido_entry.first_seen` — the unix time a purchase was first +/// indexed (block MTP for block-first entries, wall clock for mempool-first), +/// exposed through the entries RPC. /// There is no migration from earlier data; delete ido.db and re-index from /// scratch. -const IDO_DB_VERSION: i64 = 4; +const IDO_DB_VERSION: i64 = 5; pub async fn set_db_version(pool: &SqlitePool) -> Result<()> { sqlx::query(&format!("PRAGMA user_version = {IDO_DB_VERSION}")) @@ -1113,6 +1116,12 @@ pub async fn prepare_tables(pool: &SqlitePool) { demand_amount INTEGER NOT NULL, lockup_timeval INTEGER NOT NULL, discount INTEGER NOT NULL, + -- Unix timestamp (seconds) the entry was first indexed: the + -- block's MTP when first seen in a confirmed block, wall-clock + -- time when first seen in the mempool. Written once (the insert is + -- ON CONFLICT DO NOTHING), so a mempool-seen purchase keeps its + -- mempool timestamp when the tx later confirms. + first_seen INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (ido_id, txid) )", ) @@ -3004,25 +3013,28 @@ fn apply_updates_to_context(context: &mut IdoContext, updates: &[IdoUpdate]) { /// /// Entries are written once and keyed by the block that introduced them /// (ON CONFLICT DO NOTHING, so replaying a chain that already has them is a -/// no-op and their original blockhash is preserved). Distribution is recorded -/// as a row in ido_distribution rather than mutating the entry in place, so a -/// reorg that drops `blockhash` un-distributes the purchase. `tx_txid` is the -/// txid of the tx currently being indexed (the distributing tx), and -/// `blockhash` is None while the tx is only seen in the mempool. +/// no-op and their original blockhash and first_seen are preserved). +/// Distribution is recorded as a row in ido_distribution rather than mutating +/// the entry in place, so a reorg that drops `blockhash` un-distributes the +/// purchase. `tx_txid` is the txid of the tx currently being indexed (the +/// distributing tx), `blockhash` is None while the tx is only seen in the +/// mempool, and `first_seen` is the unix time stamped onto new entries (block +/// MTP or mempool wall clock, see index_txs). async fn upsert_updates_to_ido_entries( dbtx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, internal_id: i64, updates: &[IdoUpdate], tx_txid: &[u8], blockhash: Option<&BlockHash>, + first_seen: i64, ) -> Result<()> { let blockhash_blob = blockhash.map(|h| h.to_blob()); for update in updates { match update { IdoUpdate::Entry(entry) => { sqlx::query( - "INSERT INTO ido_entry (ido_id, txid, blockhash, owner_nfthash, commitment, supply_amount, demand_amount, lockup_timeval, discount) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + "INSERT INTO ido_entry (ido_id, txid, blockhash, owner_nfthash, commitment, supply_amount, demand_amount, lockup_timeval, discount, first_seen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(ido_id, txid) DO NOTHING", ) .bind(internal_id) @@ -3034,6 +3046,7 @@ async fn upsert_updates_to_ido_entries( .bind(entry.demand_amount as i64) .bind(entry.lockup_timeval as i64) .bind(entry.discount as i64) + .bind(first_seen) .execute(&mut **dbtx) .await?; } @@ -3083,12 +3096,14 @@ async fn stamp_block_for_tx( /// Apply `tx` to the ido whose chain it extends. `prev` is the state snapshot /// that `tx` continues (found by lookup_state_by_next_output). Because every /// past state is kept in ido_state, the snapshot is read directly from `prev` — -/// no replay of the chain from the preinit is needed. +/// no replay of the chain from the preinit is needed. `first_seen` stamps any +/// purchase entries the tx introduces. async fn on_add_ido_tx( pool: &SqlitePool, prev: &IdoDBRecord, tx: &Transaction, blockhash: Option<&BlockHash>, + first_seen: i64, ) -> Result<()> { let tx_txid = tx.compute_txid().to_blob(); debug!( @@ -3151,6 +3166,7 @@ async fn on_add_ido_tx( &result.updates, &tx_txid, blockhash, + first_seen, ) .await?; dbtx.commit().await?; @@ -3173,12 +3189,19 @@ fn is_ido_sig_in_tx_inputs(tx: &Transaction) -> bool { /// indexing the mempool — mempool txs have no block yet, so their rows are /// stored with a NULL blockhash and stamped once the tx confirms (see /// stamp_block_for_tx). +/// +/// `mtp` is the block's median-time-past when indexing a confirmed block and +/// None for the mempool; it becomes new purchase entries' `first_seen` (the +/// mempool falls back to wall-clock time, matching the cauldron pool-history +/// first_seen_timestamp convention). pub async fn index_txs( network: Option, pool: &SqlitePool, sorted_txs: &[Transaction], blockhash: Option<&BlockHash>, + mtp: Option, ) -> Result<()> { + let first_seen = mtp.unwrap_or_else(crate::timeutil::time_now); for tx in sorted_txs { // detect a new ido if is_preinit_broadcast(tx) { @@ -3226,7 +3249,9 @@ pub async fn index_txs( ) .await? { - if let Err(err) = on_add_ido_tx(pool, &prev, tx, blockhash).await { + if let Err(err) = + on_add_ido_tx(pool, &prev, tx, blockhash, first_seen).await + { info!( "add tx to an ido failed, txid: {}, {}", blob_to_display_hex::(&tx.compute_txid().to_blob())?, @@ -3321,6 +3346,10 @@ pub struct IdoEntryRpcRecord { pub lockup_timeval: i64, pub discount: i64, pub distributed: bool, + /// Unix timestamp (seconds) the purchase was first indexed: the block MTP + /// when first seen in a confirmed block, wall-clock time when first seen + /// in the mempool. 0 for rows written before the field existed. + pub first_seen: i64, } impl IdoDBRecord { @@ -3612,7 +3641,7 @@ pub async fn list_ido_entries( let dist_expr = "EXISTS(SELECT 1 FROM ido_distribution d WHERE d.ido_id = e.ido_id AND d.entry_txid = e.txid)"; let mut qb: sqlx::QueryBuilder = sqlx::QueryBuilder::new(format!( "SELECT e.txid, e.owner_nfthash, e.commitment, e.supply_amount, e.demand_amount, \ - e.lockup_timeval, e.discount, {dist_expr} FROM ido_entry e WHERE e.ido_id = " + e.lockup_timeval, e.discount, {dist_expr}, e.first_seen FROM ido_entry e WHERE e.ido_id = " )); qb.push_bind(internal_id); if let Some(v) = distributed { @@ -3651,6 +3680,7 @@ pub async fn list_ido_entries( lockup_timeval: row.get(5), discount: row.get(6), distributed: distributed_int != 0, + first_seen: row.get(8), }) }) .collect() @@ -4060,6 +4090,32 @@ mod querytests { assert_eq!(all.len(), 2); } + #[rocket::async_test] + async fn list_ido_entries_carries_first_seen() { + let pool = make_pool().await; + let ido_id = insert_test_ido(&pool, txid(1), "ACTIVE", true, true, None, None).await; + let preinit_hex = hex::encode(txid(1)); + sqlx::query( + "INSERT INTO ido_entry (ido_id, txid, owner_nfthash, commitment, + supply_amount, demand_amount, lockup_timeval, discount, first_seen) + VALUES (?, ?, ?, NULL, 0, 0, 0, 0, 1700000123)", + ) + .bind(ido_id) + .bind(txid(10)) + .bind(vec![0u8; 32]) + .execute(&pool) + .await + .unwrap(); + // A row written without the column (pre-upgrade shape) defaults to 0. + insert_test_entry(&pool, ido_id, txid(11), false).await; + let all = list_ido_entries(&pool, ido_id, &preinit_hex, None, &[], 0, 100) + .await + .unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].first_seen, 1_700_000_123); + assert_eq!(all[1].first_seen, 0); + } + #[rocket::async_test] async fn list_ido_entries_filter_distributed() { let pool = make_pool().await; diff --git a/src/index.rs b/src/index.rs index 0813c50..930f0e9 100644 --- a/src/index.rs +++ b/src/index.rs @@ -275,7 +275,7 @@ pub async fn update_mempool( // an ido chain can have several unconfirmed txs in flight; index parents first let txs_to_add = ttor_sorted_kahn(txs_to_add); - db::ido::index_txs(network, &db.ido_w, &txs_to_add, None).await?; + db::ido::index_txs(network, &db.ido_w, &txs_to_add, None, None).await?; // bcmr updates: index newly registered BCMRs without waiting for a confirmation @@ -641,7 +641,8 @@ pub async fn index_blocks( ) .await?; - db::ido::index_txs(network, &db.ido_w, &sorted_txs, Some(&blockhash)).await?; + db::ido::index_txs(network, &db.ido_w, &sorted_txs, Some(&blockhash), Some(mtp as i64)) + .await?; let autheader_updates = if bcmr_enabled { let updates = index_bcmr(&db.bcmr_w, &blockhash, sorted_txs).await?;