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:
parent
f3818fd7a2
commit
c2b1b5be09
2 changed files with 70 additions and 13 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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?;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue