This commit is contained in:
Jakob Notland 2026-03-31 12:41:27 +02:00
parent f603baf2ef
commit 08310c62c0
2 changed files with 53 additions and 22 deletions

View file

@ -169,12 +169,14 @@ pub async fn create_tables(pool: &SqlitePool) {
// Unspent entry NFT UTXOs created by participants.
// Populated once offering_category is known (after createIDO fires).
// token_amount = purchasedAmount (the offered tokens the participant is buying).
sqlx::query(
"CREATE TABLE IF NOT EXISTS ido_entry (
utxo_txid BLOB NOT NULL,
utxo_n INTEGER NOT NULL,
offering_category BLOB NOT NULL,
sats BIGINT NOT NULL,
token_amount BIGINT NOT NULL DEFAULT 0,
commitment BLOB NOT NULL,
spent INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (utxo_txid, utxo_n)
@ -259,11 +261,18 @@ pub(crate) async fn insert_ido(
/// Advance the pre-init UTXO chain for any IDO whose tracked UTXO is spent by this tx.
///
/// In each idoPreInitStep, the pre-init contract UTXO moves from output[1] of the previous tx
/// to output[1] of the current tx, carrying the offered token. When createIDO fires, the
/// pre-init UTXO is consumed without a continuation at output[1] (no offered token there),
/// and the offering initiator appears at output[0]. At that point, this tx's txid becomes
/// the offering_category (the category used by entry NFTs).
/// In each idoPreInitStep the output layout is:
/// output[0]: storage or OP_RETURN (no token)
/// output[1]: pre-init contract UTXO (BCH only, **never** has a token)
/// output[2]: offered token storage (always carries the offered FT)
///
/// In the final step (not isPreInitStep / createIDO), the offering initiator is placed at
/// output[0] and the offered tokens move to output[5] and output[7]; output[2] is now a
/// bytecode storage with **no** token. Checking output[2] for the offered token therefore
/// correctly distinguishes the final step from all preceding pre-init steps.
///
/// When the final step fires, this tx's txid becomes offering_category (the token category
/// used by participant entry NFTs).
async fn advance_preinit_chain(conn: &mut SqliteConnection, tx: &Transaction) -> Result<()> {
let txid = tx.txid();
@ -294,10 +303,12 @@ async fn advance_preinit_chain(conn: &mut SqliteConnection, tx: &Transaction) ->
.execute(&mut *conn)
.await?;
// If output[1] carries the offered token → this is another pre-init step
// output[1] is the pre-init contract (BCH only, never has a token).
// output[2] is the offered token storage — present in every pre-init step,
// absent (replaced by bytecode storage) in the final createIDO step.
let is_preinit_step = tx
.output
.get(1)
.get(2)
.and_then(|o| o.token.as_ref())
.map(|t| t.id.to_blob() == offered_token_cat_blob)
.unwrap_or(false);
@ -370,10 +381,14 @@ async fn track_entry_nfts(conn: &mut SqliteConnection, tx: &Transaction) -> Resu
.await?;
}
// Create new entries: NFT outputs whose token category is a known offering_category
// Create new entries: mutable NFT outputs whose token category is a known offering_category.
// Entry NFTs are mutable (capability 0x01, per `offering.cash` line 93: tokenCategory ==
// offeringCategory + 0x01). The launcher is immutable (capability 0x00), so the mutable
// check cleanly excludes it and other non-entry NFTs with the same category.
for (n, output) in tx.output.iter().enumerate() {
let Some(token) = &output.token else { continue };
if !token.has_nft() {
// has_nft() checks bitfield & 0x20; capability() = bitfield & 0x0f
if !token.has_nft() || token.capability() != 0x01 {
continue;
}
let cat_blob = token.id.to_blob();
@ -382,13 +397,14 @@ async fn track_entry_nfts(conn: &mut SqliteConnection, tx: &Transaction) -> Resu
}
sqlx::query(
"INSERT OR IGNORE INTO ido_entry
(utxo_txid, utxo_n, offering_category, sats, commitment)
VALUES (?, ?, ?, ?, ?)",
(utxo_txid, utxo_n, offering_category, sats, token_amount, commitment)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(txid.to_blob())
.bind(n as i64)
.bind(&cat_blob)
.bind(output.value as i64)
.bind(token.amount)
.bind(&token.commitment)
.execute(&mut *conn)
.await?;
@ -486,13 +502,13 @@ fn rows_to_ido_list(
}
/// Returns unspent entry NFT UTXOs for a specific IDO (identified by creation txid).
/// (utxo_txid_hex, utxo_n, sats, commitment_hex)
/// (utxo_txid_hex, utxo_n, sats, token_amount, commitment_hex)
pub async fn get_ido_entries(
pool: &SqlitePool,
creation_txid: &Txid,
) -> Result<Vec<(String, u32, i64, String)>> {
) -> Result<Vec<(String, u32, i64, i64, String)>> {
let rows = sqlx::query(
"SELECT e.utxo_txid, e.utxo_n, e.sats, e.commitment
"SELECT e.utxo_txid, e.utxo_n, e.sats, e.token_amount, e.commitment
FROM ido_entry e
JOIN ido i ON i.offering_category = e.offering_category
WHERE i.creation_txid = ? AND e.spent = 0",
@ -506,11 +522,13 @@ pub async fn get_ido_entries(
let txid_blob: Vec<u8> = row.get(0);
let n: i64 = row.get(1);
let sats: i64 = row.get(2);
let commitment: Vec<u8> = row.get(3);
let token_amount: i64 = row.get(3);
let commitment: Vec<u8> = row.get(4);
result.push((
blob_to_display_hex::<Txid>(&txid_blob)?,
n as u32,
sats,
token_amount,
hex::encode(commitment),
));
}
@ -625,7 +643,11 @@ mod tests {
}
}
/// Build a pre-init step tx: spends prev_txid:1, output[1] carries offered token.
/// Build a pre-init step tx matching the real contract layout:
/// input: spends prev_txid:1 (the pre-init contract UTXO)
/// output[0]: storage / OP_RETURN (no token)
/// output[1]: new pre-init contract UTXO (BCH only, NO token)
/// output[2]: offered token storage (HAS offered token) ← the key detection signal
fn build_preinit_step_tx(prev_txid: Txid, offered_category: TokenID) -> Transaction {
use bitcoincash::{OutPoint, TxIn};
Transaction {
@ -638,8 +660,11 @@ mod tests {
witness: bitcoincash::Witness::default(),
}],
output: vec![
// output[0]: storage (no token)
TxOut { value: 0, script_pubkey: Script::new(), token: None },
// output[1]: pre-init contract continues with offered token
// output[1]: new pre-init contract UTXO — BCH only, never carries a token
TxOut { value: 1000, script_pubkey: Script::new(), token: None },
// output[2]: offered token storage (detection signal for is_preinit_step)
TxOut {
value: 1000,
script_pubkey: Script::new(),
@ -654,7 +679,12 @@ mod tests {
}
}
/// Build a createIDO tx: spends prev_txid:1, output[1] does NOT carry offered token.
/// Build the final pre-init step (createIDO equivalent):
/// input: spends prev_txid:1
/// output[0]: offering initiator (P2SH32, no token)
/// output[1]: offeringBytecode storage (no token)
/// output[2]: launcherBytecode storage (no token) ← offered token absent → final step
/// output[5]: offeredTokenFunding (has offered token, but at index 5 not 2)
fn build_create_ido_tx(prev_txid: Txid) -> Transaction {
use bitcoincash::{OutPoint, TxIn};
Transaction {
@ -686,7 +716,7 @@ mod tests {
script_pubkey: Script::new(),
token: Some(OutputData {
id: offering_category,
bitfield: 0x20, // HasNFT
bitfield: 0x21, // HasNFT | mutable capability
amount: 0,
commitment,
}),
@ -913,7 +943,7 @@ mod tests {
let entries = get_ido_entries(&pool, &ann_txid).await.unwrap();
assert_eq!(entries.len(), 1);
let (txid_hex, n, sats, commitment_hex) = &entries[0];
let (txid_hex, n, sats, _token_amount, commitment_hex) = &entries[0];
assert_eq!(txid_hex, &entry_txid.to_hex());
assert_eq!(*n, 0u32);
assert_eq!(*sats, 10_000i64);
@ -967,7 +997,7 @@ mod tests {
let entries = get_ido_entries(&pool, &ann_txid).await.unwrap();
assert_eq!(entries.len(), 1, "only unspent entry should be returned");
let (txid_hex, _, sats, _) = &entries[0];
let (txid_hex, _, sats, _, _) = &entries[0];
assert_eq!(txid_hex, &entry_a_txid.to_hex());
assert_eq!(*sats, 5_000i64);
}

View file

@ -102,11 +102,12 @@ pub async fn get_ido_entries(creation_txid: &str, conn: &State<DB>) -> CachedApi
let entries_json: Vec<Value> = entries
.into_iter()
.map(|(txid, n, sats, commitment)| {
.map(|(txid, n, sats, token_amount, commitment)| {
json!({
"txid": txid,
"n": n,
"sats": sats,
"token_amount": token_amount,
"commitment": commitment,
})
})