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) <noreply@anthropic.com>
This commit is contained in:
Hossein Zoda 2026-06-11 14:12:12 +00:00
parent d5bdf5a740
commit b8468a1256

View file

@ -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<i64>,
is_valid: bool,
is_token_created_at_preinit: bool,
created_at: i64,
launched_at: Option<i64>,
}
impl IdoDBRecord {
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Self> {
@ -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<Option<IdoDBRecord>> {
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<Vec<u8>>,
offering_token_id: Option<Vec<u8>>,
head_txid: Vec<u8>,
created_at: i64,
launched_at: Option<i64>,
}
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<Error>, invalid_ido_reasons: &mut Vec<Error>) -> Option<IdoPreinitParseParamsResult> {
@ -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<Vec<u8>>,
created_at: i64,
}
fn parse_ido_preinit_tx(network: Option<Network>, tx: &Transaction, errors: &mut Vec<Error>, invalid_ido_reasons: &mut Vec<Error>) -> IdoPreinitParseResult {
@ -1285,17 +1307,20 @@ fn parse_ido_preinit_tx(network: Option<Network>, tx: &Transaction, errors: &mut
let offered_token_is_in_supply: bool;
let mut is_valid_ido: bool;
let nullable_preinit_parameters: Option<IdoPreInitParameters>;
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<Network>, 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<Network>, 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<Network>, 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::<i64>)
.bind(None::<i64>)
.execute(&mut *dbtx)
@ -1655,7 +1686,10 @@ struct IdoUpdateEntry {
#[derive(Clone)]
enum IdoUpdate {
InitTxId(Vec<u8>),
LaunchTxId(Vec<u8>),
LaunchTxId {
txid: Vec<u8>,
launched_at: Option<i64>,
},
OTokenGenesisTxId(Vec<u8>),
Status(String),
State(IdoState),
@ -1677,6 +1711,26 @@ fn rev_blob(v: &[u8]) -> Vec<u8> {
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<i64> {
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<i64> {
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<String>,
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<i64>,
}
#[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::<Txid>).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::Sqlite> = 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<Vec<u8>> = row.get(14);
let txchain_head_txid: Option<Vec<u8>> = 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<Option<IdoRpcRecord>> {
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<Vec<u8>> = r.get(14);
let txchain_head_txid: Option<Vec<u8>> = 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<Option<IdoRpcRecord>> {
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<Vec<u8>> = r.get(14);
let txchain_head_txid: Option<Vec<u8>> = 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);
}