diff --git a/src/chain.rs b/src/chain.rs index a8c3088..f52b5b1 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -88,6 +88,12 @@ impl BlockUndoer for StoreBlockUndoer { { was_indexed = true; } + if db::ido::delete_entries(&self.db.ido_w, Some(&blockheader.block_hash())) + .await? + > 0 + { + was_indexed = true; + } if was_indexed { config_set( diff --git a/src/db/ido/mod.rs b/src/db/ido/mod.rs index 0c0281e..120ff7a 100644 --- a/src/db/ido/mod.rs +++ b/src/db/ido/mod.rs @@ -13,7 +13,7 @@ use bitcoincash::blockdata::opcodes; use bitcoincash::blockdata::script::{Builder, Instruction, PushBytes, Script, ScriptBuf}; use bitcoincash::blockdata::transaction::Transaction; use bitcoincash::{BlockHash, Network, TokenID, Txid}; -use log::{debug, info, warn}; +use log::{debug, info}; use malachite::base::num::arithmetic::traits::Sign; use malachite::base::num::basic::traits::Zero; use malachite::base::num::conversion::traits::PowerOf2Digits; @@ -22,7 +22,6 @@ use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DeserializeAs, SerializeAs}; use sqlx::{Row, SqlitePool}; use std::cmp::Ordering; -use std::collections::{HashMap, HashSet}; use std::str::FromStr; use std::sync::LazyLock; @@ -58,8 +57,6 @@ const DISTRIBUTOR_FLAG_IS_REFUND: u8 = 0x10; const CONFIRMATION_NFT_FLAG_IS_REFUND: u8 = 0x10; // 0b00000000 const ITEM_TYPE_OFFERING: u8 = 0x00; -//UNUSED 0b00000001 -//const ITEM_TYPE_LAUNCHER: u8 = 0x01; // 0b00000010 const ITEM_TYPE_DISTRIBUTOR: u8 = 0x02; // 0b00000101 @@ -67,16 +64,6 @@ const ITEM_TYPE_CONFIRMATION_NFT: u8 = 0x05; static PERMANENT_LIQUIDITY_SHARE_DENOMINATOR: LazyLock = LazyLock::new(|| Integer::from(100_000_000i64)); -// UNUSED -//static ONE_YEAR_TIMEVAL: LazyLock = LazyLock::new(|| Integer::from(31536000i64)); -// UNUSED -//static ANNUAL_RATE_DENOMINATOR: LazyLock = LazyLock::new(|| Integer::from(100_000_000i64)); -// UNUSED -//static PLATFORM_FEE_DENOMINATOR: LazyLock = LazyLock::new(|| Integer::from(100_000_000i64)); -// UNUSED -//static PRICE_DENOMINATOR: LazyLock = LazyLock::new(|| Integer::from(100_000_000_000i64)); -// below should be euqal -// PERMANENT_LIQUIDITY_SHARE_DENOMINATOR == ANNUAL_RATE_DENOMINATOR static MIN_PLP_SHARE: LazyLock = LazyLock::new(|| Integer::from(20_000_000i64)); // 20% static MAX_PLP_SHARE: LazyLock = LazyLock::new(|| Integer::from(80_000_000i64)); // 80% @@ -685,16 +672,6 @@ fn build_p2sh32_script(bytecode: &[u8]) -> ScriptBuf { .push_opcode(opcodes::all::OP_EQUAL) .into_script() } -/* -//UNUSED -fn build_p2sh20_script(bytecode: &[u8]) -> Script { - Builder::new() - .push_opcode(opcodes::all::OP_HASH160) - .push_slice(pb(hash160::Hash::hash(bytecode).as_byte_array())) - .push_opcode(opcodes::all::OP_EQUAL) - .into_script() -} -*/ pub fn bigint_to_push_opcode(n: &Integer) -> Vec { if *n > 0i64 && *n <= 16i64 { @@ -705,19 +682,24 @@ pub fn bigint_to_push_opcode(n: &Integer) -> Vec { vec![0x4f_u8] } else { let bytes = bigint_to_vm_number(n); - if bytes.len() < 76 { + if bytes.len() <= 75 { let mut r = vec![bytes.len() as u8]; r.extend(bytes); r - } else if bytes.len() < 255 { + } else if bytes.len() <= 255 { let mut r = vec![0x4c_u8, bytes.len() as u8]; r.extend(bytes); r - } else { + } else if bytes.len() <= 65535 { let mut r = vec![0x4d_u8]; r.extend_from_slice(&(bytes.len() as u16).to_le_bytes()); r.extend(bytes); r + } else { + let mut r = vec![0x4e_u8]; + r.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + r.extend(bytes); + r } } } @@ -833,52 +815,104 @@ pub fn pad_minimally_encoded_vm_number(bin: &[u8], length: usize) -> Vec { } pub async fn prepare_tables(pool: &SqlitePool) { + // The IDO schema is append-only and block-keyed so a chain reorg can be + // undone by deleting every row introduced by the orphaned block (see + // delete_entries). `ido` holds only immutable identity; all state + // that advances with the txchain is versioned in `ido_state` (current state + // = MAX(seq) for an ido), and distribution facts live in `ido_distribution` + // instead of an in-place flag. sqlx::query( "CREATE TABLE ido ( internal_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, preinit_txid BLOB NOT NULL UNIQUE, - init_txid BLOB NULL, - launch_txid BLOB NULL, - otoken_genesis_txid BLOB NULL, - offering_token_id BLOB NULL UNIQUE, - offered_token_id BLOB NULL, - status VARCHAR(20) NOT NULL, - -- status: - -- - PREINIT - -- - ACTIVE - -- - DISTRIBUTING - -- - DISTRIBUTED - parameters BLOB NOT NULL, - state BLOB NOT NULL, - txchain_entrypoint INTEGER NULL, - txchain_head INTEGER NULL, - is_valid 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 + created_at INTEGER NOT NULL DEFAULT 0 )", ) .execute(pool) .await .expect("failed to create ido table"); + // Append-only, block-keyed snapshot of everything that changes as the + // txchain advances. One row per state transition; the current state of an + // ido is the row with the greatest seq. Deleting rows by blockhash on a + // reorg automatically reverts the ido to its prior snapshot. + sqlx::query( + "CREATE TABLE ido_state ( + ido_id INTEGER NOT NULL REFERENCES ido(internal_id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + -- the tx that produced this state (preinit_txid for seq 0) + txid BLOB NOT NULL, + -- block that introduced this state; NULL while only seen in the + -- mempool, stamped with the real blockhash once it confirms. + blockhash BLOB NULL, + status VARCHAR(20) NOT NULL, + -- status: + -- - PREINIT + -- - ACTIVE + -- - DISTRIBUTING + -- - POSTLAUNCH + -- - DISTRIBUTED + parameters BLOB NOT NULL, + state BLOB NOT NULL, + init_txid BLOB NULL, + launch_txid BLOB NULL, + otoken_genesis_txid BLOB NULL, + offering_token_id BLOB NULL, + offered_token_id BLOB NULL, + is_valid INTEGER NOT NULL, + -- 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, + -- The head of the txchain at this state is this row's own `txid`. + -- The output index of `txid` that the next tx in the chain spends to + -- continue, or NULL for a terminal state (no continuation). A new tx + -- spending (txid, next_output_index) extends this ido + next_output_index INTEGER NULL, + PRIMARY KEY (ido_id, seq) + )", + ) + .execute(pool) + .await + .expect("failed to create ido_state table"); + + sqlx::query("CREATE INDEX idx_ido_state_blockhash ON ido_state(blockhash)") + .execute(pool) + .await + .expect("failed to create index ido_state(blockhash)"); + + sqlx::query("CREATE INDEX idx_ido_state_offering_token_id ON ido_state(offering_token_id)") + .execute(pool) + .await + .expect("failed to create index ido_state(offering_token_id)"); + + // Serves chain-following: find the state a new tx extends by matching the + // spent output against (txid, next_output_index). Also covers txid-only + // lookups (has_indexed_tx, blockhash stamping) as a prefix. + sqlx::query("CREATE INDEX idx_ido_state_next ON ido_state(txid, next_output_index)") + .execute(pool) + .await + .expect("failed to create index ido_state(txid, next_output_index)"); + + // One row per purchase, written once when first seen and keyed by the block + // that introduced it so a reorg can delete it. Whether an entry has been + // distributed is tracked separately in ido_distribution. sqlx::query( "CREATE TABLE ido_entry ( - ido_id INTEGER NOT NULL, - txid BLOB NOT NULL PRIMARY KEY, + ido_id INTEGER NOT NULL REFERENCES ido(internal_id) ON DELETE CASCADE, + txid BLOB NOT NULL, + blockhash BLOB NULL, owner_nfthash BLOB NOT NULL, commitment BLOB, supply_amount INTEGER NOT NULL, demand_amount INTEGER NOT NULL, lockup_timeval INTEGER NOT NULL, discount INTEGER NOT NULL, - distributed INTEGER NOT NULL + PRIMARY KEY (ido_id, txid) )", ) .execute(pool) @@ -890,50 +924,32 @@ pub async fn prepare_tables(pool: &SqlitePool) { .await .expect("failed to create index ido_entry(ido_id)"); - sqlx::query( - "CREATE TABLE ido_txchain ( - id INTEGER NOT NULL PRIMARY KEY, - prev_id INTEGER NULL, - ido_id INTEGER NOT NULL REFERENCES ido(internal_id), - txid BLOB NOT NULL UNIQUE, - tx BLOB NOT NULL - )", - ) - .execute(pool) - .await - .expect("failed to create ido_txchain table"); - - sqlx::query("CREATE INDEX idx_ido_txchain_ido_id ON ido_txchain(ido_id)") + sqlx::query("CREATE INDEX idx_ido_entry_blockhash ON ido_entry(blockhash)") .execute(pool) .await - .expect("failed to create index ido_txchain(ido_id)"); + .expect("failed to create index ido_entry(blockhash)"); + // Block-keyed distribution facts: an entry counts as distributed iff a row + // exists here for it. Deleting by blockhash on a reorg un-distributes the + // affected purchases without touching the entries themselves. sqlx::query( - "CREATE TABLE ido_txchain_tracker_map ( - txid BLOB NOT NULL PRIMARY KEY, - next_output_index INTEGER NULL, - height INTEGER NOT NULL, - txchain_id INTEGER NOT NULL + "CREATE TABLE ido_distribution ( + ido_id INTEGER NOT NULL REFERENCES ido(internal_id) ON DELETE CASCADE, + entry_txid BLOB NOT NULL, + txid BLOB NOT NULL, + blockhash BLOB NULL, + PRIMARY KEY (ido_id, entry_txid) )", ) .execute(pool) .await - .expect("failed to create ido_txchain_tracker_map table"); -} + .expect("failed to create ido_distribution table"); -/*NOTUSED -pub struct IdoEntryDBRecord { - ido_id: i64, - txid: Vec, - owner_nfthash: Vec, - commitment: Vec, - supply_amount: u64, - demand_amount: u64, - lockup_timeval: u64, - discount: u64, - distributed: bool, + sqlx::query("CREATE INDEX idx_ido_distribution_blockhash ON ido_distribution(blockhash)") + .execute(pool) + .await + .expect("failed to create index ido_distribution(blockhash)"); } -*/ #[derive(Debug, Clone, serde::Serialize)] pub struct IdoDBRecord { @@ -947,8 +963,8 @@ pub struct IdoDBRecord { status: String, parameters: Vec, state: Vec, - txchain_entrypoint: Option, - txchain_head: Option, + // txid of the head tx of the txchain at the current state + txchain_head: Option>, is_valid: bool, is_token_created_at_preinit: bool, created_at: i64, @@ -967,119 +983,56 @@ impl IdoDBRecord { status: row.get(7), parameters: row.get(8), state: row.get(9), - txchain_entrypoint: row.get(10), - 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), + txchain_head: row.get(10), + is_valid: row.get(11), + is_token_created_at_preinit: row.get(12), + created_at: row.get(13), + launched_at: row.get(14), }) } } -#[derive(Debug, Clone, serde::Serialize)] -pub struct IdoTxChainDBRecord { - id: i64, - prev_id: Option, - ido_id: i64, - txid: Vec, - tx: Vec, -} -impl IdoTxChainDBRecord { - fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result { - Ok(Self { - id: row.get(0), - prev_id: row.get(1), - ido_id: row.get(2), - txid: row.get(3), - tx: row.get(4), - }) - } -} +// SELECT clause that reconstructs an IdoDBRecord by joining an ido to one of +// its ido_state rows (aliased `s`). Column order matches IdoDBRecord::from_row. +// The state's own `txid` is the head of the txchain at that state, so it is +// selected as txchain_head. Append a FROM/JOIN and filter as needed. +const IDO_RECORD_SELECT: &str = "SELECT ido.internal_id, ido.preinit_txid, s.init_txid, s.launch_txid, s.otoken_genesis_txid, \ + s.offering_token_id, s.offered_token_id, s.status, s.parameters, s.state, \ + s.txid, s.is_valid, ido.is_token_created_at_preinit, ido.created_at, s.launched_at"; -async fn get_ido_txchain_list( - pool: &SqlitePool, - internal_id: i64, -) -> Result> { - sqlx::query( - "SELECT id, prev_id, ido_id, txid, tx - FROM ido_txchain WHERE ido_id = ?", - ) - .bind(internal_id) - .fetch_all(pool) - .await? - .iter() - .map(IdoTxChainDBRecord::from_row) - .collect() -} +// JOIN that binds `s` to the current (max-seq) ido_state row for each ido. +const IDO_CURRENT_STATE_JOIN: &str = " FROM ido JOIN ido_state s ON s.ido_id = ido.internal_id \ + AND s.seq = (SELECT MAX(seq) FROM ido_state WHERE ido_id = ido.internal_id)"; -async fn txchain_lookup_next( +/// Find the ido_state whose tx output `(txid, vout)` a new tx is spending — +/// i.e. the state that the new tx extends. Returns it as an IdoDBRecord (a full +/// snapshot of the ido at that state), or None if no chain continues from there. +/// This replaces the old tracker-map lookup; `ido_state.next_output_index` +/// records which output of each state's tx continues the chain. +async fn lookup_state_by_next_output( pool: &SqlitePool, txid: &Txid, - index: u32, -) -> Result> { - let row = sqlx::query( - "SELECT t1.id, t1.prev_id, t1.ido_id, t1.txid, t1.tx - FROM ido_txchain_tracker_map t0 LEFT JOIN ido_txchain t1 ON t0.txchain_id = t1.id WHERE t0.txid = ? AND t0.next_output_index = ?", - ) + vout: u32, +) -> Result> { + let sql = format!( + "{IDO_RECORD_SELECT} FROM ido_state s JOIN ido ON ido.internal_id = s.ido_id \ + WHERE s.txid = ? AND s.next_output_index = ?" + ); + let row = sqlx::query(&sql) .bind(txid.to_blob()) - .bind(index) + .bind(vout as i64) .fetch_optional(pool) .await?; - if let Some(row) = row { - Ok(Some(IdoTxChainDBRecord::from_row(&row)?)) - } else { - Ok(None) - } + row.map(|r| IdoDBRecord::from_row(&r)).transpose() } -async fn get_txchain_item_by_txid( - pool: &SqlitePool, - txid: &Txid, -) -> Result> { - let row = sqlx::query( - "SELECT id, prev_id, ido_id, txid, tx - FROM ido_txchain WHERE txid = ?", - ) - .bind(txid.to_blob()) - .fetch_optional(pool) - .await?; - if let Some(row) = row { - Ok(Some(IdoTxChainDBRecord::from_row(&row)?)) - } else { - Ok(None) - } -} - -async fn ido_lookup(pool: &SqlitePool, internal_id: i64) -> 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, created_at, launched_at - FROM ido WHERE internal_id = ?", - ) - .bind(internal_id) - .fetch_optional(pool) +/// Whether any ido_state row already records this tx (the tx has been indexed). +pub async fn has_indexed_tx(pool: &SqlitePool, txid: &[u8]) -> Result { + let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM ido_state WHERE txid = ?)") + .bind(txid) + .fetch_one(pool) .await?; - if let Some(row) = row { - Ok(Some(IdoDBRecord::from_row(&row)?)) - } else { - Ok(None) - } -} - -const TRACKER_MAX_BLOCK_DEPTH: i64 = 1000; - -async fn txchain_trim_tracker(pool: &SqlitePool, current_block_height: i64) -> Result<()> { - // A negative height marks a mempool entry stamped with the negative of the - // highest known block height when it was indexed (see index_mempool), so - // abs(height) is the entry's reference height in both cases. - sqlx::query( - "DELETE FROM ido_txchain_tracker_map AS t0 WHERE abs(t0.height) < ? AND NOT EXISTS (SELECT 1 FROM ido t1 WHERE t1.txchain_head = t0.txchain_id)", - ) - .bind(current_block_height - TRACKER_MAX_BLOCK_DEPTH) - .execute(pool) - .await?; - Ok(()) + Ok(exists) } // Prefix of the announcement OP_RETURN carried in the last output of a preinit @@ -1855,7 +1808,7 @@ async fn on_create_ido( network: Option, pool: &SqlitePool, tx: &Transaction, - block_height: i64, + blockhash: Option<&BlockHash>, ) -> Result<()> { debug!( "IDO on_create_ido: {}", @@ -1885,51 +1838,32 @@ async fn on_create_ido( } match result { Ok(context) => { - // create the ido + // create the ido (immutable identity only) 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, created_at, launched_at, txchain_entrypoint, txchain_head) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO ido (preinit_txid, is_token_created_at_preinit, created_at) + VALUES (?, ?, ?)", ) - .bind(context.preinit_txid) - .bind(context.init_txid) - .bind(context.launch_txid) - .bind(context.otoken_genesis_txid) - .bind(context.offering_token_id) - .bind(context.offered_token_id) - .bind(context.status) - .bind(serde_json::to_vec(&context.parameters).unwrap()) - .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) - .await; + .bind(&context.preinit_txid) + .bind(context.is_token_created_at_preinit) + .bind(context.created_at) + .execute(&mut *dbtx) + .await; match result { Ok(r) => { - // insert txchain entrypoint let internal_id = r.last_insert_rowid(); - let txchain_item_id = upsert_ido_txchain( + // seq-0 state snapshot, keyed by the preinit tx / block. Its + // txid (the preinit) is the head of the chain at this state, + // and output#1 is where the next tx continues the chain. + append_ido_state( &mut dbtx, - tx, internal_id, - None::, - block_height, + &context, + &context.preinit_txid, 1, + blockhash, ) .await?; - sqlx::query( - "UPDATE ido SET txchain_entrypoint = ?, txchain_head = ? - WHERE internal_id = ?", - ) - .bind(txchain_item_id) - .bind(txchain_item_id) - .bind(internal_id) - .execute(&mut *dbtx) - .await?; dbtx.commit().await?; Ok(()) } @@ -1956,7 +1890,6 @@ struct IdoUpdateEntry { lockup_timeval: u64, discount: u64, commitment: Vec, - distributed: bool, } #[derive(Clone)] @@ -2262,7 +2195,6 @@ fn ido_add_tx(context: &IdoContext, tx: &Transaction) -> Result { .unwrap_or(0), discount, commitment: second_output.token.as_ref().unwrap().commitment.clone(), - distributed: false, })); updates.push(IdoUpdate::State(IdoState::Active(IdoActiveState { authguardCategory: active_state.authguardCategory.clone(), @@ -2489,30 +2421,51 @@ fn ido_add_tx(context: &IdoContext, tx: &Transaction) -> Result { } } -async fn update_ido( +/// Append a new versioned snapshot of an ido's full mutable state. The current +/// state of an ido is always the row with the greatest seq; this never mutates +/// an earlier row, so deleting this block's rows on a reorg reverts the ido to +/// its previous snapshot. `txid` is the tx that produced this state (the preinit +/// txid for the seq-0 row) and is also the head of the txchain at this state; +/// `next_output_index` is the output of `txid` the next tx spends to continue +/// the chain (negative meaning no continuation, stored as NULL); `blockhash` is +/// None while only seen in the mempool. +async fn append_ido_state( dbtx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, internal_id: i64, context: &IdoContext, - txchain_head: i64, + txid: &[u8], + next_output_index: i32, + blockhash: Option<&BlockHash>, ) -> Result<()> { + let seq: i64 = + sqlx::query_scalar("SELECT IFNULL(MAX(seq), -1) + 1 FROM ido_state WHERE ido_id = ?") + .bind(internal_id) + .fetch_one(&mut **dbtx) + .await?; + let next_output_index = (next_output_index >= 0).then_some(next_output_index as i64); 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 = ?, launched_at = ? WHERE internal_id = ?", + "INSERT INTO ido_state (ido_id, seq, txid, blockhash, status, parameters, state, \ + init_txid, launch_txid, otoken_genesis_txid, offering_token_id, offered_token_id, \ + is_valid, launched_at, next_output_index) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) - .bind(&context.init_txid) - .bind(&context.launch_txid) - .bind(&context.otoken_genesis_txid) - .bind(&context.status) - .bind(serde_json::to_vec(&context.parameters).unwrap()) - .bind(serde_json::to_vec(&context.state).unwrap()) - .bind(&context.offering_token_id) - .bind(&context.offered_token_id) - .bind(txchain_head) - .bind(context.is_valid_ido) - .bind(context.launched_at) - .bind(internal_id) - .execute(&mut **dbtx) - .await?; + .bind(internal_id) + .bind(seq) + .bind(txid) + .bind(blockhash.map(|h| h.to_blob())) + .bind(&context.status) + .bind(serde_json::to_vec(&context.parameters).unwrap()) + .bind(serde_json::to_vec(&context.state).unwrap()) + .bind(&context.init_txid) + .bind(&context.launch_txid) + .bind(&context.otoken_genesis_txid) + .bind(&context.offering_token_id) + .bind(&context.offered_token_id) + .bind(context.is_valid_ido) + .bind(context.launched_at) + .bind(next_output_index) + .execute(&mut **dbtx) + .await?; Ok(()) } @@ -2549,271 +2502,153 @@ fn apply_updates_to_context(context: &mut IdoContext, updates: &[IdoUpdate]) { } } +/// Persist the entry and distribution facts carried by `updates`. +/// +/// 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. async fn upsert_updates_to_ido_entries( dbtx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, internal_id: i64, updates: &[IdoUpdate], + tx_txid: &[u8], + blockhash: Option<&BlockHash>, ) -> Result<()> { - let mut insert_list: Vec = Vec::new(); - let mut mark_distributed_list: Vec<&Vec> = Vec::new(); + let blockhash_blob = blockhash.map(|h| h.to_blob()); for update in updates { match update { - IdoUpdate::Entry(value) => { - insert_list.push(value.clone()); + IdoUpdate::Entry(entry) => { + sqlx::query( + "INSERT INTO ido_entry (ido_id, txid, blockhash, owner_nfthash, commitment, supply_amount, demand_amount, lockup_timeval, discount) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(ido_id, txid) DO NOTHING", + ) + .bind(internal_id) + .bind(&entry.txid) + .bind(&blockhash_blob) + .bind(&entry.owner_nfthash) + .bind(&entry.commitment) + .bind(entry.supply_amount as i64) + .bind(entry.demand_amount as i64) + .bind(entry.lockup_timeval as i64) + .bind(entry.discount as i64) + .execute(&mut **dbtx) + .await?; } IdoUpdate::EntryDistributed { txid } => { - let entry = insert_list.iter_mut().find(|a| a.txid == *txid); - if let Some(e) = entry { - e.distributed = true; - } else { - mark_distributed_list.push(txid); - } + sqlx::query( + "INSERT INTO ido_distribution (ido_id, entry_txid, txid, blockhash) + VALUES (?, ?, ?, ?) + ON CONFLICT(ido_id, entry_txid) DO NOTHING", + ) + .bind(internal_id) + .bind(txid) + .bind(tx_txid) + .bind(&blockhash_blob) + .execute(&mut **dbtx) + .await?; } _ => {} } } - - for entry in insert_list { - sqlx::query( - "INSERT INTO ido_entry (ido_id, txid, owner_nfthash, commitment, supply_amount, demand_amount, lockup_timeval, discount, distributed) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(internal_id) - .bind(entry.txid) - .bind(entry.owner_nfthash) - .bind(entry.commitment) - .bind(entry.supply_amount as i64) - .bind(entry.demand_amount as i64) - .bind(entry.lockup_timeval as i64) - .bind(entry.discount as i64) - .bind(entry.distributed) - .execute(&mut **dbtx) - .await?; - } - - for item_txid in mark_distributed_list { - sqlx::query("UPDATE ido_entry SET distributed = 1 WHERE txid = ?") - .bind(item_txid) - .execute(&mut **dbtx) - .await?; - } Ok(()) } -async fn upsert_ido_txchain( +/// Stamp the real blockhash onto rows that were first indexed from the mempool +/// (blockhash NULL) for a tx that has now confirmed in a block. Without this the +/// rows would stay unkeyed and a reorg delete (which matches on blockhash) could +/// never remove them. +async fn stamp_block_for_tx( dbtx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, - tx: &Transaction, - internal_id: i64, - prev_txchain_id: Option, - block_height: i64, - next_output_index: i32, -) -> Result { - let serialized_tx = bitcoincash::consensus::serialize(tx); - let result = sqlx::query( - "REPLACE INTO ido_txchain (prev_id, ido_id, txid, tx) VALUES - (?, ?, ?, ?)", - ) - .bind(prev_txchain_id) - .bind(internal_id) - .bind(tx.compute_txid().to_blob()) - .bind(serialized_tx) - .execute(&mut **dbtx) - .await?; - let txchain_item_id: i64 = result.last_insert_rowid(); - if next_output_index >= 0 { - sqlx::query( - "REPLACE INTO ido_txchain_tracker_map (txid, next_output_index, height, txchain_id) VALUES - (?, ?, ?, ?)" - ) - .bind(tx.compute_txid().to_blob()) - .bind(next_output_index) - .bind(block_height) - .bind(txchain_item_id) - .execute(&mut **dbtx) - .await?; - } - Ok(txchain_item_id) -} - -async fn update_ido_txchain_tracker_block_height( - dbtx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, - tx: &Transaction, - block_height: i64, + tx_txid: &[u8], + blockhash: &BlockHash, ) -> Result<()> { - // only update block_height - sqlx::query("UPDATE ido_txchain_tracker_map SET height = ? WHERE txid = ?") - .bind(block_height) - .bind(tx.compute_txid().to_blob()) - .execute(&mut **dbtx) - .await?; + let bh = blockhash.to_blob(); + for sql in [ + "UPDATE ido_state SET blockhash = ? WHERE txid = ? AND blockhash IS NULL", + "UPDATE ido_entry SET blockhash = ? WHERE txid = ? AND blockhash IS NULL", + "UPDATE ido_distribution SET blockhash = ? WHERE txid = ? AND blockhash IS NULL", + ] { + sqlx::query(sql) + .bind(&bh) + .bind(tx_txid) + .execute(&mut **dbtx) + .await?; + } Ok(()) } -/// Reconstruct the ordered txchain (entrypoint .. start) by following prev_id -/// back from `start_id` through `items_by_id`. The returned vec is ordered from -/// the oldest item to `start_id`; it stops at the entrypoint (the item with no -/// prev_id) and is guarded against a malformed prev_id cycle. The chain is only -/// well-formed if its first item is the ido's entrypoint. -fn reconstruct_txchain<'a>( - items_by_id: &HashMap, - start_id: Option, -) -> Vec<&'a IdoTxChainDBRecord> { - let mut chain: Vec<&IdoTxChainDBRecord> = Vec::new(); - let mut visited: HashSet = HashSet::new(); - let mut cursor = start_id.and_then(|id| items_by_id.get(&id).copied()); - while let Some(item) = cursor { - if !visited.insert(item.id) { - break; // guard against a malformed prev_id cycle - } - chain.insert(0, item); - cursor = item - .prev_id - .and_then(|prev_id| items_by_id.get(&prev_id).copied()); - } - chain -} - +/// 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. async fn on_add_ido_tx( - network: Option, pool: &SqlitePool, - ido: &IdoDBRecord, - prev_txchain_item: &IdoTxChainDBRecord, + prev: &IdoDBRecord, tx: &Transaction, - block_height: i64, + blockhash: Option<&BlockHash>, ) -> Result<()> { + let tx_txid = tx.compute_txid().to_blob(); debug!( "IDO on_add_ido_tx: {}", - blob_to_display_hex::(&tx.compute_txid().to_blob())? + blob_to_display_hex::(&tx_txid)? ); let mut dbtx = pool.begin().await?; - let items = get_ido_txchain_list(pool, ido.internal_id).await?; - // Reconstruct this ido's current txchain — exactly txchain_head back to - // txchain_entrypoint, following prev_id — and check whether this tx is part - // of it. A mempool tx that confirms after the chain has advanced past it is - // still part of the chain even though it is no longer the head; it only - // needs its block height updated, not a full rebuild. - let items_by_id: HashMap = - items.iter().map(|item| (item.id, item)).collect(); - let chain = reconstruct_txchain(&items_by_id, ido.txchain_head); - // the chain is only valid if it runs the whole way from the head down to - // the entrypoint (preinit) — nothing more, nothing less - let is_complete_chain = ido.txchain_entrypoint.is_some() - && chain.first().map(|item| item.id) == ido.txchain_entrypoint; - let tx_txid = tx.compute_txid().to_blob(); - let tx_in_current_chain = is_complete_chain && chain.iter().any(|item| item.txid == tx_txid); - if tx_in_current_chain { - // already part of the current txchain, only update block height - update_ido_txchain_tracker_block_height(&mut dbtx, tx, block_height).await?; - } else if prev_txchain_item.id != ido.txchain_head.unwrap_or(0) { - debug!( - "IDO rebuild: {}, prev: {}", - blob_to_display_hex::(&tx.compute_txid().to_blob())?, - blob_to_display_hex::(&prev_txchain_item.txid)? - ); - // rebuild the state, txchain is broken, recreate the chain - let mut items_tx_map: HashMap, Transaction> = HashMap::new(); - for item in &items { - let tx_deser: Transaction = bitcoincash::consensus::deserialize(&item.tx) - .map_err(|e| anyhow::anyhow!("failed to deserialize tx: {e}"))?; - items_tx_map.insert(item.txid.clone(), tx_deser); + // If this tx already produced a state, it has been indexed before (e.g. seen + // in the mempool and now confirming). A tx is deterministic, so don't append + // a duplicate snapshot — just stamp the confirming blockhash onto its rows + // so a reorg can undo them. + if has_indexed_tx(pool, &tx_txid).await? { + if let Some(blockhash) = blockhash { + stamp_block_for_tx(&mut dbtx, &tx_txid, blockhash).await?; } - let new_chain = reconstruct_txchain(&items_by_id, Some(prev_txchain_item.id)); - if new_chain - .first() - .ok_or_else(|| anyhow::anyhow!("txchain is empty"))? - .txid - != ido.preinit_txid - { - return Err(anyhow::anyhow!( - "txchain entrypoint does not match preinit_txid" - )); - } - // start from PREINIT - let preinit_tx = items_tx_map - .get(&ido.preinit_txid) - .ok_or_else(|| anyhow::anyhow!("ido's entrypoint does not exist in the txchain"))?; - let mut _errors: Vec = Vec::new(); - let mut _invalid_ido_reasons: Vec = Vec::new(); - let mut context = create_ido_context_from_preinit( - network, - preinit_tx, - &mut _errors, - &mut _invalid_ido_reasons, - )?; - let mut updates: Vec = Vec::new(); - for i in 1..new_chain.len() { - let item = new_chain - .get(i) - .ok_or_else(|| anyhow::anyhow!("txchain item not found at index"))?; - let item_tx = items_tx_map - .get(&item.txid) - .ok_or_else(|| anyhow::anyhow!("txchain tx not found in map"))?; - // add tx - let result = ido_add_tx(&context, item_tx)?; - context.head_txid = item_tx.compute_txid().to_blob(); - apply_updates_to_context(&mut context, &result.updates); - updates.extend_from_slice(&result.updates); - } - // add the tx to the chain - let result = ido_add_tx(&context, tx)?; - context.head_txid = tx.compute_txid().to_blob(); - apply_updates_to_context(&mut context, &result.updates); - updates.extend_from_slice(&result.updates); - let txchain_item_id = upsert_ido_txchain( - &mut dbtx, - tx, - ido.internal_id, - Some(prev_txchain_item.id), - block_height, - result.next_output_index, - ) - .await?; - update_ido(&mut dbtx, ido.internal_id, &context, txchain_item_id).await?; - // delete all entries - sqlx::query("DELETE FROM ido_entry WHERE ido_id = ?") - .bind(ido.internal_id) - .execute(&mut *dbtx) - .await?; - // insert the entries of the new state - upsert_updates_to_ido_entries(&mut dbtx, ido.internal_id, &updates).await?; - } else { - // create context from ido - let parameters: IdoParameters = serde_json::from_slice(&ido.parameters) - .map_err(|e| anyhow::anyhow!("Failed to parse parameters: {e}"))?; - let state: IdoState = serde_json::from_slice(&ido.state) - .map_err(|e| anyhow::anyhow!("Failed to parse state: {e}"))?; - let mut context: IdoContext = IdoContext { - preinit_txid: ido.preinit_txid.clone(), - init_txid: ido.init_txid.clone(), - launch_txid: ido.launch_txid.clone(), - otoken_genesis_txid: ido.otoken_genesis_txid.clone(), - status: ido.status.clone(), - parameters, - state, - is_valid_ido: ido.is_valid, - is_token_created_at_preinit: ido.is_token_created_at_preinit, - 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)?; - context.head_txid = tx.compute_txid().to_blob(); - apply_updates_to_context(&mut context, &result.updates); - let txchain_item_id = upsert_ido_txchain( - &mut dbtx, - tx, - ido.internal_id, - Some(prev_txchain_item.id), - block_height, - result.next_output_index, - ) - .await?; - update_ido(&mut dbtx, ido.internal_id, &context, txchain_item_id).await?; - upsert_updates_to_ido_entries(&mut dbtx, ido.internal_id, &result.updates).await?; + dbtx.commit().await?; + return Ok(()); } + + // Build the context from the prior state snapshot and apply the tx. The new + // snapshot is appended at the next seq, becoming the current head — even if + // `prev` was not the previous head (a fork), the latest-seen tx wins, which + // matches the prior rebuild behaviour. + let parameters: IdoParameters = serde_json::from_slice(&prev.parameters) + .map_err(|e| anyhow::anyhow!("Failed to parse parameters: {e}"))?; + let state: IdoState = serde_json::from_slice(&prev.state) + .map_err(|e| anyhow::anyhow!("Failed to parse state: {e}"))?; + let mut context: IdoContext = IdoContext { + preinit_txid: prev.preinit_txid.clone(), + init_txid: prev.init_txid.clone(), + launch_txid: prev.launch_txid.clone(), + otoken_genesis_txid: prev.otoken_genesis_txid.clone(), + status: prev.status.clone(), + parameters, + state, + is_valid_ido: prev.is_valid, + is_token_created_at_preinit: prev.is_token_created_at_preinit, + offered_token_id: prev.offered_token_id.clone(), + offering_token_id: prev.offering_token_id.clone(), + // prev.txchain_head is the prev state's own txid (the head being extended) + head_txid: prev.txchain_head.clone().unwrap_or_default(), + created_at: prev.created_at, + launched_at: prev.launched_at, + }; + let result = ido_add_tx(&context, tx)?; + context.head_txid = tx_txid.clone(); + apply_updates_to_context(&mut context, &result.updates); + append_ido_state( + &mut dbtx, + prev.internal_id, + &context, + &tx_txid, + result.next_output_index, + blockhash, + ) + .await?; + upsert_updates_to_ido_entries(&mut dbtx, prev.internal_id, &result.updates, &tx_txid, blockhash) + .await?; dbtx.commit().await?; Ok(()) } @@ -2828,11 +2663,17 @@ fn is_ido_sig_in_tx_inputs(tx: &Transaction) -> bool { false } -async fn index_txs( +/// Index a batch of dependency-ordered txs into the IDO state. +/// +/// Pass `Some(blockhash)` when indexing a confirmed block; pass `None` when +/// 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). +pub async fn index_txs( network: Option, pool: &SqlitePool, sorted_txs: &[Transaction], - block_height: i64, + blockhash: Option<&BlockHash>, ) -> Result<()> { for tx in sorted_txs { // detect a new ido @@ -2845,13 +2686,16 @@ async fn index_txs( .await? .is_some() { - // already created (eg. indexed from the mempool), only update block height - let mut dbtx = pool.begin().await?; - update_ido_txchain_tracker_block_height(&mut dbtx, tx, block_height).await?; - dbtx.commit().await?; + // already created (eg. indexed from the mempool); when confirming + // in a block, stamp the blockhash onto its mempool-keyed rows. + if let Some(blockhash) = blockhash { + let mut dbtx = pool.begin().await?; + stamp_block_for_tx(&mut dbtx, &tx.compute_txid().to_blob(), blockhash).await?; + dbtx.commit().await?; + } continue; } - if let Err(err) = on_create_ido(network, pool, tx, block_height).await { + if let Err(err) = on_create_ido(network, pool, tx, blockhash).await { info!( "failed to detect an ido, or an invalid ido detected, txid: {}, {}", hex::encode(tx.compute_txid().to_blob()), @@ -2863,37 +2707,23 @@ async fn index_txs( "IDO tx to index: {}", blob_to_display_hex::(&tx.compute_txid().to_blob())? ); - // has ido sig + // has ido sig: find the ido state this tx extends by matching one of + // its inputs against some state's (txid, next_output_index). for input_index in [0, 1, 3] { if let Some(input) = tx.input.get(input_index) { - if let Some(txchain_item) = txchain_lookup_next( + if let Some(prev) = lookup_state_by_next_output( pool, &input.previous_output.txid, input.previous_output.vout, ) .await? { - match ido_lookup(pool, txchain_item.ido_id).await { - Ok(Some(ido)) => { - if let Err(err) = on_add_ido_tx( - network, - pool, - &ido, - &txchain_item, - tx, - block_height, - ) - .await - { - info!( - "add tx to an ido failed, txid: {}, {}", - hex::encode(tx.compute_txid().to_blob()), - err - ) - } - } - Err(err) => warn!("ido record not found!!, {}", err), - _ => warn!("ido record not found!!"), + if let Err(err) = on_add_ido_tx(pool, &prev, tx, blockhash).await { + info!( + "add tx to an ido failed, txid: {}, {}", + hex::encode(tx.compute_txid().to_blob()), + err + ) } break; } @@ -2904,38 +2734,47 @@ async fn index_txs( Ok(()) } -pub async fn index_block( - network: Option, - pool: &SqlitePool, - sorted_txs: &[Transaction], - _blockhash: &BlockHash, - _mtp: i64, - block_height: i64, -) -> Result<()> { - index_txs(network, pool, sorted_txs, block_height).await?; - txchain_trim_tracker(pool, block_height).await?; - Ok(()) -} - -pub async fn index_mempool( - network: Option, - pool: &SqlitePool, - sorted_txs: &[Transaction], - highest_known_block_height: u64, -) -> Result<()> { - // Txs indexed from the mempool are stamped with the negative of the - // highest known block height, so txchain_trim_tracker can garbage collect - // entries that never confirm (e.g. invalidated by a double spend) once - // they pass TRACKER_MAX_BLOCK_DEPTH. The height is replaced with the real - // (positive) one once the tx confirms in a block. max(1) avoids storing 0, - // which would be indistinguishable from a confirmed height. - let mempool_height = -(highest_known_block_height.max(1) as i64); - index_txs(network, pool, sorted_txs, mempool_height).await -} - -/// Whether the tx is already part of an indexed ido txchain. -pub async fn has_txchain_tx(pool: &SqlitePool, txid: &Txid) -> Result { - Ok(get_txchain_item_by_txid(pool, txid).await?.is_some()) +/// Delete IDO rows for a single block, or all mempool rows. +/// +/// Pass `Some(blockhash)` to undo a confirmed block on reorg: the rows that +/// block wrote are removed and, because the current state of an ido is always +/// MAX(seq), the previous (still-present) snapshot becomes current again. +/// +/// Pass `None` to drop everything indexed from the mempool (blockhash IS NULL) +/// before applying confirmed blocks. A mempool tx that never confirmed — e.g. +/// one replaced by a different on-chain tx — would otherwise leave a stale +/// snapshot that can win MAX(seq) or resurface after a reorg deletes the +/// confirmed snapshot above it; confirmed blocks re-create rows for the txs they +/// contain and the next mempool pass rebuilds the rest, so nothing is lost. +/// +/// In both cases an ido left with no state snapshot is removed entirely (so a +/// later confirmation re-creates it via on_create_ido); ON DELETE CASCADE clears +/// any leftover children. Returns the number of state snapshots removed. +/// +/// The selector uses SQLite's `IS` operator so a single query handles both: it +/// behaves like `=` against a bound blob and matches NULL rows when bound to +/// NULL. +pub async fn delete_entries(pool: &SqlitePool, blockhash: Option<&BlockHash>) -> Result { + let bh: Option> = blockhash.map(|h| h.to_blob()); + let mut dbtx = pool.begin().await?; + sqlx::query("DELETE FROM ido_distribution WHERE blockhash IS ?") + .bind(bh.as_deref()) + .execute(&mut *dbtx) + .await?; + sqlx::query("DELETE FROM ido_entry WHERE blockhash IS ?") + .bind(bh.as_deref()) + .execute(&mut *dbtx) + .await?; + let removed = sqlx::query("DELETE FROM ido_state WHERE blockhash IS ?") + .bind(bh.as_deref()) + .execute(&mut *dbtx) + .await? + .rows_affected() as usize; + sqlx::query("DELETE FROM ido WHERE internal_id NOT IN (SELECT DISTINCT ido_id FROM ido_state)") + .execute(&mut *dbtx) + .await?; + dbtx.commit().await?; + Ok(removed) } // ─── Public RPC types ──────────────────────────────────────────────────────── @@ -2976,28 +2815,10 @@ pub struct IdoEntryRpcRecord { pub distributed: bool, } -#[derive(Serialize, Clone)] -pub struct IdoTxChainRpcRecord { - pub id: i64, - pub prev_id: Option, - pub ido_id: String, - pub ido_internal_id: i64, - pub txid: String, -} - -#[derive(Serialize, Clone)] -pub struct IdoTrackerMapRpcRecord { - pub txid: String, - pub next_output_index: Option, - pub height: i64, - pub txchain_id: i64, -} - impl IdoDBRecord { - /// Build the public RPC record. `txchain_head_txid` is the resolved txid blob of the - /// txchain head record (joined in by the query); the internal `txchain_head` id and - /// `txchain_entrypoint` are intentionally not exposed. - fn into_rpc_record(self, txchain_head_txid: Option>) -> Result { + /// Build the public RPC record. `txchain_head` is the head txid of the current + /// state (the current state row's own txid). + fn into_rpc_record(self) -> Result { let preinit_txid_hex = blob_to_display_hex::(&self.preinit_txid)?; Ok(IdoRpcRecord { id: preinit_txid_hex.clone(), @@ -3030,7 +2851,8 @@ impl IdoDBRecord { status: self.status, parameters: serde_json::from_slice(&self.parameters)?, state: serde_json::from_slice(&self.state)?, - txchain_head: txchain_head_txid + txchain_head: self + .txchain_head .as_deref() .map(blob_to_display_hex::) .transpose()?, @@ -3043,15 +2865,13 @@ impl IdoDBRecord { #[cfg(test)] mod tests { - /* use super::*; use bitcoincash::blockdata::transaction::Transaction; use std::sync::LazyLock; - static PREINIT_TEST_TX01: LazyLock> = LazyLock::new(|| hex::decode("").unwrap()); - static PREINIT_TEST_TX02: LazyLock> = LazyLock::new(|| hex::decode("0200000003f26bc22ff5728caa22bbe219909c7eadb344d2130b833dad61733dec78d6b4ed00000000fd1101514d0d012082fa7e456ada87ddd4ff6195ca1284d8ab09e5b55caf73ddfb7dca8faf9620e620b757276de32bb650f7969569f685b1616473f5d02b75655c26c030facc190a4e209110b4023c0864f684eda7f6e84ae8016a3d3ced6c2240274d9a8aa02fc432235379009c6300ce01207f7588c0d276827760a269c0cf78587f77547f758178587f77547f7581a0697c567f75817c567f7581a069c0ccc0c6a269c0cdc0c788c0d1c0ce87777777675379519c63c0cf567f77527f75817600a269016495c0ccc0c67b93a269c0cdc0c788c0d1c0ce88c0d2c0cf886d6d51675379529c6300ce01207f757b88c0cdc0c788c0d1c0ce88c0d2c0cf8777777767537a539d00ce01207f75537a877777686868ffffffff26811c3b54b8a4a3a7713cc0467d4f3f3c9a3f2f168a2a044a230cfc4d6e83530b0000006441423f10f8880c972a46bfdee92a61cd52f62b4c74a69377510565e50174f0803a0be106a1ddf932dab898bafd7902bdd720eb9f3314a768ee5d415116fda4f8ce612102c1547ca5906ae616000ff9e82a8808ae72e3b10280ea388bc4c53c698e1b72a7000000001b2a2bf0a4a8ed49ea9bcbcf3ea9db1860dc1ad22ebc0ae2ea39f1dcd909308601000000644193ed31a5f0a65890f8b00e648d9d3a0cd868a656ea049c42318b29a5a49e24aa6e4abdc75ce13065fa6241708960f0efca235069cd42249b9555f279d8bc89fa612102950b9aef0776e4effd67ba6de41e797102a6ae526413a0db436a736ca9eaab68000000000de80300000000000056ef9a66f2918e9a845e263d71d126949ca0ef35476eb13d382482aacfc94082941b611008f61e6a000000003f21190069ac0000aa206024ca62d059de8235ec20424a8ac4d0b36845a7c373bc480004d3758092177087e80300000000000023aa2044e18a3f922c873cd60c612ab6521b3a88bdfef66563c136d38951aaa9dc0e3787e80300000000000023aa20e250354ce04f89b08ee8dd2b40c83dd1c053b6f79cb886066da2b964719c33c387e80300000000000023aa20e6752cc56a3108981a51ec691ae9be082d7ab99cdc792e26b2a65e96f74b89ce87e80300000000000023aa20f69ff67df71b9ecd4b6b39af393551468bb2a46fb261e2e5a61cfb27dbcbb25087e80300000000000023aa20e5b1d9865bf78f7cfc02e60ba0bcf327e0d3949f455a37dd8870979c3fc0f42d87e80300000000000023aa208b3c520007a78d00dcebfd2e8bcda6020ed7f7ed7d5f0d687ac325b0fc3e309187e80300000000000023aa20bb42c223cc37b5a6504f2a659b7de17314bad9709b1311f86ae5a59f8dda0f9b87b0040000000000000f0201008178c99dc8c0c88702000075b004000000000000c70201008178c99dc8c0c8874cb90c0602c400024a0104011901526a0442434d5220b8b96bb250d3eb5d27a8677f5404ffe1178ba798d31526c81ad59eed30a12b034968747470733a2f2f697066732e72696674656e2e6e65742f7541565553494c693561374a51302d74644a36686e663151455f2d4558693665593078556d794272566e7530776f53734438697066733a2f2f7541565553494c693561374a51302d74644a36686e663151455f2d4558693665593078556d794272566e7530776f537344b70075e09304000000000023aa20629ccbca71e4c6b24e02bcad1ad2f8136a904b55526e83d5b2612c89b6a49d81872678321d000000001976a91416c842ffdafbe036c77c0d2b09468bbdba62f28188ac0000000000000000d86a4cbb4361756c64726f6e49646f30ab3aba01311b672808aa06f5e7f332d930e4ed0c99407343f7fc5d743d15ff55809698001b948240c9cfaa8224383db16e4735efa09c9426d1713d265e849a8e91f2669ab9f22b6a0000e02e000000000000e02e000000000000002d310180969800d08c8906c20000000000000000000000e02e0000000000000000000000000000000000000000000000000000000000000000000050c3000080c3c90100e40b540200000000ca9a3b00000000811976a914d0157fb7419d0bc7d9672cf78c8b07786102d96b88ac00000000").unwrap()); + static PREINIT_TEST_TX01: LazyLock> = LazyLock::new(|| hex::decode("02000000036854132bb537f062a312c2c6a393cee5377461bcf8b3cdcdb13c077331714fa200000000fd1101514d0d012082fa7e456ada87ddd4ff6195ca1284d8ab09e5b55caf73ddfb7dca8faf9620e620b757276de32bb650f7969569f685b1616473f5d02b75655c26c030facc190a4e209110b4023c0864f684eda7f6e84ae8016a3d3ced6c2240274d9a8aa02fc432235379009c6300ce01207f7588c0d276827760a269c0cf78587f77547f758178587f77547f7581a0697c567f75817c567f7581a069c0ccc0c6a269c0cdc0c788c0d1c0ce87777777675379519c63c0cf567f77527f75817600a269016495c0ccc0c67b93a269c0cdc0c788c0d1c0ce88c0d2c0cf886d6d51675379529c6300ce01207f757b88c0cdc0c788c0d1c0ce88c0d2c0cf8777777767537a539d00ce01207f75537a877777686868ffffffff72418c9330df09f3d3300afb3caa859440009d16df550e04f1a051e32a9199900a00000064415b48f4d468f412023977f57339b7ca91371c4deb2e3355f53dd84a89bb3c02385b3ee4186e6b2d12851ec5ebfe6f203212a944cb7134b3221d451c5f8d559881612103372aa86dfa2f302c04fc5d386e18209113c08f573256af832cac9ba2014a9b56000000004cc25d2af4d177ffabe85803824d97be7d974d602b16e232cd1185bd68a7cfac0200000064418afb3a8bbab1912e06245d9ce73b9e33aafcf54423cf410c06620e732e8fb161a8f674b409433ef665625a5c23de2417984f2ebe7128d6048d625cd43079af17612102c7c744133f5fedf684e9908fa52cfdcfd0ce9f155b609878906a7877403c4987000000000ee80300000000000056ef9a66f2918e9a845e263d71d126949ca0ef35476eb13d382482aacfc94082941b6110ec5a316a0000000075131a0064540000aa206024ca62d059de8235ec20424a8ac4d0b36845a7c373bc480004d3758092177087e80300000000000023aa203733248829afd52aa30112205114210af9f8e64001188273abcfab1ddf95b07887e80300000000000023aa20a8e6aed82ff79e824aa86d14cc469704bda8e8c20057ff06d4948004e6ade0d287e80300000000000023aa20de0a3c5fe9a1d0fea9ca1d0e82628ab14c61214d17d3f9df4b175fec46a52b5e87e80300000000000023aa20f69ff67df71b9ecd4b6b39af393551468bb2a46fb261e2e5a61cfb27dbcbb25087e80300000000000023aa20e5b1d9865bf78f7cfc02e60ba0bcf327e0d3949f455a37dd8870979c3fc0f42d87e80300000000000023aa208b3c520007a78d00dcebfd2e8bcda6020ed7f7ed7d5f0d687ac325b0fc3e309187e80300000000000023aa20beafa293c3d712876b389da252b1f7f584a8f8b10da2e526e38a34856f1c15e487b0040000000000000f0201008178c99dc8c0c88702000075b004000000000000c50201008178c99dc8c0c8874cb70c0602c400024a0104011701506a0442434d5220314e518b4207e9252edc443abea7dede08a93165975a451fd802a847aee86f1c4768747470733a2f2f7733732e6c696e6b2f697066732f75415655534944464f55597443422d6b6c4c7478454f72366e337434497154466c6c31704648396743714565753647386338697066733a2f2f75415655534944464f55597443422d6b6c4c7478454f72366e337434497154466c6c317046483967437145657536473863b50075e8030000000000003defbfa57ca201b401db5496995a69a333e9354e8782fbf176eabec3f891a29aa94760010076a9145aba2374503e6596392c0bdf3902a65fffee561c88ace09304000000000023aa20629ccbca71e4c6b24e02bcad1ad2f8136a904b55526e83d5b2612c89b6a49d8187ab527a00000000001976a914e8e38c5bbdcd2488800ed9fa7af27981c4846b8888ac0000000000000000bf6a4cbb4361756c64726f6e49646f30ab3aba01311b672808aa06f5e7f332d930e4ed0c99407343f7fc5d743d15ff55809698001b948240c9cfaa8224383db16e4735efa09c9426d1713d265e849a8e91f2669a6cac326a00001c480000000000001c48000000000000002d3101404b4c009f06241f7e00000000000000000000001c480000000000000000000000000000000000000000000000000000000000000000000050c3000080c3c90100e40b5402000000800fd92d000000000a0000000000").unwrap()); - static PREINIT_STEP_TEST_TX01: LazyLock> = LazyLock::new(|| hex::decode("").unwrap()); + static PREINIT_STEP_TEST_TX01: LazyLock> = LazyLock::new(|| hex::decode("020000000a4416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a402000000fddb01514dd7010201008178c99dc8c0c8874dc801124361756c64726f6e49646f2d32303236513275088178c99dc8c0c88736827701209dc0519dc0ce01207f7500ce01207f758800cf517f755f845288c0cf517f7501208401008763c0c878c88876c9529d68755104002d310103404b4c059f06241f7e021c4800c0ce01207f75c0519c637600ce8800cf517f755f845188675879827701209dc0009d00d000d394765479a269765579950500e876481796005c7900a063577900a069587900a0695c79587995048033e1015a7995a169785d7995587995048033e1010400e1f5059596776e947b757c7800a0696854790087535e7900a06376608577687863760120857768005f7900a0635f795680547958807e77686e7e51d28851d15779517e8851d356799d02aa20012060797e5e797eaa7e01877e51cd8852796351cc5579a26952d100876452d101207f75577987916968c4539d6752d158798852d35579a26902aa20525152807e5f797eaa7e01877e52cd8853d100876453d101207f7557798791696854d100876454d101207f75577987916968c4559d6800cf517f77547f758100cc00c6527993a26900cd00c78800cf557f77547f758100cf557f75788b54807e00d28800ce00d1886d6d6d6d686d6d6d6d6d51c60175000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a401000000fd0a094d0709124361756c64726f6e49646f2d32303236513275000020000000000000000000000000000000000000000000000000000000000000000020bfa57ca201b401db5496995a69a333e9354e8782fbf176eabec3f891a29aa94700004cb520314e518b4207e9252edc443abea7dede08a93165975a451fd802a847aee86f1c0602c400024a0151894c87011701504c814768747470733a2f2f7733732e6c696e6b2f697066732f75415655534944464f55597443422d6b6c4c7478454f72366e337434497154466c6c31704648396743714565753647386338697066733a2f2f75415655534944464f55597443422d6b6c4c7478454f72366e337434497154466c6c31704648396743714565753647386352894df5010902a9147ca97e01877e57897c6b00c08851d100887c635ab2756d51cd016a88674d0301404142434445464748494a4b4c4d4e4f505152535455565758595a6162636465666768696a6b6c6d6e6f707172737475767778797a303132333435363738392d5f6b007c8253a26365537f7c76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7b7c7e7c82539f666882760087636d677d537c94007c807e76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7c51937f757e686c7554893a10303132333435363738396162636465667c006b65517f7c5279785f84817f77517f756c7e6b52797c5499817f77517f756c7e6b82009c666d6c5689097f7b827b7c7f777e7e538978a8886b518ac0ce568a65766c537a538a6b74519c66756c766ba804015512207c7e548a01757c7e6b528a6c7c6b65766c537a538a6b74519c66756c6ca87c7e076a0442434d52207c7e51cd886801206c0f51ce8851d0009d6300cdc0c78868517e7e578a00cd8800ce00d18800d2008800d000d38800c600cca26952d10088c453870f51ce8851d0009d6300cdc0c7886851088178c99dc8c0c8870480c3c90104800fd92d0500e40b5402c0519dc0c800c88800c9529dc0c852c88852c9539dc0c853c88853c9549dc0c854c88854c9559dc0c855c88855c9569dc0c856c88856c9579d5c79009e63c0c858c88858c9599d67c0c858c88858c9599d68c0c857c88857c9589d597981009c5d79009c9b5b7981009c9b63c0d1008852cd00c78852d1008853cd52c78853d1008854cd53c78854d1008855cd54c78855d1008856cd55c78856d1008857cd56c78857d100885c79009e6359cd58c78859cc58c6a26959d158ce8859d358d09d59d258cf8867597981009e5c79009e9a6459cd58c78859d10088686858cd57c78858d1008802aa200302010055797eaa7e01877ec101587f775b7981009c6301005f79009e63015177685e79009c6300cd53798800d10088760251207e5e797e01207e5d797e52797e7b757c67c0c859c88859c9009d00cd016a8800d100885acd5c79885ad1c0c8885ad3009d5ad20100885bd10088c45c9d760200207e5e797e01207ec0c87e52797e7b757c6875675e79009c635d79009c6300cd52798800d10088030051205d797e01207e5c797e787e7767c0c859c88859c9009d53795579950400e1f50596547978935479789455795279a06902aa20012060797e5d797e5c797eaa7e01877e5c798277009c6302a91401200111797e5c797ea97e01877e776800cd788800d1c0c88800d352799d00d2008859cd56798859d1c0c88859d353799d59d2008858c7827758c77853947f77527f758158c7527953945279947f77787f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a77685acd78885ad100885bd10088c45c9d035100200114797e01207e0113797e58797e587a757c6b7c6b7c6b7c6b7c6b7c6b7c6c6c6c6c6c6c6d6d6d7568675d79009c6300cd52798800d10088035151205d797e01207e5c797e787e7767c0c859c88859c9009d00cd016a8800d100885acd5279885ad1c0c8885ad3009d5ad201ff885bd10088c45c9d03510020c0c87e01207e5c797e787e7768686802aa20c101147f7552797eaa7e01877ec0cd886d67c0c859c88859c95a9d55ca827755ca7853947f77527f758155ca527953945279947f77787f75012302aa2001205f797e5d797e5b797eaa7e01877e7e5b798277009c63011702a914012060797e5b797ea97e01877e7e776800cd02aa20c101147f7553797e54797eaa7e01877e8800d1008800ca827700ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686800ca537953945379945279947f7702aa20030200005d797e52797eaa7e01877e51cd8851d1008852ca827752ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686852ca537953945379945279947f7702aa20030200000111797e707c0114937f757e01207e01187901ff7eaa7e707c0135937f777eaa7e01877e52cd8852d1008853ca827753ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686853ca537953945379945279947f7702aa20030200000115797e52797eaa7e01877e53cd8853d1008854ca827754ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686854ca537953945379945279947f7702aa20030200000119797e52797eaa7e01877e54cd8854d1008857c7827757c77853947f77527f75815178529302ff00a063755367785293014ba0637552686857c7537953945379945279947f7755cd03020000011d797e52797e8855d1008802aa2003020000011d797eaa7e01877e56cd8856cc58c6a26956d158ce8856d3011a799d56d2008856ca827756ca7853947f77527f758156ca527953945279947f77787f7557cd02aa20c101147f7501207e01277901007eaa7e53797eaa7e01877e8857cc59c6a26957d159ce8857d359d09d57d259cf8802aa20525752807e0120797eaa7e01877e58cd8858d158ce8858d358d0011e79949d58d200886d6d6d6d6d6d6d6d6d6d6d6d6d75686d6d6d6d6d6d7551000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a403000000fdb503514db1030201008178c99dc8c0c8874da203124361756c64726f6e49646f2d3230323651327520000000000000000000000000000000000000000000000000000000000000000020ab3aba01311b672808aa06f5e7f332d930e4ed0c99407343f7fc5d743d15ff5504809698003dc0ce00ce01207f758800cf517f755f84528800cf577f77547f758151a169c0cf517f7701207f7502aa2001207b7e7b7eaa7e01877e52cd8852ccc0c6a2088178c99dc8c0c8873fc0cf527f7701207f75c0cf01227f77815479ce01207f757b88537acf567f75817600a0699f6978ce7bcf7eaa88c0cf517f77517f7581c0c85279c8887cc99c0778ce7bcf7eaa87209a66f2918e9a845e263d71d126949ca0ef35476eb13d382482aacfc94082941b046cac326a021c48021c48c0009d00c852c88852c9529d00c854c88854c9549d00c855c88855c9559d00ce01207f7551ce78527e8851cf517f755f8401008853ce01207f7555798853cf567f75817600a06953d100876453d101207f7552798791696851d0547aa07651d0557aa09b63537952799f696851cf557f77547f758100a16302aa2001205d797e57797eaa7e01877e556085537956807e0058807e52d058807e0058807e00d28800d154798800d3009d00cd788851cd788851cc52c6a26951d152ce8851d352d09d51d2008852d10088c4549d75675279517e00d18800d3009d766355ca827755ca7853947f77527f758155ca527953945279947f77787f75526085557956807e51cf557f77547f757e0058807e52d058807e0058807e00d28802aa20c101147f755a79827751807e5a797e01207e60797e52797eaa7e01877e00cd8802aa20012060797e5a797eaa7e01877e52cd8852cc52c6a26952d152ce8852d352d09d52d2008854d10088c4559d6d756754ca827754ca7853947f77527f758154ca527953945279947f77787f7551d07600a06354d152ce8854d3789d54d2008802aa2001200111797e5b797eaa7e01877e54cd8855d10088c4569d6754d10088c4559d6852567956807e51cf557f77547f757e0058807e7858807e0058807e00d28802aa20c101147f755d79827751807e5d797e5c79827751807e5c797e5b79827751807e5b797e01207e5a797e547e5f797e01207e60797e01207e0111797e53797eaa7e01877e00cd8852d152ce8852d352d05279949d52cc52c6a26902aa20520052807e5d797eaa7e01877e52cd8852d200886d6d6802aa2056798277518057797e5a797eaa7e01877e51cd88545b797e51d28851cc51c6a26951d153798851d3009d686d6d6d6d6d6d51a00375000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a404000000fdb902514db5020201008178c99dc8c0c8874da602c0009d00c852c88852c9529d00cf517f77567f758100ce01207f7551ce01207f75788851cf517f755f84538851cf517f7551ca51ca82770136940120947f7701207f750000537960840100876451cf517f77567f75817b757c51cf577f77587f758177687800a2697600a26952d352d051d0949d587a810000567901208401008764527900a26951c6765479950400e1f50596537a757c6b7c6c765379947b757c53cc5279a26953d1008854cc5379a26954d10088756753d05379950400e1f505967b757c53d0527994777600a06353d3789d53d153ce886753d10088687800a06354d352799d54d153ce886754d100886868547900a06302aa20012057797e5f797eaa7e01877e51cd8851d1587988565551807e5c797e597956799356807e51d28851d3009d55d351d09d55d152ce8802aa20525152807e60797eaa7e01877e55cd8855d200886751d351d09d51d152ce8802aa20012057797e5e797eaa7e01877e51cd8851d20088687600a06302aa2001205b797e5e797eaa7e01877e53cd886753cd016a88687c00a06302aa2001205b797e5d797eaa7e01877e54cd886754cd016a886800cf577f77547f75817651a06300cf517f7500cf517f77567f757e788c54807e00cf5b7f77587f758153799358807e00cf01137f77587f757e00cf011b7f77587f758155799358807e00d28800ce00d18800c700cd8800d000d39d52cf52d28852ce52d18852c752cd88675500cf517f77567f757e00cf5b7f77587f758153799358807e00cf01137f77587f757e00cf011b7f77587f758155799358807e00d28800d158798800d3009d02aa2001205b797e5e797eaa7e01877e00cd8852d100885457790120840100876475536876ce59798876cf517f755f845488756855557a00a063755668c4789e6376d10088c4788b9d686d6d6d6d6d6d6d7551a40275000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a405000000fd4701514d43010201008178c99dc8c0c8874d3401c0009d00ce01207f7551ce01207f75788851cf517f755f84538851ca51ca82770136940120947f7701207f7551cf517f75760120840100876451cc51c6a26951d100886751d352d09d51d152ce886802aa200120537a7e55797eaa7e01877e51cd8800cf577f77547f75817651a06300cf517f7500cf517f77567f757e788c54807e00cf5b7f77587f757e00cf01137f77587f757e00cf011b7f77587f757e00d28800ce00d18800c700cd88c4529e6352d10088c4539d686755608500cf517f77567f757e00cf5b7f77587f757e00cf01137f77587f757e00cf011b7f77587f757e00d288527900d18800d3009d02aa20012055797e56797eaa7e01877e00cd8852d100885352790120840100876475526876ce54798876cf517f755f845488c4539e6353d10088c4549d6875686d6d7551320175000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a406000000fd0f03514d0b030201008178c99dc8c0c8874dfc02088178c99dc8c0c887575655545352510450c30000c0c9009dc0c85b79c8885a79c9577a9d5979ce827701209d567900a263c0c85b79c8885a79c957799d68c0c800d1788800d2578800d3009d00cd5a7a88c08bc0c878c88876c9547a9d76ca827778ca7853947f77527f75817bca7b53945279947f777c7f75c05293c0c878c88876c9557a9d76ca827778ca7853947f77527f75817bca7b53945279947f777c7f75c05393c0c878c88876c9567a9d76ca827778ca7853947f77527f75815178529302ff00a063755367785293014ba063755268685379ca537a5394537a947b947f7702aa20525352807e5b797e7b7eaa7e01877e54cd8854cc7cc6a26954d10088c05493c0c878c88876c9567a9d76ca827778ca7853947f77527f75815178529302ff00a063755367785293014ba063755268685379ca537a5394537a947b947f7702aa20525352807e5a797e7b7eaa7e01877e55cd8855cc7cc6a26955d100885779d07600a0690100557a7e04000000007e51d28851d15479527e8851d3789d51cd02aa20547aaa7e01877e885153d28853d153798853d3009d53cd02aa20537aaa7e01877e8802aa20525352807e567a7eaa7e01877e52cd8852cc5579c6a26952d1557ace8852d39d52d20088c05593c0c878c88876c9537a9d76c7827778c77853947f77527f75817bc77b53945279947f777c7f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a776856cd8856d1008857d100876457d101207f75788791696857cd567f75066a0442434d52879169c4589e6358d100876458d101207f75788791696858cd567f75066a0442434d52879169c4599e6359d100876459d101207f75788791696859cd567f75066a0442434d52879169c45a9e635ad10087645ad101207f7578879169685acd567f75066a0442434d52879169c45b9e635bd10087645bd101207f7578879169685bcd567f75066a0442434d52879169c45c9d686868686d7551fa0275000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a407000000fd2506514d21060201008178c99dc8c0c8874d12064d09050480c3c901059f06241f7e5879009c63c0009dc0c9009e69c0cdc0c788c0ccc0c6a269c0d1c0ce88c0d3c0d09dc0d2c0cf8802aa20520052807e597a7eaa7e01877ec0c851c88851c9589d7651cd8851cc51c6a26951d151ce8851d351d09dc0c852c88852c9599d52cd8852cc52c6a26952d152ce8852d352d09dc353a06353ce0088c3549d6853d10088c4549d6d6d6d6d51675879519c63c0009dc0c9009dc0cdc0c788c0d1c0ce88c0d3c0d09dc0d2c0cf88c0c852c88852c9529d52cd52c78852cc52c6a26952d152ce8852d352d09d02aa200120c0cec0cf7eaa7e587a7eaa7e01877e00005376c75479876376ce59798763527978d093537a757c6b7c6c6776ce0088686ec6937b757c6776ce008868768bc378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c3789d6868686868686868c0ccc0c6547a93a269c0c851c88851c9519d51cd51c78851cc51c6a26951d0537a937600a06351d159798851d378a26951d200886751d100886853d10088c4549d6d6d6d6d6d6d5167587a529dc0009dc0c9009d5379827701209d53ce567a8853cf517f755f845588c0c653cf577f77587f7581a269c0c851c88851c9519d53cf5f7f77587f75817600a06351d078a2696851d000a06351ce5679886851cf0088c0c852c88852c9529d52ce567988000053cf517f75608401008763557981537994765679950400e1f505967652d0a06352d07768765679950500e87648179653cf01177f77587f758194760500e8764817955779967802e803a27800a09a6378567a757c6b7c6b7c6b7c6b7c6c6c6c6c765379a16376557a757c6b7c6b7c6b7c6c6c6c675279557a757c6b7c6b7c6b7c6c6c6c68686d6d68c0c651c69352c69352799451d052d093527994537900a0537900a09a6302aa2005746376a914000114807e2b88ac67c0d1c0ce88c25288c0cdc0c788c0c6c0d095c0c6c0cc9490539502e80396c0cc7c94c0d3957ca2687eaa7e01877e00cd8800cc54799d00d15a798800d353799d00d20088096a0653554d4d4f4e14000114807e51cd8851cc009d51d100886700d1008800cd016a8851d1008851cd016a886802aa2001205a7a7e5b7a7eaa7e01877e52cd8852cc7ba2697600a06352d378a26952d158798852d200886752d100886853d10088c4549d6d6d6d6d75516868088178c99dc8c0c8870778ce7bcf7eaa875800c0ce827701209dc0cf827700a0695579827701209dc0c85779c8885679c99dc0c85779c8885679c99d5479529376ca827778ca7853947f77527f75817bca7b53945279947f777c7f7501157f7701207f75c0cec0cf7eaa88547ac852d1827701209d52d300a06902aa20c101147f755479827751807e54797e5379827751807e537a7e01207e7b7e01207e52d17e01207e547a7e587e52d358807e537a7eaa7e01877e57cd8857ccc0c6a26957d1c0ce8857d2c0cf8857d3c0d09d02aa20525752807e7b7eaa7e01877e7658cd8858cc02e803a26958d1008859cd8859cc78c6a26959d178ce8859d278cf8859d37cd09c100675000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a4080000000151000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a409000000015100000000d9d78914b49e4313f22b27e1ea03dd7c2963775ce944e0b81c1ad71490e865710100000064411de3a0757e6c479e5c20ca63f5d7f6e2029f27d7349d61be689a99a655afd2024b4d23d1e736d7119ed226dbd66597521e2fd2b8be6f640e3e0b51cc2ce0a1fe612102c1547ca5906ae616000ff9e82a8808ae72e3b10280ea388bc4c53c698e1b72a7ffffffff0be80300000000000023aa204358e719285ca59fffedcff34a5d1a39b32711ec2ad21c51765aeaa79d60409887e80300000000000023aa20890580b8a7a29eca5c414ddf82057cb65924950acb189661cf2727136eb4a49887e80300000000000023aa20a8e6aed82ff79e824aa86d14cc469704bda8e8c20057ff06d4948004e6ade0d287e80300000000000023aa20de0a3c5fe9a1d0fea9ca1d0e82628ab14c61214d17d3f9df4b175fec46a52b5e87e80300000000000023aa20f69ff67df71b9ecd4b6b39af393551468bb2a46fb261e2e5a61cfb27dbcbb25087e80300000000000023aa20e5b1d9865bf78f7cfc02e60ba0bcf327e0d3949f455a37dd8870979c3fc0f42d87e80300000000000023aa208b3c520007a78d00dcebfd2e8bcda6020ed7f7ed7d5f0d687ac325b0fc3e309187e80300000000000023aa20beafa293c3d712876b389da252b1f7f584a8f8b10da2e526e38a34856f1c15e487b0040000000000000f0201008178c99dc8c0c88702000075b004000000000000c50201008178c99dc8c0c8874cb70c0602c400024a0104011701506a0442434d5220314e518b4207e9252edc443abea7dede08a93165975a451fd802a847aee86f1c4768747470733a2f2f7733732e6c696e6b2f697066732f75415655534944464f55597443422d6b6c4c7478454f72366e337434497154466c6c31704648396743714565753647386338697066733a2f2f75415655534944464f55597443422d6b6c4c7478454f72366e337434497154466c6c317046483967437145657536473863b50075586e0400000000001976a91495570c60bbdeaf648a259bb177911169e060a76788ac00000000").unwrap()); #[test] fn preinit_detect() { @@ -3090,17 +2910,11 @@ mod tests { test_preinit_tx(&PREINIT_TEST_TX01); } - #[test] - fn parse_ido_preinit_from_valid_ido_with_bcmr() { - test_preinit_tx(&PREINIT_TEST_TX02); - } - #[test] fn detect_preinit_step_tx() { let tx: Transaction = bitcoincash::consensus::deserialize(&PREINIT_STEP_TEST_TX01).expect("should be valid"); assert!(is_ido_sig_in_tx_inputs(&tx)); } - */ } // ─── Public RPC query functions ────────────────────────────────────────────── @@ -3124,23 +2938,18 @@ pub async fn list_idos( offset: i64, limit: i64, ) -> Result> { - 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.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", - ); + let base = format!("{IDO_RECORD_SELECT}{IDO_CURRENT_STATE_JOIN} WHERE 1=1"); + let mut qb: sqlx::QueryBuilder = sqlx::QueryBuilder::new(base); if let Some(v) = is_valid { - qb.push(" AND ido.is_valid = "); + qb.push(" AND s.is_valid = "); qb.push_bind(v as i64); } if let Some(v) = status { - qb.push(" AND ido.status = "); + qb.push(" AND s.status = "); qb.push_bind(v); } if let Some(v) = offered_token_id_blob { - qb.push(" AND ido.offered_token_id = "); + qb.push(" AND s.offered_token_id = "); qb.push_bind(v); } qb.push(" ORDER BY ido.internal_id ASC LIMIT "); @@ -3152,10 +2961,7 @@ pub async fn list_idos( .fetch_all(pool) .await? .into_iter() - .map(|row| { - let txchain_head_txid: Option> = row.get(16); - IdoDBRecord::from_row(&row)?.into_rpc_record(txchain_head_txid) - }) + .map(|row| IdoDBRecord::from_row(&row)?.into_rpc_record()) .collect() } @@ -3163,40 +2969,26 @@ pub async fn get_ido_by_preinit_txid( pool: &SqlitePool, preinit_txid: Vec, ) -> 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.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 = ?", - ) - .bind(preinit_txid) - .fetch_optional(pool) - .await?; - row.map(|r| { - let txchain_head_txid: Option> = r.get(16); - IdoDBRecord::from_row(&r)?.into_rpc_record(txchain_head_txid) - }) - .transpose() + let sql = format!("{IDO_RECORD_SELECT}{IDO_CURRENT_STATE_JOIN} WHERE ido.preinit_txid = ?"); + let row = sqlx::query(&sql) + .bind(preinit_txid) + .fetch_optional(pool) + .await?; + row.map(|r| IdoDBRecord::from_row(&r)?.into_rpc_record()) + .transpose() } pub async fn get_ido_by_offering_token_id( pool: &SqlitePool, offering_token_id_blob: Vec, ) -> 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.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 = ?", - ) - .bind(offering_token_id_blob) - .fetch_optional(pool) - .await?; - row.map(|r| { - let txchain_head_txid: Option> = r.get(16); - IdoDBRecord::from_row(&r)?.into_rpc_record(txchain_head_txid) - }) - .transpose() + let sql = format!("{IDO_RECORD_SELECT}{IDO_CURRENT_STATE_JOIN} WHERE s.offering_token_id = ?"); + let row = sqlx::query(&sql) + .bind(offering_token_id_blob) + .fetch_optional(pool) + .await?; + row.map(|r| IdoDBRecord::from_row(&r)?.into_rpc_record()) + .transpose() } pub async fn list_ido_entries( @@ -3208,24 +3000,28 @@ pub async fn list_ido_entries( offset: i64, limit: i64, ) -> Result> { - let mut qb: sqlx::QueryBuilder = sqlx::QueryBuilder::new( - "SELECT txid, owner_nfthash, commitment, supply_amount, demand_amount, \ - lockup_timeval, discount, distributed FROM ido_entry WHERE ido_id = ", - ); + // An entry is "distributed" iff a row exists for it in ido_distribution. + // (SQLite forbids referencing the output alias in WHERE, so the same EXISTS + // expression is repeated in the optional filter below.) + 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 = " + )); qb.push_bind(internal_id); if let Some(v) = distributed { - qb.push(" AND distributed = "); + qb.push(format!(" AND {dist_expr} = ")); qb.push_bind(v as i64); } if !owner_nfthashes.is_empty() { - qb.push(" AND owner_nfthash IN ("); + qb.push(" AND e.owner_nfthash IN ("); let mut separated = qb.separated(", "); for hash in owner_nfthashes { separated.push_bind(hash.clone()); } separated.push_unseparated(")"); } - qb.push(" ORDER BY rowid ASC LIMIT "); + qb.push(" ORDER BY e.rowid ASC LIMIT "); qb.push_bind(limit); qb.push(" OFFSET "); qb.push_bind(offset); @@ -3254,109 +3050,6 @@ pub async fn list_ido_entries( .collect() } -pub async fn list_ido_txchain( - pool: &SqlitePool, - internal_id: i64, - preinit_txid_hex: &str, - offset: i64, - limit: i64, -) -> Result> { - let head_id: Option = sqlx::query("SELECT txchain_head FROM ido WHERE internal_id = ?") - .bind(internal_id) - .fetch_optional(pool) - .await? - .and_then(|row| row.get(0)); - - let head_id = match head_id { - Some(id) => id, - None => return Ok(vec![]), - }; - - let rows = sqlx::query("SELECT id, prev_id, txid FROM ido_txchain WHERE ido_id = ?") - .bind(internal_id) - .fetch_all(pool) - .await?; - - // Parse rows into records - let records: Vec = rows - .iter() - .map(|row| { - let txid_blob: Vec = row.get(2); - Ok(IdoTxChainRpcRecord { - id: row.get(0), - prev_id: row.get(1), - ido_id: preinit_txid_hex.to_string(), - ido_internal_id: internal_id, - txid: blob_to_display_hex::(&txid_blob)?, - }) - }) - .collect::>>()?; - - // Build id → index map for O(1) lookup - let id_to_idx: HashMap = - records.iter().enumerate().map(|(i, r)| (r.id, i)).collect(); - - // Walk linked list from head backwards, then reverse to get oldest-first - let mut ordered: Vec = Vec::with_capacity(records.len()); - let mut current_id = head_id; - loop { - ordered.push(current_id); - match id_to_idx.get(¤t_id).and_then(|&i| records[i].prev_id) { - Some(prev) => current_id = prev, - None => break, - } - } - ordered.reverse(); - - let start = (offset as usize).min(ordered.len()); - let end = ((offset + limit) as usize).min(ordered.len()); - - Ok(ordered[start..end] - .iter() - .map(|id| records[id_to_idx[id]].clone()) - .collect()) -} - -pub async fn get_txchain_tx_hex(pool: &SqlitePool, txid: &[u8]) -> Result> { - let row = sqlx::query("SELECT tx FROM ido_txchain WHERE txid = ?") - .bind(txid) - .fetch_optional(pool) - .await?; - Ok(row.map(|r| { - let tx: Vec = r.get(0); - hex::encode(tx) - })) -} - -pub async fn list_txchain_tracker_map( - pool: &SqlitePool, - offset: i64, - limit: i64, -) -> Result> { - let rows = sqlx::query( - "SELECT txid, next_output_index, height, txchain_id \ - FROM ido_txchain_tracker_map \ - ORDER BY height ASC, txchain_id ASC \ - LIMIT ? OFFSET ?", - ) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await?; - - rows.iter() - .map(|row| { - let txid_blob: Vec = row.get(0); - Ok(IdoTrackerMapRpcRecord { - txid: blob_to_display_hex::(&txid_blob)?, - next_output_index: row.get(1), - height: row.get(2), - txchain_id: row.get(3), - }) - }) - .collect() -} - #[cfg(test)] mod querytests { use super::*; @@ -3381,23 +3074,36 @@ mod querytests { offered_token_id: Option>, offering_token_id: Option>, ) -> i64 { - 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 (?, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)", + // identity row + let internal_id = sqlx::query( + "INSERT INTO ido (preinit_txid, is_token_created_at_preinit, created_at) + VALUES (?, ?, 0)", ) - .bind(preinit_txid) - .bind(offering_token_id) - .bind(offered_token_id) - .bind(status) - .bind(b"{}".as_slice()) - .bind(b"{}".as_slice()) - .bind(is_valid as i64) + .bind(&preinit_txid) .bind(is_token_created_at_preinit as i64) .execute(pool) .await .unwrap() - .last_insert_rowid() + .last_insert_rowid(); + // seq-0 state snapshot. Its txid (the preinit) is the txchain head; + // tests that exercise the txchain repoint it via set_txchain_head. + sqlx::query( + "INSERT INTO ido_state (ido_id, seq, txid, status, parameters, state, + offering_token_id, offered_token_id, is_valid) + VALUES (?, 0, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(internal_id) + .bind(&preinit_txid) + .bind(status) + .bind(b"{}".as_slice()) + .bind(b"{}".as_slice()) + .bind(offering_token_id) + .bind(offered_token_id) + .bind(is_valid as i64) + .execute(pool) + .await + .unwrap(); + internal_id } // ─── vm number encoding ─────────────────────────────────────────────────── @@ -3496,6 +3202,37 @@ mod querytests { assert_eq!(result, vec![0x01, 17]); } + #[test] + fn push_opcode_pushdata1_boundaries() { + // Build a positive, minimally-encoded vm-number occupying exactly `len` + // bytes: 0xFF filler with a 0x7F top byte (high bit clear => positive, + // nonzero => no trailing-zero trimming). + let n_bytes = |len: usize| -> Integer { + let mut payload = vec![0xFF_u8; len]; + payload[len - 1] = 0x7F; + let n = vm_number_to_bigint(&payload); + assert_eq!(bigint_to_vm_number(&n).len(), len, "payload not minimal at len={len}"); + n + }; + + // 75-byte payload: direct push (single length byte = 0x4b) + let p75 = bigint_to_push_opcode(&n_bytes(75)); + assert_eq!(p75[0], 0x4b); + assert_eq!(p75.len(), 1 + 75); + + // 255-byte payload: must use PUSHDATA1 (minimal), not PUSHDATA2. + let p255 = bigint_to_push_opcode(&n_bytes(255)); + assert_eq!(p255[0], 0x4c, "255-byte payload must use OP_PUSHDATA1"); + assert_eq!(p255[1], 255); + assert_eq!(p255.len(), 2 + 255); + + // 256-byte payload: PUSHDATA2 + let p256 = bigint_to_push_opcode(&n_bytes(256)); + assert_eq!(p256[0], 0x4d, "256-byte payload must use OP_PUSHDATA2"); + assert_eq!(&p256[1..3], &[0x00, 0x01]); // 256 little-endian + assert_eq!(p256.len(), 3 + 256); + } + #[test] fn push_opcode_roundtrip_via_vm_number() { // bigint_to_push_opcode should produce bytes whose payload decodes back @@ -3679,16 +3416,27 @@ mod querytests { ) { sqlx::query( "INSERT INTO ido_entry (ido_id, txid, owner_nfthash, commitment, - supply_amount, demand_amount, lockup_timeval, discount, distributed) - VALUES (?, ?, ?, NULL, 0, 0, 0, 0, ?)", + supply_amount, demand_amount, lockup_timeval, discount) + VALUES (?, ?, ?, NULL, 0, 0, 0, 0)", ) .bind(ido_id) - .bind(txid_val) + .bind(&txid_val) .bind(vec![0u8; 32]) - .bind(distributed as i64) .execute(pool) .await .unwrap(); + // Distribution is a separate block-keyed fact, not a column on the entry. + if distributed { + sqlx::query( + "INSERT INTO ido_distribution (ido_id, entry_txid, txid) VALUES (?, ?, ?)", + ) + .bind(ido_id) + .bind(&txid_val) + .bind(&txid_val) + .execute(pool) + .await + .unwrap(); + } } #[rocket::async_test] @@ -3744,100 +3492,82 @@ mod querytests { assert_eq!(e2.len(), 1); } - // ─── DB: list_ido_txchain ───────────────────────────────────────────────── + // ─── DB: chain-follow lookup (replaces the removed txchain tracker) ─────── - async fn insert_txchain_item( + fn txid_t(n: u8) -> Txid { + Txid::from_byte_array([n; 32]) + } + + async fn set_next_output_index(pool: &SqlitePool, ido_id: i64, seq: i64, noi: i64) { + sqlx::query("UPDATE ido_state SET next_output_index = ? WHERE ido_id = ? AND seq = ?") + .bind(noi) + .bind(ido_id) + .bind(seq) + .execute(pool) + .await + .unwrap(); + } + + /// Insert an extra state snapshot at `seq` for an existing ido. + async fn insert_state( pool: &SqlitePool, ido_id: i64, + seq: i64, txid_val: Vec, - prev_id: Option, - ) -> i64 { - sqlx::query("INSERT INTO ido_txchain (prev_id, ido_id, txid, tx) VALUES (?, ?, ?, ?)") - .bind(prev_id) - .bind(ido_id) - .bind(txid_val) - .bind(vec![0u8; 4]) // dummy tx bytes - .execute(pool) + status: &str, + offering_token_id: Option>, + ) { + sqlx::query( + "INSERT INTO ido_state (ido_id, seq, txid, status, parameters, state, offering_token_id, is_valid) + VALUES (?, ?, ?, ?, ?, ?, ?, 1)", + ) + .bind(ido_id) + .bind(seq) + .bind(txid_val) + .bind(status) + .bind(b"{}".as_slice()) + .bind(b"{}".as_slice()) + .bind(offering_token_id) + .execute(pool) + .await + .unwrap(); + } + + #[rocket::async_test] + async fn lookup_state_by_next_output_matches_continuation() { + let pool = make_pool().await; + let ido_id = insert_test_ido(&pool, txid(1), "PREINIT", true, true, None, None).await; + // The seq-0 snapshot's preinit output#1 continues the chain. + set_next_output_index(&pool, ido_id, 0, 1).await; + + // A tx spending (preinit, 1) extends this ido. + let prev = lookup_state_by_next_output(&pool, &txid_t(1), 1) + .await + .unwrap(); + assert_eq!(prev.unwrap().internal_id, ido_id); + + // A different output index of the same tx does not continue the chain. + assert!(lookup_state_by_next_output(&pool, &txid_t(1), 2) .await .unwrap() - .last_insert_rowid() - } - - async fn set_txchain_head(pool: &SqlitePool, ido_id: i64, head_id: i64) { - sqlx::query("UPDATE ido SET txchain_head = ? WHERE internal_id = ?") - .bind(head_id) - .bind(ido_id) - .execute(pool) + .is_none()); + // An unknown tx does not match. + assert!(lookup_state_by_next_output(&pool, &txid_t(9), 1) .await - .unwrap(); + .unwrap() + .is_none()); } #[rocket::async_test] - async fn list_ido_txchain_ordered_oldest_first() { + async fn has_indexed_tx_reports_known_txids() { 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)); - let id_a = insert_txchain_item(&pool, ido_id, txid(10), None).await; - let id_b = insert_txchain_item(&pool, ido_id, txid(11), Some(id_a)).await; - let id_c = insert_txchain_item(&pool, ido_id, txid(12), Some(id_b)).await; - set_txchain_head(&pool, ido_id, id_c).await; - - let chain = list_ido_txchain(&pool, ido_id, &preinit_hex, 0, 100) - .await - .unwrap(); - assert_eq!(chain.len(), 3); - assert_eq!(chain[0].id, id_a); - assert_eq!(chain[1].id, id_b); - assert_eq!(chain[2].id, id_c); + insert_test_ido(&pool, txid(1), "PREINIT", true, true, None, None).await; + // The seq-0 state records the preinit txid. + assert!(has_indexed_tx(&pool, &txid(1)).await.unwrap()); + assert!(!has_indexed_tx(&pool, &txid(2)).await.unwrap()); } - #[rocket::async_test] - async fn list_ido_txchain_pagination() { - 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)); - let id_a = insert_txchain_item(&pool, ido_id, txid(10), None).await; - let id_b = insert_txchain_item(&pool, ido_id, txid(11), Some(id_a)).await; - let id_c = insert_txchain_item(&pool, ido_id, txid(12), Some(id_b)).await; - let id_d = insert_txchain_item(&pool, ido_id, txid(13), Some(id_c)).await; - set_txchain_head(&pool, ido_id, id_d).await; - - let page1 = list_ido_txchain(&pool, ido_id, &preinit_hex, 0, 2) - .await - .unwrap(); - let page2 = list_ido_txchain(&pool, ido_id, &preinit_hex, 2, 2) - .await - .unwrap(); - assert_eq!(page1.len(), 2); - assert_eq!(page2.len(), 2); - assert_eq!(page1[0].id, id_a); - assert_eq!(page1[1].id, id_b); - assert_eq!(page2[0].id, id_c); - assert_eq!(page2[1].id, id_d); - } - - #[rocket::async_test] - async fn list_ido_txchain_no_head_returns_empty() { - 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)); - // txchain_head is NULL (default from insert_test_ido) - let chain = list_ido_txchain(&pool, ido_id, &preinit_hex, 0, 100) - .await - .unwrap(); - assert_eq!(chain.len(), 0); - } - - #[rocket::async_test] - async fn list_ido_txchain_unknown_ido_returns_empty() { - let pool = make_pool().await; - let chain = list_ido_txchain(&pool, 9999, "deadbeef", 0, 100) - .await - .unwrap(); - assert_eq!(chain.len(), 0); - } - - // ─── DB: txchain_head is exposed as the head record's txid ──────────────── + // ─── DB: txchain_head is exposed as the current state's txid ────────────── #[rocket::async_test] async fn ido_record_resolves_txchain_head_to_txid() { @@ -3846,23 +3576,22 @@ mod querytests { let ido_id = insert_test_ido( &pool, txid(1), - "ACTIVE", + "PREINIT", true, true, None, Some(tok.clone()), ) .await; - let id_a = insert_txchain_item(&pool, ido_id, txid(10), None).await; - let head_id = insert_txchain_item(&pool, ido_id, txid(11), Some(id_a)).await; - set_txchain_head(&pool, ido_id, head_id).await; + // Advance to a later state; the head is the latest state's txid. + insert_state(&pool, ido_id, 1, txid(11), "ACTIVE", Some(tok.clone())).await; - // list_idos exposes txchain_head as the head record's txid (display hex), not its id. let listed = list_idos(&pool, None, None, None, 0, 100).await.unwrap(); assert_eq!(listed.len(), 1); + assert_eq!(listed[0].status, "ACTIVE"); assert_eq!(listed[0].txchain_head, Some(hex::encode(txid(11)))); - // get_ido_by_offering_token_id resolves it the same way. + // get_ido_by_offering_token_id resolves the same current state. let one = get_ido_by_offering_token_id(&pool, tok) .await .unwrap() @@ -3871,13 +3600,207 @@ mod querytests { } #[rocket::async_test] - async fn ido_record_txchain_head_null_is_none() { + async fn ido_record_txchain_head_defaults_to_preinit() { let pool = make_pool().await; - let ido_id = insert_test_ido(&pool, txid(1), "ACTIVE", true, true, None, None).await; - // A txchain row exists, but txchain_head is NULL: the join must not match it. - let _ = insert_txchain_item(&pool, ido_id, txid(10), None).await; + // A fresh ido that has not advanced has a single seq-0 state whose txid + // is the preinit, so the exposed head is the preinit txid. + insert_test_ido(&pool, txid(1), "PREINIT", true, true, None, None).await; let listed = list_idos(&pool, None, None, None, 0, 100).await.unwrap(); assert_eq!(listed.len(), 1); - assert_eq!(listed[0].txchain_head, None); + assert_eq!(listed[0].txchain_head, Some(hex::encode(txid(1)))); + } + + // ─── DB: reorg undo (delete_entries) ────────────────────────────────────── + + async fn insert_state_bh( + pool: &SqlitePool, + ido_id: i64, + seq: i64, + txid_val: Vec, + blockhash: &BlockHash, + status: &str, + ) { + sqlx::query( + "INSERT INTO ido_state (ido_id, seq, txid, blockhash, status, parameters, state, is_valid) + VALUES (?, ?, ?, ?, ?, ?, ?, 1)", + ) + .bind(ido_id) + .bind(seq) + .bind(txid_val) + .bind(blockhash.to_blob()) + .bind(status) + .bind(b"{}".as_slice()) + .bind(b"{}".as_slice()) + .execute(pool) + .await + .unwrap(); + } + + /// An ido created in block A and advanced (with a purchase + its + /// distribution) in block B should: revert to its block-A state when B is + /// undone, then disappear entirely when A is undone. + #[rocket::async_test] + async fn delete_entries_block_reverts_then_drops() { + let pool = make_pool().await; + let block_a = BlockHash::from_byte_array([0xA1; 32]); + let block_b = BlockHash::from_byte_array([0xB2; 32]); + let preinit = txid(1); + let preinit_hex = hex::encode(&preinit); + + let ido_id = sqlx::query( + "INSERT INTO ido (preinit_txid, is_token_created_at_preinit, created_at) VALUES (?, 1, 0)", + ) + .bind(&preinit) + .execute(&pool) + .await + .unwrap() + .last_insert_rowid(); + + // block A: preinit / seq 0 (PREINIT) + insert_state_bh(&pool, ido_id, 0, preinit.clone(), &block_a, "PREINIT").await; + + // block B: advance / seq 1 (ACTIVE) + a purchase entry and its distribution + let advance = txid(2); + insert_state_bh(&pool, ido_id, 1, advance.clone(), &block_b, "ACTIVE").await; + let entry = txid(10); + sqlx::query( + "INSERT INTO ido_entry (ido_id, txid, blockhash, owner_nfthash, commitment, supply_amount, demand_amount, lockup_timeval, discount) + VALUES (?, ?, ?, ?, NULL, 0, 0, 0, 0)", + ) + .bind(ido_id) + .bind(&entry) + .bind(block_b.to_blob()) + .bind(vec![0u8; 32]) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO ido_distribution (ido_id, entry_txid, txid, blockhash) VALUES (?, ?, ?, ?)", + ) + .bind(ido_id) + .bind(&entry) + .bind(&advance) + .bind(block_b.to_blob()) + .execute(&pool) + .await + .unwrap(); + + // current state is the block-B snapshot, with one distributed entry + let current = get_ido_by_preinit_txid(&pool, preinit.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(current.status, "ACTIVE"); + let entries = list_ido_entries(&pool, ido_id, &preinit_hex, None, &[], 0, 100) + .await + .unwrap(); + assert_eq!(entries.len(), 1); + assert!(entries[0].distributed); + + // undo block B: revert to the block-A (PREINIT) snapshot; entry and its + // distribution are gone, but the ido itself survives. + let removed = delete_entries(&pool, Some(&block_b)).await.unwrap(); + assert_eq!(removed, 1); + let reverted = get_ido_by_preinit_txid(&pool, preinit.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(reverted.status, "PREINIT"); + let entries = list_ido_entries(&pool, ido_id, &preinit_hex, None, &[], 0, 100) + .await + .unwrap(); + assert_eq!(entries.len(), 0); + + // undo block A: the preinit is gone, so the whole ido is dropped and its + // children cascade away. + delete_entries(&pool, Some(&block_a)).await.unwrap(); + assert!(get_ido_by_preinit_txid(&pool, preinit) + .await + .unwrap() + .is_none()); + let (state_rows,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM ido_state") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(state_rows, 0); + } + + /// delete_entries(None) should revert an ido with an unconfirmed advance + /// back to its last confirmed snapshot, and drop an ido that was only ever + /// seen in the mempool. + #[rocket::async_test] + async fn delete_entries_mempool_drops_unconfirmed_state() { + let pool = make_pool().await; + let block_a = BlockHash::from_byte_array([0xA1; 32]); + + // ido #1: confirmed preinit (seq0, block_a) + an unconfirmed mempool + // advance (seq1, NULL blockhash) carrying an entry and a distribution. + let confirmed = sqlx::query( + "INSERT INTO ido (preinit_txid, is_token_created_at_preinit, created_at) VALUES (?, 1, 0)", + ) + .bind(txid(1)) + .execute(&pool) + .await + .unwrap() + .last_insert_rowid(); + insert_state_bh(&pool, confirmed, 0, txid(1), &block_a, "PREINIT").await; + insert_state(&pool, confirmed, 1, txid(2), "ACTIVE", None).await; + sqlx::query( + "INSERT INTO ido_entry (ido_id, txid, blockhash, owner_nfthash, commitment, supply_amount, demand_amount, lockup_timeval, discount) + VALUES (?, ?, NULL, ?, NULL, 0, 0, 0, 0)", + ) + .bind(confirmed) + .bind(txid(10)) + .bind(vec![0u8; 32]) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO ido_distribution (ido_id, entry_txid, txid, blockhash) VALUES (?, ?, ?, NULL)", + ) + .bind(confirmed) + .bind(txid(10)) + .bind(txid(2)) + .execute(&pool) + .await + .unwrap(); + + // ido #2: only ever seen in the mempool (seq0 preinit, NULL blockhash). + let mempool_only = insert_test_ido(&pool, txid(3), "PREINIT", true, true, None, None).await; + + // Before the wipe, #1's current state is the unconfirmed ACTIVE snapshot. + let before = get_ido_by_preinit_txid(&pool, txid(1)) + .await + .unwrap() + .unwrap(); + assert_eq!(before.status, "ACTIVE"); + + // Two unconfirmed state rows: #1 seq1 and #2 seq0. + let removed = delete_entries(&pool, None).await.unwrap(); + assert_eq!(removed, 2); + + // #1 reverts to its confirmed PREINIT snapshot; the unconfirmed entry and + // its distribution are gone. + let after = get_ido_by_preinit_txid(&pool, txid(1)) + .await + .unwrap() + .unwrap(); + assert_eq!(after.status, "PREINIT"); + let entries = list_ido_entries(&pool, confirmed, &hex::encode(txid(1)), None, &[], 0, 100) + .await + .unwrap(); + assert_eq!(entries.len(), 0); + + // #2 (mempool-only) is dropped entirely. + assert!(get_ido_by_preinit_txid(&pool, txid(3)) + .await + .unwrap() + .is_none()); + let (ido2_rows,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM ido WHERE internal_id = ?") + .bind(mempool_only) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(ido2_rows, 0); } } diff --git a/src/index.rs b/src/index.rs index ee6a9da..4ec99e7 100644 --- a/src/index.rs +++ b/src/index.rs @@ -26,6 +26,7 @@ use crate::{ crc20::index_crc20, db::{ self, + blob::ToBlob, cauldron::{ config::{config_get, config_set}, header::{db_get_header, store_headers}, @@ -189,17 +190,10 @@ pub async fn update_mempool( // ido updates let ido_electrum = electrum.clone(); - // highest known block height; mempool tracker entries are stamped with its - // negative so ido's txchain_trim_tracker can age them out (see index_mempool) - let tip_electrum = electrum.clone(); - let (_, tip_height) = - tokio::task::spawn_blocking(move || electrum_get_tip(&tip_electrum.lock().unwrap())) - .await??; - - // filter txs already in an ido txchain + // filter txs already recorded in an ido's state chain let mut ido_to_add = Vec::new(); for txid in ido_txs { - if !db::ido::has_txchain_tx(&db.ido_w, &txid).await? { + if !db::ido::has_indexed_tx(&db.ido_w, &txid.to_blob()).await? { ido_to_add.push(txid); } } @@ -220,9 +214,9 @@ pub async fn update_mempool( }) .await?; - // an ido txchain 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); - db::ido::index_mempool(network, &db.ido_w, &txs_to_add, tip_height).await?; + db::ido::index_txs(network, &db.ido_w, &txs_to_add, None).await?; Ok(()) } @@ -305,6 +299,14 @@ pub async fn index_blocks( } } + // Before applying confirmed blocks, drop any mempool-indexed IDO state + // (blockhash IS NULL). A mempool tx that never confirmed — e.g. one replaced + // by a different on-chain tx — must not leave a stale snapshot that wins + // MAX(seq) or resurfaces after the reorg undo above. Confirmed blocks + // re-create rows for the txs they contain, and the next mempool pass + // (update_mempool) rebuilds the rest. + db::ido::delete_entries(&db.ido_w, None).await?; + let db_cpy = db.clone(); let chain_cpy = chain.clone(); let client_cpy = client.clone(); @@ -486,15 +488,7 @@ pub async fn index_blocks( ) .await?; - db::ido::index_block( - network, - &db.ido_w, - &sorted_txs, - &blockhash, - mtp as i64, - block_height as i64, - ) - .await?; + db::ido::index_txs(network, &db.ido_w, &sorted_txs, Some(&blockhash)).await?; let autheader_updates = if bcmr_enabled { let updates = index_bcmr(&db.bcmr_w, &blockhash, sorted_txs).await?; diff --git a/src/main.rs b/src/main.rs index 8d56ee1..94a8cd3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -380,9 +380,6 @@ async fn launch() -> _ { config }; - // Debug-only RPC endpoints (e.g. txchain inspection) are gated behind this flag. - let debug_endpoints = config.debug; - let ( dbpool, bcmrdownloader, @@ -570,20 +567,12 @@ async fn launch() -> _ { } // Always-on IDO endpoints. - let mut ido_routes = routes![ + let ido_routes = routes![ rpc::ido::list_idos, rpc::ido::get_ido_by_id, rpc::ido::get_ido_by_offering_token, rpc::ido::list_ido_entries, - rpc::ido::get_txchain_tx, ]; - // Debug-only IDO endpoints, mounted only when `debug = true` in the config. - if debug_endpoints { - ido_routes.extend(routes![ - rpc::ido::list_ido_txchain, - rpc::ido::list_txchain_tracker_map, - ]); - } rocket::build() .attach(signal::ShutdownFairing) diff --git a/src/rpc/ido.rs b/src/rpc/ido.rs index 40fde82..c75baa1 100644 --- a/src/rpc/ido.rs +++ b/src/rpc/ido.rs @@ -20,9 +20,6 @@ const MAX_OWNER_NFTHASH_FILTERS: usize = 20; /// Length in bytes of an owner_nfthash. const OWNER_NFTHASH_LEN: usize = 32; -const DEBUG_DEFAULT_LIMIT: i64 = 100; -const DEBUG_MAX_LIMIT: i64 = 10_000; - /// List IDOs with optional filters and pagination. /// /// Filters: @@ -190,51 +187,6 @@ pub async fn list_ido_entries( Ok(cached_ok(serde_json::to_value(items).unwrap(), CACHE_NONE)) } -/// Get the raw transaction hex for a txchain item by its txid. -/// Returns `{"tx": ""}` or `{"tx": null}` if not found. -#[get("/txchain//tx")] -pub async fn get_txchain_tx(txid: &str, db: &State) -> CachedApiResult { - let txid_blob = display_hex_to_blob::(txid).map_err(|e| { - bad_request( - ApiErrorCode::InvalidParameters, - &format!("Invalid txid hex: {e}"), - ) - })?; - - let tx_hex = crate::db::ido::get_txchain_tx_hex(&db.ido_r, &txid_blob) - .await - .map_err(db_error)?; - - Ok(cached_ok(serde_json::json!({ "tx": tx_hex }), CACHE_NONE)) -} - -/// [Debug] List the txchain for an IDO, sorted oldest-first (head is last). -/// -/// - `id`: the IDO's public id (preinit txid, 64-char hex) -/// -/// Pagination: `offset` (default 0), `limit` (default 100, max 10000) -#[get("//txchain?&")] -pub async fn list_ido_txchain( - id: &str, - offset: Option, - limit: Option, - db: &State, -) -> CachedApiResult { - let offset = offset.unwrap_or(0).max(0); - let limit = limit - .unwrap_or(DEBUG_DEFAULT_LIMIT) - .clamp(1, DEBUG_MAX_LIMIT); - - let (internal_id, preinit_txid_hex) = resolve_ido(id, &db.ido_r).await?; - - let items = - crate::db::ido::list_ido_txchain(&db.ido_r, internal_id, &preinit_txid_hex, offset, limit) - .await - .map_err(db_error)?; - - Ok(cached_ok(serde_json::to_value(items).unwrap(), CACHE_NONE)) -} - async fn resolve_ido( id: &str, pool: &sqlx::SqlitePool, @@ -260,24 +212,3 @@ async fn resolve_ido( Ok((internal_id, preinit_txid_hex)) } - -/// [Debug] List all entries in the txchain tracker map. -/// -/// Pagination: `offset` (default 0), `limit` (default 100, max 10000) -#[get("/txchain-tracker?&")] -pub async fn list_txchain_tracker_map( - offset: Option, - limit: Option, - db: &State, -) -> CachedApiResult { - let offset = offset.unwrap_or(0).max(0); - let limit = limit - .unwrap_or(DEBUG_DEFAULT_LIMIT) - .clamp(1, DEBUG_MAX_LIMIT); - - let items = crate::db::ido::list_txchain_tracker_map(&db.ido_r, offset, limit) - .await - .map_err(db_error)?; - - Ok(cached_ok(serde_json::to_value(items).unwrap(), CACHE_NONE)) -}