ido: stamp purchase entries with a first_seen timestamp

ido_entry gains a first_seen column: the unix time the purchase was
first indexed — the block's median-time-past when first seen in a
confirmed block, wall-clock time when first seen in the mempool. The
entry insert is ON CONFLICT DO NOTHING, so a mempool-seen purchase
keeps its (earlier) mempool stamp when the tx later confirms. This
matches the first_seen_timestamp convention already used by the
cauldron pool-history tables.

index_txs takes a new mtp: Option<i64> parameter: the block path in
index.rs passes the block MTP it already has in scope, the mempool
path passes None and the ido module falls back to timeutil::time_now().
The stamp is threaded through on_add_ido_tx into
upsert_updates_to_ido_entries — the only real entry write path.

The entries RPC exposes it as IdoEntryRpcRecord.first_seen; 0 means
unknown (rows written before the column existed). This lets the
frontend show purchase recency: an activity feed with time-ago, a
"last purchase X ago" signal, and raise-over-time series derived
client-side from the entries.

IDO_DB_VERSION bumps 4 -> 5. As with previous versions there is no
migration: delete ido.db and re-index from scratch — historical
entries then pick up their block MTP as first_seen.

Adds a querytest covering the round-trip and the pre-upgrade
default-0 shape (list_ido_entries_carries_first_seen).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hossein Zoda 2026-08-05 02:18:43 +00:00
parent f3818fd7a2
commit c2b1b5be09
2 changed files with 70 additions and 13 deletions

View file

@ -989,9 +989,12 @@ pub fn pad_minimally_encoded_vm_number(bin: &[u8], length: usize) -> Vec<u8> {
/// PoolParams io#4 and the altPPOut fallback — the conf NFT is consumed once, /// 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 /// at run() input#4, and is forbidden in init/collect), plus native-BCH
/// xToken IDOs (nullable xTokenCategory, single-UTXO tokenbch permanent pool). /// 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 /// There is no migration from earlier data; delete ido.db and re-index from
/// scratch. /// scratch.
const IDO_DB_VERSION: i64 = 4; const IDO_DB_VERSION: i64 = 5;
pub async fn set_db_version(pool: &SqlitePool) -> Result<()> { pub async fn set_db_version(pool: &SqlitePool) -> Result<()> {
sqlx::query(&format!("PRAGMA user_version = {IDO_DB_VERSION}")) 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, demand_amount INTEGER NOT NULL,
lockup_timeval INTEGER NOT NULL, lockup_timeval INTEGER NOT NULL,
discount 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) 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 /// 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 /// (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 /// no-op and their original blockhash and first_seen are preserved).
/// as a row in ido_distribution rather than mutating the entry in place, so a /// Distribution is recorded as a row in ido_distribution rather than mutating
/// reorg that drops `blockhash` un-distributes the purchase. `tx_txid` is the /// the entry in place, so a reorg that drops `blockhash` un-distributes the
/// txid of the tx currently being indexed (the distributing tx), and /// purchase. `tx_txid` is the txid of the tx currently being indexed (the
/// `blockhash` is None while the tx is only seen in the mempool. /// 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( async fn upsert_updates_to_ido_entries(
dbtx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, dbtx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
internal_id: i64, internal_id: i64,
updates: &[IdoUpdate], updates: &[IdoUpdate],
tx_txid: &[u8], tx_txid: &[u8],
blockhash: Option<&BlockHash>, blockhash: Option<&BlockHash>,
first_seen: i64,
) -> Result<()> { ) -> Result<()> {
let blockhash_blob = blockhash.map(|h| h.to_blob()); let blockhash_blob = blockhash.map(|h| h.to_blob());
for update in updates { for update in updates {
match update { match update {
IdoUpdate::Entry(entry) => { IdoUpdate::Entry(entry) => {
sqlx::query( sqlx::query(
"INSERT INTO ido_entry (ido_id, txid, blockhash, owner_nfthash, commitment, supply_amount, demand_amount, lockup_timeval, discount) "INSERT INTO ido_entry (ido_id, txid, blockhash, owner_nfthash, commitment, supply_amount, demand_amount, lockup_timeval, discount, first_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ido_id, txid) DO NOTHING", ON CONFLICT(ido_id, txid) DO NOTHING",
) )
.bind(internal_id) .bind(internal_id)
@ -3034,6 +3046,7 @@ async fn upsert_updates_to_ido_entries(
.bind(entry.demand_amount as i64) .bind(entry.demand_amount as i64)
.bind(entry.lockup_timeval as i64) .bind(entry.lockup_timeval as i64)
.bind(entry.discount as i64) .bind(entry.discount as i64)
.bind(first_seen)
.execute(&mut **dbtx) .execute(&mut **dbtx)
.await?; .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 /// 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 /// 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` — /// 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( async fn on_add_ido_tx(
pool: &SqlitePool, pool: &SqlitePool,
prev: &IdoDBRecord, prev: &IdoDBRecord,
tx: &Transaction, tx: &Transaction,
blockhash: Option<&BlockHash>, blockhash: Option<&BlockHash>,
first_seen: i64,
) -> Result<()> { ) -> Result<()> {
let tx_txid = tx.compute_txid().to_blob(); let tx_txid = tx.compute_txid().to_blob();
debug!( debug!(
@ -3151,6 +3166,7 @@ async fn on_add_ido_tx(
&result.updates, &result.updates,
&tx_txid, &tx_txid,
blockhash, blockhash,
first_seen,
) )
.await?; .await?;
dbtx.commit().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 /// 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 /// stored with a NULL blockhash and stamped once the tx confirms (see
/// stamp_block_for_tx). /// 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( pub async fn index_txs(
network: Option<Network>, network: Option<Network>,
pool: &SqlitePool, pool: &SqlitePool,
sorted_txs: &[Transaction], sorted_txs: &[Transaction],
blockhash: Option<&BlockHash>, blockhash: Option<&BlockHash>,
mtp: Option<i64>,
) -> Result<()> { ) -> Result<()> {
let first_seen = mtp.unwrap_or_else(crate::timeutil::time_now);
for tx in sorted_txs { for tx in sorted_txs {
// detect a new ido // detect a new ido
if is_preinit_broadcast(tx) { if is_preinit_broadcast(tx) {
@ -3226,7 +3249,9 @@ pub async fn index_txs(
) )
.await? .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!( info!(
"add tx to an ido failed, txid: {}, {}", "add tx to an ido failed, txid: {}, {}",
blob_to_display_hex::<Txid>(&tx.compute_txid().to_blob())?, blob_to_display_hex::<Txid>(&tx.compute_txid().to_blob())?,
@ -3321,6 +3346,10 @@ pub struct IdoEntryRpcRecord {
pub lockup_timeval: i64, pub lockup_timeval: i64,
pub discount: i64, pub discount: i64,
pub distributed: bool, 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 { 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 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::Sqlite> = sqlx::QueryBuilder::new(format!( let mut qb: sqlx::QueryBuilder<sqlx::Sqlite> = sqlx::QueryBuilder::new(format!(
"SELECT e.txid, e.owner_nfthash, e.commitment, e.supply_amount, e.demand_amount, \ "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); qb.push_bind(internal_id);
if let Some(v) = distributed { if let Some(v) = distributed {
@ -3651,6 +3680,7 @@ pub async fn list_ido_entries(
lockup_timeval: row.get(5), lockup_timeval: row.get(5),
discount: row.get(6), discount: row.get(6),
distributed: distributed_int != 0, distributed: distributed_int != 0,
first_seen: row.get(8),
}) })
}) })
.collect() .collect()
@ -4060,6 +4090,32 @@ mod querytests {
assert_eq!(all.len(), 2); 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] #[rocket::async_test]
async fn list_ido_entries_filter_distributed() { async fn list_ido_entries_filter_distributed() {
let pool = make_pool().await; let pool = make_pool().await;

View file

@ -275,7 +275,7 @@ pub async fn update_mempool(
// an ido chain can have several unconfirmed txs in flight; index parents first // an ido chain can have several unconfirmed txs in flight; index parents first
let txs_to_add = ttor_sorted_kahn(txs_to_add); 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 // bcmr updates: index newly registered BCMRs without waiting for a confirmation
@ -641,7 +641,8 @@ pub async fn index_blocks(
) )
.await?; .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 autheader_updates = if bcmr_enabled {
let updates = index_bcmr(&db.bcmr_w, &blockhash, sorted_txs).await?; let updates = index_bcmr(&db.bcmr_w, &blockhash, sorted_txs).await?;