ido: validate delphi nft in preinit first output; announcement moves to last

The preinit tx layout changed: the first output is now the Delphi NFT and
the announcement OP_RETURN is the last output. Read the announcement from the
last output in both is_preinit_broadcast and parse_ido_preinit_tx_params.

Add validity checks on the first output's Delphi NFT: its category must match
the announcement's delphiCategory, and the 48-bit commitment timestamp (current
time) must place launchConditions.expiresAt within a 1-30 day window.

The two PREINIT_TEST_TX vectors use the old layout, so the three preinit parse
tests are marked #[ignore] pending new-format sample transactions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hossein Zoda 2026-05-31 00:15:19 +00:00
parent b56ce5bc63
commit 366823cf02

View file

@ -57,6 +57,13 @@ static ENTRY_EXECUTION_FEE: LazyLock<BigInt> = LazyLock::new(|| BigInt::from(100
static IDO_CREATE_EXECUTION_FEE: LazyLock<BigInt> = LazyLock::new(|| BigInt::from(30000i64)); // 30k sats
static PLATFORM_FEE: LazyLock<BigInt> = LazyLock::new(|| BigInt::from(10_000_000i64)); // 10%
// Allowed IDO offering window. The preinit's first output is a Delphi NFT whose
// commitment carries the current timestamp; `launchConditions.expiresAt` must fall
// between 1 and 30 days (inclusive) after that timestamp for the IDO to be valid.
const SECONDS_PER_DAY: i64 = 86_400;
static MIN_OFFERING_DURATION: LazyLock<BigInt> = LazyLock::new(|| BigInt::from(SECONDS_PER_DAY)); // 1 day
static MAX_OFFERING_DURATION: LazyLock<BigInt> = LazyLock::new(|| BigInt::from(30 * SECONDS_PER_DAY)); // 30 days
const CHIPNET_DELPHI_TOKEN_ID: &[u8; 32] = &[
0x1b, 0x94, 0x82, 0x40, 0xc9, 0xcf, 0xaa, 0x82, 0x24,
@ -921,7 +928,8 @@ const IDO_PREINIT_ANNOUNCEMENT_SIGNATURE: &[u8; 15] = &[
fn is_preinit_broadcast(
tx: &Transaction,
) -> bool {
if let Some(out) = tx.output.get(0) {
// The announcement OP_RETURN is carried in the last output (the first output is the Delphi NFT).
if let Some(out) = tx.output.last() {
let bytes = out.script_pubkey.as_bytes();
bytes.len() >= 15 && &bytes[0..15] == IDO_PREINIT_ANNOUNCEMENT_SIGNATURE
} else {
@ -1027,8 +1035,8 @@ struct IdoPreinitParseParamsResult {
}
fn parse_ido_preinit_tx_params(tx: &Transaction, delphi_token_id: &[u8], platform_fee_nfth: &[u8], errors: &mut Vec<Error>, invalid_ido_reasons: &mut Vec<Error>) -> Option<IdoPreinitParseParamsResult> {
// preinit announcement
let annOut = tx.output.get(0); // preinit params
// preinit announcement (now carried in the last output; the first output is the Delphi NFT)
let annOut = tx.output.last();
if annOut.is_some() {
let instructions: Vec<_> = annOut.unwrap().script_pubkey.instructions().collect();
if instructions.len() != 3 {
@ -1180,6 +1188,46 @@ fn parse_ido_preinit_tx_params(tx: &Transaction, delphi_token_id: &[u8], platfor
invalid_ido_reasons.push(anyhow::anyhow!("preinit_parameters.offering.executionFee < *ENTRY_EXECUTION_FEE"));
is_valid_ido = false;
}
// The first output must be the Delphi NFT. Its category must match the
// announcement's delphiCategory, and the commitment timestamp (the current
// time) must place launchConditions.expiresAt within the allowed offering window.
match tx.output.first() {
Some(delphiOut) => match delphiOut.token.as_ref() {
Some(delphiToken) if delphiToken.has_nft() => {
if delphiToken.id.to_blob() != delphi_token_id {
invalid_ido_reasons.push(anyhow::anyhow!("delphi nft category in first output != preinit_parameters.offering.delphiCategory"));
is_valid_ido = false;
}
if delphiToken.commitment.len() < 6 {
invalid_ido_reasons.push(anyhow::anyhow!("delphi nft commitment too short to contain a timestamp"));
is_valid_ido = false;
} else {
let c = &delphiToken.commitment;
// 48-bit little-endian timestamp (see riftenlabs_defi::delphi::parse_delphi_update).
let delphi_timestamp = u64::from_le_bytes([c[0], c[1], c[2], c[3], c[4], c[5], 0, 0]);
let offering_duration = &preinit_parameters.offering.launchConditions.expiresAt - BigInt::from(delphi_timestamp);
if offering_duration < *MIN_OFFERING_DURATION {
invalid_ido_reasons.push(anyhow::anyhow!("expiresAt - delphi timestamp < MIN_OFFERING_DURATION (1 day)"));
is_valid_ido = false;
}
if offering_duration > *MAX_OFFERING_DURATION {
invalid_ido_reasons.push(anyhow::anyhow!("expiresAt - delphi timestamp > MAX_OFFERING_DURATION (30 days)"));
is_valid_ido = false;
}
}
}
_ => {
invalid_ido_reasons.push(anyhow::anyhow!("first output is not a delphi nft"));
is_valid_ido = false;
}
},
None => {
invalid_ido_reasons.push(anyhow::anyhow!("delphi nft output (first output) is missing"));
is_valid_ido = false;
}
}
return Some(IdoPreinitParseParamsResult {
preinit_parameters,
offered_token_is_in_supply,
@ -2338,12 +2386,14 @@ mod tests {
static PREINIT_TEST_TX02: LazyLock<Vec<u8>> = LazyLock::new(|| hex::decode("0200000001e2365768e0f14db4f3686481a0f53c9bc8e5f5093b858503744c42605653a99b0b0000006441cb694ca0293e4c34ebaf8339aa81b32c9965fea6503e1ef5748f6c1d7b6cfce8904643e1cb4d09458b44004a632dbd103bc14dc1faaebfaaf5cdf7cba9c94f1c612103b67517abb88bd00b5d81154aeab86a2e65ab952e5d24d178e1c3ea82112834fc000000000c0000000000000000d86a4cbb4361756c64726f6e49646f30072d5fbecfa1bc37a13f58b188f50a5bc1dfb9ab8ae0bf5d3d9a63f6f81efc0b809698001b948240c9cfaa8224383db16e4735efa09c9426d1713d265e849a8e91f2669afefc0a6a0000c027090000000000c027090000000000002d310180969800a92669e1030000000000000000000000c027090000000000000000000000000000000000000000000000000000000000000000001027000080c3c9010010a5d4e800000000e8764817000000811976a91495570c60bbdeaf648a259bb177911169e060a76788ace80300000000000023aa200b8614ab1785d536b190f9244cb8eac7e4139bac23c9cd198a48d987cd94196287e80300000000000023aa201c8817a666e4a1ede4afafd752fd372f7271c3a2036507b29902e9ea1835f27587e80300000000000023aa208abe922d98f699c05865a769eeeb05300e7d74982d0b38ba8c03774db8bc257c87e80300000000000023aa20f69ff67df71b9ecd4b6b39af393551468bb2a46fb261e2e5a61cfb27dbcbb25087e80300000000000023aa20e5b1d9865bf78f7cfc02e60ba0bcf327e0d3949f455a37dd8870979c3fc0f42d87e80300000000000023aa20bcf0c9aabff1ff311bccd16c598ac0bc7860253b2c8535cef71dbb2c0a1a782c87e80300000000000023aa206ead0ce800334c2decb31c9f6b4d3a6a1bf03789513206a107b464481c917bb687b0040000000000000f0201008178c99dc8c0c88702000075b004000000000000c70201008178c99dc8c0c8874cb90c0602c400024a0104011901526a0442434d52206f6465e0112ce788c1ac118d7783add22cc3d0d28b3e3616b89be097cd6919b84968747470733a2f2f697066732e72696674656e2e6e65742f75415655534947396b5a6541524c4f6549776177526a5865447264497377394453697a343246726962344a664e61526d3438697066733a2f2f75415655534947396b5a6541524c4f6549776177526a5865447264497377394453697a343246726962344a664e61526d34b70075307500000000000023aa2060935eaee5df8476303b86855a14c093d02aaf816409533ad31ef87e79705b7f87a91ecc1d000000001976a914b093805bb56927c08724b92bfdfd77cf07c2cdc288ac00000000").unwrap());
#[test]
#[ignore = "old preinit layout (announcement in output 0); needs new-format sample tx with delphi nft first + announcement last"]
fn preinit_detect() {
let tx: Transaction = bitcoincash::consensus::deserialize(&PREINIT_TEST_TX01).expect("should be valid");
assert!(is_preinit_broadcast(&tx));
}
#[test]
#[ignore = "old preinit layout (announcement in output 0); needs new-format sample tx with delphi nft first + announcement last"]
fn parse_ido_preinit_from_valid_ido() {
let tx: Transaction = bitcoincash::consensus::deserialize(&PREINIT_TEST_TX01).expect("should be valid");
let mut errors: Vec<Error> = Vec::new();
@ -2371,6 +2421,7 @@ mod tests {
}
#[test]
#[ignore = "old preinit layout (announcement in output 0); needs new-format sample tx with delphi nft first + announcement last"]
fn parse_ido_preinit_from_valid_ido_with_bcmr() {
let tx: Transaction = bitcoincash::consensus::deserialize(&PREINIT_TEST_TX02).expect("should be valid");
let mut errors: Vec<Error> = Vec::new();