From b8468a125673ef726f1ad10d2323e50be045dd6b Mon Sep 17 00:00:00 2001 From: Hossein Zoda Date: Thu, 11 Jun 2026 14:12:12 +0000 Subject: [PATCH] ido: record created_at and launched_at timestamps Add two timestamp columns to the ido table, both sourced from Delphi NFT commitments (48-bit LE unix seconds): - created_at (NOT NULL, default 0): from the Delphi NFT in the preinit's first output, set at IDO creation. 0 if the commitment is too short. - launched_at (NULL): from the Delphi NFT in output#3 of the launch transaction, set in lockstep with launch_txid. Null until launched. Both are threaded through the parse/context/update paths and exposed on IdoRpcRecord. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/db/ido/mod.rs | 114 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 93 insertions(+), 21 deletions(-) diff --git a/src/db/ido/mod.rs b/src/db/ido/mod.rs index 7f2cebb..e44a400 100644 --- a/src/db/ido/mod.rs +++ b/src/db/ido/mod.rs @@ -708,7 +708,15 @@ pub async fn prepare_tables(pool: &SqlitePool) { txchain_entrypoint INTEGER NULL, txchain_head INTEGER NULL, is_valid INTEGER NOT NULL, - is_token_created_at_preinit INTEGER NOT NULL + is_token_created_at_preinit INTEGER NOT NULL, + -- Unix timestamp (seconds) the IDO was created, taken from the + -- Delphi NFT commitment in the preinit's first output. 0 if the + -- commitment was too short to carry a timestamp. + created_at INTEGER NOT NULL DEFAULT 0, + -- Unix timestamp (seconds) the IDO was launched, taken from the + -- Delphi NFT in output#3 of the launch transaction. NULL until the + -- IDO is launched (launch_txid set). + launched_at INTEGER NULL )", ) .execute(pool) @@ -799,6 +807,8 @@ pub struct IdoDBRecord { txchain_head: Option, is_valid: bool, is_token_created_at_preinit: bool, + created_at: i64, + launched_at: Option, } impl IdoDBRecord { fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result { @@ -817,6 +827,8 @@ impl IdoDBRecord { txchain_head: row.get(11), is_valid: row.get(12), is_token_created_at_preinit: row.get(13), + created_at: row.get(14), + launched_at: row.get(15), }) } } @@ -901,7 +913,7 @@ async fn ido_lookup( ) -> Result> { let row = sqlx::query( "SELECT internal_id, preinit_txid, init_txid, launch_txid, otoken_genesis_txid, offering_token_id, offered_token_id, - status, parameters, state, txchain_entrypoint, txchain_head, is_valid, is_token_created_at_preinit + status, parameters, state, txchain_entrypoint, txchain_head, is_valid, is_token_created_at_preinit, created_at, launched_at FROM ido WHERE internal_id = ?", ) .bind(internal_id) @@ -1042,12 +1054,17 @@ struct IdoContext { offered_token_id: Option>, offering_token_id: Option>, head_txid: Vec, + created_at: i64, + launched_at: Option, } struct IdoPreinitParseParamsResult { preinit_parameters: IdoPreInitParameters, offered_token_is_in_supply: bool, is_valid_ido: bool, + /// Creation time (unix seconds) from the Delphi NFT commitment in the + /// preinit's first output; 0 if the commitment was too short to carry one. + created_at: i64, } fn parse_ido_preinit_tx_params(tx: &Transaction, delphi_token_id: &[u8], platform_fee_nfth: &[u8], errors: &mut Vec, invalid_ido_reasons: &mut Vec) -> Option { @@ -1209,6 +1226,8 @@ fn parse_ido_preinit_tx_params(tx: &Transaction, delphi_token_id: &[u8], platfor // The first output must be the Delphi NFT. Its category must match the // announcement's delphiCategory, and the commitment timestamp (the current // time) must place launchConditions.expiresAt within the allowed offering window. + // We also record the timestamp as the IDO's creation time. + let mut created_at: i64 = 0; match tx.output.first() { Some(delphiOut) => match delphiOut.token.as_ref() { Some(delphiToken) if delphiToken.has_nft() => { @@ -1224,6 +1243,7 @@ fn parse_ido_preinit_tx_params(tx: &Transaction, delphi_token_id: &[u8], platfor let c = &delphiToken.commitment; // 48-bit little-endian timestamp (see riftenlabs_defi::delphi::parse_delphi_update). let delphi_timestamp = u64::from_le_bytes([c[0], c[1], c[2], c[3], c[4], c[5], 0, 0]); + created_at = delphi_timestamp as i64; let offering_duration = &preinit_parameters.offering.launchConditions.expiresAt - BigInt::from(delphi_timestamp); if offering_duration < *MIN_OFFERING_DURATION { invalid_ido_reasons.push(anyhow::anyhow!("expiresAt - delphi timestamp < MIN_OFFERING_DURATION (1 day)")); @@ -1250,6 +1270,7 @@ fn parse_ido_preinit_tx_params(tx: &Transaction, delphi_token_id: &[u8], platfor preinit_parameters, offered_token_is_in_supply, is_valid_ido, + created_at, }); } _ => { @@ -1269,6 +1290,7 @@ struct IdoPreinitParseResult { is_valid_ido: bool, offered_token_is_in_supply: bool, offered_token_id: Option>, + created_at: i64, } fn parse_ido_preinit_tx(network: Option, tx: &Transaction, errors: &mut Vec, invalid_ido_reasons: &mut Vec) -> IdoPreinitParseResult { @@ -1285,17 +1307,20 @@ fn parse_ido_preinit_tx(network: Option, tx: &Transaction, errors: &mut let offered_token_is_in_supply: bool; let mut is_valid_ido: bool; let nullable_preinit_parameters: Option; + let created_at: i64; // preinit announcement match parse_ido_preinit_tx_params(tx, &delphi_token_id, &platform_fee_nfth, errors, invalid_ido_reasons) { Some(output) => { offered_token_is_in_supply = output.offered_token_is_in_supply; is_valid_ido = output.is_valid_ido; nullable_preinit_parameters = Some(output.preinit_parameters); + created_at = output.created_at; } None => { offered_token_is_in_supply = false; is_valid_ido = false; nullable_preinit_parameters = None; + created_at = 0; } } @@ -1526,6 +1551,7 @@ fn parse_ido_preinit_tx(network: Option, tx: &Transaction, errors: &mut is_valid_ido, offered_token_is_in_supply, offered_token_id, + created_at, } } else { IdoPreinitParseResult { @@ -1534,6 +1560,7 @@ fn parse_ido_preinit_tx(network: Option, tx: &Transaction, errors: &mut is_valid_ido, offered_token_is_in_supply: false, offered_token_id: None, + created_at, } } } @@ -1558,6 +1585,8 @@ fn create_ido_context_from_preinit(network: Option, tx: &Transaction, e offering_token_id: None, is_token_created_at_preinit: !result.offered_token_is_in_supply, head_txid: tx.compute_txid().to_blob(), + created_at: result.created_at, + launched_at: None, }) } } @@ -1589,8 +1618,8 @@ async fn on_create_ido( // create the ido let mut dbtx = pool.begin().await?; let result = sqlx::query( - "INSERT INTO ido (preinit_txid, init_txid, launch_txid, otoken_genesis_txid, offering_token_id, offered_token_id, status, parameters, state, is_valid, is_token_created_at_preinit, txchain_entrypoint, txchain_head) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO ido (preinit_txid, init_txid, launch_txid, otoken_genesis_txid, offering_token_id, offered_token_id, status, parameters, state, is_valid, is_token_created_at_preinit, created_at, launched_at, txchain_entrypoint, txchain_head) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(context.preinit_txid) .bind(context.init_txid) @@ -1603,6 +1632,8 @@ async fn on_create_ido( .bind(serde_json::to_vec(&context.state).unwrap()) .bind(context.is_valid_ido) .bind(context.is_token_created_at_preinit) + .bind(context.created_at) + .bind(context.launched_at) .bind(None::) .bind(None::) .execute(&mut *dbtx) @@ -1655,7 +1686,10 @@ struct IdoUpdateEntry { #[derive(Clone)] enum IdoUpdate { InitTxId(Vec), - LaunchTxId(Vec), + LaunchTxId { + txid: Vec, + launched_at: Option, + }, OTokenGenesisTxId(Vec), Status(String), State(IdoState), @@ -1677,6 +1711,26 @@ fn rev_blob(v: &[u8]) -> Vec { v.iter().copied().rev().collect() } +/// Read the 48-bit little-endian unix timestamp (seconds) carried in the first +/// 6 bytes of a Delphi NFT commitment. Returns None if the commitment is too +/// short to contain one. (see riftenlabs_defi::delphi::parse_delphi_update) +fn delphi_commitment_timestamp(commitment: &[u8]) -> Option { + if commitment.len() < 6 { + return None; + } + let c = commitment; + Some(u64::from_le_bytes([c[0], c[1], c[2], c[3], c[4], c[5], 0, 0]) as i64) +} + +/// Read `launched_at` (unix seconds) from the Delphi NFT in output#3 of a launch +/// transaction. Returns None if the output, its token, or its timestamp is absent. +fn launch_tx_launched_at(tx: &Transaction) -> Option { + tx.output + .get(3) + .and_then(|o| o.token.as_ref()) + .and_then(|t| delphi_commitment_timestamp(&t.commitment)) +} + fn print_updates(updates: &[IdoUpdate]) -> () { for update in updates { match update { @@ -1684,7 +1738,7 @@ fn print_updates(updates: &[IdoUpdate]) -> () { let v=rev_blob(txid); debug!("IDO IdoUpdate::InitTxId(txid): {}", hex::encode(v)); }, - IdoUpdate::LaunchTxId(txid) => { + IdoUpdate::LaunchTxId { txid, .. } => { let v=rev_blob(txid); debug!("IDO IdoUpdate::LaunchTxId(txid): {}", hex::encode(v)); }, @@ -1882,7 +1936,10 @@ fn ido_add_tx( return Err(anyhow::anyhow!("Incorrect commitment size at output#0")); } updates.push(IdoUpdate::Status("DISTRIBUTING".to_string())); - updates.push(IdoUpdate::LaunchTxId(tx.compute_txid().to_blob())); + updates.push(IdoUpdate::LaunchTxId { + txid: tx.compute_txid().to_blob(), + launched_at: launch_tx_launched_at(tx), + }); updates.push(IdoUpdate::State( IdoState::Distributing(IdoDistributingState { authguardCategory: active_state.authguardCategory.clone(), @@ -1904,7 +1961,10 @@ fn ido_add_tx( return Err(anyhow::anyhow!("Incorrect commitment size at output#0")); } updates.push(IdoUpdate::Status("POSTLAUNCH".to_string())); - updates.push(IdoUpdate::LaunchTxId(tx.compute_txid().to_blob())); + updates.push(IdoUpdate::LaunchTxId { + txid: tx.compute_txid().to_blob(), + launched_at: launch_tx_launched_at(tx), + }); updates.push(IdoUpdate::State( IdoState::PostLaunch(IdoPostLaunchState { authguardCategory: active_state.authguardCategory.clone(), @@ -2057,7 +2117,7 @@ async fn update_ido( ) -> Result<()> { sqlx::query( "UPDATE ido set init_txid = ?, launch_txid = ?, otoken_genesis_txid = ?, status = ?, parameters = ?, state = ?, - offering_token_id = ?, offered_token_id = ?, txchain_head = ?, is_valid = ? WHERE internal_id = ?", + offering_token_id = ?, offered_token_id = ?, txchain_head = ?, is_valid = ?, launched_at = ? WHERE internal_id = ?", ) .bind(&context.init_txid) .bind(&context.launch_txid) @@ -2069,6 +2129,7 @@ async fn update_ido( .bind(&context.offered_token_id) .bind(txchain_head) .bind(context.is_valid_ido) + .bind(context.launched_at) .bind(internal_id) .execute(&mut **dbtx) .await?; @@ -2084,8 +2145,9 @@ fn apply_updates_to_context( IdoUpdate::InitTxId(txid) => { context.init_txid = Some(txid.clone()); }, - IdoUpdate::LaunchTxId(txid) => { + IdoUpdate::LaunchTxId { txid, launched_at } => { context.launch_txid = Some(txid.clone()); + context.launched_at = *launched_at; }, IdoUpdate::OTokenGenesisTxId(txid) => { context.otoken_genesis_txid = Some(txid.clone()); @@ -2306,6 +2368,8 @@ async fn on_add_ido_tx( offered_token_id: ido.offered_token_id.clone(), offering_token_id: ido.offering_token_id.clone(), head_txid: prev_txchain_item.txid.clone(), + created_at: ido.created_at, + launched_at: ido.launched_at, }; // add to the chain let result = ido_add_tx(&context, tx)?; @@ -2449,6 +2513,12 @@ pub struct IdoRpcRecord { /// Txid (display hex) of the txchain head record, or null if there is no head. pub txchain_head: Option, pub is_valid: bool, + /// Unix timestamp (seconds) the IDO was created, from the preinit's Delphi + /// NFT commitment; 0 if unavailable. + pub created_at: i64, + /// Unix timestamp (seconds) the IDO was launched, from the Delphi NFT in + /// output#3 of the launch transaction; null until launched. + pub launched_at: Option, } #[derive(Serialize, Clone)] @@ -2500,6 +2570,8 @@ impl IdoDBRecord { state: serde_json::from_slice(&self.state)?, txchain_head: txchain_head_txid.as_deref().map(blob_to_display_hex::).transpose()?, is_valid: self.is_valid, + created_at: self.created_at, + launched_at: self.launched_at, }) } } @@ -2591,7 +2663,7 @@ pub async fn list_idos( let mut qb: sqlx::QueryBuilder = sqlx::QueryBuilder::new( "SELECT ido.internal_id, ido.preinit_txid, ido.init_txid, ido.launch_txid, ido.otoken_genesis_txid, \ ido.offering_token_id, ido.offered_token_id, \ - ido.status, ido.parameters, ido.state, ido.txchain_entrypoint, ido.txchain_head, ido.is_valid, ido.is_token_created_at_preinit, \ + ido.status, ido.parameters, ido.state, ido.txchain_entrypoint, ido.txchain_head, ido.is_valid, ido.is_token_created_at_preinit, ido.created_at, ido.launched_at, \ head_tx.txid \ FROM ido LEFT JOIN ido_txchain head_tx ON head_tx.id = ido.txchain_head WHERE 1=1", ); @@ -2617,7 +2689,7 @@ pub async fn list_idos( .await? .into_iter() .map(|row| { - let txchain_head_txid: Option> = row.get(14); + let txchain_head_txid: Option> = row.get(16); IdoDBRecord::from_row(&row)?.into_rpc_record(txchain_head_txid) }) .collect() @@ -2629,7 +2701,7 @@ pub async fn get_ido_by_preinit_txid( ) -> Result> { let row = sqlx::query( "SELECT ido.internal_id, ido.preinit_txid, ido.init_txid, ido.launch_txid, ido.otoken_genesis_txid, ido.offering_token_id, ido.offered_token_id, \ - ido.status, ido.parameters, ido.state, ido.txchain_entrypoint, ido.txchain_head, ido.is_valid, ido.is_token_created_at_preinit, \ + ido.status, ido.parameters, ido.state, ido.txchain_entrypoint, ido.txchain_head, ido.is_valid, ido.is_token_created_at_preinit, ido.created_at, ido.launched_at, \ head_tx.txid \ FROM ido LEFT JOIN ido_txchain head_tx ON head_tx.id = ido.txchain_head WHERE ido.preinit_txid = ?", ) @@ -2637,7 +2709,7 @@ pub async fn get_ido_by_preinit_txid( .fetch_optional(pool) .await?; row.map(|r| { - let txchain_head_txid: Option> = r.get(14); + let txchain_head_txid: Option> = r.get(16); IdoDBRecord::from_row(&r)?.into_rpc_record(txchain_head_txid) }) .transpose() @@ -2649,7 +2721,7 @@ pub async fn get_ido_by_offering_token_id( ) -> Result> { let row = sqlx::query( "SELECT ido.internal_id, ido.preinit_txid, ido.init_txid, ido.launch_txid, ido.otoken_genesis_txid, ido.offering_token_id, ido.offered_token_id, \ - ido.status, ido.parameters, ido.state, ido.txchain_entrypoint, ido.txchain_head, ido.is_valid, ido.is_token_created_at_preinit, \ + ido.status, ido.parameters, ido.state, ido.txchain_entrypoint, ido.txchain_head, ido.is_valid, ido.is_token_created_at_preinit, ido.created_at, ido.launched_at, \ head_tx.txid \ FROM ido LEFT JOIN ido_txchain head_tx ON head_tx.id = ido.txchain_head WHERE ido.offering_token_id = ?", ) @@ -2657,7 +2729,7 @@ pub async fn get_ido_by_offering_token_id( .fetch_optional(pool) .await?; row.map(|r| { - let txchain_head_txid: Option> = r.get(14); + let txchain_head_txid: Option> = r.get(16); IdoDBRecord::from_row(&r)?.into_rpc_record(txchain_head_txid) }) .transpose() @@ -3157,7 +3229,7 @@ mod querytests { let preinit_hex = hex::encode(txid(1)); insert_test_entry(&pool, ido_id, txid(10), false).await; insert_test_entry(&pool, ido_id, txid(11), true).await; - let all = list_ido_entries(&pool, ido_id, &preinit_hex, None, 0, 100).await.unwrap(); + let all = list_ido_entries(&pool, ido_id, &preinit_hex, None, &[], 0, 100).await.unwrap(); assert_eq!(all.len(), 2); } @@ -3169,10 +3241,10 @@ mod querytests { insert_test_entry(&pool, ido_id, txid(10), false).await; insert_test_entry(&pool, ido_id, txid(11), true).await; insert_test_entry(&pool, ido_id, txid(12), true).await; - let dist = list_ido_entries(&pool, ido_id, &preinit_hex, Some(true), 0, 100).await.unwrap(); + let dist = list_ido_entries(&pool, ido_id, &preinit_hex, Some(true), &[], 0, 100).await.unwrap(); assert_eq!(dist.len(), 2); assert!(dist.iter().all(|e| e.distributed)); - let undist = list_ido_entries(&pool, ido_id, &preinit_hex, Some(false), 0, 100).await.unwrap(); + let undist = list_ido_entries(&pool, ido_id, &preinit_hex, Some(false), &[], 0, 100).await.unwrap(); assert_eq!(undist.len(), 1); assert!(!undist[0].distributed); } @@ -3187,8 +3259,8 @@ mod querytests { insert_test_entry(&pool, ido1, txid(10), false).await; insert_test_entry(&pool, ido1, txid(11), false).await; insert_test_entry(&pool, ido2, txid(12), false).await; - let e1 = list_ido_entries(&pool, ido1, &preinit_hex_1, None, 0, 100).await.unwrap(); - let e2 = list_ido_entries(&pool, ido2, &preinit_hex_2, None, 0, 100).await.unwrap(); + let e1 = list_ido_entries(&pool, ido1, &preinit_hex_1, None, &[], 0, 100).await.unwrap(); + let e2 = list_ido_entries(&pool, ido2, &preinit_hex_2, None, &[], 0, 100).await.unwrap(); assert_eq!(e1.len(), 2); assert_eq!(e2.len(), 1); }