// Copyright (C) 2024-2026 Whiterun LLC // // This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later. // A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html #![allow(non_snake_case)] use anyhow::Result; use log::{info,warn,debug}; use num_bigint::{BigInt, Sign}; use num_traits::{Zero, ToPrimitive}; use bitcoincash::blockdata::script::{Builder, Script, ScriptBuf, Instruction, PushBytes}; use bitcoincash::blockdata::opcodes; use bitcoin_hashes::sha256d; use bitcoin_hashes::Hash; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DisplayFromStr}; use bitcoincash::blockdata::transaction::Transaction; use bitcoincash::{Txid, BlockHash, TokenID, Network}; use std::sync::LazyLock; use std::collections::{HashMap, HashSet}; use sqlx::{Row, SqlitePool}; use crate::db::blob::{ToBlob, blob_to_display_hex}; use anyhow::Error; const BCMR_SIGNATURE: &[u8] = &[ 0x6a, 0x04, 0x42, 0x43, 0x4d, 0x52, 0x20, ]; const ITEM_TYPE_BITS: u8 = 0x0f; 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 const ITEM_TYPE_CONFIRMATION_NFT: u8 = 0x05; static PERMANENT_LIQUIDITY_SHARE_DENOMINATOR: LazyLock = LazyLock::new(|| BigInt::from(100_000_000i64)); // UNUSED //static ONE_YEAR_TIMEVAL: LazyLock = LazyLock::new(|| BigInt::from(31536000i64)); // UNUSED //static ANNUAL_RATE_DENOMINATOR: LazyLock = LazyLock::new(|| BigInt::from(100_000_000i64)); // UNUSED //static PLATFORM_FEE_DENOMINATOR: LazyLock = LazyLock::new(|| BigInt::from(100_000_000i64)); // UNUSED //static PRICE_DENOMINATOR: LazyLock = LazyLock::new(|| BigInt::from(100_000_000_000i64)); // below should be euqal // PERMANENT_LIQUIDITY_SHARE_DENOMINATOR == ANNUAL_RATE_DENOMINATOR static MIN_PLP_SHARE: LazyLock = LazyLock::new(|| BigInt::from(20_000_000i64)); // 20% static MAX_PLP_SHARE: LazyLock = LazyLock::new(|| BigInt::from(80_000_000i64)); // 80% static MAX_PLP_ANNUAL_DISCOUNT: LazyLock = LazyLock::new(|| BigInt::from(10_000_000i64)); // 10% static MIN_PLP_ANNUAL_DISCOUNT: LazyLock = LazyLock::new(|| BigInt::from(0i64)); // 0% static MIN_PLP_AFTER_DISCOUNT: LazyLock = LazyLock::new(|| BigInt::from(5_000_000i64)); // 5% static ENTRY_EXECUTION_FEE: LazyLock = LazyLock::new(|| BigInt::from(50_000i64)); // 50k sats static IDO_CREATE_EXECUTION_FEE: LazyLock = LazyLock::new(|| BigInt::from(300_000i64)); // 300k sats static PLATFORM_FEE: LazyLock = 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 = LazyLock::new(|| BigInt::from(SECONDS_PER_DAY)); // 1 day static MAX_OFFERING_DURATION: LazyLock = 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, 0x38, 0x3d, 0xb1, 0x6e, 0x47, 0x35, 0xef, 0xa0, 0x9c, 0x94, 0x26, 0xd1, 0x71, 0x3d, 0x26, 0x5e, 0x84, 0x9a, 0x8e, 0x91, 0xf2, 0x66, 0x9a ]; const MAINNET_DELPHI_TOKEN_ID: &[u8; 32] = &[ 0xbe, 0x0d, 0x0d, 0x83, 0x24, 0xe8, 0xcd, 0xa4, 0x1d, 0x34, 0xb8, 0x5b, 0xd2, 0x03, 0xce, 0x24, 0x82, 0x25, 0x6e, 0xb3, 0x37, 0xa0, 0xad, 0x0f, 0xea, 0x82, 0xc2, 0xdd, 0xd7, 0x30, 0x6c, 0x88 ]; // TODO:: set the platform fee nfth const CHIPNET_PLATFORM_FEE_NFTH: LazyLock> = LazyLock::new(|| hex::decode("ab3aba01311b672808aa06f5e7f332d930e4ed0c99407343f7fc5d743d15ff55").unwrap()); // TODO:: set a mock nfthash for mainnet const MAINNET_PLATFORM_FEE_NFTH: LazyLock> = LazyLock::new(|| hex::decode("").unwrap()); // (OP_PUSH18 "CauldronIdo-2026Q2" OP_DROP), found at the start of every ido // state machine redeem script. pub const IDO_SIGNATURE: &[u8] = &[ 0x12, 0x43, 0x61, 0x75, 0x6c, 0x64, 0x72, 0x6f, 0x6e, 0x49, 0x64, 0x6f, 0x2d, 0x32, 0x30, 0x32, 0x36, 0x51, 0x32, 0x75, ]; static OFFERING_CONTRACT: LazyLock> = LazyLock::new(|| hex::decode("c0ce01207f75c0519c637600ce8800cf517f755f845188675879827701209dc0009d00d000d394765479a269765579950500e876481796005c7900a063577900a069587900a0695c79587995048033e1015a7995a169785d7995587995048033e1010400e1f5059596776e947b757c7800a0696854790087535e7900a06376608577687863760120857768005f7900a0635f795680547958807e77686e7e51d28851d15779517e8851d356799d02aa20012060797e5e797eaa7e01877e51cd8852796351cc5579a26952d100876452d101207f75577987916968c4539d6752d158798852d35579a26902aa20525152807e5f797eaa7e01877e52cd8853d100876453d101207f7557798791696854d100876454d101207f75577987916968c4559d6800cf517f77547f758100cc00c6527993a26900cd00c78800cf557f77547f758100cf557f75788b54807e00d28800ce00d1886d6d6d6d686d6d6d6d6d51").unwrap()); static OFFERING_ENTRY_CONTRACT: LazyLock> = LazyLock::new(|| hex::decode("827701209dc0519dc0ce01207f7500ce01207f758800cf517f755f845288c0cf517f7501208401008763c0c878c88876c9529d687551").unwrap()); static LAUNCHER_CONTRACT: LazyLock> = LazyLock::new(|| hex::decode("c0009d00c852c88852c9529d00c854c88854c9549d00c855c88855c9559d00ce01207f7551ce78527e8851cf517f755f8401008853ce01207f7555798853cf567f75817600a06953d100876453d101207f7552798791696851d0547aa07651d0557aa09b63537952799f696851cf557f77547f758100a16302aa2001205d797e57797eaa7e01877e556085537956807e0058807e52d058807e0058807e00d28800d154798800d3009d00cd788851cd788851cc52c6a26951d152ce8851d352d09d51d2008852d10088c4549d75675279517e00d18800d3009d766355ca827755ca7853947f77527f758155ca527953945279947f77787f75526085557956807e51cf557f77547f757e0058807e52d058807e0058807e00d28802aa20c101147f755a79827751807e5a797e01207e60797e52797eaa7e01877e00cd8802aa20012060797e5a797eaa7e01877e52cd8852cc52c6a26952d152ce8852d352d09d52d2008854d10088c4559d6d756754ca827754ca7853947f77527f758154ca527953945279947f77787f7551d07600a06354d152ce8854d3789d54d2008802aa2001200111797e5b797eaa7e01877e54cd8855d10088c4569d6754d10088c4559d6852567956807e51cf557f77547f757e0058807e7858807e0058807e00d28802aa20c101147f755d79827751807e5d797e5c79827751807e5c797e5b79827751807e5b797e01207e5a797e547e5f797e01207e60797e01207e0111797e53797eaa7e01877e00cd8852d152ce8852d352d05279949d52cc52c6a26902aa20520052807e5d797eaa7e01877e52cd8852d200886d6d6802aa2056798277518057797e5a797eaa7e01877e51cd88545b797e51d28851cc51c6a26951d153798851d3009d686d6d6d6d6d6d51").unwrap()); static DISTRIBUTOR_DEPLOY_CONTRACT: LazyLock> = LazyLock::new(|| hex::decode("c0009d00c852c88852c9529d00cf517f77567f758100ce01207f7551ce01207f75788851cf517f755f84538851cf517f7551ca51ca82770136940120947f7701207f750000537960840100876451cf517f77567f75817b757c51cf577f77587f758177687800a2697600a26952d352d051d0949d587a810000567901208401008764527900a26951c6765479950400e1f50596537a757c6b7c6c765379947b757c53cc5279a26953d1008854cc5379a26954d10088756753d05379950400e1f505967b757c53d0527994777600a06353d3789d53d153ce886753d10088687800a06354d352799d54d153ce886754d100886868547900a06302aa20012057797e5f797eaa7e01877e51cd8851d1587988565551807e5c797e597956799356807e51d28851d3009d55d351d09d55d152ce8802aa20525152807e60797eaa7e01877e55cd8855d200886751d351d09d51d152ce8802aa20012057797e5e797eaa7e01877e51cd8851d20088687600a06302aa2001205b797e5e797eaa7e01877e53cd886753cd016a88687c00a06302aa2001205b797e5d797eaa7e01877e54cd886754cd016a886800cf577f77547f75817651a06300cf517f7500cf517f77567f757e788c54807e00cf5b7f77587f758153799358807e00cf01137f77587f757e00cf011b7f77587f758155799358807e00d28800ce00d18800c700cd8800d000d39d52cf52d28852ce52d18852c752cd88675500cf517f77567f757e00cf5b7f77587f758153799358807e00cf01137f77587f757e00cf011b7f77587f758155799358807e00d28800d158798800d3009d02aa2001205b797e5e797eaa7e01877e00cd8852d100885457790120840100876475536876ce59798876cf517f755f845488756855557a00a063755668c4789e6376d10088c4788b9d686d6d6d6d6d6d6d7551").unwrap()); static DISTRIBUTOR_REFUND_CONTRACT: LazyLock> = LazyLock::new(|| hex::decode("c0009d00ce01207f7551ce01207f75788851cf517f755f84538851ca51ca82770136940120947f7701207f7551cf517f75760120840100876451cc51c6a26951d100886751d352d09d51d152ce886802aa200120537a7e55797eaa7e01877e51cd8800cf577f77547f75817651a06300cf517f7500cf517f77567f757e788c54807e00cf5b7f77587f757e00cf01137f77587f757e00cf011b7f77587f757e00d28800ce00d18800c700cd88c4529e6352d10088c4539d686755608500cf517f77567f757e00cf5b7f77587f757e00cf01137f77587f757e00cf011b7f77587f757e00d288527900d18800d3009d02aa20012055797e56797eaa7e01877e00cd8852d100885352790120840100876475526876ce54798876cf517f755f845488c4539e6353d10088c4549d6875686d6d7551").unwrap()); static EXECUTION_FEE_PAYOUT_CONTRACT: LazyLock> = LazyLock::new(|| hex::decode("c0ce00ce01207f758800cf517f755f84528800cf577f77547f758151a169c0cf517f7701207f7502aa2001207b7e7b7eaa7e01877e52cd8852ccc0c6a2").unwrap()); static OFFERING_INITIATOR_CONTRACT: LazyLock> = LazyLock::new(|| hex::decode("c0c9009dc0c85b79c8885a79c9577a9d5979ce827701209d567900a263c0c85b79c8885a79c957799d68c0c800d1788800d2578800d3009d00cd5a7a88c08bc0c878c88876c9547a9d76ca827778ca7853947f77527f75817bca7b53945279947f777c7f75c05293c0c878c88876c9557a9d76ca827778ca7853947f77527f75817bca7b53945279947f777c7f75c05393c0c878c88876c9567a9d76ca827778ca7853947f77527f75815178529302ff00a063755367785293014ba063755268685379ca537a5394537a947b947f7702aa20525352807e5b797e7b7eaa7e01877e54cd8854cc7cc6a26954d10088c05493c0c878c88876c9567a9d76ca827778ca7853947f77527f75815178529302ff00a063755367785293014ba063755268685379ca537a5394537a947b947f7702aa20525352807e5a797e7b7eaa7e01877e55cd8855cc7cc6a26955d100885779d07600a0690100557a7e04000000007e51d28851d15479527e8851d3789d51cd02aa20547aaa7e01877e885153d28853d153798853d3009d53cd02aa20537aaa7e01877e8802aa20525352807e567a7eaa7e01877e52cd8852cc5579c6a26952d1557ace8852d39d52d20088c05593c0c878c88876c9537a9d76c7827778c77853947f77527f75817bc77b53945279947f777c7f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a776856cd8856d1008857d100876457d101207f75788791696857cd567f75066a0442434d52879169c4589e6358d100876458d101207f75788791696858cd567f75066a0442434d52879169c4599e6359d100876459d101207f75788791696859cd567f75066a0442434d52879169c45a9e635ad10087645ad101207f7578879169685acd567f75066a0442434d52879169c45b9e635bd10087645bd101207f7578879169685bcd567f75066a0442434d52879169c45c9d686868686d7551").unwrap()); static OFFERING_INTEGRATED_TIMELOCKED_P2NFTH_CONTRACT: LazyLock> = LazyLock::new(|| hex::decode("c0cf527f7701207f75c0cf01227f77815479ce01207f757b88537acf567f75817600a0699f6978ce7bcf7eaa88c0cf517f77517f7581c0c85279c8887cc99c").unwrap()); static IDO_INITIATOR_CONTRACT: LazyLock> = LazyLock::new(|| hex::decode("c0ce827701209dc0cf827700a0695579827701209dc0c85779c8885679c99dc0c85779c8885679c99d5479529376ca827778ca7853947f77527f75817bca7b53945279947f777c7f7501157f7701207f75c0cec0cf7eaa88547ac852d1827701209d52d300a06902aa20c101147f755479827751807e54797e5379827751807e537a7e01207e7b7e01207e52d17e01207e547a7e587e52d358807e537a7eaa7e01877e57cd8857ccc0c6a26957d1c0ce8857d2c0cf8857d3c0d09d02aa20525752807e7b7eaa7e01877e7658cd8858cc02e803a26958d1008859cd8859cc78c6a26959d178ce8859d278cf8859d37cd09c").unwrap()); static IDO_POSTLAUNCH_CONTRACT: LazyLock> = LazyLock::new(|| hex::decode("5879009c63c0009dc0c9009e69c0cdc0c788c0ccc0c6a269c0d1c0ce88c0d3c0d09dc0d2c0cf8802aa20520052807e597a7eaa7e01877ec0c851c88851c9589d7651cd8851cc51c6a26951d151ce8851d351d09dc0c852c88852c9599d52cd8852cc52c6a26952d152ce8852d352d09dc353a06353ce0088c3549d6853d10088c4549d6d6d6d6d51675879519c63c0009dc0c9009dc0cdc0c788c0d1c0ce88c0d3c0d09dc0d2c0cf88c0c852c88852c9529d52cd52c78852cc52c6a26952d152ce8852d352d09d02aa200120c0cec0cf7eaa7e587a7eaa7e01877e00005376c75479876376ce59798763527978d093537a757c6b7c6c6776ce0088686ec6937b757c6776ce008868768bc378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c3789d6868686868686868c0ccc0c6547a93a269c0c851c88851c9519d51cd51c78851cc51c6a26951d0537a937600a06351d159798851d378a26951d200886751d100886853d10088c4549d6d6d6d6d6d6d5167587a529dc0009dc0c9009d5379827701209d53ce567a8853cf517f755f845588c0c653cf577f77587f7581a269c0c851c88851c9519d53cf5f7f77587f75817600a06351d078a2696851d000a06351ce5679886851cf0088c0c852c88852c9529d52ce567988000053cf517f75608401008763557981537994765679950400e1f505967652d0a06352d07768765679950500e87648179653cf01177f77587f758194760500e8764817955779967802e803a27800a09a6378567a757c6b7c6b7c6b7c6b7c6c6c6c6c765379a16376557a757c6b7c6b7c6b7c6c6c6c675279557a757c6b7c6b7c6b7c6c6c6c68686d6d68c0c651c69352c69352799451d052d093527994537900a0537900a09a6302aa2005746376a914000114807e2b88ac67c0d1c0ce88c25288c0cdc0c788c0c6c0d095c0c6c0cc9490539502e80396c0cc7c94c0d3957ca2687eaa7e01877e00cd8800cc54799d00d15a798800d353799d00d20088096a0653554d4d4f4e14000114807e51cd8851cc009d51d100886700d1008800cd016a8851d1008851cd016a886802aa2001205a7a7e5b7a7eaa7e01877e52cd8852cc7ba2697600a06352d378a26952d158798852d200886752d100886853d10088c4549d6d6d6d6d75516868").unwrap()); static IDO_PREINIT_CONTRACT: LazyLock> = LazyLock::new(|| hex::decode("c0519dc0c800c88800c9529dc0c852c88852c9539dc0c853c88853c9549dc0c854c88854c9559dc0c855c88855c9569dc0c856c88856c9579d5c79009e63c0c858c88858c9599d67c0c858c88858c9599d68c0c857c88857c9589d597981009c5d79009c9b5b7981009c9b63c0d1008852cd00c78852d1008853cd52c78853d1008854cd53c78854d1008855cd54c78855d1008856cd55c78856d1008857cd56c78857d100885c79009e6359cd58c78859cc58c6a26959d158ce8859d358d09d59d258cf8867597981009e5c79009e9a6459cd58c78859d10088686858cd57c78858d1008802aa200302010055797eaa7e01877ec101587f775b7981009c6301005f79009e63015177685e79009c6300cd53798800d10088760251207e5e797e01207e5d797e52797e7b757c67c0c859c88859c9009d00cd016a8800d100885acd5c79885ad1c0c8885ad3009d5ad20100885bd10088c45c9d760200207e5e797e01207ec0c87e52797e7b757c6875675e79009c635d79009c6300cd52798800d10088030051205d797e01207e5c797e787e7767c0c859c88859c9009d53795579950400e1f50596547978935479789455795279a06902aa20012060797e5d797e5c797eaa7e01877e5c798277009c6302a91401200111797e5c797ea97e01877e776800cd788800d1c0c88800d352799d00d2008859cd56798859d1c0c88859d353799d59d2008858c7827758c77853947f77527f758158c7527953945279947f77787f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a77685acd78885ad100885bd10088c45c9d035100200114797e01207e0113797e58797e587a757c6b7c6b7c6b7c6b7c6b7c6b7c6c6c6c6c6c6c6d6d6d7568675d79009c6300cd52798800d10088035151205d797e01207e5c797e787e7767c0c859c88859c9009d00cd016a8800d100885acd5279885ad1c0c8885ad3009d5ad201ff885bd10088c45c9d03510020c0c87e01207e5c797e787e7768686802aa20c101147f7552797eaa7e01877ec0cd886d67c0c859c88859c95a9d55ca827755ca7853947f77527f758155ca527953945279947f77787f75012302aa2001205f797e5d797e5b797eaa7e01877e7e5b798277009c63011702a914012060797e5b797ea97e01877e7e776800cd02aa20c101147f7553797e54797eaa7e01877e8800d1008800ca827700ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686800ca537953945379945279947f7702aa20030200005d797e52797eaa7e01877e51cd8851d1008852ca827752ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686852ca537953945379945279947f7702aa20030200000111797e707c0114937f757e01207e01187901ff7eaa7e707c0135937f777eaa7e01877e52cd8852d1008853ca827753ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686853ca537953945379945279947f7702aa20030200000115797e52797eaa7e01877e53cd8853d1008854ca827754ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686854ca537953945379945279947f7702aa20030200000119797e52797eaa7e01877e54cd8854d1008857c7827757c77853947f77527f75815178529302ff00a063755367785293014ba0637552686857c7537953945379945279947f7755cd03020000011d797e52797e8855d1008802aa2003020000011d797eaa7e01877e56cd8856cc58c6a26956d158ce8856d3011a799d56d2008856ca827756ca7853947f77527f758156ca527953945279947f77787f7557cd02aa20c101147f7501207e01277901007eaa7e53797eaa7e01877e8857cc59c6a26957d159ce8857d359d09d57d259cf8802aa20525752807e0120797eaa7e01877e58cd8858d158ce8858d358d0011e79949d58d200886d6d6d6d6d6d6d6d6d6d6d6d6d75686d6d6d6d6d6d7551").unwrap()); static STORAGE_CONTRACT: LazyLock> = LazyLock::new(|| hex::decode("8178c99dc8c0c887").unwrap()); static P2NFTH_CONTRACT: LazyLock> = LazyLock::new(|| hex::decode("78ce7bcf7eaa87").unwrap()); static CASHTOKENS_STUDIO_PAY2CATEGORY_CONTRACT: LazyLock> = LazyLock::new(|| hex::decode("51ce8851d0009d6300cdc0c7886851").unwrap()); static REBUILD_IPFS_PLACEHOLDER_BCMR_FOR_GENESIS_WITH_AUTHGUARD_CONTRACT: LazyLock> = LazyLock::new(|| hex::decode("0902a9147ca97e01877e57897c6b00c08851d100887c635ab2756d51cd016a88674d0301404142434445464748494a4b4c4d4e4f505152535455565758595a6162636465666768696a6b6c6d6e6f707172737475767778797a303132333435363738392d5f6b007c8253a26365537f7c76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7b7c7e7c82539f666882760087636d677d537c94007c807e76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7c51937f757e686c7554893a10303132333435363738396162636465667c006b65517f7c5279785f84817f77517f756c7e6b52797c5499817f77517f756c7e6b82009c666d6c5689097f7b827b7c7f777e7e538978a8886b518ac0ce568a65766c537a538a6b74519c66756c766ba804015512207c7e548a01757c7e6b528a6c7c6b65766c537a538a6b74519c66756c6ca87c7e076a0442434d52207c7e51cd886801206c0f51ce8851d0009d6300cdc0c78868517e7e578a00cd8800ce00d18800d2008800d000d38800c600cca26952d10088c45387").unwrap()); #[serde_as] #[derive(Serialize, Deserialize, Clone)] pub struct IpfsBcmrWithPlaceholderMetadata { #[serde_as(as = "serde_with::hex::Hex")] registryReplaceCalls: Vec, #[serde_as(as = "serde_with::hex::Hex")] bcmrUrisReplaceCalls: Vec, } #[serde_as] #[derive(Serialize, Deserialize, Clone)] pub struct IpfsBcmrWithPlaceholder { metadata: IpfsBcmrWithPlaceholderMetadata, #[serde_as(as = "serde_with::hex::Hex")] contentHash: Vec, #[serde_as(as = "serde_with::hex::Hex")] urisBytecode: Vec, } #[derive(Serialize, Deserialize, Clone)] pub struct IdoPreInitBcmrParameters { oToken: Option, offering: Option, } pub struct IdoPreInitLockParameters { preInitBcmr: IdoPreInitBcmrParameters, authguardLockingBytecode: Vec, permanentLiquidityShareNumerator: BigInt, offeredTokenAmount: BigInt, offeredTokenTotalSupply: BigInt, } #[serde_as] #[derive(Serialize, Deserialize, Clone)] pub struct IdoParametersOfferingLaunchConditions { #[serde_as(as = "DisplayFromStr")] expiresAt: BigInt, #[serde_as(as = "DisplayFromStr")] deployThreshold: BigInt, #[serde_as(as = "DisplayFromStr")] immediateDeployThreshold: BigInt, } #[serde_as] #[derive(Serialize, Deserialize, Clone)] pub struct IdoParametersOfferingOffer { #[serde_as(as = "DisplayFromStr")] maxDiscountRateNumerator: BigInt, #[serde_as(as = "DisplayFromStr")] discountAnnualRateNumerator: BigInt, #[serde_as(as = "DisplayFromStr")] priceNumerator: BigInt, #[serde_as(as = "DisplayFromStr")] minOffer: BigInt, #[serde_as(as = "Option")] xTokenCategory: Option>, } #[serde_as] #[derive(Serialize, Deserialize, Clone)] pub struct IdoParametersOffering { #[serde_as(as = "DisplayFromStr")] platformFeeNumerator: BigInt, #[serde_as(as = "serde_with::hex::Hex")] platformFeeNFTH: Vec, #[serde_as(as = "serde_with::hex::Hex")] delphiCategory: Vec, launchConditions: IdoParametersOfferingLaunchConditions, offer: IdoParametersOfferingOffer, #[serde_as(as = "DisplayFromStr")] executionFee: BigInt, } #[serde_as] #[derive(Serialize, Deserialize, Clone)] pub struct IdoPreInitParameters { preInitBcmr: IdoPreInitBcmrParameters, #[serde_as(as = "serde_with::hex::Hex")] authguardLockingBytecode: Vec, #[serde_as(as = "DisplayFromStr")] authguardNftOutputIndex: BigInt, #[serde_as(as = "DisplayFromStr")] permanentLiquidityShareNumerator: BigInt, #[serde_as(as = "DisplayFromStr")] offeredTokenTotalSupply: BigInt, #[serde_as(as = "DisplayFromStr")] offeredTokenAmount: BigInt, offering: IdoParametersOffering, } #[serde_as] #[derive(Serialize, Deserialize, Clone)] pub struct IdoActiveParameters { preInitBcmr: IdoPreInitBcmrParameters, #[serde_as(as = "serde_with::hex::Hex")] collectorNFTH: Vec, #[serde_as(as = "DisplayFromStr")] permanentLiquidityShareNumerator: BigInt, #[serde_as(as = "DisplayFromStr")] offeredTokenAmount: BigInt, offering: IdoParametersOffering, } #[derive(Serialize, Deserialize, Clone)] #[serde(tag = "type")] pub enum IdoParameters { PreInit(IdoPreInitParameters), Active(IdoActiveParameters), } #[serde_as] #[derive(Serialize, Deserialize, Clone)] pub struct IdoPreInitState { #[serde_as(as = "serde_with::hex::Hex")] authguardCategory: Vec, #[serde_as(as = "serde_with::hex::Hex")] idoCategory: Vec, #[serde_as(as = "DisplayFromStr")] nextTxSetterFlag: BigInt, #[serde_as(as = "DisplayFromStr")] oTokenGenerated: BigInt, initiatorCreated: bool, } #[serde_as] #[derive(Serialize, Deserialize, Clone)] pub struct IdoActiveState { #[serde_as(as = "serde_with::hex::Hex")] authguardCategory: Vec, #[serde_as(as = "serde_with::hex::Hex")] idoCategory: Vec, #[serde_as(as = "DisplayFromStr")] counter: BigInt, // Running totals over the IDO's entries, maintained while the IDO is ACTIVE // so "raised so far" can be served from state instead of aggregating the // ido_entry table on every request. Initialized to 0 at the preinit→active // transition and incremented on each entry added. #[serde_as(as = "DisplayFromStr")] totalDemandAmount: BigInt, #[serde_as(as = "DisplayFromStr")] totalSupplyAmount: BigInt, #[serde_as(as = "DisplayFromStr")] totalDiscount: BigInt, } #[serde_as] #[derive(Serialize, Deserialize, Clone)] pub struct IdoDistributingState { #[serde_as(as = "serde_with::hex::Hex")] authguardCategory: Vec, #[serde_as(as = "serde_with::hex::Hex")] idoCategory: Vec, isRefund: bool, #[serde_as(as = "DisplayFromStr")] timestamp: BigInt, #[serde_as(as = "DisplayFromStr")] counter: BigInt, #[serde_as(as = "DisplayFromStr")] earnedAmount: BigInt, #[serde_as(as = "DisplayFromStr")] refundAmount: BigInt, #[serde_as(as = "DisplayFromStr")] discountAmount: BigInt, #[serde_as(as = "DisplayFromStr")] platformEarnedAmount: BigInt, } #[serde_as] #[derive(Serialize, Deserialize, Clone)] pub struct IdoPostLaunchState { #[serde_as(as = "serde_with::hex::Hex")] authguardCategory: Vec, #[serde_as(as = "serde_with::hex::Hex")] idoCategory: Vec, isRefund: bool, #[serde_as(as = "DisplayFromStr")] timestamp: BigInt, #[serde_as(as = "DisplayFromStr")] earnedAmount: BigInt, #[serde_as(as = "DisplayFromStr")] refundAmount: BigInt, #[serde_as(as = "DisplayFromStr")] discountAmount: BigInt, #[serde_as(as = "DisplayFromStr")] platformEarnedAmount: BigInt, } #[serde_as] #[derive(Serialize, Deserialize, Clone)] pub struct IdoPermanentPoolV0 { #[serde_as(as = "DisplayFromStr")] tokenAmount: BigInt, #[serde_as(as = "DisplayFromStr")] satoshiAmount: BigInt, } #[serde_as] #[derive(Serialize, Deserialize, Clone)] pub struct IdoDistributedState { #[serde_as(as = "serde_with::hex::Hex")] authguardCategory: Vec, #[serde_as(as = "serde_with::hex::Hex")] idoCategory: Vec, permanentPool: Option, #[serde_as(as = "DisplayFromStr")] refundAmount: BigInt, #[serde_as(as = "DisplayFromStr")] discountAmount: BigInt, #[serde_as(as = "DisplayFromStr")] collectorEarnedAmount: BigInt, #[serde_as(as = "DisplayFromStr")] platformEarnedAmount: BigInt, } #[derive(Serialize, Deserialize, Clone)] #[serde(tag = "type")] pub enum IdoState { PreInit(IdoPreInitState), Active(IdoActiveState), Distributing(IdoDistributingState), PostLaunch(IdoPostLaunchState), Distributed(IdoDistributedState), } /// Convert a byte slice into a `&PushBytes` for use with `Builder::push_slice`. /// bitcoincash 0.32 requires `AsRef` rather than a raw `&[u8]`. fn pb(data: &[u8]) -> &PushBytes { data.try_into().expect("push slice exceeds maximum length") } fn build_p2nfth_script(nfthash: &[u8]) -> ScriptBuf { let mut bytes = Builder::new() .push_slice(pb(nfthash)) .into_script() .to_bytes(); bytes.extend_from_slice(&P2NFTH_CONTRACT); ScriptBuf::from(bytes) } fn build_storage_script(governingOutpointIndex: u32) -> ScriptBuf { let mut bytes = Builder::new() .push_slice(pb(&encode_padded_vm_number(&BigInt::from(governingOutpointIndex), 2).unwrap())) .into_script() .to_bytes(); bytes.extend_from_slice(&STORAGE_CONTRACT); ScriptBuf::from(bytes) } fn build_storage_script_with_data_and_size(governingOutpointIndex: u32, data: &[u8]) -> ScriptBuf { let mut data_and_size = Vec::new(); data_and_size.extend_from_slice(data); data_and_size.extend_from_slice(&encode_padded_vm_number(&BigInt::from(data.len()), 2).unwrap()); let mut bytes = Builder::new() .push_slice(pb(&encode_padded_vm_number(&BigInt::from(governingOutpointIndex), 2).unwrap())) .into_script() .to_bytes(); bytes.extend_from_slice(&STORAGE_CONTRACT); let suffix = Builder::new() .push_slice(pb(&data_and_size)) .push_opcode(opcodes::all::OP_DROP) .into_script() .to_bytes(); bytes.extend_from_slice(&suffix); ScriptBuf::from(bytes) } fn extract_data_from_storage_script_with_data_and_size(script: &Script) -> Result> { let inst_list: Vec<_> = script.instructions().collect(); let n = inst_list.len(); if n < 2 { return Err(anyhow::anyhow!("storage with data should have at least two instructions")); } match &inst_list[n - 2] { Ok(Instruction::PushBytes(data)) => { let data = data.as_bytes(); Ok(data[0..data.len()-2].to_vec()) }, _ => Err(anyhow::anyhow!("storage with data should have a push opcode at instructions[count - 2]")), } } fn extract_data_from_unlocking_bytecode_of_storage_with_data_and_size(unlocking_bytecode: &Script) -> Result> { let instructions: Vec<_> = unlocking_bytecode.instructions().collect(); if instructions.len() != 2 { return Err(anyhow::anyhow!("not a storage unlocking bytecode, should have only two push opcodes")); } match &instructions[1] { Ok(Instruction::PushBytes(redeem_bytecode)) => { extract_data_from_storage_script_with_data_and_size(Script::from_bytes(redeem_bytecode.as_bytes())) }, _ => Err(anyhow::anyhow!("invalid unlocking bytecode, p2sh redeem script push opcode is expected")), } } fn has_script_ido_signature(unlocking_bytecode: &Script) -> bool { let instructions: Vec<_> = unlocking_bytecode.instructions().collect(); match instructions.last() { Some(Ok(Instruction::PushBytes(redeem_bytecode))) => { let redeem_bytecode = redeem_bytecode.as_bytes(); redeem_bytecode.len() >= IDO_SIGNATURE.len() && &redeem_bytecode[0..IDO_SIGNATURE.len()] == IDO_SIGNATURE }, _ => false, } } fn build_offering_bytecode(maxDiscountRate: &BigInt, discountAnnualRate: &BigInt, price: &BigInt, minOffer: &BigInt, xTokenCategory: &[u8]) -> Vec { let mut input_bytecode = Builder::new() .push_slice(pb(&STORAGE_CONTRACT)) .push_slice(pb(&OFFERING_ENTRY_CONTRACT)) .into_script() .to_bytes(); input_bytecode.extend_from_slice(&bigint_to_push_opcode(maxDiscountRate)); input_bytecode.extend_from_slice(&bigint_to_push_opcode(discountAnnualRate)); input_bytecode.extend_from_slice(&bigint_to_push_opcode(price)); input_bytecode.extend_from_slice(&bigint_to_push_opcode(minOffer)); input_bytecode.extend_from_slice(&Builder::new() .push_slice(pb(xTokenCategory)) .into_script() .to_bytes() ); let mut bytecode = IDO_SIGNATURE.to_vec(); bytecode.extend_from_slice(&input_bytecode); bytecode.extend_from_slice(&OFFERING_CONTRACT); bytecode } fn build_launcher_bytecode(collectorNFTH: &[u8], platformFeeNFTH: &[u8], platformFee: &BigInt, delphiCategory: &[u8], expiresAt: &BigInt, deployThreshold: &BigInt, immediateDeployThreshold: &BigInt) -> Vec { let rev_delphi_cat: Vec = delphiCategory.iter().copied().rev().collect(); let mut input_bytecode = Builder::new() .push_slice(pb(collectorNFTH)) .push_slice(pb(platformFeeNFTH)) .push_slice(pb(&encode_padded_vm_number(&platformFee, 4).unwrap())) .push_slice(pb(&EXECUTION_FEE_PAYOUT_CONTRACT)) .push_slice(pb(&STORAGE_CONTRACT)) .push_slice(pb(&OFFERING_INTEGRATED_TIMELOCKED_P2NFTH_CONTRACT)) .push_slice(pb(&P2NFTH_CONTRACT)) .push_slice(pb(&rev_delphi_cat)) .into_script() .to_bytes(); input_bytecode.extend_from_slice(&bigint_to_push_opcode(expiresAt)); input_bytecode.extend_from_slice(&bigint_to_push_opcode(deployThreshold)); input_bytecode.extend_from_slice(&bigint_to_push_opcode(immediateDeployThreshold)); let mut bytecode = IDO_SIGNATURE.to_vec(); bytecode.extend_from_slice(&input_bytecode); bytecode.extend_from_slice(&LAUNCHER_CONTRACT); bytecode } fn build_partial_offering_initiator_bytecode(extensionOutpointIndex: &BigInt, tokenStorageOutpointIndex: &BigInt, offeringBcmrStorageOutpointIndex: &BigInt, distRefundOutpointIndex: &BigInt, distDeployOutpointIndex: &BigInt, launcherBytecodeOutpointIndex: &BigInt, offeringBytecodeOutpointIndex: &BigInt, executionFee: &BigInt) -> Vec { let mut bytecode = Builder::new() .push_slice(pb(&STORAGE_CONTRACT)) .into_script() .to_bytes(); bytecode.extend_from_slice(&bigint_to_push_opcode(extensionOutpointIndex)); bytecode.extend_from_slice(&bigint_to_push_opcode(tokenStorageOutpointIndex)); bytecode.extend_from_slice(&bigint_to_push_opcode(offeringBcmrStorageOutpointIndex)); bytecode.extend_from_slice(&bigint_to_push_opcode(distRefundOutpointIndex)); bytecode.extend_from_slice(&bigint_to_push_opcode(distDeployOutpointIndex)); bytecode.extend_from_slice(&bigint_to_push_opcode(launcherBytecodeOutpointIndex)); bytecode.extend_from_slice(&bigint_to_push_opcode(offeringBytecodeOutpointIndex)); bytecode.push(0x04); // OP_PUSH4 bytecode.extend_from_slice(&encode_padded_vm_number(executionFee, 4).unwrap()); bytecode.extend_from_slice(&OFFERING_INITIATOR_CONTRACT); bytecode } fn build_partial_postlaunch_bytecode(permanentLiquidityShare: &BigInt, price: &BigInt) -> Vec { let mut bytecode = Vec::new(); bytecode.extend_from_slice(&bigint_to_push_opcode(permanentLiquidityShare)); bytecode.extend_from_slice(&bigint_to_push_opcode(price)); bytecode.extend_from_slice(&IDO_POSTLAUNCH_CONTRACT); bytecode } fn build_partial_ido_initiator_bytecode(postLaunchPartialBytecode: &[u8], permanentPoolOTokenReserveOutpointIndex: &BigInt, offeringInitiatorOutpointIndex: &BigInt) -> Vec { let mut bytecode = Builder::new() .push_slice(pb(postLaunchPartialBytecode)) .push_slice(pb(&STORAGE_CONTRACT)) .push_slice(pb(&P2NFTH_CONTRACT)) .into_script() .to_bytes(); bytecode.extend_from_slice(&bigint_to_push_opcode(permanentPoolOTokenReserveOutpointIndex)); bytecode.extend_from_slice(&bigint_to_push_opcode(offeringInitiatorOutpointIndex)); bytecode.extend_from_slice(&IDO_INITIATOR_CONTRACT); bytecode } fn create_rebuild_ipfs_placeholder_bcmr_partial_inputs(data: &IpfsBcmrWithPlaceholder) -> Vec { let mut bcmrUrisWithReplaceCalls = Vec::new(); bcmrUrisWithReplaceCalls.extend_from_slice(&data.metadata.bcmrUrisReplaceCalls); bcmrUrisWithReplaceCalls.extend_from_slice( &Builder::new() .push_slice(pb(&data.urisBytecode)) .into_script() .to_bytes() ); Builder::new() .push_slice(pb(&data.contentHash)) .push_slice(pb(&data.metadata.registryReplaceCalls)) .push_int(1i64) // OP_DEFINE .push_opcode(bitcoincash::blockdata::opcodes::All::from(0x89u8)) .push_slice(pb(&bcmrUrisWithReplaceCalls)) .push_int(2i64) // OP_DEFINE .push_opcode(bitcoincash::blockdata::opcodes::All::from(0x89u8)) .into_script() .to_bytes() } fn build_preinit_bytecode(parameters: &IdoPreInitLockParameters, state: &IdoPreInitState) -> Vec { let rev_ido: Vec = state.idoCategory.iter().copied().rev().collect(); let rev_authguard: Vec = state.authguardCategory.iter().copied().rev().collect(); let offering_bcmr_partial_inputs = match ¶meters.preInitBcmr.offering { Some(bcmr) => create_rebuild_ipfs_placeholder_bcmr_partial_inputs(bcmr), _ => Vec::new(), }; let oToken_bcmr_partial_inputs = match ¶meters.preInitBcmr.oToken { Some(bcmr) => create_rebuild_ipfs_placeholder_bcmr_partial_inputs(bcmr), _ => Vec::new(), }; let middle = Builder::new() .push_slice(pb(&rev_ido)) .push_slice(pb(&rev_authguard)) .push_slice(pb(¶meters.authguardLockingBytecode)) .push_slice(pb(&offering_bcmr_partial_inputs)) .push_slice(pb(&oToken_bcmr_partial_inputs)) .push_slice(pb(&REBUILD_IPFS_PLACEHOLDER_BCMR_FOR_GENESIS_WITH_AUTHGUARD_CONTRACT)) .push_slice(pb(&CASHTOKENS_STUDIO_PAY2CATEGORY_CONTRACT)) .push_slice(pb(&STORAGE_CONTRACT)) .into_script() .to_bytes(); let mut bytecode = IDO_SIGNATURE.to_vec(); bytecode.extend_from_slice(&bigint_to_push_opcode(&state.oTokenGenerated)); bytecode.extend_from_slice(&bigint_to_push_opcode(&state.nextTxSetterFlag)); bytecode.extend_from_slice(&middle); bytecode.extend_from_slice(&bigint_to_push_opcode(¶meters.permanentLiquidityShareNumerator)); bytecode.extend_from_slice(&bigint_to_push_opcode(¶meters.offeredTokenAmount)); bytecode.extend_from_slice(&bigint_to_push_opcode(¶meters.offeredTokenTotalSupply)); bytecode.extend_from_slice(&IDO_PREINIT_CONTRACT); bytecode } fn build_p2sh32_script(bytecode: &[u8]) -> ScriptBuf { Builder::new() .push_opcode(opcodes::all::OP_HASH256) .push_slice(pb(sha256d::Hash::hash(bytecode).as_byte_array())) .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: &BigInt) -> Vec { if *n > BigInt::from(0i64) && *n <= BigInt::from(16i64) { vec![0x50_u8 + n.to_u8().unwrap()] } else if *n == BigInt::from(0i64) { vec![0x00_u8] } else if *n == BigInt::from(-1i64) { vec![0x4f_u8] } else { let bytes = bigint_to_vm_number(n); if bytes.len() < 76 { let mut r = vec![bytes.len() as u8]; r.extend(bytes); r } else if bytes.len() < 255 { let mut r = vec![0x4c_u8, bytes.len() as u8]; r.extend(bytes); r } else { let mut r = vec![0x4d_u8]; r.extend_from_slice(&(bytes.len() as u16).to_le_bytes()); r.extend(bytes); r } } } pub fn encode_padded_vm_number(v: &BigInt, length: usize) -> Result> { let encoded = bigint_to_vm_number(v); if encoded.len() > length { Err(anyhow::anyhow!("The value does not fit in the target length")) } else { Ok(pad_minimally_encoded_vm_number(&encoded, length)) } } pub fn decode_padded_vm_number(bytes: &[u8]) -> BigInt { vm_number_to_bigint(bytes) } pub fn vm_number_to_bigint(bytes: &[u8]) -> BigInt { if bytes.is_empty() { return BigInt::zero(); } // Copy bytes so we can modify the sign bit if necessary let mut data = bytes.to_vec(); let last_idx = data.len() - 1; let last_byte = data[last_idx]; // Check the sign bit (the high bit of the last byte) let is_negative = (last_byte & 0x80) != 0; if is_negative { // Mask out the sign bit for magnitude calculation data[last_idx] &= 0x7F; } // Convert little-endian bytes to BigInt let magnitude = BigInt::from_bytes_le(Sign::Plus, &data); if is_negative { -magnitude } else { magnitude } } pub fn bigint_to_vm_number(n: &BigInt) -> Vec { if n.is_zero() { return vec![]; } // Get the absolute magnitude in little-endian bytes let (sign, mut bytes) = n.to_bytes_le(); // Check if the high bit of the last byte is set // In Bitcoin VM numbers, the high bit of the last byte is the sign bit. // If it's already set by the magnitude, we must add an extra 0x00 or 0x80 byte. if let Some(&last) = bytes.last() { if (last & 0x80) != 0 { // High bit is set; push a new byte to carry the sign if sign == Sign::Minus { bytes.push(0x80); } else { bytes.push(0x00); } } else if sign == Sign::Minus { // High bit was NOT set; we can safely flip it on the current last byte if let Some(last_mut) = bytes.last_mut() { *last_mut |= 0x80; } } } bytes } pub fn pad_minimally_encoded_vm_number(bin: &[u8], length: usize) -> Vec { if bin.len() >= length { return bin.to_vec(); } let mut padded = bin.to_vec(); if let Some(&last_byte) = bin.last() { // Check if the current last byte has the sign bit set (0x80) if (last_byte & 0x80) != 0 { // Remove the sign bit from the current byte let last_idx = padded.len() - 1; padded[last_idx] &= 0x7f; // Pad with zeros until the second-to-last byte padded.resize(length - 1, 0x00); // Re-apply the sign bit to the new last byte padded.push(0x80); } else { // If the number is positive and the sign bit isn't used, // just pad with zeros. padded.resize(length, 0x00); } } else { // Input was empty (effectively 0), pad with zeros padded.resize(length, 0x00); } padded } pub async fn prepare_tables(pool: &SqlitePool) { 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 )", ) .execute(pool) .await .expect("failed to create ido table"); sqlx::query( "CREATE TABLE ido_entry ( ido_id INTEGER NOT NULL, txid BLOB NOT NULL PRIMARY KEY, 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 )", ) .execute(pool) .await .expect("failed to create ido_entry table"); sqlx::query("CREATE INDEX idx_ido_entry_ido_id ON ido_entry(ido_id)") .execute(pool) .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)") .execute(pool) .await .expect("failed to create index ido_txchain(ido_id)"); 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 )", ) .execute(pool) .await .expect("failed to create ido_txchain_tracker_map 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, } */ #[derive(Debug, Clone, serde::Serialize)] pub struct IdoDBRecord { internal_id: i64, preinit_txid: Vec, init_txid: Option>, launch_txid: Option>, otoken_genesis_txid: Option>, offering_token_id: Option>, offered_token_id: Option>, status: String, parameters: Vec, state: Vec, txchain_entrypoint: Option, txchain_head: Option, is_valid: bool, is_token_created_at_preinit: bool, created_at: i64, launched_at: Option, } impl IdoDBRecord { fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result { Ok(Self { internal_id: row.get(0), preinit_txid: row.get(1), init_txid: row.get(2), launch_txid: row.get(3), otoken_genesis_txid: row.get(4), offering_token_id: row.get(5), offered_token_id: row.get(6), 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), }) } } #[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), }) } } 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(|a| IdoTxChainDBRecord::from_row(a)) .collect() } async fn txchain_lookup_next( 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 = ?", ) .bind(txid.to_blob()) .bind(index) .fetch_optional(pool) .await?; if let Some(row) = row { Ok(Some(IdoTxChainDBRecord::from_row(&row)?)) } else { Ok(None) } } 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) .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(()) } // Prefix of the announcement OP_RETURN carried in the last output of a preinit // broadcast tx. pub const IDO_PREINIT_ANNOUNCEMENT_SIGNATURE: &[u8; 15] = &[ 0x6a, 0x4c, 0xbb, // OP_RETURN OP_PUSHDATA1 (187) 0x43, 0x61, 0x75, 0x6c, 0x64, 0x72, 0x6f, 0x6e, 0x49, 0x64, 0x6f, 0x30, // CauldronIdo0 ]; fn is_preinit_broadcast( tx: &Transaction, ) -> bool { // 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 { false } } fn deserialize_ipfs_bcmr_with_placeholder( bcmr_data: &[u8], ) -> Result> { if bcmr_data.len() == 0 || (bcmr_data[0] == 0 && bcmr_data.len() == 1) { return Ok(None); } if bcmr_data.len() == 0 || !(bcmr_data[0] > 0 && bcmr_data[0] < 75) || bcmr_data[0].to_usize().unwrap() >= bcmr_data.len() { return Err(anyhow::anyhow!("bcmr needs metadata!")); } let metadata_size = bcmr_data[0].to_usize().unwrap(); let metadata_script = ScriptBuf::from(bcmr_data[1..(metadata_size+1)].to_vec()); let metadata_inst_list: Vec<_> = metadata_script.instructions().collect(); if metadata_inst_list.len() != 2 { return Err(anyhow::anyhow!("bcmr metadata should have two push opcodes!")); } let registryReplaceCalls = match &metadata_inst_list[0] { Ok(Instruction::PushBytes(data)) => data.as_bytes().to_vec(), _ => { return Err(anyhow::anyhow!("oToken bcmr metadata opcode#0 is not a push opcode")); } }; let bcmrUrisReplaceCalls = match &metadata_inst_list[1] { Ok(Instruction::PushBytes(data)) => data.as_bytes().to_vec(), _ => { return Err(anyhow::anyhow!("oToken bcmr metadata opcode#0 is not a push opcode")); } }; let bcmr_opreturn: Vec<_> = bcmr_data[(metadata_size+1)..bcmr_data.len()].to_vec(); if bcmr_opreturn.len() < BCMR_SIGNATURE.len() + 32 || &bcmr_opreturn[0..BCMR_SIGNATURE.len()] != BCMR_SIGNATURE { return Err(anyhow::anyhow!("invalid bcmr opreturn")); } Ok(Some(IpfsBcmrWithPlaceholder { metadata: IpfsBcmrWithPlaceholderMetadata { registryReplaceCalls, bcmrUrisReplaceCalls, }, contentHash: bcmr_opreturn[BCMR_SIGNATURE.len()..BCMR_SIGNATURE.len()+32].to_vec(), urisBytecode: bcmr_opreturn[BCMR_SIGNATURE.len()+32..bcmr_opreturn.len()].to_vec(), })) } fn serialize_ipfs_bcmr_with_placeholder( input: &Option, ) -> Vec { match input { Some(value) => { let metadata_bytecode = Builder::new() .push_slice(pb(&value.metadata.registryReplaceCalls)) .push_slice(pb(&value.metadata.bcmrUrisReplaceCalls)) .into_script() .to_bytes(); let mut bcmr_bytecode = Vec::new(); bcmr_bytecode.extend_from_slice(&BCMR_SIGNATURE); bcmr_bytecode.extend_from_slice(&value.contentHash); bcmr_bytecode.extend_from_slice(&value.urisBytecode); let metadata_push = Builder::new() .push_slice(pb(&metadata_bytecode)) .into_script() .to_bytes(); let mut bytes = Vec::new(); bytes.extend_from_slice(&metadata_push); bytes.extend_from_slice(&bcmr_bytecode); bytes }, None => Vec::new(), } } fn deserialize_ipfs_bcmr_with_placeholder_from_p2s_storage(script: &Script) -> Result> { deserialize_ipfs_bcmr_with_placeholder( &extract_data_from_storage_script_with_data_and_size(script)? ) } struct IdoContext { preinit_txid: Vec, init_txid: Option>, launch_txid: Option>, otoken_genesis_txid: Option>, status: String, parameters: IdoParameters, state: IdoState, is_valid_ido: bool, is_token_created_at_preinit: bool, offered_token_id: Option>, offering_token_id: Option>, head_txid: Vec, created_at: i64, launched_at: Option, } struct IdoPreinitParseParamsResult { preinit_parameters: IdoPreInitParameters, offered_token_is_in_supply: bool, is_valid_ido: bool, /// Creation time (unix seconds) from the Delphi NFT commitment in the /// preinit's first output; 0 if the commitment was too short to carry one. created_at: i64, } fn parse_ido_preinit_tx_params(tx: &Transaction, delphi_token_id: &[u8], platform_fee_nfth: &[u8], errors: &mut Vec, invalid_ido_reasons: &mut Vec) -> Option { // 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 { errors.push(anyhow::anyhow!("should have 3 opcodes")); return None; } // authguard locking bytecode let authguardLockingBytecode; match &instructions[2] { Ok(Instruction::PushBytes(data)) => { authguardLockingBytecode = data.as_bytes().to_vec(); } _ => { errors.push(anyhow::anyhow!("invalid announcement (3)")); return None; } } // params main match &instructions[1] { Ok(Instruction::PushBytes(data)) => { let data = data.as_bytes(); if data.len() < 187 { errors.push(anyhow::anyhow!("Incorrect announcement.mainData size")); return None; } let mut is_valid_ido = true; // preinit_parameters.offeredTokenTotalSupply < BigInt::from(0) let offered_token_is_in_supply = vm_number_to_bigint(&data[170..178]) < BigInt::from(0); let offeringBcmrStorageOut = tx.output.get(8); if offeringBcmrStorageOut.is_none() { errors.push(anyhow::anyhow!("offering bcmr storage not defined!")); return None; } let offeringBcmrResult = deserialize_ipfs_bcmr_with_placeholder_from_p2s_storage(&offeringBcmrStorageOut.unwrap().script_pubkey); if offeringBcmrResult.is_err() { errors.push(anyhow::anyhow!("offering bcmr deserialize failed, {}", offeringBcmrResult.err().unwrap())); return None; } let offeringBcmr = offeringBcmrResult.unwrap(); let mut oTokenBcmr: Option = None; if !offered_token_is_in_supply { let oTokenBcmrStorageOut = tx.output.get(9); if oTokenBcmrStorageOut.is_none() { errors.push(anyhow::anyhow!("oToken bcmr storage not defined!")); return None; } let oTokenBcmrResult = deserialize_ipfs_bcmr_with_placeholder_from_p2s_storage(&oTokenBcmrStorageOut.unwrap().script_pubkey); if oTokenBcmrResult.is_err() { errors.push(anyhow::anyhow!("oToken bcmr deserialize failed, {}", oTokenBcmrResult.err().unwrap())); return None; } else { oTokenBcmr = oTokenBcmrResult.unwrap(); } } let preinit_parameters = IdoPreInitParameters { preInitBcmr: IdoPreInitBcmrParameters { offering: offeringBcmr, oToken: oTokenBcmr, }, authguardLockingBytecode: authguardLockingBytecode, offering: IdoParametersOffering { platformFeeNFTH: data[12..44].to_vec(), platformFeeNumerator: vm_number_to_bigint(&data[44..48]), delphiCategory: data[48..80].to_vec(), launchConditions: IdoParametersOfferingLaunchConditions { expiresAt: vm_number_to_bigint(&data[80..86]), deployThreshold: vm_number_to_bigint(&data[86..94]), immediateDeployThreshold: vm_number_to_bigint(&data[94..102]), }, offer: IdoParametersOfferingOffer { maxDiscountRateNumerator: vm_number_to_bigint(&data[102..106]), discountAnnualRateNumerator: vm_number_to_bigint(&data[106..110]), priceNumerator: vm_number_to_bigint(&data[110..126]), minOffer: vm_number_to_bigint(&data[126..130]), xTokenCategory: if vm_number_to_bigint(&data[130..162]) == BigInt::from(0) { None } else { Some(data[130..162].to_vec()) }, }, executionFee: vm_number_to_bigint(&data[162..166]), }, permanentLiquidityShareNumerator: vm_number_to_bigint(&data[166..170]), offeredTokenTotalSupply: vm_number_to_bigint(&data[170..178]), offeredTokenAmount: vm_number_to_bigint(&data[178..186]), authguardNftOutputIndex: vm_number_to_bigint(&data[186..187]), }; // enforce limitation (xToken == NATIVE_BCH) if preinit_parameters.offering.offer.xTokenCategory.is_some() { errors.push(anyhow::anyhow!("non-null xTokenCategory is not supported")); return None; } if !offered_token_is_in_supply { let permanentLiquidityOTokenReserve: BigInt = &preinit_parameters.offeredTokenAmount * &preinit_parameters.permanentLiquidityShareNumerator / &PERMANENT_LIQUIDITY_SHARE_DENOMINATOR.clone(); if preinit_parameters.offeredTokenTotalSupply <= &preinit_parameters.offeredTokenAmount + &permanentLiquidityOTokenReserve { invalid_ido_reasons.push(anyhow::anyhow!("ido parse, not a valid ido, preinit_parameters.offeredTokenTotalSupply <= preinit_parameters.offeredTokenAmount + permanentLiquidityOTokenReserve")); is_valid_ido = false; } } if preinit_parameters.permanentLiquidityShareNumerator < *MIN_PLP_SHARE { invalid_ido_reasons.push(anyhow::anyhow!("preinit_parameters.permanentLiquidityShareNumerator < *MIN_PLP_SHARE")); is_valid_ido = false; } if preinit_parameters.permanentLiquidityShareNumerator > *MAX_PLP_SHARE { invalid_ido_reasons.push(anyhow::anyhow!("preinit_parameters.permanentLiquidityShareNumerator > *MAX_PLP_SHARE")); is_valid_ido = false; } if preinit_parameters.offering.offer.discountAnnualRateNumerator < *MIN_PLP_ANNUAL_DISCOUNT { invalid_ido_reasons.push(anyhow::anyhow!("preinit_parameters.offering.offer.discountAnnualRateNumerator > *MAX_PLP_ANNUAL_DISCOUNT")); is_valid_ido = false; } if preinit_parameters.offering.offer.discountAnnualRateNumerator > *MAX_PLP_ANNUAL_DISCOUNT { invalid_ido_reasons.push(anyhow::anyhow!("preinit_parameters.offering.offer.discountAnnualRateNumerator > *MAX_PLP_ANNUAL_DISCOUNT")); is_valid_ido = false; } if preinit_parameters.offering.offer.maxDiscountRateNumerator < BigInt::from(0) { invalid_ido_reasons.push(anyhow::anyhow!("preinit_parameters.offering.offer.maxDiscountRateNumerator < BigInt::from(0)")); is_valid_ido = false; } if &preinit_parameters.offering.offer.maxDiscountRateNumerator + &MIN_PLP_AFTER_DISCOUNT.clone() > preinit_parameters.permanentLiquidityShareNumerator { invalid_ido_reasons.push(anyhow::anyhow!("preinit_parameters.offering.offer.maxDiscountRateNumerator + MIN_PLP_AFTER_DISCOUNT > preinit_parameters.permanentLiquidityShareNumerator")); is_valid_ido = false; } if preinit_parameters.offeredTokenAmount <= BigInt::from(0) { invalid_ido_reasons.push(anyhow::anyhow!("preinit_parameters.offeredTokenAmount <= BigInt::from(0)")); is_valid_ido = false; } if preinit_parameters.offering.delphiCategory != delphi_token_id { invalid_ido_reasons.push(anyhow::anyhow!("preinit_parameters.offering.delphiCategory != DELPHI_TOKEN_ID")); is_valid_ido = false; } if &preinit_parameters.offering.platformFeeNFTH != platform_fee_nfth { invalid_ido_reasons.push(anyhow::anyhow!("preinit_parameters.offering.platformFeeNFTH != platform_fee_nfth")); is_valid_ido = false; } if preinit_parameters.offering.platformFeeNumerator < *PLATFORM_FEE { invalid_ido_reasons.push(anyhow::anyhow!("preinit_parameters.offering.platformFeeNumerator < *PLATFORM_FEE")); is_valid_ido = false; } if preinit_parameters.offering.executionFee < *ENTRY_EXECUTION_FEE { 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. // We also record the timestamp as the IDO's creation time. let mut created_at: i64 = 0; match tx.output.first() { Some(delphiOut) => match delphiOut.token.as_ref() { Some(delphiToken) if delphiToken.has_nft() => { let delphiTokenId: Vec = delphiToken.id.to_blob().iter().copied().rev().collect(); if delphiTokenId != delphi_token_id { invalid_ido_reasons.push(anyhow::anyhow!("delphi nft category in first output != expected_delphi_token_id")); 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]); created_at = delphi_timestamp as i64; let offering_duration = &preinit_parameters.offering.launchConditions.expiresAt - BigInt::from(delphi_timestamp); if offering_duration < *MIN_OFFERING_DURATION { invalid_ido_reasons.push(anyhow::anyhow!("expiresAt - delphi timestamp < MIN_OFFERING_DURATION (1 day)")); 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, is_valid_ido, created_at, }); } _ => { errors.push(anyhow::anyhow!("invalid announcement (2)")); return None; } } } else { errors.push(anyhow::anyhow!("announcement output is missing")); return None; } } struct IdoPreinitParseResult { parameters: Option, state: Option, is_valid_ido: bool, offered_token_is_in_supply: bool, offered_token_id: Option>, created_at: i64, } fn parse_ido_preinit_tx(network: Option, tx: &Transaction, errors: &mut Vec, invalid_ido_reasons: &mut Vec) -> IdoPreinitParseResult { let platform_fee_nfth = match network { Some(Network::Chipnet) => CHIPNET_PLATFORM_FEE_NFTH.clone(), Some(Network::Bitcoin) => MAINNET_PLATFORM_FEE_NFTH.clone(), _ => MAINNET_PLATFORM_FEE_NFTH.clone(), }; let delphi_token_id = match network { Some(Network::Chipnet) => CHIPNET_DELPHI_TOKEN_ID.clone(), Some(Network::Bitcoin) => MAINNET_DELPHI_TOKEN_ID.clone(), _ => MAINNET_DELPHI_TOKEN_ID.clone(), }; let offered_token_is_in_supply: bool; let mut is_valid_ido: bool; let nullable_preinit_parameters: Option; let created_at: i64; // preinit announcement match parse_ido_preinit_tx_params(tx, &delphi_token_id, &platform_fee_nfth, errors, invalid_ido_reasons) { Some(output) => { offered_token_is_in_supply = output.offered_token_is_in_supply; is_valid_ido = output.is_valid_ido; nullable_preinit_parameters = Some(output.preinit_parameters); created_at = output.created_at; } None => { offered_token_is_in_supply = false; is_valid_ido = false; nullable_preinit_parameters = None; created_at = 0; } } let mut authguardNftOut = None; // find authguardCategory if let Some(params) = nullable_preinit_parameters { if params.authguardNftOutputIndex >= BigInt::from(0) { match params.authguardNftOutputIndex.to_usize() { Some(index) => { authguardNftOut = tx.output.get(index); if authguardNftOut.is_none() { errors.push(anyhow::anyhow!("authguardNftOutputIndex out of range")); } if authguardNftOut.unwrap().token.is_none() { errors.push(anyhow::anyhow!("authguardNftOutput is not an nft")); } else if authguardNftOut.unwrap().token.as_ref().unwrap().commitment.len() != 1 && authguardNftOut.unwrap().token.as_ref().unwrap().commitment[0] == 0x00 { errors.push(anyhow::anyhow!("authguardNftOutput nft commitment should be 0x00")); } }, _ => errors.push(anyhow::anyhow!("authguardNftOutputIndex out of range")), } } let state = IdoPreInitState { authguardCategory: if authguardNftOut.is_some() && authguardNftOut.unwrap().token.is_some() { authguardNftOut.unwrap().token.as_ref().unwrap().id.to_blob().iter().copied().rev().collect() } else { vec![0; 32] }, idoCategory: vec![0; 32], nextTxSetterFlag: BigInt::from(0), oTokenGenerated: if offered_token_is_in_supply { BigInt::from(1) } else { BigInt::from(0) }, initiatorCreated: false, }; let preInitOut = tx.output.get(1); if preInitOut.is_none() { errors.push(anyhow::anyhow!("preinit output is not defined!")); } else { let preInit_lock_parameters = IdoPreInitLockParameters { preInitBcmr: params.preInitBcmr.clone(), authguardLockingBytecode: params.authguardLockingBytecode.clone(), permanentLiquidityShareNumerator: params.permanentLiquidityShareNumerator.clone(), offeredTokenAmount: params.offeredTokenAmount.clone(), offeredTokenTotalSupply: params.offeredTokenTotalSupply.clone(), }; let bytecode = build_preinit_bytecode(&preInit_lock_parameters, &state); if build_p2sh32_script(&bytecode) != preInitOut.unwrap().script_pubkey { errors.push(anyhow::anyhow!("preInit output bytecode does not match, expected bytecode: {}", hex::encode(bytecode))); } } let offeringBytecodeStorageOut = tx.output.get(2); if offeringBytecodeStorageOut.is_none() { errors.push(anyhow::anyhow!("offering bytecode storage not defined!")); } else { let bytecode = build_storage_script_with_data_and_size( 1, &build_offering_bytecode( ¶ms.offering.offer.maxDiscountRateNumerator, ¶ms.offering.offer.discountAnnualRateNumerator, ¶ms.offering.offer.priceNumerator, ¶ms.offering.offer.minOffer, params.offering.offer.xTokenCategory.as_deref().unwrap_or(&[]), ) ).to_bytes(); if build_p2sh32_script(&bytecode) != offeringBytecodeStorageOut.unwrap().script_pubkey { errors.push(anyhow::anyhow!("offering bytecode storage does not match, expected bytecode: {}", hex::encode(bytecode))); } } let launcherBytecodeStorageOut = tx.output.get(3); if launcherBytecodeStorageOut.is_none() { errors.push(anyhow::anyhow!("launcher bytecode storage not defined!")); } else { let bytecode = build_storage_script_with_data_and_size( 1, &build_launcher_bytecode( &vec![0u8; 32], // collectorNFTH ¶ms.offering.platformFeeNFTH, ¶ms.offering.platformFeeNumerator, ¶ms.offering.delphiCategory, ¶ms.offering.launchConditions.expiresAt, ¶ms.offering.launchConditions.deployThreshold, ¶ms.offering.launchConditions.immediateDeployThreshold, ) ).to_bytes(); if build_p2sh32_script(&bytecode) != launcherBytecodeStorageOut.unwrap().script_pubkey { errors.push(anyhow::anyhow!("launcher bytecode storage does not match, expected bytecode: {}", hex::encode(bytecode))); } } let distDeployBytecodeStorageOut = tx.output.get(4); if distDeployBytecodeStorageOut.is_none() { errors.push(anyhow::anyhow!("dist deploy bytecode storage not defined!")); } else { let bytecode = build_storage_script_with_data_and_size( 1, &DISTRIBUTOR_DEPLOY_CONTRACT, ).to_bytes(); if build_p2sh32_script(&bytecode) != distDeployBytecodeStorageOut.unwrap().script_pubkey { errors.push(anyhow::anyhow!("dist deploy bytecode storage does not match, expected bytecode: {}", hex::encode(bytecode))); } } let distRefundBytecodeStorageOut = tx.output.get(5); if distRefundBytecodeStorageOut.is_none() { errors.push(anyhow::anyhow!("dist refund bytecode storage not defined!")); } else { let bytecode = build_storage_script_with_data_and_size( 1, &DISTRIBUTOR_REFUND_CONTRACT, ).to_bytes(); if build_p2sh32_script(&bytecode) != distRefundBytecodeStorageOut.unwrap().script_pubkey { errors.push(anyhow::anyhow!("dist refund bytecode storage does not match, expected bytecode: {}", hex::encode(bytecode))); } } let offeringInitiatorBytecodeStorageOut = tx.output.get(6); if offeringInitiatorBytecodeStorageOut.is_none() { errors.push(anyhow::anyhow!("offering initiator bytecode storage not defined!")); } else { let bytecode = build_storage_script_with_data_and_size( 1, &build_partial_offering_initiator_bytecode( &BigInt::from(7i64), // extensionOutpointIndex &BigInt::from(6i64), // tokenStorageOutpointIndex &BigInt::from(5i64), // offeringBcmrStorageOutpointIndex &BigInt::from(4i64), // distRefundOutpointIndex &BigInt::from(3i64), // distDeployOutpointIndex &BigInt::from(2i64), // launcherBytecodeOutpointIndex &BigInt::from(1i64), // offeringBytecodeOutpointIndex ¶ms.offering.executionFee, ), ).to_bytes(); if build_p2sh32_script(&bytecode) != offeringInitiatorBytecodeStorageOut.unwrap().script_pubkey { errors.push(anyhow::anyhow!("offering initiator bytecode storage does not match, expected bytecode: {}", hex::encode(bytecode))); } } let idoInitiatorBytecodeStorageOut = tx.output.get(7); if idoInitiatorBytecodeStorageOut.is_none() { errors.push(anyhow::anyhow!("ido initiator bytecode storage not defined!")); } else { let bytecode = build_storage_script_with_data_and_size( 1, &build_partial_ido_initiator_bytecode( &build_partial_postlaunch_bytecode( ¶ms.permanentLiquidityShareNumerator, ¶ms.offering.offer.priceNumerator, ), &BigInt::from(8i64), // permanentPoolOTokenReserveOutpointIndex &BigInt::from(0i64), // offeringInitiatorOutpointIndex ), ).to_bytes(); if build_p2sh32_script(&bytecode) != idoInitiatorBytecodeStorageOut.unwrap().script_pubkey { errors.push(anyhow::anyhow!("ido initiator bytecode storage does not match, expected bytecode: {}", hex::encode(bytecode))); } } let offeringBcmrStorageOut = tx.output.get(8); if offeringBcmrStorageOut.is_none() { errors.push(anyhow::anyhow!("offered token bcmr storage not found!")); } else { let bytecode = build_storage_script_with_data_and_size( 1u32, &serialize_ipfs_bcmr_with_placeholder(¶ms.preInitBcmr.offering), ); if bytecode != offeringBcmrStorageOut.unwrap().script_pubkey { errors.push(anyhow::anyhow!("offering bcmr storage locking bytecode does not match, expected bytecode: {}", hex::encode(bytecode))); } } let mut offered_token_id: Option> = None; if offered_token_is_in_supply { // verify offered token storage let offeredTokenStorageOut = tx.output.get(9); if offeredTokenStorageOut.is_none() { errors.push(anyhow::anyhow!("offered token supply not found!")); } else { let permanentLiquidityOTokenReserve: BigInt = ¶ms.offeredTokenAmount * ¶ms.permanentLiquidityShareNumerator / &PERMANENT_LIQUIDITY_SHARE_DENOMINATOR.clone(); let requiredTokens = ¶ms.offeredTokenAmount + permanentLiquidityOTokenReserve; if offeredTokenStorageOut.unwrap().token.is_none() { errors.push(anyhow::anyhow!("offered token storage does not contain a token!")); } else { let bytecode = build_storage_script(1u32).to_bytes(); if build_p2sh32_script(&bytecode) != offeredTokenStorageOut.unwrap().script_pubkey { errors.push(anyhow::anyhow!("offered token locking bytecode does not match, expected bytecode: {}", hex::encode(bytecode))); } let token_amount = BigInt::from(offeredTokenStorageOut.unwrap().token.as_ref().unwrap().amount); if token_amount < requiredTokens { errors.push(anyhow::anyhow!("invalid offered token amount!")); } offered_token_id = Some(offeredTokenStorageOut.unwrap().token.as_ref().unwrap().id.to_blob()); } } } else { let oTokenBcmrStorageOut = tx.output.get(9); if oTokenBcmrStorageOut.is_none() { errors.push(anyhow::anyhow!("offered token bcmr storage not found!")); } else { let bytecode = build_storage_script_with_data_and_size( 1u32, &serialize_ipfs_bcmr_with_placeholder(¶ms.preInitBcmr.oToken), ); if bytecode != oTokenBcmrStorageOut.unwrap().script_pubkey { errors.push(anyhow::anyhow!("offered token bcmr storage locking bytecode does not match, expected bytecode: {}", hex::encode(bytecode))); } } } // collect preinit fee let mut preinit_paid_fee: u64 = 0; for output in &tx.output { if build_p2sh32_script( &build_p2nfth_script(&platform_fee_nfth).to_bytes() ) == output.script_pubkey { preinit_paid_fee += output.value.to_sat(); } } if BigInt::from(preinit_paid_fee) < *IDO_CREATE_EXECUTION_FEE { is_valid_ido = false; invalid_ido_reasons.push(anyhow::anyhow!("ido parse, not enough preinit fee paid!")); } IdoPreinitParseResult { parameters: Some(params), state: Some(state), is_valid_ido, offered_token_is_in_supply, offered_token_id, created_at, } } else { IdoPreinitParseResult { parameters: None, state: None, is_valid_ido, offered_token_is_in_supply: false, offered_token_id: None, created_at, } } } fn create_ido_context_from_preinit(network: Option, tx: &Transaction, errors: &mut Vec, invalid_ido_reasons: &mut Vec) -> Result { let result = parse_ido_preinit_tx(network, tx, errors, invalid_ido_reasons); if errors.len() > 0 { Err(anyhow::anyhow!("Failed to parse the preinit tx!")) } else { let params = result.parameters.expect("parameters should be defined!"); let state = result.state.expect("state should be defined!"); Ok(IdoContext { preinit_txid: tx.compute_txid().to_blob(), init_txid: None, launch_txid: None, otoken_genesis_txid: None, status: "PREINIT".to_string(), parameters: IdoParameters::PreInit(params), state: IdoState::PreInit(state), is_valid_ido: result.is_valid_ido, offered_token_id: result.offered_token_id, offering_token_id: None, is_token_created_at_preinit: !result.offered_token_is_in_supply, head_txid: tx.compute_txid().to_blob(), created_at: result.created_at, launched_at: None, }) } } async fn on_create_ido( network: Option, pool: &SqlitePool, tx: &Transaction, block_height: i64, ) -> Result<()> { debug!("IDO on_create_ido: {}", blob_to_display_hex::(&tx.compute_txid().to_blob())?); let mut errors: Vec = Vec::new(); let mut invalid_ido_reasons: Vec = Vec::new(); let result = create_ido_context_from_preinit(network, tx, &mut errors, &mut invalid_ido_reasons); if errors.len() > 0 { info!("Parse ido preinit failed, txid: {}\nError(s):", hex::encode(tx.compute_txid().to_blob())); for error in errors { info!(" - {}", error); } } if invalid_ido_reasons.len() > 0 { info!("Invalid ido found, preinit_txid: {}\nReason(s):", hex::encode(tx.compute_txid().to_blob())); for reason in invalid_ido_reasons { info!(" - {}", reason); } } match result { Ok(context) => { // create the ido let mut dbtx = pool.begin().await?; let result = sqlx::query( "INSERT INTO ido (preinit_txid, init_txid, launch_txid, otoken_genesis_txid, offering_token_id, offered_token_id, status, parameters, state, is_valid, is_token_created_at_preinit, created_at, launched_at, txchain_entrypoint, txchain_head) 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; match result { Ok(r) => { // insert txchain entrypoint let internal_id = r.last_insert_rowid(); let txchain_item_id = upsert_ido_txchain(&mut dbtx, tx, internal_id, None::, block_height, 1) .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(()) }, Err(e) => Err(e.into()), } }, Err(e) => Err(e), } } fn is_preinit_state_ready_to_init(state: &IdoPreInitState) -> bool { state.oTokenGenerated != BigInt::from(0) && state.nextTxSetterFlag == BigInt::from(0) && state.authguardCategory.iter().any(|&b| b != 0) && state.idoCategory.iter().any(|&b| b != 0) } #[derive(Clone)] struct IdoUpdateEntry { txid: Vec, owner_nfthash: Vec, supply_amount: u64, demand_amount: u64, lockup_timeval: u64, discount: u64, commitment: Vec, distributed: bool, } #[derive(Clone)] enum IdoUpdate { InitTxId(Vec), LaunchTxId { txid: Vec, launched_at: Option, }, OTokenGenesisTxId(Vec), Status(String), State(IdoState), Parameters(IdoParameters), OfferedTokenId(Vec), OfferingTokenId(Vec), Entry(IdoUpdateEntry), EntryDistributed { txid: Vec, }, } struct IdoAddResult { updates: Vec, next_output_index: i32, } fn rev_blob(v: &[u8]) -> Vec { v.iter().copied().rev().collect() } /// Read the 48-bit little-endian unix timestamp (seconds) carried in the first /// 6 bytes of a Delphi NFT commitment. Returns None if the commitment is too /// short to contain one. (see riftenlabs_defi::delphi::parse_delphi_update) fn delphi_commitment_timestamp(commitment: &[u8]) -> Option { if commitment.len() < 6 { return None; } let c = commitment; Some(u64::from_le_bytes([c[0], c[1], c[2], c[3], c[4], c[5], 0, 0]) as i64) } /// Read `launched_at` (unix seconds) from the Delphi NFT in output#3 of a launch /// transaction. Returns None if the output, its token, or its timestamp is absent. fn launch_tx_launched_at(tx: &Transaction) -> Option { tx.output .get(3) .and_then(|o| o.token.as_ref()) .and_then(|t| delphi_commitment_timestamp(&t.commitment)) } fn print_updates(updates: &[IdoUpdate]) -> () { for update in updates { match update { IdoUpdate::InitTxId(txid) => { let v=rev_blob(txid); debug!("IDO IdoUpdate::InitTxId(txid): {}", hex::encode(v)); }, IdoUpdate::LaunchTxId { txid, .. } => { let v=rev_blob(txid); debug!("IDO IdoUpdate::LaunchTxId(txid): {}", hex::encode(v)); }, IdoUpdate::OTokenGenesisTxId(txid) => { let v=rev_blob(txid); debug!("IDO IdoUpdate::OTokenGenesisTxId(txid): {}", hex::encode(v)); }, IdoUpdate::Status(status) => { debug!("IDO IdoUpdate::Status(status): {}", status); }, IdoUpdate::State(state) => { debug!("IDO IdoUpdate::State(state): {}", String::from_utf8(serde_json::to_vec_pretty(&state).unwrap()).unwrap()); }, IdoUpdate::Parameters(parameters) => { debug!("IDO IdoUpdate::Parameters(parameters): {}", String::from_utf8(serde_json::to_vec_pretty(¶meters).unwrap()).unwrap()); }, IdoUpdate::OfferedTokenId(token_id) => { let v=rev_blob(token_id); debug!("IDO IdoUpdate::OfferedTokenId(token_id): {}", hex::encode(v)); }, IdoUpdate::OfferingTokenId(token_id) => { let v=rev_blob(token_id); debug!("IDO IdoUpdate::OfferingTokenId(token_id): {}", hex::encode(v)); }, IdoUpdate::Entry(value) => { let v=rev_blob(&value.txid); debug!("IDO IdoUpdate::Entry(value): {}", hex::encode(v)); }, IdoUpdate::EntryDistributed { txid } => { let v=rev_blob(txid); debug!("IDO IdoUpdate::EntryDistributed: {}", hex::encode(v)); }, } } } fn ido_add_tx( context: &IdoContext, tx: &Transaction, ) -> Result { // inputs to check #0, #1, #3 let mut updates: Vec = Vec::new(); match context.status.as_str() { "PREINIT" => { if let IdoState::PreInit(ref preinit_state) = context.state { if preinit_state.initiatorCreated { // init outputs created & init tx updates.push(IdoUpdate::Status("ACTIVE".to_string())); updates.push(IdoUpdate::InitTxId(tx.compute_txid().to_blob())); let launcherBytecodeIn = tx.input.get(2) .ok_or_else(|| anyhow::anyhow!("launcher bytecode storage does not exist!"))?; let launcher_data = extract_data_from_unlocking_bytecode_of_storage_with_data_and_size(&launcherBytecodeIn.script_sig) .map_err(|e| anyhow::anyhow!("failed to extract launcher bytecode: {e}"))?; let launcher_script = ScriptBuf::from(launcher_data); let launcher_inst_list: Vec<_> = launcher_script.instructions().collect(); let collectorNFTH: Vec; if launcher_inst_list.len() == 0 { return Err(anyhow::anyhow!("empty launcher bytecode!")); } match &launcher_inst_list[2] { Ok(Instruction::PushBytes(data)) => { collectorNFTH = data.as_bytes().to_vec(); } _ => return Err(anyhow::anyhow!("expecting push opcode for collectorNFTH")), } let preinit_params: &IdoPreInitParameters; match &context.parameters { IdoParameters::PreInit(value) => { preinit_params = value; } _ => return Err(anyhow::anyhow!("expecting PreInit parameters")), } updates.push(IdoUpdate::Parameters(IdoParameters::Active(IdoActiveParameters { preInitBcmr: preinit_params.preInitBcmr.clone(), collectorNFTH: collectorNFTH, permanentLiquidityShareNumerator: preinit_params.permanentLiquidityShareNumerator.clone(), offeredTokenAmount: preinit_params.offeredTokenAmount.clone(), offering: preinit_params.offering.clone(), }))); updates.push(IdoUpdate::State(IdoState::Active(IdoActiveState { authguardCategory: preinit_state.authguardCategory.clone(), idoCategory: preinit_state.idoCategory.clone(), counter: BigInt::from(0), totalDemandAmount: BigInt::from(0), totalSupplyAmount: BigInt::from(0), totalDiscount: BigInt::from(0), }))); let tokenStorageOut = tx.output.get(2).ok_or_else(|| anyhow::anyhow!("token storage does not exist!"))?; let offeringOut = tx.output.get(1).ok_or_else(|| anyhow::anyhow!("offering does not exist!"))?; if tokenStorageOut.token.is_none() || offeringOut.token.is_none() { return Err(anyhow::anyhow!("output#1 & output#2 should have token!")); } updates.push(IdoUpdate::OfferedTokenId(tokenStorageOut.token.as_ref().unwrap().id.to_blob())); updates.push(IdoUpdate::OfferingTokenId(offeringOut.token.as_ref().unwrap().id.to_blob())); print_updates(&updates); return Ok(IdoAddResult { updates: updates, next_output_index: 0, // offering utxo }); } else { let ready = is_preinit_state_ready_to_init(&preinit_state); let mut next_state = preinit_state.clone(); if ready { next_state.initiatorCreated = true; } else { // preinit is a state machine, we know the next state if preinit_state.nextTxSetterFlag == BigInt::from(1i64) { // define next category if !preinit_state.authguardCategory.iter().any(|&b| b != 0) { next_state.authguardCategory = context.head_txid.iter().copied().rev().collect(); } else if preinit_state.oTokenGenerated == BigInt::from(0) { next_state.oTokenGenerated = BigInt::from(1); updates.push(IdoUpdate::OTokenGenesisTxId(tx.compute_txid().to_blob())); } else if !preinit_state.idoCategory.iter().any(|&b| b != 0) { next_state.idoCategory = context.head_txid.iter().copied().rev().collect(); } next_state.nextTxSetterFlag = BigInt::from(0i64); } else { next_state.nextTxSetterFlag = BigInt::from(1i64); } } updates.push(IdoUpdate::State(IdoState::PreInit(next_state))); print_updates(&updates); return Ok(IdoAddResult { updates: updates, next_output_index: if ready { 0 } else { 1 }, }); } } else { return Err(anyhow::anyhow!("expecting PreInitState!")); } }, "ACTIVE" => { if let IdoState::Active(ref active_state) = context.state { // output#0 should contain a token with offering_token_id let first_output = tx.output.get(0).ok_or_else(|| anyhow::anyhow!("Should have first output!"))?; if first_output.token.is_none() || first_output.token.as_ref().unwrap().id.to_blob() != context.offering_token_id.clone().unwrap_or_default() || !first_output.token.as_ref().unwrap().has_nft() { return Err(anyhow::anyhow!("output#0 is not one of the offering nft!")); } if first_output.token.as_ref().unwrap().commitment.len() == 0 { return Err(anyhow::anyhow!("Incorrect commitment size at output#0, should be greater than zero")); } if first_output.token.as_ref().unwrap().commitment[0] & ITEM_TYPE_BITS == ITEM_TYPE_OFFERING { // enforce limitation (xToken == NATIVE_BCH) if let IdoParameters::Active(ref active_params) = context.parameters { if active_params.offering.offer.xTokenCategory.is_some() { return Err(anyhow::anyhow!("non-null xTokenCategory is not supported")); } } let second_output = tx.output.get(1).ok_or_else(|| anyhow::anyhow!("Should have the second output!"))?; if second_output.token.is_none() || second_output.token.as_ref().unwrap().id.to_blob() != context.offering_token_id.clone().unwrap_or_default() || !second_output.token.as_ref().unwrap().has_nft() { return Err(anyhow::anyhow!("output#1 should be an offering entry nft!")); } // input#0 is the offering, add entry // pull owner_nfthash from unlocking bytecode of input#0 let owner_nfthash; let first_input = tx.input.get(0).ok_or_else(|| anyhow::anyhow!("Should have first input!"))?; let instructions: Vec<_> = first_input.script_sig.instructions().collect(); if instructions.len() == 0 { return Err(anyhow::anyhow!("empty unlocking bytecode!")); } match &instructions[0] { Ok(Instruction::PushBytes(data)) => { owner_nfthash = data.as_bytes().to_vec(); }, _ => return Err(anyhow::anyhow!("OP_PUSH expected in the unlocking bytecode of the offering utxo!")), } if first_output.token.as_ref().unwrap().commitment.len() < 9 { return Err(anyhow::anyhow!("Incorrect commitment size at output#0")); } if second_output.token.as_ref().unwrap().commitment.len() < 15 { return Err(anyhow::anyhow!("Incorrect commitment size at output#1")); } let supply_amount = second_output.value.to_sat(); let demand_amount = second_output.token.as_ref().unwrap().amount as u64; let discount = decode_padded_vm_number(&second_output.token.as_ref().unwrap().commitment[7..15]).to_u64().unwrap_or(0); updates.push(IdoUpdate::Entry(IdoUpdateEntry { txid: tx.compute_txid().to_blob(), owner_nfthash: owner_nfthash, supply_amount, demand_amount, lockup_timeval: decode_padded_vm_number(&second_output.token.as_ref().unwrap().commitment[1..7]).to_u64().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(), idoCategory: active_state.idoCategory.clone(), counter: decode_padded_vm_number(&first_output.token.as_ref().unwrap().commitment[5..9]), totalDemandAmount: &active_state.totalDemandAmount + BigInt::from(demand_amount), totalSupplyAmount: &active_state.totalSupplyAmount + BigInt::from(supply_amount), totalDiscount: &active_state.totalDiscount + BigInt::from(discount), }))); } else if (first_output.token.as_ref().unwrap().commitment[0] & ITEM_TYPE_BITS) == ITEM_TYPE_DISTRIBUTOR { // input#0 is the launcher // output#0 distributor let dist_commitment = first_output.token.as_ref().unwrap().commitment.clone(); if dist_commitment.len() < 35 { return Err(anyhow::anyhow!("Incorrect commitment size at output#0")); } updates.push(IdoUpdate::Status("DISTRIBUTING".to_string())); updates.push(IdoUpdate::LaunchTxId { txid: tx.compute_txid().to_blob(), launched_at: launch_tx_launched_at(tx), }); updates.push(IdoUpdate::State( IdoState::Distributing(IdoDistributingState { authguardCategory: active_state.authguardCategory.clone(), idoCategory: active_state.idoCategory.clone(), isRefund: (dist_commitment[0] & DISTRIBUTOR_FLAG_IS_REFUND) != 0, timestamp: decode_padded_vm_number(&dist_commitment[1..7]), counter: decode_padded_vm_number(&dist_commitment[7..11]), earnedAmount: decode_padded_vm_number(&dist_commitment[11..19]), refundAmount: decode_padded_vm_number(&dist_commitment[19..27]), discountAmount: decode_padded_vm_number(&dist_commitment[27..35]), platformEarnedAmount: BigInt::from(0), }) )); } else if (first_output.token.as_ref().unwrap().commitment[0] & ITEM_TYPE_BITS) == ITEM_TYPE_CONFIRMATION_NFT { // input#0 is the launcher, nodist, jump to postlaunch // output#0 is the confirmation nft let conf_commitment = first_output.token.as_ref().unwrap().commitment.clone(); if conf_commitment.len() < 31 { return Err(anyhow::anyhow!("Incorrect commitment size at output#0")); } updates.push(IdoUpdate::Status("POSTLAUNCH".to_string())); updates.push(IdoUpdate::LaunchTxId { txid: tx.compute_txid().to_blob(), launched_at: launch_tx_launched_at(tx), }); updates.push(IdoUpdate::State( IdoState::PostLaunch(IdoPostLaunchState { authguardCategory: active_state.authguardCategory.clone(), idoCategory: active_state.idoCategory.clone(), isRefund: (conf_commitment[0] & CONFIRMATION_NFT_FLAG_IS_REFUND) != 0, timestamp: decode_padded_vm_number(&conf_commitment[1..7]), earnedAmount: decode_padded_vm_number(&conf_commitment[7..15]), refundAmount: decode_padded_vm_number(&conf_commitment[15..23]), discountAmount: decode_padded_vm_number(&conf_commitment[23..31]), platformEarnedAmount: BigInt::from(0), }) )); } else { return Err(anyhow::anyhow!("output#0 is not recognized!")); } } else { return Err(anyhow::anyhow!("expecting PreInitState!")); } print_updates(&updates); return Ok(IdoAddResult { updates: updates, next_output_index: 0, }); }, "DISTRIBUTING" => { match &context.parameters { IdoParameters::Active(parameters) => { // enforce limitation (xToken == NATIVE_BCH) if parameters.offering.offer.xTokenCategory.is_some() { return Err(anyhow::anyhow!("non-null xTokenCategory is not supported")); } } _ => return Err(anyhow::anyhow!("expecting active parameters")), } // output#0 should contain a token with offering_token_id let first_output = tx.output.get(0).ok_or_else(|| anyhow::anyhow!("Should have first output!"))?; if first_output.token.is_none() || first_output.token.as_ref().unwrap().id.to_blob() != context.offering_token_id.clone().unwrap_or_default() || !first_output.token.as_ref().unwrap().has_nft() { return Err(anyhow::anyhow!("output#0 is not one of the offering nft!")); } let prev_state: &IdoDistributingState; match &context.state { IdoState::Distributing(value) => { prev_state = value; } _ => return Err(anyhow::anyhow!("expecting distributing state")), } let platform_fee_output = tx.output.get(4).ok_or_else(|| anyhow::anyhow!("platform fee output does not exist!"))?; if first_output.token.as_ref().unwrap().commitment.len() == 0 { return Err(anyhow::anyhow!("Incorrect commitment size at output#0, should be greater than zero")); } if (first_output.token.as_ref().unwrap().commitment[0] & ITEM_TYPE_BITS) == ITEM_TYPE_DISTRIBUTOR { // output#0 distributor let dist_commitment = first_output.token.as_ref().unwrap().commitment.clone(); if dist_commitment.len() < 35 { return Err(anyhow::anyhow!("Incorrect dist_commitment size at output#0")); } updates.push(IdoUpdate::State( IdoState::Distributing(IdoDistributingState { authguardCategory: prev_state.authguardCategory.clone(), idoCategory: prev_state.idoCategory.clone(), isRefund: (dist_commitment[0] & DISTRIBUTOR_FLAG_IS_REFUND) != 0, timestamp: decode_padded_vm_number(&dist_commitment[1..7]), counter: decode_padded_vm_number(&dist_commitment[7..11]), earnedAmount: decode_padded_vm_number(&dist_commitment[11..19]), refundAmount: decode_padded_vm_number(&dist_commitment[19..27]), discountAmount: decode_padded_vm_number(&dist_commitment[27..35]), platformEarnedAmount: &prev_state.platformEarnedAmount + BigInt::from(platform_fee_output.value.to_sat()), }) )); } else if (first_output.token.as_ref().unwrap().commitment[0] & ITEM_TYPE_BITS) == ITEM_TYPE_CONFIRMATION_NFT { // output#0 is the confirmation nft let conf_commitment = first_output.token.as_ref().unwrap().commitment.clone(); if conf_commitment.len() < 31 { return Err(anyhow::anyhow!("Incorrect conf_commitment size at output#0")); } updates.push(IdoUpdate::Status("POSTLAUNCH".to_string())); updates.push(IdoUpdate::State( IdoState::PostLaunch(IdoPostLaunchState { authguardCategory: prev_state.authguardCategory.clone(), idoCategory: prev_state.idoCategory.clone(), isRefund: (conf_commitment[0] & CONFIRMATION_NFT_FLAG_IS_REFUND) != 0, timestamp: decode_padded_vm_number(&conf_commitment[1..7]), earnedAmount: decode_padded_vm_number(&conf_commitment[7..15]), refundAmount: decode_padded_vm_number(&conf_commitment[15..23]), discountAmount: decode_padded_vm_number(&conf_commitment[23..31]), platformEarnedAmount: &prev_state.platformEarnedAmount + BigInt::from(platform_fee_output.value.to_sat()), }) )); } else { return Err(anyhow::anyhow!("output#0 is not recognized!")); } print_updates(&updates); return Ok(IdoAddResult { updates: updates, next_output_index: 0, }); }, "POSTLAUNCH" => { let pp_output = tx.output.get(0).ok_or_else(|| anyhow::anyhow!("Should have first output!"))?; let collector_p2nfth = tx.output.get(2).ok_or_else(|| anyhow::anyhow!("Should have third output!"))?; updates.push(IdoUpdate::Status("DISTRIBUTED".to_string())); let prev_state: &IdoPostLaunchState; match &context.state { IdoState::PostLaunch(value) => { prev_state = value; } _ => return Err(anyhow::anyhow!("expecting postlaunch state")), } updates.push(IdoUpdate::State( IdoState::Distributed(IdoDistributedState { authguardCategory: prev_state.authguardCategory.clone(), idoCategory: prev_state.idoCategory.clone(), permanentPool: if pp_output.token.is_none() { None } else { Some(IdoPermanentPoolV0 { tokenAmount: BigInt::from(pp_output.token.as_ref() .ok_or_else(|| anyhow::anyhow!("pp should have tokens"))? .amount), satoshiAmount: BigInt::from(pp_output.value.to_sat()), }) }, refundAmount: BigInt::from(collector_p2nfth.token.as_ref() .ok_or_else(|| anyhow::anyhow!("collector_p2nfth should have tokens"))? .amount), discountAmount: prev_state.discountAmount.clone(), collectorEarnedAmount: BigInt::from(collector_p2nfth.value.to_sat()), platformEarnedAmount: prev_state.platformEarnedAmount.clone(), }) )); updates.push(IdoUpdate::EntryDistributed { txid: tx.compute_txid().to_blob(), }); print_updates(&updates); return Ok(IdoAddResult { updates: updates, next_output_index: -1, }); }, _ => return Err(anyhow::anyhow!( "An ido with an unknown status: {}", context.status )), } } async fn update_ido( dbtx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, internal_id: i64, context: &IdoContext, txchain_head: i64, ) -> Result<()> { sqlx::query( "UPDATE ido set init_txid = ?, launch_txid = ?, otoken_genesis_txid = ?, status = ?, parameters = ?, state = ?, offering_token_id = ?, offered_token_id = ?, txchain_head = ?, is_valid = ?, launched_at = ? WHERE internal_id = ?", ) .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?; Ok(()) } fn apply_updates_to_context( context: &mut IdoContext, updates: &[IdoUpdate], ) -> () { for update in updates { match update { IdoUpdate::InitTxId(txid) => { context.init_txid = Some(txid.clone()); }, IdoUpdate::LaunchTxId { txid, launched_at } => { context.launch_txid = Some(txid.clone()); context.launched_at = *launched_at; }, IdoUpdate::OTokenGenesisTxId(txid) => { context.otoken_genesis_txid = Some(txid.clone()); }, IdoUpdate::Status(status) => { context.status = status.clone(); }, IdoUpdate::State(state) => { context.state = state.clone(); }, IdoUpdate::Parameters(parameters) => { context.parameters = parameters.clone(); }, IdoUpdate::OfferedTokenId(token_id) => { context.offered_token_id = Some(token_id.clone()); }, IdoUpdate::OfferingTokenId(token_id) => { context.offering_token_id = Some(token_id.clone()); }, _ => {} } } } async fn upsert_updates_to_ido_entries( dbtx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, internal_id: i64, updates: &[IdoUpdate], ) -> Result<()> { let mut insert_list: Vec = Vec::new(); let mut mark_distributed_list: Vec<&Vec> = Vec::new(); for update in updates { match update { IdoUpdate::Entry(value) => { insert_list.push(value.clone()); }, 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); } }, _ => {} } } 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( 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, ) -> 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?; 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 } async fn on_add_ido_tx( network: Option, pool: &SqlitePool, ido: &IdoDBRecord, prev_txchain_item: &IdoTxChainDBRecord, tx: &Transaction, block_height: i64, ) -> Result<()> { debug!("IDO on_add_ido_tx: {}", blob_to_display_hex::(&tx.compute_txid().to_blob())?); 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); } let new_chain = reconstruct_txchain(&items_by_id, Some(prev_txchain_item.id)); if new_chain.get(0).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?; Ok(()) } fn is_ido_sig_in_tx_inputs(tx: &Transaction) -> bool { for input_index in [ 0, 1 ] { let input = tx.input.get(input_index); if input.is_some() { if has_script_ido_signature(&input.unwrap().script_sig) { return true; } } } return false; } async fn index_txs( network: Option, pool: &SqlitePool, sorted_txs: &[Transaction], block_height: i64, ) -> Result<()> { for tx in sorted_txs { // detect a new ido if is_preinit_broadcast(tx) { debug!("IDO index preinit: {}", blob_to_display_hex::(&tx.compute_txid().to_blob())?); if lookup_internal_id_by_preinit_txid(pool, &tx.compute_txid().to_blob()).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?; continue; } match on_create_ido(network, pool, tx, block_height) .await { Err(err) => info!("failed to detect an ido, or an invalid ido detected, txid: {}, {}", hex::encode(tx.compute_txid().to_blob()), err), _ => (), } } else if is_ido_sig_in_tx_inputs(tx) { debug!("IDO tx to index: {}", blob_to_display_hex::(&tx.compute_txid().to_blob())?); // has ido sig for input_index in [ 0, 1, 3 ] { let input = tx.input.get(input_index); if input.is_some() { if let Some(txchain_item) = txchain_lookup_next( pool, &input.unwrap().previous_output.txid, input.unwrap().previous_output.vout ) .await? { match ido_lookup(pool, txchain_item.ido_id) .await { Ok(Some(ido)) => { match on_add_ido_tx(network, pool, &ido, &txchain_item, tx, block_height) .await { Err(err) => 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!!"), } break; } } } } } 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()) } // ─── Public RPC types ──────────────────────────────────────────────────────── #[derive(Serialize, Clone)] pub struct IdoRpcRecord { pub id: String, pub preinit_txid: String, pub init_txid: Option, pub launch_txid: Option, pub otoken_genesis_txid: Option, pub offering_token_id: Option, pub offered_token_id: Option, pub status: String, pub parameters: serde_json::Value, pub state: serde_json::Value, /// Txid (display hex) of the txchain head record, or null if there is no head. pub txchain_head: Option, pub is_valid: bool, /// Unix timestamp (seconds) the IDO was created, from the preinit's Delphi /// NFT commitment; 0 if unavailable. pub created_at: i64, /// Unix timestamp (seconds) the IDO was launched, from the Delphi NFT in /// output#3 of the launch transaction; null until launched. pub launched_at: Option, } #[derive(Serialize, Clone)] pub struct IdoEntryRpcRecord { pub ido_id: String, pub txid: String, pub owner_nfthash: String, pub commitment: Option, pub supply_amount: i64, pub demand_amount: i64, pub lockup_timeval: i64, pub discount: i64, 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 { let preinit_txid_hex = blob_to_display_hex::(&self.preinit_txid)?; Ok(IdoRpcRecord { id: preinit_txid_hex.clone(), preinit_txid: preinit_txid_hex, init_txid: self.init_txid.as_deref().map(blob_to_display_hex::).transpose()?, launch_txid: self.launch_txid.as_deref().map(blob_to_display_hex::).transpose()?, otoken_genesis_txid: self.otoken_genesis_txid.as_deref().map(blob_to_display_hex::).transpose()?, offering_token_id: self.offering_token_id.as_deref().map(blob_to_display_hex::).transpose()?, offered_token_id: self.offered_token_id.as_deref().map(blob_to_display_hex::).transpose()?, status: self.status, parameters: serde_json::from_slice(&self.parameters)?, state: serde_json::from_slice(&self.state)?, txchain_head: txchain_head_txid.as_deref().map(blob_to_display_hex::).transpose()?, is_valid: self.is_valid, created_at: self.created_at, launched_at: self.launched_at, }) } } #[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_STEP_TEST_TX01: LazyLock> = LazyLock::new(|| hex::decode("").unwrap()); #[test] fn preinit_detect() { let tx: Transaction = bitcoincash::consensus::deserialize(&PREINIT_TEST_TX01).expect("should be valid"); assert!(is_preinit_broadcast(&tx)); } fn test_preinit_tx(txbytes: &[u8]) { let tx: Transaction = bitcoincash::consensus::deserialize(txbytes).expect("should be valid"); let mut errors: Vec = Vec::new(); let mut invalid_ido_reasons: Vec = Vec::new(); let network = Some(Network::Chipnet); let result = parse_ido_preinit_tx(network, &tx, &mut errors, &mut invalid_ido_reasons); if errors.len() > 0 { debug!("Parse ido preinit failed, txid: {}\nError(s):", hex::encode(tx.compute_txid().to_blob())); for error in &errors { debug!(" - {}", error); } } if invalid_ido_reasons.len() > 0 { debug!("Invalid ido found, preinit_txid: {}\nReason(s):", hex::encode(tx.compute_txid().to_blob())); for reason in &invalid_ido_reasons { debug!(" - {}", reason); } } let params = result.parameters.expect("parameters should be defined!"); let state = result.state.expect("state should be defined!"); debug!("params: {}", String::from_utf8(serde_json::to_vec_pretty(¶ms).unwrap()).unwrap()); debug!("state: {}", String::from_utf8(serde_json::to_vec_pretty(&state).unwrap()).unwrap()); assert!(errors.len() == 0 && invalid_ido_reasons.len() == 0); assert!(result.is_valid_ido); } #[test] fn parse_ido_preinit_from_valid_ido() { 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 ────────────────────────────────────────────── pub async fn lookup_internal_id_by_preinit_txid( pool: &SqlitePool, preinit_txid: &[u8], ) -> Result> { let row = sqlx::query("SELECT internal_id FROM ido WHERE preinit_txid = ?") .bind(preinit_txid) .fetch_optional(pool) .await?; Ok(row.map(|r| r.get(0))) } pub async fn list_idos( pool: &SqlitePool, is_valid: Option, status: Option, offered_token_id_blob: Option>, 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", ); if let Some(v) = is_valid { qb.push(" AND ido.is_valid = "); qb.push_bind(v as i64); } if let Some(v) = status { qb.push(" AND ido.status = "); qb.push_bind(v); } if let Some(v) = offered_token_id_blob { qb.push(" AND ido.offered_token_id = "); qb.push_bind(v); } qb.push(" ORDER BY ido.internal_id ASC LIMIT "); qb.push_bind(limit); qb.push(" OFFSET "); qb.push_bind(offset); qb.build() .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) }) .collect() } 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() } 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() } pub async fn list_ido_entries( pool: &SqlitePool, internal_id: i64, preinit_txid_hex: &str, distributed: Option, owner_nfthashes: &[Vec], 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 = ", ); qb.push_bind(internal_id); if let Some(v) = distributed { qb.push(" AND distributed = "); qb.push_bind(v as i64); } if !owner_nfthashes.is_empty() { qb.push(" AND 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_bind(limit); qb.push(" OFFSET "); qb.push_bind(offset); qb.build() .fetch_all(pool) .await? .into_iter() .map(|row| { let txid_blob: Vec = row.get(0); let owner_nfthash: Vec = row.get(1); let commitment: Option> = row.get(2); let distributed_int: i64 = row.get(7); Ok(IdoEntryRpcRecord { ido_id: preinit_txid_hex.to_string(), txid: blob_to_display_hex::(&txid_blob)?, owner_nfthash: hex::encode(&owner_nfthash), commitment: commitment.map(|c| hex::encode(&c)), supply_amount: row.get(3), demand_amount: row.get(4), lockup_timeval: row.get(5), discount: row.get(6), distributed: distributed_int != 0, }) }) .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::*; use num_bigint::BigInt; use sqlx::SqlitePool; async fn make_pool() -> SqlitePool { let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); prepare_tables(&pool).await; pool } fn txid(n: u8) -> Vec { vec![n; 32] } async fn insert_test_ido( pool: &SqlitePool, preinit_txid: Vec, status: &str, is_valid: bool, is_token_created_at_preinit: bool, 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)", ) .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(is_token_created_at_preinit as i64) .execute(pool) .await .unwrap() .last_insert_rowid() } // ─── vm number encoding ─────────────────────────────────────────────────── #[test] fn vm_number_roundtrip_zero() { let n = BigInt::from(0i64); assert_eq!(bigint_to_vm_number(&n), Vec::::new()); assert_eq!(vm_number_to_bigint(&[]), n); } #[test] fn vm_number_roundtrip_positive() { for v in [1i64, 127, 128, 255, 256, 32767, 65535, 1_000_000] { let n = BigInt::from(v); assert_eq!(vm_number_to_bigint(&bigint_to_vm_number(&n)), n, "v={v}"); } } #[test] fn vm_number_roundtrip_negative() { for v in [-1i64, -127, -128, -255, -256, -65535] { let n = BigInt::from(v); assert_eq!(vm_number_to_bigint(&bigint_to_vm_number(&n)), n, "v={v}"); } } #[test] fn vm_number_sign_bit_boundary() { // 127 fits in one byte without extra sign byte; 128 requires one assert_eq!(bigint_to_vm_number(&BigInt::from(127i64)).len(), 1); assert_eq!(bigint_to_vm_number(&BigInt::from(128i64)).len(), 2); assert_eq!(bigint_to_vm_number(&BigInt::from(-127i64)).len(), 1); assert_eq!(bigint_to_vm_number(&BigInt::from(-128i64)).len(), 2); } // ─── encode_padded_vm_number ────────────────────────────────────────────── #[test] fn padded_vm_number_roundtrip() { for (v, len) in [(0i64, 4), (1, 4), (127, 4), (255, 4), (65535, 4), (0x7FFFFFFFi64, 4)] { let n = BigInt::from(v); let enc = encode_padded_vm_number(&n, len).expect("encode failed"); assert_eq!(enc.len(), len, "wrong length for {v}"); assert_eq!(decode_padded_vm_number(&enc), n, "roundtrip failed for {v}"); } } #[test] fn padded_vm_number_overflow_is_err() { // value needs 5 bytes, cannot fit in 4 let n = BigInt::from(0xFFFF_FFFFi64 + 1); assert!(encode_padded_vm_number(&n, 4).is_err()); } #[test] fn padded_vm_number_zero_any_length() { for len in [1usize, 2, 4, 8] { let enc = encode_padded_vm_number(&BigInt::from(0i64), len).unwrap(); assert_eq!(enc.len(), len); assert_eq!(decode_padded_vm_number(&enc), BigInt::from(0i64)); } } // ─── bigint_to_push_opcode ──────────────────────────────────────────────── #[test] fn push_opcode_zero() { assert_eq!(bigint_to_push_opcode(&BigInt::from(0i64)), vec![0x00]); } #[test] fn push_opcode_minus_one() { assert_eq!(bigint_to_push_opcode(&BigInt::from(-1i64)), vec![0x4f]); } #[test] fn push_opcode_small_range_1_to_16() { for v in 1u8..=16 { let result = bigint_to_push_opcode(&BigInt::from(v)); assert_eq!(result, vec![0x50 + v], "v={v}"); } } #[test] fn push_opcode_17_is_data_push() { let result = bigint_to_push_opcode(&BigInt::from(17i64)); // length prefix + one byte payload assert_eq!(result, vec![0x01, 17]); } #[test] fn push_opcode_roundtrip_via_vm_number() { // bigint_to_push_opcode should produce bytes whose payload decodes back let n = BigInt::from(12345i64); let opcode = bigint_to_push_opcode(&n); // first byte is the length; rest is the vm-number payload let payload = &opcode[1..]; assert_eq!(vm_number_to_bigint(payload), n); } // ─── is_preinit_state_ready_to_init ────────────────────────────────────── #[allow(non_snake_case)] fn ready_state() -> IdoPreInitState { IdoPreInitState { authguardCategory: vec![0xAA; 32], idoCategory: vec![0xBB; 32], nextTxSetterFlag: BigInt::from(0i64), oTokenGenerated: BigInt::from(1i64), initiatorCreated: false, } } #[test] fn preinit_ready_when_all_conditions_met() { assert!(is_preinit_state_ready_to_init(&ready_state())); } #[test] #[allow(non_snake_case)] fn preinit_not_ready_o_token_not_generated() { let mut s = ready_state(); s.oTokenGenerated = BigInt::from(0i64); assert!(!is_preinit_state_ready_to_init(&s)); } #[test] #[allow(non_snake_case)] fn preinit_not_ready_next_tx_setter_flag_set() { let mut s = ready_state(); s.nextTxSetterFlag = BigInt::from(1i64); assert!(!is_preinit_state_ready_to_init(&s)); } #[test] #[allow(non_snake_case)] fn preinit_not_ready_authguard_category_zero() { let mut s = ready_state(); s.authguardCategory = vec![0x00; 32]; assert!(!is_preinit_state_ready_to_init(&s)); } #[test] #[allow(non_snake_case)] fn preinit_not_ready_ido_category_zero() { let mut s = ready_state(); s.idoCategory = vec![0x00; 32]; assert!(!is_preinit_state_ready_to_init(&s)); } // ─── DB: list_idos ──────────────────────────────────────────────────────── #[rocket::async_test] async fn list_idos_returns_all() { let pool = make_pool().await; insert_test_ido(&pool, txid(1), "PREINIT", true, true, None, None).await; insert_test_ido(&pool, txid(2), "ACTIVE", true, true, None, None).await; let results = list_idos(&pool, None, None, None, 0, 100).await.unwrap(); assert_eq!(results.len(), 2); } #[rocket::async_test] async fn list_idos_filter_is_valid() { let pool = make_pool().await; insert_test_ido(&pool, txid(1), "PREINIT", true, true, None, None).await; insert_test_ido(&pool, txid(2), "PREINIT", false, false, None, None).await; let valid = list_idos(&pool, Some(true), None, None, 0, 100).await.unwrap(); assert_eq!(valid.len(), 1); assert!(valid[0].is_valid); let invalid = list_idos(&pool, Some(false), None, None, 0, 100).await.unwrap(); assert_eq!(invalid.len(), 1); assert!(!invalid[0].is_valid); } #[rocket::async_test] async fn list_idos_filter_status() { let pool = make_pool().await; insert_test_ido(&pool, txid(1), "PREINIT", true, true, None, None).await; insert_test_ido(&pool, txid(2), "ACTIVE", true, true, None, None).await; insert_test_ido(&pool, txid(3), "ACTIVE", true, true, None, None).await; let active = list_idos(&pool, None, Some("ACTIVE".to_string()), None, 0, 100).await.unwrap(); assert_eq!(active.len(), 2); assert!(active.iter().all(|r| r.status == "ACTIVE")); } #[rocket::async_test] async fn list_idos_filter_offered_token_id() { let pool = make_pool().await; let token = vec![0xAB; 32]; insert_test_ido(&pool, txid(1), "ACTIVE", true, true, Some(token.clone()), None).await; insert_test_ido(&pool, txid(2), "ACTIVE", true, true, None, None).await; let filtered = list_idos(&pool, None, None, Some(token), 0, 100).await.unwrap(); assert_eq!(filtered.len(), 1); } #[rocket::async_test] async fn list_idos_pagination() { let pool = make_pool().await; for i in 1..=5u8 { insert_test_ido(&pool, txid(i), "PREINIT", true, true, None, None).await; } let page1 = list_idos(&pool, None, None, None, 0, 2).await.unwrap(); let page2 = list_idos(&pool, None, None, None, 2, 2).await.unwrap(); let page3 = list_idos(&pool, None, None, None, 4, 2).await.unwrap(); assert_eq!(page1.len(), 2); assert_eq!(page2.len(), 2); assert_eq!(page3.len(), 1); // Pages reflect internal_id ascending insertion order: preinit_txid hex of txid(i) bytes // For txid(i) = [i; 32], the display hex is "ii".repeat(32). assert_eq!(page1[0].id, hex::encode(txid(1))); assert_eq!(page1[1].id, hex::encode(txid(2))); assert_eq!(page2[0].id, hex::encode(txid(3))); assert_eq!(page2[1].id, hex::encode(txid(4))); assert_eq!(page3[0].id, hex::encode(txid(5))); } // ─── DB: get_ido_by_offering_token_id ──────────────────────────────────── #[rocket::async_test] async fn get_ido_by_offering_token_found() { let pool = make_pool().await; let tok = vec![0xCC; 32]; insert_test_ido(&pool, txid(1), "ACTIVE", true, true, None, Some(tok.clone())).await; let result = get_ido_by_offering_token_id(&pool, tok).await.unwrap(); assert!(result.is_some()); assert_eq!(result.unwrap().status, "ACTIVE"); } #[rocket::async_test] async fn get_ido_by_offering_token_not_found() { let pool = make_pool().await; let result = get_ido_by_offering_token_id(&pool, vec![0xFF; 32]).await.unwrap(); assert!(result.is_none()); } // ─── DB: list_ido_entries ───────────────────────────────────────────────── async fn insert_test_entry(pool: &SqlitePool, ido_id: i64, txid_val: Vec, distributed: bool) { 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, ?)", ) .bind(ido_id) .bind(txid_val) .bind(vec![0u8; 32]) .bind(distributed as i64) .execute(pool) .await .unwrap(); } #[rocket::async_test] async fn list_ido_entries_all() { 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)); insert_test_entry(&pool, ido_id, txid(10), false).await; insert_test_entry(&pool, ido_id, txid(11), true).await; let all = list_ido_entries(&pool, ido_id, &preinit_hex, None, &[], 0, 100).await.unwrap(); assert_eq!(all.len(), 2); } #[rocket::async_test] async fn list_ido_entries_filter_distributed() { 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)); insert_test_entry(&pool, ido_id, txid(10), false).await; insert_test_entry(&pool, ido_id, txid(11), true).await; insert_test_entry(&pool, ido_id, txid(12), true).await; let dist = list_ido_entries(&pool, ido_id, &preinit_hex, Some(true), &[], 0, 100).await.unwrap(); assert_eq!(dist.len(), 2); assert!(dist.iter().all(|e| e.distributed)); let undist = list_ido_entries(&pool, ido_id, &preinit_hex, Some(false), &[], 0, 100).await.unwrap(); assert_eq!(undist.len(), 1); assert!(!undist[0].distributed); } #[rocket::async_test] async fn list_ido_entries_scoped_to_ido() { let pool = make_pool().await; let ido1 = insert_test_ido(&pool, txid(1), "ACTIVE", true, true, None, None).await; let ido2 = insert_test_ido(&pool, txid(2), "ACTIVE", true, true, None, None).await; let preinit_hex_1 = hex::encode(txid(1)); let preinit_hex_2 = hex::encode(txid(2)); insert_test_entry(&pool, ido1, txid(10), false).await; insert_test_entry(&pool, ido1, txid(11), false).await; insert_test_entry(&pool, ido2, txid(12), false).await; let e1 = list_ido_entries(&pool, ido1, &preinit_hex_1, None, &[], 0, 100).await.unwrap(); let e2 = list_ido_entries(&pool, ido2, &preinit_hex_2, None, &[], 0, 100).await.unwrap(); assert_eq!(e1.len(), 2); assert_eq!(e2.len(), 1); } // ─── DB: list_ido_txchain ───────────────────────────────────────────────── async fn insert_txchain_item( pool: &SqlitePool, ido_id: 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) .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) .await .unwrap(); } #[rocket::async_test] async fn list_ido_txchain_ordered_oldest_first() { 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); } #[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 ──────────────── #[rocket::async_test] async fn ido_record_resolves_txchain_head_to_txid() { let pool = make_pool().await; let tok = vec![0xCC; 32]; let ido_id = insert_test_ido(&pool, txid(1), "ACTIVE", 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; // 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].txchain_head, Some(hex::encode(txid(11)))); // get_ido_by_offering_token_id resolves it the same way. let one = get_ido_by_offering_token_id(&pool, tok).await.unwrap().unwrap(); assert_eq!(one.txchain_head, Some(hex::encode(txid(11)))); } #[rocket::async_test] async fn ido_record_txchain_head_null_is_none() { 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; let listed = list_idos(&pool, None, None, None, 0, 100).await.unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].txchain_head, None); } }