diff --git a/Cargo.lock b/Cargo.lock index 90464cb..eed5447 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -408,6 +408,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link 0.1.1", ] @@ -549,6 +550,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.101", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.101", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -568,11 +603,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -643,6 +679,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -965,7 +1007,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap", + "indexmap 2.9.0", "slab", "tokio", "tokio-util", @@ -1024,6 +1066,9 @@ name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] [[package]] name = "hex-conservative" @@ -1330,6 +1375,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -1351,6 +1402,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.9.0" @@ -1663,9 +1725,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -2244,6 +2306,7 @@ dependencies = [ "rust_decimal_macros", "serde", "serde_json", + "serde_with", "sqlx", "stderrlog", "tokio", @@ -2306,7 +2369,7 @@ dependencies = [ "either", "figment", "futures", - "indexmap", + "indexmap 2.9.0", "log", "memchr", "multer", @@ -2338,7 +2401,7 @@ checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" dependencies = [ "devise", "glob", - "indexmap", + "indexmap 2.9.0", "proc-macro2", "quote", "rocket_http", @@ -2375,7 +2438,7 @@ dependencies = [ "futures", "http 0.2.12", "hyper 0.14.32", - "indexmap", + "indexmap 2.9.0", "log", "memchr", "pear", @@ -2527,6 +2590,30 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -2567,18 +2654,28 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -2587,14 +2684,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -2618,6 +2716,37 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.9.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2758,7 +2887,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.3", "hashlink", - "indexmap", + "indexmap 2.9.0", "log", "memchr", "once_cell", @@ -2963,6 +3092,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -3071,30 +3206,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -3224,7 +3359,7 @@ version = "0.22.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" dependencies = [ - "indexmap", + "indexmap 2.9.0", "serde", "serde_spanned", "toml_datetime", @@ -4070,3 +4205,9 @@ dependencies = [ "quote", "syn 2.0.101", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 6d9a1d7..6ebbcda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ anyhow = { version = "1.0.94", features = ["backtrace"] } bitcoincash = "0.32.2" bitcoin_hashes = { version = "0.14.100", default-features = false } # Same version as used by bitcoincash electrum-client-netagnostic = { version = "0.21.2", features = ["use-rustls", "use-websocket"] } -hex = "0.4.3" +hex = { version = "0.4.3", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.133" rocket = { version = "0.5.1", features = ["json"] } @@ -33,6 +33,7 @@ reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-feature rand = "0.8.5" configure_me = "0.4.0" futures = "0.3.32" +serde_with = { version = "3.18.0", features = ["hex"] } [build-dependencies] configure_me_codegen = "0.4.8" diff --git a/internal/config_specification.toml b/internal/config_specification.toml index 3ad8fe9..9cabe37 100644 --- a/internal/config_specification.toml +++ b/internal/config_specification.toml @@ -51,3 +51,15 @@ name = "moria_read_slots" type = "u32" doc = "Number of read connection pool slots for the Moria lending database (default: 8)" default = "8" + +[[param]] +name = "ido_read_slots" +type = "u32" +doc = "Number of read connection pool slots for the Moria lending database (default: 8)" +default = "8" + +[[param]] +name = "debug" +type = "bool" +doc = "Enable debug RPC endpoints (e.g. txchain inspection). Do not use in production." +default = "false" diff --git a/src/chain.rs b/src/chain.rs index a8c3088..93fbf5d 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -88,6 +88,9 @@ impl BlockUndoer for StoreBlockUndoer { { was_indexed = true; } + if db::ido::delete_entries(&self.db.ido_w, Some(&blockheader.block_hash())).await? > 0 { + was_indexed = true; + } if was_indexed { config_set( diff --git a/src/db/ido/mod.rs b/src/db/ido/mod.rs new file mode 100644 index 0000000..74ad1f0 --- /dev/null +++ b/src/db/ido/mod.rs @@ -0,0 +1,3839 @@ +// 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 crate::db::blob::{blob_to_display_hex, ToBlob}; +use anyhow::Error; +use anyhow::Result; +use bitcoin_hashes::sha256d; +use bitcoin_hashes::Hash; +use bitcoincash::blockdata::opcodes; +use bitcoincash::blockdata::script::{Builder, Instruction, PushBytes, Script, ScriptBuf}; +use bitcoincash::blockdata::transaction::Transaction; +use bitcoincash::{BlockHash, Network, TokenID, Txid}; +use log::{debug, info}; +use malachite::base::num::arithmetic::traits::Sign; +use malachite::base::num::basic::traits::Zero; +use malachite::base::num::conversion::traits::PowerOf2Digits; +use malachite::{Integer, Natural}; +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, DeserializeAs, SerializeAs}; +use sqlx::{Row, SqlitePool}; +use std::cmp::Ordering; +use std::str::FromStr; +use std::sync::LazyLock; + +/// `serde_with` adapter that (de)serializes a malachite [`Integer`] as a decimal +/// string. Mirrors what `DisplayFromStr` did for `num_bigint::BigInt`; a custom +/// adapter is needed because malachite's `FromStr::Err` is `()`, which does not +/// implement `Display` (a `DisplayFromStr` bound). +struct IntegerAsStr; + +impl SerializeAs for IntegerAsStr { + fn serialize_as(value: &Integer, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&value.to_string()) + } +} + +impl<'de> DeserializeAs<'de, Integer> for IntegerAsStr { + fn deserialize_as(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Integer::from_str(&s).map_err(|()| serde::de::Error::custom("invalid integer string")) + } +} + +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; +// 0b00000010 +const ITEM_TYPE_DISTRIBUTOR: u8 = 0x02; +// 0b00000101 +const ITEM_TYPE_CONFIRMATION_NFT: u8 = 0x05; + +static PERMANENT_LIQUIDITY_SHARE_DENOMINATOR: LazyLock = + LazyLock::new(|| Integer::from(100_000_000i64)); + +static MIN_PLP_SHARE: LazyLock = LazyLock::new(|| Integer::from(20_000_000i64)); // 20% +static MAX_PLP_SHARE: LazyLock = LazyLock::new(|| Integer::from(80_000_000i64)); // 80% +static MAX_PLP_ANNUAL_DISCOUNT: LazyLock = LazyLock::new(|| Integer::from(10_000_000i64)); // 10% +static MIN_PLP_ANNUAL_DISCOUNT: LazyLock = LazyLock::new(|| Integer::from(0i64)); // 0% +static MIN_PLP_AFTER_DISCOUNT: LazyLock = LazyLock::new(|| Integer::from(5_000_000i64)); // 5% + +static ENTRY_EXECUTION_FEE: LazyLock = LazyLock::new(|| Integer::from(50_000i64)); // 50k sats +static IDO_CREATE_EXECUTION_FEE: LazyLock = LazyLock::new(|| Integer::from(300_000i64)); // 300k sats +static PLATFORM_FEE: LazyLock = LazyLock::new(|| Integer::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(|| Integer::from(SECONDS_PER_DAY)); // 1 day +static MAX_OFFERING_DURATION: LazyLock = + LazyLock::new(|| Integer::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 +static CHIPNET_PLATFORM_FEE_NFTH: LazyLock> = LazyLock::new(|| { + hex::decode("ab3aba01311b672808aa06f5e7f332d930e4ed0c99407343f7fc5d743d15ff55").unwrap() +}); +// TODO:: set a mock nfthash for mainnet +static 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: Integer, + offeredTokenAmount: Integer, + offeredTokenTotalSupply: Integer, +} + +#[serde_as] +#[derive(Serialize, Deserialize, Clone)] +pub struct IdoParametersOfferingLaunchConditions { + #[serde_as(as = "IntegerAsStr")] + expiresAt: Integer, + #[serde_as(as = "IntegerAsStr")] + deployThreshold: Integer, + #[serde_as(as = "IntegerAsStr")] + immediateDeployThreshold: Integer, +} + +#[serde_as] +#[derive(Serialize, Deserialize, Clone)] +pub struct IdoParametersOfferingOffer { + #[serde_as(as = "IntegerAsStr")] + maxDiscountRateNumerator: Integer, + #[serde_as(as = "IntegerAsStr")] + discountAnnualRateNumerator: Integer, + #[serde_as(as = "IntegerAsStr")] + priceNumerator: Integer, + #[serde_as(as = "IntegerAsStr")] + minOffer: Integer, + #[serde_as(as = "Option")] + xTokenCategory: Option>, +} + +#[serde_as] +#[derive(Serialize, Deserialize, Clone)] +pub struct IdoParametersOffering { + #[serde_as(as = "IntegerAsStr")] + platformFeeNumerator: Integer, + #[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 = "IntegerAsStr")] + executionFee: Integer, +} + +#[serde_as] +#[derive(Serialize, Deserialize, Clone)] +pub struct IdoPreInitParameters { + preInitBcmr: IdoPreInitBcmrParameters, + #[serde_as(as = "serde_with::hex::Hex")] + authguardLockingBytecode: Vec, + #[serde_as(as = "IntegerAsStr")] + authguardNftOutputIndex: Integer, + #[serde_as(as = "IntegerAsStr")] + permanentLiquidityShareNumerator: Integer, + #[serde_as(as = "IntegerAsStr")] + offeredTokenTotalSupply: Integer, + #[serde_as(as = "IntegerAsStr")] + offeredTokenAmount: Integer, + 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 = "IntegerAsStr")] + permanentLiquidityShareNumerator: Integer, + #[serde_as(as = "IntegerAsStr")] + offeredTokenAmount: Integer, + 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 = "IntegerAsStr")] + nextTxSetterFlag: Integer, + #[serde_as(as = "IntegerAsStr")] + oTokenGenerated: Integer, + 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 = "IntegerAsStr")] + counter: Integer, + // 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 = "IntegerAsStr")] + totalDemandAmount: Integer, + #[serde_as(as = "IntegerAsStr")] + totalSupplyAmount: Integer, + #[serde_as(as = "IntegerAsStr")] + totalDiscount: Integer, +} + +#[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 = "IntegerAsStr")] + timestamp: Integer, + #[serde_as(as = "IntegerAsStr")] + counter: Integer, + #[serde_as(as = "IntegerAsStr")] + earnedAmount: Integer, + #[serde_as(as = "IntegerAsStr")] + refundAmount: Integer, + #[serde_as(as = "IntegerAsStr")] + discountAmount: Integer, + #[serde_as(as = "IntegerAsStr")] + platformEarnedAmount: Integer, +} + +#[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 = "IntegerAsStr")] + timestamp: Integer, + #[serde_as(as = "IntegerAsStr")] + earnedAmount: Integer, + #[serde_as(as = "IntegerAsStr")] + refundAmount: Integer, + #[serde_as(as = "IntegerAsStr")] + discountAmount: Integer, + #[serde_as(as = "IntegerAsStr")] + platformEarnedAmount: Integer, +} + +#[serde_as] +#[derive(Serialize, Deserialize, Clone)] +pub struct IdoPermanentPoolV0 { + #[serde_as(as = "IntegerAsStr")] + tokenAmount: Integer, + #[serde_as(as = "IntegerAsStr")] + satoshiAmount: Integer, +} + +#[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 = "IntegerAsStr")] + refundAmount: Integer, + #[serde_as(as = "IntegerAsStr")] + discountAmount: Integer, + #[serde_as(as = "IntegerAsStr")] + collectorEarnedAmount: Integer, + #[serde_as(as = "IntegerAsStr")] + platformEarnedAmount: Integer, +} + +#[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( + &Integer::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(&Integer::from(data.len()), 2).unwrap()); + let mut bytes = Builder::new() + .push_slice(pb(&encode_padded_vm_number( + &Integer::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: &Integer, + discountAnnualRate: &Integer, + price: &Integer, + minOffer: &Integer, + 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: &Integer, + delphiCategory: &[u8], + expiresAt: &Integer, + deployThreshold: &Integer, + immediateDeployThreshold: &Integer, +) -> 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 +} + +/// The seven outpoint indices consumed by the partial offering initiator +/// bytecode, pushed in the order the fields are declared. +struct OfferingInitiatorOutpointIndices<'a> { + extension: &'a Integer, + tokenStorage: &'a Integer, + offeringBcmrStorage: &'a Integer, + distRefund: &'a Integer, + distDeploy: &'a Integer, + launcherBytecode: &'a Integer, + offeringBytecode: &'a Integer, +} + +fn build_partial_offering_initiator_bytecode( + indices: &OfferingInitiatorOutpointIndices, + executionFee: &Integer, +) -> Vec { + let mut bytecode = Builder::new() + .push_slice(pb(&STORAGE_CONTRACT)) + .into_script() + .to_bytes(); + bytecode.extend_from_slice(&bigint_to_push_opcode(indices.extension)); + bytecode.extend_from_slice(&bigint_to_push_opcode(indices.tokenStorage)); + bytecode.extend_from_slice(&bigint_to_push_opcode(indices.offeringBcmrStorage)); + bytecode.extend_from_slice(&bigint_to_push_opcode(indices.distRefund)); + bytecode.extend_from_slice(&bigint_to_push_opcode(indices.distDeploy)); + bytecode.extend_from_slice(&bigint_to_push_opcode(indices.launcherBytecode)); + bytecode.extend_from_slice(&bigint_to_push_opcode(indices.offeringBytecode)); + 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: &Integer, + price: &Integer, +) -> 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: &Integer, + offeringInitiatorOutpointIndex: &Integer, +) -> 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() +} + +pub fn bigint_to_push_opcode(n: &Integer) -> Vec { + if *n > 0i64 && *n <= 16i64 { + vec![0x50_u8 + u8::try_from(n).unwrap()] + } else if *n == 0i64 { + vec![0x00_u8] + } else if *n == -1i64 { + vec![0x4f_u8] + } else { + let bytes = bigint_to_vm_number(n); + if bytes.len() <= 75 { + 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 if bytes.len() <= 65535 { + let mut r = vec![0x4d_u8]; + r.extend_from_slice(&(bytes.len() as u16).to_le_bytes()); + r.extend(bytes); + r + } else { + let mut r = vec![0x4e_u8]; + r.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + r.extend(bytes); + r + } + } +} + +pub fn encode_padded_vm_number(v: &Integer, 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]) -> Integer { + vm_number_to_bigint(bytes) +} + +pub fn vm_number_to_bigint(bytes: &[u8]) -> Integer { + if bytes.is_empty() { + return Integer::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 Integer + let magnitude = Integer::from( + Natural::from_power_of_2_digits_asc(8, data.iter().copied()) + .expect("u8 digits always fit base 2^8"), + ); + + if is_negative { + -magnitude + } else { + magnitude + } +} + +pub fn bigint_to_vm_number(n: &Integer) -> Vec { + let sign = n.sign(); + if sign == Ordering::Equal { + return vec![]; + } + let is_negative = sign == Ordering::Less; + + // Get the absolute magnitude in little-endian bytes + let mut bytes: Vec = n.unsigned_abs_ref().to_power_of_2_digits_asc(8); + + // 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 is_negative { + bytes.push(0x80); + } else { + bytes.push(0x00); + } + } else if is_negative { + // 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) { + // The IDO schema is append-only and block-keyed so a chain reorg can be + // undone by deleting every row introduced by the orphaned block (see + // delete_entries). `ido` holds only immutable identity; all state + // that advances with the txchain is versioned in `ido_state` (current state + // = MAX(seq) for an ido), and distribution facts live in `ido_distribution` + // instead of an in-place flag. + sqlx::query( + "CREATE TABLE ido ( + internal_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + preinit_txid BLOB NOT NULL UNIQUE, + 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 + )", + ) + .execute(pool) + .await + .expect("failed to create ido table"); + + // Append-only, block-keyed snapshot of everything that changes as the + // txchain advances. One row per state transition; the current state of an + // ido is the row with the greatest seq. Deleting rows by blockhash on a + // reorg automatically reverts the ido to its prior snapshot. + sqlx::query( + "CREATE TABLE ido_state ( + ido_id INTEGER NOT NULL REFERENCES ido(internal_id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + -- the tx that produced this state (preinit_txid for seq 0) + txid BLOB NOT NULL, + -- block that introduced this state; NULL while only seen in the + -- mempool, stamped with the real blockhash once it confirms. + blockhash BLOB NULL, + status VARCHAR(20) NOT NULL, + -- status: + -- - PREINIT + -- - ACTIVE + -- - DISTRIBUTING + -- - POSTLAUNCH + -- - DISTRIBUTED + parameters BLOB NOT NULL, + state BLOB NOT NULL, + init_txid BLOB NULL, + launch_txid BLOB NULL, + otoken_genesis_txid BLOB NULL, + offering_token_id BLOB NULL, + offered_token_id BLOB NULL, + is_valid INTEGER NOT NULL, + -- Unix timestamp (seconds) the IDO was launched, taken from the + -- Delphi NFT in output#3 of the launch transaction. NULL until the + -- IDO is launched (launch_txid set). + launched_at INTEGER NULL, + -- The head of the txchain at this state is this row's own `txid`. + -- The output index of `txid` that the next tx in the chain spends to + -- continue, or NULL for a terminal state (no continuation). A new tx + -- spending (txid, next_output_index) extends this ido + next_output_index INTEGER NULL, + PRIMARY KEY (ido_id, seq) + )", + ) + .execute(pool) + .await + .expect("failed to create ido_state table"); + + sqlx::query("CREATE INDEX idx_ido_state_blockhash ON ido_state(blockhash)") + .execute(pool) + .await + .expect("failed to create index ido_state(blockhash)"); + + sqlx::query("CREATE INDEX idx_ido_state_offering_token_id ON ido_state(offering_token_id)") + .execute(pool) + .await + .expect("failed to create index ido_state(offering_token_id)"); + + // Serves chain-following: find the state a new tx extends by matching the + // spent output against (txid, next_output_index). Also covers txid-only + // lookups (has_indexed_tx, blockhash stamping) as a prefix. + sqlx::query("CREATE INDEX idx_ido_state_next ON ido_state(txid, next_output_index)") + .execute(pool) + .await + .expect("failed to create index ido_state(txid, next_output_index)"); + + // One row per purchase, written once when first seen and keyed by the block + // that introduced it so a reorg can delete it. Whether an entry has been + // distributed is tracked separately in ido_distribution. + sqlx::query( + "CREATE TABLE ido_entry ( + ido_id INTEGER NOT NULL REFERENCES ido(internal_id) ON DELETE CASCADE, + txid BLOB NOT NULL, + blockhash BLOB NULL, + owner_nfthash BLOB NOT NULL, + commitment BLOB, + supply_amount INTEGER NOT NULL, + demand_amount INTEGER NOT NULL, + lockup_timeval INTEGER NOT NULL, + discount INTEGER NOT NULL, + PRIMARY KEY (ido_id, txid) + )", + ) + .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 INDEX idx_ido_entry_blockhash ON ido_entry(blockhash)") + .execute(pool) + .await + .expect("failed to create index ido_entry(blockhash)"); + + // Block-keyed distribution facts: an entry counts as distributed iff a row + // exists here for it. Deleting by blockhash on a reorg un-distributes the + // affected purchases without touching the entries themselves. + sqlx::query( + "CREATE TABLE ido_distribution ( + ido_id INTEGER NOT NULL REFERENCES ido(internal_id) ON DELETE CASCADE, + entry_txid BLOB NOT NULL, + txid BLOB NOT NULL, + blockhash BLOB NULL, + PRIMARY KEY (ido_id, entry_txid) + )", + ) + .execute(pool) + .await + .expect("failed to create ido_distribution table"); + + sqlx::query("CREATE INDEX idx_ido_distribution_blockhash ON ido_distribution(blockhash)") + .execute(pool) + .await + .expect("failed to create index ido_distribution(blockhash)"); +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct IdoDBRecord { + 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, + // txid of the head tx of the txchain at the current state + txchain_head: Option>, + is_valid: bool, + is_token_created_at_preinit: bool, + created_at: i64, + 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_head: row.get(10), + is_valid: row.get(11), + is_token_created_at_preinit: row.get(12), + created_at: row.get(13), + launched_at: row.get(14), + }) + } +} + +// SELECT clause that reconstructs an IdoDBRecord by joining an ido to one of +// its ido_state rows (aliased `s`). Column order matches IdoDBRecord::from_row. +// The state's own `txid` is the head of the txchain at that state, so it is +// selected as txchain_head. Append a FROM/JOIN and filter as needed. +const IDO_RECORD_SELECT: &str = + "SELECT ido.internal_id, ido.preinit_txid, s.init_txid, s.launch_txid, s.otoken_genesis_txid, \ + s.offering_token_id, s.offered_token_id, s.status, s.parameters, s.state, \ + s.txid, s.is_valid, ido.is_token_created_at_preinit, ido.created_at, s.launched_at"; + +// JOIN that binds `s` to the current (max-seq) ido_state row for each ido. +const IDO_CURRENT_STATE_JOIN: &str = " FROM ido JOIN ido_state s ON s.ido_id = ido.internal_id \ + AND s.seq = (SELECT MAX(seq) FROM ido_state WHERE ido_id = ido.internal_id)"; + +/// Find the ido_state whose tx output `(txid, vout)` a new tx is spending — +/// i.e. the state that the new tx extends. Returns it as an IdoDBRecord (a full +/// snapshot of the ido at that state), or None if no chain continues from there. +/// This replaces the old tracker-map lookup; `ido_state.next_output_index` +/// records which output of each state's tx continues the chain. +async fn lookup_state_by_next_output( + pool: &SqlitePool, + txid: &Txid, + vout: u32, +) -> Result> { + let sql = format!( + "{IDO_RECORD_SELECT} FROM ido_state s JOIN ido ON ido.internal_id = s.ido_id \ + WHERE s.txid = ? AND s.next_output_index = ?" + ); + let row = sqlx::query(&sql) + .bind(txid.to_blob()) + .bind(vout as i64) + .fetch_optional(pool) + .await?; + row.map(|r| IdoDBRecord::from_row(&r)).transpose() +} + +/// Whether any ido_state row already records this tx (the tx has been indexed). +pub async fn has_indexed_tx(pool: &SqlitePool, txid: &[u8]) -> Result { + let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM ido_state WHERE txid = ?)") + .bind(txid) + .fetch_one(pool) + .await?; + Ok(exists) +} + +// 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.is_empty() || (bcmr_data[0] == 0 && bcmr_data.len() == 1) { + return Ok(None); + } + if bcmr_data.is_empty() + || !(bcmr_data[0] > 0 && bcmr_data[0] < 75) + || bcmr_data[0] as usize >= bcmr_data.len() + { + return Err(anyhow::anyhow!("bcmr needs metadata!")); + } + let metadata_size = bcmr_data[0] as usize; + 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 Some(annOut) = tx.output.last() else { + errors.push(anyhow::anyhow!("announcement output is missing")); + return None; + }; + { + let instructions: Vec<_> = annOut.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)) => 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 < Integer::from(0) + let offered_token_is_in_supply = vm_number_to_bigint(&data[170..178]) < 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, + ); + match oTokenBcmrResult { + Ok(value) => { + oTokenBcmr = value; + } + Err(err) => { + errors.push(anyhow::anyhow!("oToken bcmr deserialize failed, {}", err)); + return None; + } + } + } + + let preinit_parameters = IdoPreInitParameters { + preInitBcmr: IdoPreInitBcmrParameters { + offering: offeringBcmr, + oToken: oTokenBcmr, + }, + 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]) == 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: Integer = &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 < 0 { + invalid_ido_reasons.push(anyhow::anyhow!("preinit_parameters.offering.offer.maxDiscountRateNumerator < Integer::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 <= 0 { + invalid_ido_reasons.push(anyhow::anyhow!( + "preinit_parameters.offeredTokenAmount <= Integer::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 + - Integer::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; + } + } + + Some(IdoPreinitParseParamsResult { + preinit_parameters, + offered_token_is_in_supply, + is_valid_ido, + created_at, + }) + } + _ => { + errors.push(anyhow::anyhow!("invalid announcement (2)")); + 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, + Some(Network::Bitcoin) => *MAINNET_DELPHI_TOKEN_ID, + _ => *MAINNET_DELPHI_TOKEN_ID, + }; + 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 >= 0 { + match usize::try_from(¶ms.authguardNftOutputIndex).ok() { + 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 let Some(token) = + authguardNftOut.and_then(|out| out.token.as_ref()) + { + token.id.to_blob().iter().copied().rev().collect() + } else { + vec![0; 32] + }, + idoCategory: vec![0; 32], + nextTxSetterFlag: Integer::from(0), + oTokenGenerated: if offered_token_is_in_supply { + Integer::from(1) + } else { + Integer::from(0) + }, + initiatorCreated: false, + }; + if let Some(preInitOut) = tx.output.get(1) { + 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.script_pubkey { + errors.push(anyhow::anyhow!( + "preInit output bytecode does not match, expected bytecode: {}", + hex::encode(bytecode) + )); + } + } else { + errors.push(anyhow::anyhow!("preinit output is not defined!")); + } + + if let Some(offeringBytecodeStorageOut) = tx.output.get(2) { + 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.script_pubkey { + errors.push(anyhow::anyhow!( + "offering bytecode storage does not match, expected bytecode: {}", + hex::encode(bytecode) + )); + } + } else { + errors.push(anyhow::anyhow!("offering bytecode storage not defined!")); + } + + if let Some(launcherBytecodeStorageOut) = tx.output.get(3) { + let bytecode = build_storage_script_with_data_and_size( + 1, + &build_launcher_bytecode( + &[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.script_pubkey { + errors.push(anyhow::anyhow!( + "launcher bytecode storage does not match, expected bytecode: {}", + hex::encode(bytecode) + )); + } + } else { + errors.push(anyhow::anyhow!("launcher bytecode storage not defined!")); + } + + if let Some(distDeployBytecodeStorageOut) = tx.output.get(4) { + let bytecode = + build_storage_script_with_data_and_size(1, &DISTRIBUTOR_DEPLOY_CONTRACT).to_bytes(); + if build_p2sh32_script(&bytecode) != distDeployBytecodeStorageOut.script_pubkey { + errors.push(anyhow::anyhow!( + "dist deploy bytecode storage does not match, expected bytecode: {}", + hex::encode(bytecode) + )); + } + } else { + errors.push(anyhow::anyhow!("dist deploy bytecode storage not defined!")); + } + + if let Some(distRefundBytecodeStorageOut) = tx.output.get(5) { + let bytecode = + build_storage_script_with_data_and_size(1, &DISTRIBUTOR_REFUND_CONTRACT).to_bytes(); + if build_p2sh32_script(&bytecode) != distRefundBytecodeStorageOut.script_pubkey { + errors.push(anyhow::anyhow!( + "dist refund bytecode storage does not match, expected bytecode: {}", + hex::encode(bytecode) + )); + } + } else { + errors.push(anyhow::anyhow!("dist refund bytecode storage not defined!")); + } + + if let Some(offeringInitiatorBytecodeStorageOut) = tx.output.get(6) { + let bytecode = build_storage_script_with_data_and_size( + 1, + &build_partial_offering_initiator_bytecode( + &OfferingInitiatorOutpointIndices { + extension: &Integer::from(7i64), + tokenStorage: &Integer::from(6i64), + offeringBcmrStorage: &Integer::from(5i64), + distRefund: &Integer::from(4i64), + distDeploy: &Integer::from(3i64), + launcherBytecode: &Integer::from(2i64), + offeringBytecode: &Integer::from(1i64), + }, + ¶ms.offering.executionFee, + ), + ) + .to_bytes(); + if build_p2sh32_script(&bytecode) != offeringInitiatorBytecodeStorageOut.script_pubkey { + errors.push(anyhow::anyhow!( + "offering initiator bytecode storage does not match, expected bytecode: {}", + hex::encode(bytecode) + )); + } + } else { + errors.push(anyhow::anyhow!( + "offering initiator bytecode storage not defined!" + )); + } + + if let Some(idoInitiatorBytecodeStorageOut) = tx.output.get(7) { + 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, + ), + &Integer::from(8i64), // permanentPoolOTokenReserveOutpointIndex + &Integer::from(0i64), // offeringInitiatorOutpointIndex + ), + ) + .to_bytes(); + if build_p2sh32_script(&bytecode) != idoInitiatorBytecodeStorageOut.script_pubkey { + errors.push(anyhow::anyhow!( + "ido initiator bytecode storage does not match, expected bytecode: {}", + hex::encode(bytecode) + )); + } + } else { + errors.push(anyhow::anyhow!( + "ido initiator bytecode storage not defined!" + )); + } + + if let Some(offeringBcmrStorageOut) = tx.output.get(8) { + let bytecode = build_storage_script_with_data_and_size( + 1u32, + &serialize_ipfs_bcmr_with_placeholder(¶ms.preInitBcmr.offering), + ); + if bytecode != offeringBcmrStorageOut.script_pubkey { + errors.push(anyhow::anyhow!( + "offering bcmr storage locking bytecode does not match, expected bytecode: {}", + hex::encode(bytecode) + )); + } + } else { + errors.push(anyhow::anyhow!("offered token bcmr storage not found!")); + } + + let mut offered_token_id: Option> = None; + + if offered_token_is_in_supply { + // verify offered token storage + if let Some(offeredTokenStorageOut) = tx.output.get(9) { + let permanentLiquidityOTokenReserve: Integer = ¶ms.offeredTokenAmount + * ¶ms.permanentLiquidityShareNumerator + / &PERMANENT_LIQUIDITY_SHARE_DENOMINATOR.clone(); + let requiredTokens = ¶ms.offeredTokenAmount + permanentLiquidityOTokenReserve; + if let Some(token) = offeredTokenStorageOut.token.as_ref() { + let bytecode = build_storage_script(1u32).to_bytes(); + if build_p2sh32_script(&bytecode) != offeredTokenStorageOut.script_pubkey { + errors.push(anyhow::anyhow!( + "offered token locking bytecode does not match, expected bytecode: {}", + hex::encode(bytecode) + )); + } + let token_amount = Integer::from(token.amount); + if token_amount < requiredTokens { + errors.push(anyhow::anyhow!("invalid offered token amount!")); + } + offered_token_id = Some(token.id.to_blob()); + } else { + errors.push(anyhow::anyhow!( + "offered token storage does not contain a token!" + )); + } + } else { + errors.push(anyhow::anyhow!("offered token supply not found!")); + } + } else if let Some(oTokenBcmrStorageOut) = tx.output.get(9) { + let bytecode = build_storage_script_with_data_and_size( + 1u32, + &serialize_ipfs_bcmr_with_placeholder(¶ms.preInitBcmr.oToken), + ); + if bytecode != oTokenBcmrStorageOut.script_pubkey { + errors.push(anyhow::anyhow!("offered token bcmr storage locking bytecode does not match, expected bytecode: {}", hex::encode(bytecode))); + } + } else { + errors.push(anyhow::anyhow!("offered token bcmr storage not found!")); + } + + // 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 *IDO_CREATE_EXECUTION_FEE > preinit_paid_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.is_empty() { + 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, + blockhash: Option<&BlockHash>, +) -> 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.is_empty() { + 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.is_empty() { + 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 (immutable identity only) + let mut dbtx = pool.begin().await?; + let result = sqlx::query( + "INSERT INTO ido (preinit_txid, is_token_created_at_preinit, created_at) + VALUES (?, ?, ?)", + ) + .bind(&context.preinit_txid) + .bind(context.is_token_created_at_preinit) + .bind(context.created_at) + .execute(&mut *dbtx) + .await; + match result { + Ok(r) => { + let internal_id = r.last_insert_rowid(); + // seq-0 state snapshot, keyed by the preinit tx / block. Its + // txid (the preinit) is the head of the chain at this state, + // and output#1 is where the next tx continues the chain. + append_ido_state( + &mut dbtx, + internal_id, + &context, + &context.preinit_txid, + 1, + blockhash, + ) + .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 != 0 + && state.nextTxSetterFlag == 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, +} + +#[derive(Clone)] +enum IdoUpdate { + InitTxId(Vec), + LaunchTxId { + txid: Vec, + launched_at: Option, + }, + OTokenGenesisTxId(Vec), + Status(String), + State(IdoState), + Parameters(Box), + OfferedTokenId(Vec), + OfferingTokenId(Vec), + Entry(IdoUpdateEntry), + EntryDistributed { + entry_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 { entry_txid } => { + let v = rev_blob(entry_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.is_empty() { + 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) => value, + _ => return Err(anyhow::anyhow!("expecting PreInit parameters")), + }; + updates.push(IdoUpdate::Parameters(Box::new(IdoParameters::Active( + IdoActiveParameters { + preInitBcmr: preinit_params.preInitBcmr.clone(), + 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: Integer::from(0), + totalDemandAmount: Integer::from(0), + totalSupplyAmount: Integer::from(0), + totalDiscount: Integer::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); + Ok(IdoAddResult { + 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 == 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 == 0 { + next_state.oTokenGenerated = Integer::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 = Integer::from(0i64); + } else { + next_state.nextTxSetterFlag = Integer::from(1i64); + } + } + updates.push(IdoUpdate::State(IdoState::PreInit(next_state))); + print_updates(&updates); + Ok(IdoAddResult { + updates, + next_output_index: if ready { 0 } else { 1 }, + }) + } + } else { + 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 + .first() + .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.is_empty() { + 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 + .first() + .ok_or_else(|| anyhow::anyhow!("Should have first input!"))?; + let instructions: Vec<_> = first_input.script_sig.instructions().collect(); + if instructions.is_empty() { + 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 = u64::try_from(&decode_padded_vm_number( + &second_output.token.as_ref().unwrap().commitment[7..15], + )) + .unwrap_or(0); + updates.push(IdoUpdate::Entry(IdoUpdateEntry { + txid: tx.compute_txid().to_blob(), + owner_nfthash, + supply_amount, + demand_amount, + lockup_timeval: u64::try_from(&decode_padded_vm_number( + &second_output.token.as_ref().unwrap().commitment[1..7], + )) + .unwrap_or(0), + discount, + commitment: second_output.token.as_ref().unwrap().commitment.clone(), + })); + 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 + + Integer::from(demand_amount), + totalSupplyAmount: &active_state.totalSupplyAmount + + Integer::from(supply_amount), + totalDiscount: &active_state.totalDiscount + Integer::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: Integer::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: Integer::from(0), + }))); + } else { + return Err(anyhow::anyhow!("output#0 is not recognized!")); + } + } else { + return Err(anyhow::anyhow!("expecting PreInitState!")); + } + print_updates(&updates); + Ok(IdoAddResult { + 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")), + } + // input#1 + let second_input = tx + .input + .get(1) + .ok_or_else(|| anyhow::anyhow!("Should have the second input!"))?; + // output#0 should contain a token with offering_token_id + let first_output = tx + .output + .first() + .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) => 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.is_empty() { + 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 + + Integer::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 + + Integer::from(platform_fee_output.value.to_sat()), + }))); + } else { + return Err(anyhow::anyhow!("output#0 is not recognized!")); + } + updates.push(IdoUpdate::EntryDistributed { + entry_txid: second_input.previous_output.txid.to_blob(), + }); + print_updates(&updates); + Ok(IdoAddResult { + updates, + next_output_index: 0, + }) + } + "POSTLAUNCH" => { + let pp_output = tx + .output + .first() + .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) => 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: Integer::from( + pp_output + .token + .as_ref() + .ok_or_else(|| anyhow::anyhow!("pp should have tokens"))? + .amount, + ), + satoshiAmount: Integer::from(pp_output.value.to_sat()), + }) + }, + refundAmount: Integer::from( + collector_p2nfth + .token + .as_ref() + .ok_or_else(|| anyhow::anyhow!("collector_p2nfth should have tokens"))? + .amount, + ), + discountAmount: prev_state.discountAmount.clone(), + collectorEarnedAmount: Integer::from(collector_p2nfth.value.to_sat()), + platformEarnedAmount: prev_state.platformEarnedAmount.clone(), + }, + ))); + print_updates(&updates); + Ok(IdoAddResult { + updates, + next_output_index: -1, + }) + } + _ => Err(anyhow::anyhow!( + "An ido with an unknown status: {}", + context.status + )), + } +} + +/// Append a new versioned snapshot of an ido's full mutable state. The current +/// state of an ido is always the row with the greatest seq; this never mutates +/// an earlier row, so deleting this block's rows on a reorg reverts the ido to +/// its previous snapshot. `txid` is the tx that produced this state (the preinit +/// txid for the seq-0 row) and is also the head of the txchain at this state; +/// `next_output_index` is the output of `txid` the next tx spends to continue +/// the chain (negative meaning no continuation, stored as NULL); `blockhash` is +/// None while only seen in the mempool. +async fn append_ido_state( + dbtx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + internal_id: i64, + context: &IdoContext, + txid: &[u8], + next_output_index: i32, + blockhash: Option<&BlockHash>, +) -> Result<()> { + let seq: i64 = + sqlx::query_scalar("SELECT IFNULL(MAX(seq), -1) + 1 FROM ido_state WHERE ido_id = ?") + .bind(internal_id) + .fetch_one(&mut **dbtx) + .await?; + let next_output_index = (next_output_index >= 0).then_some(next_output_index as i64); + sqlx::query( + "INSERT INTO ido_state (ido_id, seq, txid, blockhash, status, parameters, state, \ + init_txid, launch_txid, otoken_genesis_txid, offering_token_id, offered_token_id, \ + is_valid, launched_at, next_output_index) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(internal_id) + .bind(seq) + .bind(txid) + .bind(blockhash.map(|h| h.to_blob())) + .bind(&context.status) + .bind(serde_json::to_vec(&context.parameters).unwrap()) + .bind(serde_json::to_vec(&context.state).unwrap()) + .bind(&context.init_txid) + .bind(&context.launch_txid) + .bind(&context.otoken_genesis_txid) + .bind(&context.offering_token_id) + .bind(&context.offered_token_id) + .bind(context.is_valid_ido) + .bind(context.launched_at) + .bind(next_output_index) + .execute(&mut **dbtx) + .await?; + Ok(()) +} + +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()); + } + _ => {} + } + } +} + +/// Persist the entry and distribution facts carried by `updates`. +/// +/// Entries are written once and keyed by the block that introduced them +/// (ON CONFLICT DO NOTHING, so replaying a chain that already has them is a +/// no-op and their original blockhash is preserved). Distribution is recorded +/// as a row in ido_distribution rather than mutating the entry in place, so a +/// reorg that drops `blockhash` un-distributes the purchase. `tx_txid` is the +/// txid of the tx currently being indexed (the distributing tx), and +/// `blockhash` is None while the tx is only seen in the mempool. +async fn upsert_updates_to_ido_entries( + dbtx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + internal_id: i64, + updates: &[IdoUpdate], + tx_txid: &[u8], + blockhash: Option<&BlockHash>, +) -> Result<()> { + let blockhash_blob = blockhash.map(|h| h.to_blob()); + for update in updates { + match update { + IdoUpdate::Entry(entry) => { + sqlx::query( + "INSERT INTO ido_entry (ido_id, txid, blockhash, owner_nfthash, commitment, supply_amount, demand_amount, lockup_timeval, discount) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(ido_id, txid) DO NOTHING", + ) + .bind(internal_id) + .bind(&entry.txid) + .bind(&blockhash_blob) + .bind(&entry.owner_nfthash) + .bind(&entry.commitment) + .bind(entry.supply_amount as i64) + .bind(entry.demand_amount as i64) + .bind(entry.lockup_timeval as i64) + .bind(entry.discount as i64) + .execute(&mut **dbtx) + .await?; + } + IdoUpdate::EntryDistributed { entry_txid } => { + sqlx::query( + "INSERT INTO ido_distribution (ido_id, entry_txid, txid, blockhash) + VALUES (?, ?, ?, ?) + ON CONFLICT(ido_id, entry_txid) DO NOTHING", + ) + .bind(internal_id) + .bind(entry_txid) + .bind(tx_txid) + .bind(&blockhash_blob) + .execute(&mut **dbtx) + .await?; + } + _ => {} + } + } + Ok(()) +} + +/// Stamp the real blockhash onto rows that were first indexed from the mempool +/// (blockhash NULL) for a tx that has now confirmed in a block. Without this the +/// rows would stay unkeyed and a reorg delete (which matches on blockhash) could +/// never remove them. +async fn stamp_block_for_tx( + dbtx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + tx_txid: &[u8], + blockhash: &BlockHash, +) -> Result<()> { + let bh = blockhash.to_blob(); + for sql in [ + "UPDATE ido_state SET blockhash = ? WHERE txid = ? AND blockhash IS NULL", + "UPDATE ido_entry SET blockhash = ? WHERE txid = ? AND blockhash IS NULL", + "UPDATE ido_distribution SET blockhash = ? WHERE txid = ? AND blockhash IS NULL", + ] { + sqlx::query(sql) + .bind(&bh) + .bind(tx_txid) + .execute(&mut **dbtx) + .await?; + } + Ok(()) +} + +/// Apply `tx` to the ido whose chain it extends. `prev` is the state snapshot +/// that `tx` continues (found by lookup_state_by_next_output). Because every +/// past state is kept in ido_state, the snapshot is read directly from `prev` — +/// no replay of the chain from the preinit is needed. +async fn on_add_ido_tx( + pool: &SqlitePool, + prev: &IdoDBRecord, + tx: &Transaction, + blockhash: Option<&BlockHash>, +) -> Result<()> { + let tx_txid = tx.compute_txid().to_blob(); + debug!( + "IDO on_add_ido_tx: {}", + blob_to_display_hex::(&tx_txid)? + ); + let mut dbtx = pool.begin().await?; + // If this tx already produced a state, it has been indexed before (e.g. seen + // in the mempool and now confirming). A tx is deterministic, so don't append + // a duplicate snapshot — just stamp the confirming blockhash onto its rows + // so a reorg can undo them. + if has_indexed_tx(pool, &tx_txid).await? { + if let Some(blockhash) = blockhash { + stamp_block_for_tx(&mut dbtx, &tx_txid, blockhash).await?; + } + dbtx.commit().await?; + return Ok(()); + } + + // Build the context from the prior state snapshot and apply the tx. The new + // snapshot is appended at the next seq, becoming the current head — even if + // `prev` was not the previous head (a fork), the latest-seen tx wins, which + // matches the prior rebuild behaviour. + let parameters: IdoParameters = serde_json::from_slice(&prev.parameters) + .map_err(|e| anyhow::anyhow!("Failed to parse parameters: {e}"))?; + let state: IdoState = serde_json::from_slice(&prev.state) + .map_err(|e| anyhow::anyhow!("Failed to parse state: {e}"))?; + let mut context: IdoContext = IdoContext { + preinit_txid: prev.preinit_txid.clone(), + init_txid: prev.init_txid.clone(), + launch_txid: prev.launch_txid.clone(), + otoken_genesis_txid: prev.otoken_genesis_txid.clone(), + status: prev.status.clone(), + parameters, + state, + is_valid_ido: prev.is_valid, + is_token_created_at_preinit: prev.is_token_created_at_preinit, + offered_token_id: prev.offered_token_id.clone(), + offering_token_id: prev.offering_token_id.clone(), + // prev.txchain_head is the prev state's own txid (the head being extended) + head_txid: prev.txchain_head.clone().unwrap_or_default(), + created_at: prev.created_at, + launched_at: prev.launched_at, + }; + let result = ido_add_tx(&context, tx)?; + context.head_txid = tx_txid.clone(); + apply_updates_to_context(&mut context, &result.updates); + append_ido_state( + &mut dbtx, + prev.internal_id, + &context, + &tx_txid, + result.next_output_index, + blockhash, + ) + .await?; + upsert_updates_to_ido_entries( + &mut dbtx, + prev.internal_id, + &result.updates, + &tx_txid, + blockhash, + ) + .await?; + dbtx.commit().await?; + Ok(()) +} + +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() && has_script_ido_signature(&input.unwrap().script_sig) { + return true; + } + } + false +} + +/// Index a batch of dependency-ordered txs into the IDO state. +/// +/// Pass `Some(blockhash)` when indexing a confirmed block; pass `None` when +/// indexing the mempool — mempool txs have no block yet, so their rows are +/// stored with a NULL blockhash and stamped once the tx confirms (see +/// stamp_block_for_tx). +pub async fn index_txs( + network: Option, + pool: &SqlitePool, + sorted_txs: &[Transaction], + blockhash: Option<&BlockHash>, +) -> 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); when confirming + // in a block, stamp the blockhash onto its mempool-keyed rows. + if let Some(blockhash) = blockhash { + let mut dbtx = pool.begin().await?; + stamp_block_for_tx(&mut dbtx, &tx.compute_txid().to_blob(), blockhash).await?; + dbtx.commit().await?; + } + continue; + } + if let Err(err) = on_create_ido(network, pool, tx, blockhash).await { + 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: find the ido state this tx extends by matching one of + // its inputs against some state's (txid, next_output_index). + for input_index in [0, 1, 3] { + if let Some(input) = tx.input.get(input_index) { + if let Some(prev) = lookup_state_by_next_output( + pool, + &input.previous_output.txid, + input.previous_output.vout, + ) + .await? + { + if let Err(err) = on_add_ido_tx(pool, &prev, tx, blockhash).await { + info!( + "add tx to an ido failed, txid: {}, {}", + hex::encode(tx.compute_txid().to_blob()), + err + ) + } + break; + } + } + } + } + } + Ok(()) +} + +/// Delete IDO rows for a single block, or all mempool rows. +/// +/// Pass `Some(blockhash)` to undo a confirmed block on reorg: the rows that +/// block wrote are removed and, because the current state of an ido is always +/// MAX(seq), the previous (still-present) snapshot becomes current again. +/// +/// Pass `None` to drop everything indexed from the mempool (blockhash IS NULL) +/// before applying confirmed blocks. A mempool tx that never confirmed — e.g. +/// one replaced by a different on-chain tx — would otherwise leave a stale +/// snapshot that can win MAX(seq) or resurface after a reorg deletes the +/// confirmed snapshot above it; confirmed blocks re-create rows for the txs they +/// contain and the next mempool pass rebuilds the rest, so nothing is lost. +/// +/// In both cases an ido left with no state snapshot is removed entirely (so a +/// later confirmation re-creates it via on_create_ido); ON DELETE CASCADE clears +/// any leftover children. Returns the number of state snapshots removed. +/// +/// The selector uses SQLite's `IS` operator so a single query handles both: it +/// behaves like `=` against a bound blob and matches NULL rows when bound to +/// NULL. +pub async fn delete_entries(pool: &SqlitePool, blockhash: Option<&BlockHash>) -> Result { + let bh: Option> = blockhash.map(|h| h.to_blob()); + let mut dbtx = pool.begin().await?; + sqlx::query("DELETE FROM ido_distribution WHERE blockhash IS ?") + .bind(bh.as_deref()) + .execute(&mut *dbtx) + .await?; + sqlx::query("DELETE FROM ido_entry WHERE blockhash IS ?") + .bind(bh.as_deref()) + .execute(&mut *dbtx) + .await?; + let removed = sqlx::query("DELETE FROM ido_state WHERE blockhash IS ?") + .bind(bh.as_deref()) + .execute(&mut *dbtx) + .await? + .rows_affected() as usize; + sqlx::query("DELETE FROM ido WHERE internal_id NOT IN (SELECT DISTINCT ido_id FROM ido_state)") + .execute(&mut *dbtx) + .await?; + dbtx.commit().await?; + Ok(removed) +} + +// ─── Public RPC types ──────────────────────────────────────────────────────── + +#[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, +} + +impl IdoDBRecord { + /// Build the public RPC record. `txchain_head` is the head txid of the current + /// state (the current state row's own txid). + fn into_rpc_record(self) -> Result { + let preinit_txid_hex = blob_to_display_hex::(&self.preinit_txid)?; + Ok(IdoRpcRecord { + id: preinit_txid_hex.clone(), + 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: self + .txchain_head + .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("02000000036854132bb537f062a312c2c6a393cee5377461bcf8b3cdcdb13c077331714fa200000000fd1101514d0d012082fa7e456ada87ddd4ff6195ca1284d8ab09e5b55caf73ddfb7dca8faf9620e620b757276de32bb650f7969569f685b1616473f5d02b75655c26c030facc190a4e209110b4023c0864f684eda7f6e84ae8016a3d3ced6c2240274d9a8aa02fc432235379009c6300ce01207f7588c0d276827760a269c0cf78587f77547f758178587f77547f7581a0697c567f75817c567f7581a069c0ccc0c6a269c0cdc0c788c0d1c0ce87777777675379519c63c0cf567f77527f75817600a269016495c0ccc0c67b93a269c0cdc0c788c0d1c0ce88c0d2c0cf886d6d51675379529c6300ce01207f757b88c0cdc0c788c0d1c0ce88c0d2c0cf8777777767537a539d00ce01207f75537a877777686868ffffffff72418c9330df09f3d3300afb3caa859440009d16df550e04f1a051e32a9199900a00000064415b48f4d468f412023977f57339b7ca91371c4deb2e3355f53dd84a89bb3c02385b3ee4186e6b2d12851ec5ebfe6f203212a944cb7134b3221d451c5f8d559881612103372aa86dfa2f302c04fc5d386e18209113c08f573256af832cac9ba2014a9b56000000004cc25d2af4d177ffabe85803824d97be7d974d602b16e232cd1185bd68a7cfac0200000064418afb3a8bbab1912e06245d9ce73b9e33aafcf54423cf410c06620e732e8fb161a8f674b409433ef665625a5c23de2417984f2ebe7128d6048d625cd43079af17612102c7c744133f5fedf684e9908fa52cfdcfd0ce9f155b609878906a7877403c4987000000000ee80300000000000056ef9a66f2918e9a845e263d71d126949ca0ef35476eb13d382482aacfc94082941b6110ec5a316a0000000075131a0064540000aa206024ca62d059de8235ec20424a8ac4d0b36845a7c373bc480004d3758092177087e80300000000000023aa203733248829afd52aa30112205114210af9f8e64001188273abcfab1ddf95b07887e80300000000000023aa20a8e6aed82ff79e824aa86d14cc469704bda8e8c20057ff06d4948004e6ade0d287e80300000000000023aa20de0a3c5fe9a1d0fea9ca1d0e82628ab14c61214d17d3f9df4b175fec46a52b5e87e80300000000000023aa20f69ff67df71b9ecd4b6b39af393551468bb2a46fb261e2e5a61cfb27dbcbb25087e80300000000000023aa20e5b1d9865bf78f7cfc02e60ba0bcf327e0d3949f455a37dd8870979c3fc0f42d87e80300000000000023aa208b3c520007a78d00dcebfd2e8bcda6020ed7f7ed7d5f0d687ac325b0fc3e309187e80300000000000023aa20beafa293c3d712876b389da252b1f7f584a8f8b10da2e526e38a34856f1c15e487b0040000000000000f0201008178c99dc8c0c88702000075b004000000000000c50201008178c99dc8c0c8874cb70c0602c400024a0104011701506a0442434d5220314e518b4207e9252edc443abea7dede08a93165975a451fd802a847aee86f1c4768747470733a2f2f7733732e6c696e6b2f697066732f75415655534944464f55597443422d6b6c4c7478454f72366e337434497154466c6c31704648396743714565753647386338697066733a2f2f75415655534944464f55597443422d6b6c4c7478454f72366e337434497154466c6c317046483967437145657536473863b50075e8030000000000003defbfa57ca201b401db5496995a69a333e9354e8782fbf176eabec3f891a29aa94760010076a9145aba2374503e6596392c0bdf3902a65fffee561c88ace09304000000000023aa20629ccbca71e4c6b24e02bcad1ad2f8136a904b55526e83d5b2612c89b6a49d8187ab527a00000000001976a914e8e38c5bbdcd2488800ed9fa7af27981c4846b8888ac0000000000000000bf6a4cbb4361756c64726f6e49646f30ab3aba01311b672808aa06f5e7f332d930e4ed0c99407343f7fc5d743d15ff55809698001b948240c9cfaa8224383db16e4735efa09c9426d1713d265e849a8e91f2669a6cac326a00001c480000000000001c48000000000000002d3101404b4c009f06241f7e00000000000000000000001c480000000000000000000000000000000000000000000000000000000000000000000050c3000080c3c90100e40b5402000000800fd92d000000000a0000000000").unwrap() + }); + + static PREINIT_STEP_TEST_TX01: LazyLock> = LazyLock::new(|| { + hex::decode("020000000a4416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a402000000fddb01514dd7010201008178c99dc8c0c8874dc801124361756c64726f6e49646f2d32303236513275088178c99dc8c0c88736827701209dc0519dc0ce01207f7500ce01207f758800cf517f755f845288c0cf517f7501208401008763c0c878c88876c9529d68755104002d310103404b4c059f06241f7e021c4800c0ce01207f75c0519c637600ce8800cf517f755f845188675879827701209dc0009d00d000d394765479a269765579950500e876481796005c7900a063577900a069587900a0695c79587995048033e1015a7995a169785d7995587995048033e1010400e1f5059596776e947b757c7800a0696854790087535e7900a06376608577687863760120857768005f7900a0635f795680547958807e77686e7e51d28851d15779517e8851d356799d02aa20012060797e5e797eaa7e01877e51cd8852796351cc5579a26952d100876452d101207f75577987916968c4539d6752d158798852d35579a26902aa20525152807e5f797eaa7e01877e52cd8853d100876453d101207f7557798791696854d100876454d101207f75577987916968c4559d6800cf517f77547f758100cc00c6527993a26900cd00c78800cf557f77547f758100cf557f75788b54807e00d28800ce00d1886d6d6d6d686d6d6d6d6d51c60175000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a401000000fd0a094d0709124361756c64726f6e49646f2d32303236513275000020000000000000000000000000000000000000000000000000000000000000000020bfa57ca201b401db5496995a69a333e9354e8782fbf176eabec3f891a29aa94700004cb520314e518b4207e9252edc443abea7dede08a93165975a451fd802a847aee86f1c0602c400024a0151894c87011701504c814768747470733a2f2f7733732e6c696e6b2f697066732f75415655534944464f55597443422d6b6c4c7478454f72366e337434497154466c6c31704648396743714565753647386338697066733a2f2f75415655534944464f55597443422d6b6c4c7478454f72366e337434497154466c6c31704648396743714565753647386352894df5010902a9147ca97e01877e57897c6b00c08851d100887c635ab2756d51cd016a88674d0301404142434445464748494a4b4c4d4e4f505152535455565758595a6162636465666768696a6b6c6d6e6f707172737475767778797a303132333435363738392d5f6b007c8253a26365537f7c76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7b7c7e7c82539f666882760087636d677d537c94007c807e76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7c51937f757e686c7554893a10303132333435363738396162636465667c006b65517f7c5279785f84817f77517f756c7e6b52797c5499817f77517f756c7e6b82009c666d6c5689097f7b827b7c7f777e7e538978a8886b518ac0ce568a65766c537a538a6b74519c66756c766ba804015512207c7e548a01757c7e6b528a6c7c6b65766c537a538a6b74519c66756c6ca87c7e076a0442434d52207c7e51cd886801206c0f51ce8851d0009d6300cdc0c78868517e7e578a00cd8800ce00d18800d2008800d000d38800c600cca26952d10088c453870f51ce8851d0009d6300cdc0c7886851088178c99dc8c0c8870480c3c90104800fd92d0500e40b5402c0519dc0c800c88800c9529dc0c852c88852c9539dc0c853c88853c9549dc0c854c88854c9559dc0c855c88855c9569dc0c856c88856c9579d5c79009e63c0c858c88858c9599d67c0c858c88858c9599d68c0c857c88857c9589d597981009c5d79009c9b5b7981009c9b63c0d1008852cd00c78852d1008853cd52c78853d1008854cd53c78854d1008855cd54c78855d1008856cd55c78856d1008857cd56c78857d100885c79009e6359cd58c78859cc58c6a26959d158ce8859d358d09d59d258cf8867597981009e5c79009e9a6459cd58c78859d10088686858cd57c78858d1008802aa200302010055797eaa7e01877ec101587f775b7981009c6301005f79009e63015177685e79009c6300cd53798800d10088760251207e5e797e01207e5d797e52797e7b757c67c0c859c88859c9009d00cd016a8800d100885acd5c79885ad1c0c8885ad3009d5ad20100885bd10088c45c9d760200207e5e797e01207ec0c87e52797e7b757c6875675e79009c635d79009c6300cd52798800d10088030051205d797e01207e5c797e787e7767c0c859c88859c9009d53795579950400e1f50596547978935479789455795279a06902aa20012060797e5d797e5c797eaa7e01877e5c798277009c6302a91401200111797e5c797ea97e01877e776800cd788800d1c0c88800d352799d00d2008859cd56798859d1c0c88859d353799d59d2008858c7827758c77853947f77527f758158c7527953945279947f77787f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a77685acd78885ad100885bd10088c45c9d035100200114797e01207e0113797e58797e587a757c6b7c6b7c6b7c6b7c6b7c6b7c6c6c6c6c6c6c6d6d6d7568675d79009c6300cd52798800d10088035151205d797e01207e5c797e787e7767c0c859c88859c9009d00cd016a8800d100885acd5279885ad1c0c8885ad3009d5ad201ff885bd10088c45c9d03510020c0c87e01207e5c797e787e7768686802aa20c101147f7552797eaa7e01877ec0cd886d67c0c859c88859c95a9d55ca827755ca7853947f77527f758155ca527953945279947f77787f75012302aa2001205f797e5d797e5b797eaa7e01877e7e5b798277009c63011702a914012060797e5b797ea97e01877e7e776800cd02aa20c101147f7553797e54797eaa7e01877e8800d1008800ca827700ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686800ca537953945379945279947f7702aa20030200005d797e52797eaa7e01877e51cd8851d1008852ca827752ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686852ca537953945379945279947f7702aa20030200000111797e707c0114937f757e01207e01187901ff7eaa7e707c0135937f777eaa7e01877e52cd8852d1008853ca827753ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686853ca537953945379945279947f7702aa20030200000115797e52797eaa7e01877e53cd8853d1008854ca827754ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686854ca537953945379945279947f7702aa20030200000119797e52797eaa7e01877e54cd8854d1008857c7827757c77853947f77527f75815178529302ff00a063755367785293014ba0637552686857c7537953945379945279947f7755cd03020000011d797e52797e8855d1008802aa2003020000011d797eaa7e01877e56cd8856cc58c6a26956d158ce8856d3011a799d56d2008856ca827756ca7853947f77527f758156ca527953945279947f77787f7557cd02aa20c101147f7501207e01277901007eaa7e53797eaa7e01877e8857cc59c6a26957d159ce8857d359d09d57d259cf8802aa20525752807e0120797eaa7e01877e58cd8858d158ce8858d358d0011e79949d58d200886d6d6d6d6d6d6d6d6d6d6d6d6d75686d6d6d6d6d6d7551000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a403000000fdb503514db1030201008178c99dc8c0c8874da203124361756c64726f6e49646f2d3230323651327520000000000000000000000000000000000000000000000000000000000000000020ab3aba01311b672808aa06f5e7f332d930e4ed0c99407343f7fc5d743d15ff5504809698003dc0ce00ce01207f758800cf517f755f84528800cf577f77547f758151a169c0cf517f7701207f7502aa2001207b7e7b7eaa7e01877e52cd8852ccc0c6a2088178c99dc8c0c8873fc0cf527f7701207f75c0cf01227f77815479ce01207f757b88537acf567f75817600a0699f6978ce7bcf7eaa88c0cf517f77517f7581c0c85279c8887cc99c0778ce7bcf7eaa87209a66f2918e9a845e263d71d126949ca0ef35476eb13d382482aacfc94082941b046cac326a021c48021c48c0009d00c852c88852c9529d00c854c88854c9549d00c855c88855c9559d00ce01207f7551ce78527e8851cf517f755f8401008853ce01207f7555798853cf567f75817600a06953d100876453d101207f7552798791696851d0547aa07651d0557aa09b63537952799f696851cf557f77547f758100a16302aa2001205d797e57797eaa7e01877e556085537956807e0058807e52d058807e0058807e00d28800d154798800d3009d00cd788851cd788851cc52c6a26951d152ce8851d352d09d51d2008852d10088c4549d75675279517e00d18800d3009d766355ca827755ca7853947f77527f758155ca527953945279947f77787f75526085557956807e51cf557f77547f757e0058807e52d058807e0058807e00d28802aa20c101147f755a79827751807e5a797e01207e60797e52797eaa7e01877e00cd8802aa20012060797e5a797eaa7e01877e52cd8852cc52c6a26952d152ce8852d352d09d52d2008854d10088c4559d6d756754ca827754ca7853947f77527f758154ca527953945279947f77787f7551d07600a06354d152ce8854d3789d54d2008802aa2001200111797e5b797eaa7e01877e54cd8855d10088c4569d6754d10088c4559d6852567956807e51cf557f77547f757e0058807e7858807e0058807e00d28802aa20c101147f755d79827751807e5d797e5c79827751807e5c797e5b79827751807e5b797e01207e5a797e547e5f797e01207e60797e01207e0111797e53797eaa7e01877e00cd8852d152ce8852d352d05279949d52cc52c6a26902aa20520052807e5d797eaa7e01877e52cd8852d200886d6d6802aa2056798277518057797e5a797eaa7e01877e51cd88545b797e51d28851cc51c6a26951d153798851d3009d686d6d6d6d6d6d51a00375000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a404000000fdb902514db5020201008178c99dc8c0c8874da602c0009d00c852c88852c9529d00cf517f77567f758100ce01207f7551ce01207f75788851cf517f755f84538851cf517f7551ca51ca82770136940120947f7701207f750000537960840100876451cf517f77567f75817b757c51cf577f77587f758177687800a2697600a26952d352d051d0949d587a810000567901208401008764527900a26951c6765479950400e1f50596537a757c6b7c6c765379947b757c53cc5279a26953d1008854cc5379a26954d10088756753d05379950400e1f505967b757c53d0527994777600a06353d3789d53d153ce886753d10088687800a06354d352799d54d153ce886754d100886868547900a06302aa20012057797e5f797eaa7e01877e51cd8851d1587988565551807e5c797e597956799356807e51d28851d3009d55d351d09d55d152ce8802aa20525152807e60797eaa7e01877e55cd8855d200886751d351d09d51d152ce8802aa20012057797e5e797eaa7e01877e51cd8851d20088687600a06302aa2001205b797e5e797eaa7e01877e53cd886753cd016a88687c00a06302aa2001205b797e5d797eaa7e01877e54cd886754cd016a886800cf577f77547f75817651a06300cf517f7500cf517f77567f757e788c54807e00cf5b7f77587f758153799358807e00cf01137f77587f757e00cf011b7f77587f758155799358807e00d28800ce00d18800c700cd8800d000d39d52cf52d28852ce52d18852c752cd88675500cf517f77567f757e00cf5b7f77587f758153799358807e00cf01137f77587f757e00cf011b7f77587f758155799358807e00d28800d158798800d3009d02aa2001205b797e5e797eaa7e01877e00cd8852d100885457790120840100876475536876ce59798876cf517f755f845488756855557a00a063755668c4789e6376d10088c4788b9d686d6d6d6d6d6d6d7551a40275000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a405000000fd4701514d43010201008178c99dc8c0c8874d3401c0009d00ce01207f7551ce01207f75788851cf517f755f84538851ca51ca82770136940120947f7701207f7551cf517f75760120840100876451cc51c6a26951d100886751d352d09d51d152ce886802aa200120537a7e55797eaa7e01877e51cd8800cf577f77547f75817651a06300cf517f7500cf517f77567f757e788c54807e00cf5b7f77587f757e00cf01137f77587f757e00cf011b7f77587f757e00d28800ce00d18800c700cd88c4529e6352d10088c4539d686755608500cf517f77567f757e00cf5b7f77587f757e00cf01137f77587f757e00cf011b7f77587f757e00d288527900d18800d3009d02aa20012055797e56797eaa7e01877e00cd8852d100885352790120840100876475526876ce54798876cf517f755f845488c4539e6353d10088c4549d6875686d6d7551320175000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a406000000fd0f03514d0b030201008178c99dc8c0c8874dfc02088178c99dc8c0c887575655545352510450c30000c0c9009dc0c85b79c8885a79c9577a9d5979ce827701209d567900a263c0c85b79c8885a79c957799d68c0c800d1788800d2578800d3009d00cd5a7a88c08bc0c878c88876c9547a9d76ca827778ca7853947f77527f75817bca7b53945279947f777c7f75c05293c0c878c88876c9557a9d76ca827778ca7853947f77527f75817bca7b53945279947f777c7f75c05393c0c878c88876c9567a9d76ca827778ca7853947f77527f75815178529302ff00a063755367785293014ba063755268685379ca537a5394537a947b947f7702aa20525352807e5b797e7b7eaa7e01877e54cd8854cc7cc6a26954d10088c05493c0c878c88876c9567a9d76ca827778ca7853947f77527f75815178529302ff00a063755367785293014ba063755268685379ca537a5394537a947b947f7702aa20525352807e5a797e7b7eaa7e01877e55cd8855cc7cc6a26955d100885779d07600a0690100557a7e04000000007e51d28851d15479527e8851d3789d51cd02aa20547aaa7e01877e885153d28853d153798853d3009d53cd02aa20537aaa7e01877e8802aa20525352807e567a7eaa7e01877e52cd8852cc5579c6a26952d1557ace8852d39d52d20088c05593c0c878c88876c9537a9d76c7827778c77853947f77527f75817bc77b53945279947f777c7f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a776856cd8856d1008857d100876457d101207f75788791696857cd567f75066a0442434d52879169c4589e6358d100876458d101207f75788791696858cd567f75066a0442434d52879169c4599e6359d100876459d101207f75788791696859cd567f75066a0442434d52879169c45a9e635ad10087645ad101207f7578879169685acd567f75066a0442434d52879169c45b9e635bd10087645bd101207f7578879169685bcd567f75066a0442434d52879169c45c9d686868686d7551fa0275000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a407000000fd2506514d21060201008178c99dc8c0c8874d12064d09050480c3c901059f06241f7e5879009c63c0009dc0c9009e69c0cdc0c788c0ccc0c6a269c0d1c0ce88c0d3c0d09dc0d2c0cf8802aa20520052807e597a7eaa7e01877ec0c851c88851c9589d7651cd8851cc51c6a26951d151ce8851d351d09dc0c852c88852c9599d52cd8852cc52c6a26952d152ce8852d352d09dc353a06353ce0088c3549d6853d10088c4549d6d6d6d6d51675879519c63c0009dc0c9009dc0cdc0c788c0d1c0ce88c0d3c0d09dc0d2c0cf88c0c852c88852c9529d52cd52c78852cc52c6a26952d152ce8852d352d09d02aa200120c0cec0cf7eaa7e587a7eaa7e01877e00005376c75479876376ce59798763527978d093537a757c6b7c6c6776ce0088686ec6937b757c6776ce008868768bc378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c3789d6868686868686868c0ccc0c6547a93a269c0c851c88851c9519d51cd51c78851cc51c6a26951d0537a937600a06351d159798851d378a26951d200886751d100886853d10088c4549d6d6d6d6d6d6d5167587a529dc0009dc0c9009d5379827701209d53ce567a8853cf517f755f845588c0c653cf577f77587f7581a269c0c851c88851c9519d53cf5f7f77587f75817600a06351d078a2696851d000a06351ce5679886851cf0088c0c852c88852c9529d52ce567988000053cf517f75608401008763557981537994765679950400e1f505967652d0a06352d07768765679950500e87648179653cf01177f77587f758194760500e8764817955779967802e803a27800a09a6378567a757c6b7c6b7c6b7c6b7c6c6c6c6c765379a16376557a757c6b7c6b7c6b7c6c6c6c675279557a757c6b7c6b7c6b7c6c6c6c68686d6d68c0c651c69352c69352799451d052d093527994537900a0537900a09a6302aa2005746376a914000114807e2b88ac67c0d1c0ce88c25288c0cdc0c788c0c6c0d095c0c6c0cc9490539502e80396c0cc7c94c0d3957ca2687eaa7e01877e00cd8800cc54799d00d15a798800d353799d00d20088096a0653554d4d4f4e14000114807e51cd8851cc009d51d100886700d1008800cd016a8851d1008851cd016a886802aa2001205a7a7e5b7a7eaa7e01877e52cd8852cc7ba2697600a06352d378a26952d158798852d200886752d100886853d10088c4549d6d6d6d6d75516868088178c99dc8c0c8870778ce7bcf7eaa875800c0ce827701209dc0cf827700a0695579827701209dc0c85779c8885679c99dc0c85779c8885679c99d5479529376ca827778ca7853947f77527f75817bca7b53945279947f777c7f7501157f7701207f75c0cec0cf7eaa88547ac852d1827701209d52d300a06902aa20c101147f755479827751807e54797e5379827751807e537a7e01207e7b7e01207e52d17e01207e547a7e587e52d358807e537a7eaa7e01877e57cd8857ccc0c6a26957d1c0ce8857d2c0cf8857d3c0d09d02aa20525752807e7b7eaa7e01877e7658cd8858cc02e803a26958d1008859cd8859cc78c6a26959d178ce8859d278cf8859d37cd09c100675000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a4080000000151000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a409000000015100000000d9d78914b49e4313f22b27e1ea03dd7c2963775ce944e0b81c1ad71490e865710100000064411de3a0757e6c479e5c20ca63f5d7f6e2029f27d7349d61be689a99a655afd2024b4d23d1e736d7119ed226dbd66597521e2fd2b8be6f640e3e0b51cc2ce0a1fe612102c1547ca5906ae616000ff9e82a8808ae72e3b10280ea388bc4c53c698e1b72a7ffffffff0be80300000000000023aa204358e719285ca59fffedcff34a5d1a39b32711ec2ad21c51765aeaa79d60409887e80300000000000023aa20890580b8a7a29eca5c414ddf82057cb65924950acb189661cf2727136eb4a49887e80300000000000023aa20a8e6aed82ff79e824aa86d14cc469704bda8e8c20057ff06d4948004e6ade0d287e80300000000000023aa20de0a3c5fe9a1d0fea9ca1d0e82628ab14c61214d17d3f9df4b175fec46a52b5e87e80300000000000023aa20f69ff67df71b9ecd4b6b39af393551468bb2a46fb261e2e5a61cfb27dbcbb25087e80300000000000023aa20e5b1d9865bf78f7cfc02e60ba0bcf327e0d3949f455a37dd8870979c3fc0f42d87e80300000000000023aa208b3c520007a78d00dcebfd2e8bcda6020ed7f7ed7d5f0d687ac325b0fc3e309187e80300000000000023aa20beafa293c3d712876b389da252b1f7f584a8f8b10da2e526e38a34856f1c15e487b0040000000000000f0201008178c99dc8c0c88702000075b004000000000000c50201008178c99dc8c0c8874cb70c0602c400024a0104011701506a0442434d5220314e518b4207e9252edc443abea7dede08a93165975a451fd802a847aee86f1c4768747470733a2f2f7733732e6c696e6b2f697066732f75415655534944464f55597443422d6b6c4c7478454f72366e337434497154466c6c31704648396743714565753647386338697066733a2f2f75415655534944464f55597443422d6b6c4c7478454f72366e337434497154466c6c317046483967437145657536473863b50075586e0400000000001976a91495570c60bbdeaf648a259bb177911169e060a76788ac00000000").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 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 base = format!("{IDO_RECORD_SELECT}{IDO_CURRENT_STATE_JOIN} WHERE 1=1"); + let mut qb: sqlx::QueryBuilder = sqlx::QueryBuilder::new(base); + if let Some(v) = is_valid { + qb.push(" AND s.is_valid = "); + qb.push_bind(v as i64); + } + if let Some(v) = status { + qb.push(" AND s.status = "); + qb.push_bind(v); + } + if let Some(v) = offered_token_id_blob { + qb.push(" AND s.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| IdoDBRecord::from_row(&row)?.into_rpc_record()) + .collect() +} + +pub async fn get_ido_by_preinit_txid( + pool: &SqlitePool, + preinit_txid: Vec, +) -> Result> { + let sql = format!("{IDO_RECORD_SELECT}{IDO_CURRENT_STATE_JOIN} WHERE ido.preinit_txid = ?"); + let row = sqlx::query(&sql) + .bind(preinit_txid) + .fetch_optional(pool) + .await?; + row.map(|r| IdoDBRecord::from_row(&r)?.into_rpc_record()) + .transpose() +} + +pub async fn get_ido_by_offering_token_id( + pool: &SqlitePool, + offering_token_id_blob: Vec, +) -> Result> { + let sql = format!("{IDO_RECORD_SELECT}{IDO_CURRENT_STATE_JOIN} WHERE s.offering_token_id = ?"); + let row = sqlx::query(&sql) + .bind(offering_token_id_blob) + .fetch_optional(pool) + .await?; + row.map(|r| IdoDBRecord::from_row(&r)?.into_rpc_record()) + .transpose() +} + +pub async fn list_ido_entries( + pool: &SqlitePool, + internal_id: i64, + preinit_txid_hex: &str, + distributed: Option, + owner_nfthashes: &[Vec], + offset: i64, + limit: i64, +) -> Result> { + // An entry is "distributed" iff a row exists for it in ido_distribution. + // (SQLite forbids referencing the output alias in WHERE, so the same EXISTS + // expression is repeated in the optional filter below.) + let dist_expr = "EXISTS(SELECT 1 FROM ido_distribution d WHERE d.ido_id = e.ido_id AND d.entry_txid = e.txid)"; + let mut qb: sqlx::QueryBuilder = sqlx::QueryBuilder::new(format!( + "SELECT e.txid, e.owner_nfthash, e.commitment, e.supply_amount, e.demand_amount, \ + e.lockup_timeval, e.discount, {dist_expr} FROM ido_entry e WHERE e.ido_id = " + )); + qb.push_bind(internal_id); + if let Some(v) = distributed { + qb.push(format!(" AND {dist_expr} = ")); + qb.push_bind(v as i64); + } + if !owner_nfthashes.is_empty() { + qb.push(" AND e.owner_nfthash IN ("); + let mut separated = qb.separated(", "); + for hash in owner_nfthashes { + separated.push_bind(hash.clone()); + } + separated.push_unseparated(")"); + } + qb.push(" ORDER BY e.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() +} + +#[cfg(test)] +mod querytests { + use super::*; + 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 { + // identity row + let internal_id = sqlx::query( + "INSERT INTO ido (preinit_txid, is_token_created_at_preinit, created_at) + VALUES (?, ?, 0)", + ) + .bind(&preinit_txid) + .bind(is_token_created_at_preinit as i64) + .execute(pool) + .await + .unwrap() + .last_insert_rowid(); + // seq-0 state snapshot. Its txid (the preinit) is the txchain head; + // tests that exercise the txchain repoint it via set_txchain_head. + sqlx::query( + "INSERT INTO ido_state (ido_id, seq, txid, status, parameters, state, + offering_token_id, offered_token_id, is_valid) + VALUES (?, 0, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(internal_id) + .bind(&preinit_txid) + .bind(status) + .bind(b"{}".as_slice()) + .bind(b"{}".as_slice()) + .bind(offering_token_id) + .bind(offered_token_id) + .bind(is_valid as i64) + .execute(pool) + .await + .unwrap(); + internal_id + } + + // ─── vm number encoding ─────────────────────────────────────────────────── + + #[test] + fn vm_number_roundtrip_zero() { + let n = Integer::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 = Integer::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 = Integer::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(&Integer::from(127i64)).len(), 1); + assert_eq!(bigint_to_vm_number(&Integer::from(128i64)).len(), 2); + assert_eq!(bigint_to_vm_number(&Integer::from(-127i64)).len(), 1); + assert_eq!(bigint_to_vm_number(&Integer::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 = Integer::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 = Integer::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(&Integer::from(0i64), len).unwrap(); + assert_eq!(enc.len(), len); + assert_eq!(decode_padded_vm_number(&enc), Integer::from(0i64)); + } + } + + // ─── bigint_to_push_opcode ──────────────────────────────────────────────── + + #[test] + fn push_opcode_zero() { + assert_eq!(bigint_to_push_opcode(&Integer::from(0i64)), vec![0x00]); + } + + #[test] + fn push_opcode_minus_one() { + assert_eq!(bigint_to_push_opcode(&Integer::from(-1i64)), vec![0x4f]); + } + + #[test] + fn push_opcode_small_range_1_to_16() { + for v in 1u8..=16 { + let result = bigint_to_push_opcode(&Integer::from(v)); + assert_eq!(result, vec![0x50 + v], "v={v}"); + } + } + + #[test] + fn push_opcode_17_is_data_push() { + let result = bigint_to_push_opcode(&Integer::from(17i64)); + // length prefix + one byte payload + assert_eq!(result, vec![0x01, 17]); + } + + #[test] + fn push_opcode_pushdata1_boundaries() { + // Build a positive, minimally-encoded vm-number occupying exactly `len` + // bytes: 0xFF filler with a 0x7F top byte (high bit clear => positive, + // nonzero => no trailing-zero trimming). + let n_bytes = |len: usize| -> Integer { + let mut payload = vec![0xFF_u8; len]; + payload[len - 1] = 0x7F; + let n = vm_number_to_bigint(&payload); + assert_eq!( + bigint_to_vm_number(&n).len(), + len, + "payload not minimal at len={len}" + ); + n + }; + + // 75-byte payload: direct push (single length byte = 0x4b) + let p75 = bigint_to_push_opcode(&n_bytes(75)); + assert_eq!(p75[0], 0x4b); + assert_eq!(p75.len(), 1 + 75); + + // 255-byte payload: must use PUSHDATA1 (minimal), not PUSHDATA2. + let p255 = bigint_to_push_opcode(&n_bytes(255)); + assert_eq!(p255[0], 0x4c, "255-byte payload must use OP_PUSHDATA1"); + assert_eq!(p255[1], 255); + assert_eq!(p255.len(), 2 + 255); + + // 256-byte payload: PUSHDATA2 + let p256 = bigint_to_push_opcode(&n_bytes(256)); + assert_eq!(p256[0], 0x4d, "256-byte payload must use OP_PUSHDATA2"); + assert_eq!(&p256[1..3], &[0x00, 0x01]); // 256 little-endian + assert_eq!(p256.len(), 3 + 256); + } + + #[test] + fn push_opcode_roundtrip_via_vm_number() { + // bigint_to_push_opcode should produce bytes whose payload decodes back + let n = Integer::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: Integer::from(0i64), + oTokenGenerated: Integer::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 = Integer::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 = Integer::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) + VALUES (?, ?, ?, NULL, 0, 0, 0, 0)", + ) + .bind(ido_id) + .bind(&txid_val) + .bind(vec![0u8; 32]) + .execute(pool) + .await + .unwrap(); + // Distribution is a separate block-keyed fact, not a column on the entry. + if distributed { + sqlx::query("INSERT INTO ido_distribution (ido_id, entry_txid, txid) VALUES (?, ?, ?)") + .bind(ido_id) + .bind(&txid_val) + .bind(&txid_val) + .execute(pool) + .await + .unwrap(); + } + } + + #[rocket::async_test] + 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: chain-follow lookup (replaces the removed txchain tracker) ─────── + + fn txid_t(n: u8) -> Txid { + Txid::from_byte_array([n; 32]) + } + + async fn set_next_output_index(pool: &SqlitePool, ido_id: i64, seq: i64, noi: i64) { + sqlx::query("UPDATE ido_state SET next_output_index = ? WHERE ido_id = ? AND seq = ?") + .bind(noi) + .bind(ido_id) + .bind(seq) + .execute(pool) + .await + .unwrap(); + } + + /// Insert an extra state snapshot at `seq` for an existing ido. + async fn insert_state( + pool: &SqlitePool, + ido_id: i64, + seq: i64, + txid_val: Vec, + status: &str, + offering_token_id: Option>, + ) { + sqlx::query( + "INSERT INTO ido_state (ido_id, seq, txid, status, parameters, state, offering_token_id, is_valid) + VALUES (?, ?, ?, ?, ?, ?, ?, 1)", + ) + .bind(ido_id) + .bind(seq) + .bind(txid_val) + .bind(status) + .bind(b"{}".as_slice()) + .bind(b"{}".as_slice()) + .bind(offering_token_id) + .execute(pool) + .await + .unwrap(); + } + + #[rocket::async_test] + async fn lookup_state_by_next_output_matches_continuation() { + let pool = make_pool().await; + let ido_id = insert_test_ido(&pool, txid(1), "PREINIT", true, true, None, None).await; + // The seq-0 snapshot's preinit output#1 continues the chain. + set_next_output_index(&pool, ido_id, 0, 1).await; + + // A tx spending (preinit, 1) extends this ido. + let prev = lookup_state_by_next_output(&pool, &txid_t(1), 1) + .await + .unwrap(); + assert_eq!(prev.unwrap().internal_id, ido_id); + + // A different output index of the same tx does not continue the chain. + assert!(lookup_state_by_next_output(&pool, &txid_t(1), 2) + .await + .unwrap() + .is_none()); + // An unknown tx does not match. + assert!(lookup_state_by_next_output(&pool, &txid_t(9), 1) + .await + .unwrap() + .is_none()); + } + + #[rocket::async_test] + async fn has_indexed_tx_reports_known_txids() { + let pool = make_pool().await; + insert_test_ido(&pool, txid(1), "PREINIT", true, true, None, None).await; + // The seq-0 state records the preinit txid. + assert!(has_indexed_tx(&pool, &txid(1)).await.unwrap()); + assert!(!has_indexed_tx(&pool, &txid(2)).await.unwrap()); + } + + // ─── DB: txchain_head is exposed as the current state'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), + "PREINIT", + true, + true, + None, + Some(tok.clone()), + ) + .await; + // Advance to a later state; the head is the latest state's txid. + insert_state(&pool, ido_id, 1, txid(11), "ACTIVE", Some(tok.clone())).await; + + let listed = list_idos(&pool, None, None, None, 0, 100).await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].status, "ACTIVE"); + assert_eq!(listed[0].txchain_head, Some(hex::encode(txid(11)))); + + // get_ido_by_offering_token_id resolves the same current state. + 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_defaults_to_preinit() { + let pool = make_pool().await; + // A fresh ido that has not advanced has a single seq-0 state whose txid + // is the preinit, so the exposed head is the preinit txid. + insert_test_ido(&pool, txid(1), "PREINIT", true, true, None, None).await; + let listed = list_idos(&pool, None, None, None, 0, 100).await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].txchain_head, Some(hex::encode(txid(1)))); + } + + // ─── DB: reorg undo (delete_entries) ────────────────────────────────────── + + async fn insert_state_bh( + pool: &SqlitePool, + ido_id: i64, + seq: i64, + txid_val: Vec, + blockhash: &BlockHash, + status: &str, + ) { + sqlx::query( + "INSERT INTO ido_state (ido_id, seq, txid, blockhash, status, parameters, state, is_valid) + VALUES (?, ?, ?, ?, ?, ?, ?, 1)", + ) + .bind(ido_id) + .bind(seq) + .bind(txid_val) + .bind(blockhash.to_blob()) + .bind(status) + .bind(b"{}".as_slice()) + .bind(b"{}".as_slice()) + .execute(pool) + .await + .unwrap(); + } + + /// An ido created in block A and advanced (with a purchase + its + /// distribution) in block B should: revert to its block-A state when B is + /// undone, then disappear entirely when A is undone. + #[rocket::async_test] + async fn delete_entries_block_reverts_then_drops() { + let pool = make_pool().await; + let block_a = BlockHash::from_byte_array([0xA1; 32]); + let block_b = BlockHash::from_byte_array([0xB2; 32]); + let preinit = txid(1); + let preinit_hex = hex::encode(&preinit); + + let ido_id = sqlx::query( + "INSERT INTO ido (preinit_txid, is_token_created_at_preinit, created_at) VALUES (?, 1, 0)", + ) + .bind(&preinit) + .execute(&pool) + .await + .unwrap() + .last_insert_rowid(); + + // block A: preinit / seq 0 (PREINIT) + insert_state_bh(&pool, ido_id, 0, preinit.clone(), &block_a, "PREINIT").await; + + // block B: advance / seq 1 (ACTIVE) + a purchase entry and its distribution + let advance = txid(2); + insert_state_bh(&pool, ido_id, 1, advance.clone(), &block_b, "ACTIVE").await; + let entry = txid(10); + sqlx::query( + "INSERT INTO ido_entry (ido_id, txid, blockhash, owner_nfthash, commitment, supply_amount, demand_amount, lockup_timeval, discount) + VALUES (?, ?, ?, ?, NULL, 0, 0, 0, 0)", + ) + .bind(ido_id) + .bind(&entry) + .bind(block_b.to_blob()) + .bind(vec![0u8; 32]) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO ido_distribution (ido_id, entry_txid, txid, blockhash) VALUES (?, ?, ?, ?)", + ) + .bind(ido_id) + .bind(&entry) + .bind(&advance) + .bind(block_b.to_blob()) + .execute(&pool) + .await + .unwrap(); + + // current state is the block-B snapshot, with one distributed entry + let current = get_ido_by_preinit_txid(&pool, preinit.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(current.status, "ACTIVE"); + let entries = list_ido_entries(&pool, ido_id, &preinit_hex, None, &[], 0, 100) + .await + .unwrap(); + assert_eq!(entries.len(), 1); + assert!(entries[0].distributed); + + // undo block B: revert to the block-A (PREINIT) snapshot; entry and its + // distribution are gone, but the ido itself survives. + let removed = delete_entries(&pool, Some(&block_b)).await.unwrap(); + assert_eq!(removed, 1); + let reverted = get_ido_by_preinit_txid(&pool, preinit.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(reverted.status, "PREINIT"); + let entries = list_ido_entries(&pool, ido_id, &preinit_hex, None, &[], 0, 100) + .await + .unwrap(); + assert_eq!(entries.len(), 0); + + // undo block A: the preinit is gone, so the whole ido is dropped and its + // children cascade away. + delete_entries(&pool, Some(&block_a)).await.unwrap(); + assert!(get_ido_by_preinit_txid(&pool, preinit) + .await + .unwrap() + .is_none()); + let (state_rows,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM ido_state") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(state_rows, 0); + } + + /// delete_entries(None) should revert an ido with an unconfirmed advance + /// back to its last confirmed snapshot, and drop an ido that was only ever + /// seen in the mempool. + #[rocket::async_test] + async fn delete_entries_mempool_drops_unconfirmed_state() { + let pool = make_pool().await; + let block_a = BlockHash::from_byte_array([0xA1; 32]); + + // ido #1: confirmed preinit (seq0, block_a) + an unconfirmed mempool + // advance (seq1, NULL blockhash) carrying an entry and a distribution. + let confirmed = sqlx::query( + "INSERT INTO ido (preinit_txid, is_token_created_at_preinit, created_at) VALUES (?, 1, 0)", + ) + .bind(txid(1)) + .execute(&pool) + .await + .unwrap() + .last_insert_rowid(); + insert_state_bh(&pool, confirmed, 0, txid(1), &block_a, "PREINIT").await; + insert_state(&pool, confirmed, 1, txid(2), "ACTIVE", None).await; + sqlx::query( + "INSERT INTO ido_entry (ido_id, txid, blockhash, owner_nfthash, commitment, supply_amount, demand_amount, lockup_timeval, discount) + VALUES (?, ?, NULL, ?, NULL, 0, 0, 0, 0)", + ) + .bind(confirmed) + .bind(txid(10)) + .bind(vec![0u8; 32]) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO ido_distribution (ido_id, entry_txid, txid, blockhash) VALUES (?, ?, ?, NULL)", + ) + .bind(confirmed) + .bind(txid(10)) + .bind(txid(2)) + .execute(&pool) + .await + .unwrap(); + + // ido #2: only ever seen in the mempool (seq0 preinit, NULL blockhash). + let mempool_only = insert_test_ido(&pool, txid(3), "PREINIT", true, true, None, None).await; + + // Before the wipe, #1's current state is the unconfirmed ACTIVE snapshot. + let before = get_ido_by_preinit_txid(&pool, txid(1)) + .await + .unwrap() + .unwrap(); + assert_eq!(before.status, "ACTIVE"); + + // Two unconfirmed state rows: #1 seq1 and #2 seq0. + let removed = delete_entries(&pool, None).await.unwrap(); + assert_eq!(removed, 2); + + // #1 reverts to its confirmed PREINIT snapshot; the unconfirmed entry and + // its distribution are gone. + let after = get_ido_by_preinit_txid(&pool, txid(1)) + .await + .unwrap() + .unwrap(); + assert_eq!(after.status, "PREINIT"); + let entries = list_ido_entries(&pool, confirmed, &hex::encode(txid(1)), None, &[], 0, 100) + .await + .unwrap(); + assert_eq!(entries.len(), 0); + + // #2 (mempool-only) is dropped entirely. + assert!(get_ido_by_preinit_txid(&pool, txid(3)) + .await + .unwrap() + .is_none()); + let (ido2_rows,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM ido WHERE internal_id = ?") + .bind(mempool_only) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(ido2_rows, 0); + } +} diff --git a/src/db/init.rs b/src/db/init.rs index a4ed71f..94c63c3 100644 --- a/src/db/init.rs +++ b/src/db/init.rs @@ -16,6 +16,7 @@ use crate::db::cauldron::config::check_db_version; use crate::db::cauldron::prepare_tables as cauldron_prepare_tables; use crate::db::cauldron::tokentoken::create_table as tokentoken_prepare_tables; use crate::db::crc20::prepare_tables as crc20_prepare_tables; +use crate::db::ido::prepare_tables as ido_prepare_tables; use crate::db::moria::prepare_tables as moria_prepare_tables; use crate::db::oracle::prepare_tables as oracle_prepare_tables; @@ -89,6 +90,7 @@ pub struct ReadSlots { pub crc20: u32, pub oracle: u32, pub moria: u32, + pub ido: u32, } /// Initialize all databases and return a DB struct @@ -135,6 +137,13 @@ pub async fn initialize_databases(network: &str, read_slots: ReadSlots) -> Resul moria_prepare_tables(&moria_db_write).await; } + // Initialize ido database + let (db_exists, ido_db_write, ido_db_read) = + create_db_pool(&db_path(db_dir, "ido.db"), read_slots.ido).await; + if !db_exists { + ido_prepare_tables(&ido_db_write).await; + } + Ok(DB { cauldron_w: cauldron_db_write, cauldron_r: cauldron_db_read, @@ -146,5 +155,7 @@ pub async fn initialize_databases(network: &str, read_slots: ReadSlots) -> Resul oracle_r: oracle_db_read, moria_w: moria_db_write, moria_r: moria_db_read, + ido_r: ido_db_read, + ido_w: ido_db_write, }) } diff --git a/src/db/mod.rs b/src/db/mod.rs index 6deaa8c..352b173 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -9,6 +9,7 @@ pub mod bcmr; pub mod blob; pub mod cauldron; pub mod crc20; +pub mod ido; pub mod init; pub mod moria; pub mod oracle; @@ -35,4 +36,7 @@ pub struct DB { // moria lending protocol pub moria_w: SqlitePool, pub moria_r: SqlitePool, + // ido + pub ido_w: SqlitePool, + pub ido_r: SqlitePool, } diff --git a/src/electrum.rs b/src/electrum.rs index 9455255..e044e8d 100644 --- a/src/electrum.rs +++ b/src/electrum.rs @@ -18,6 +18,8 @@ use riftenlabs_defi::{ }; use serde_json::{json, Value}; +use crate::db::ido::{IDO_PREINIT_ANNOUNCEMENT_SIGNATURE, IDO_SIGNATURE}; + /// Fetch blockchain tip from electrum server pub fn electrum_get_tip(client: &Client) -> Result<(BlockHeader, u64)> { let tip: Value = @@ -39,7 +41,9 @@ pub fn electrum_get_tip(client: &Client) -> Result<(BlockHeader, u64)> { } /// Fetch defi (cauldron + tokentoken) and oracle mempool transactions -pub fn electrum_fetch_mempool(client: &Client) -> Result<(HashSet, HashSet)> { +pub fn electrum_fetch_mempool( + client: &Client, +) -> Result<(HashSet, HashSet, HashSet)> { let cauldron_filter = json!({ "scriptsig": hex::encode(&V2_CONTRACT_TEMPLATE[(V2_CONTRACT_TEMPLATE.len() - 43)..]), // cauldron spends "scriptpubkey": hex::encode([0x6a /* op_return */, 0x06 /* push */, b'S', b'U', b'M', b'M', b'O', b'N']), // new pools (potentially) @@ -72,6 +76,12 @@ pub fn electrum_fetch_mempool(client: &Client) -> Result<(HashSet, HashSet "scriptsig": hex::encode(DELPHI_V2_REDEEM_SCRIPT_BODY), }); + let ido_filter = json!({ + "scriptsig": hex::encode(IDO_SIGNATURE), // ido state machine spends + "scriptpubkey": hex::encode(IDO_PREINIT_ANNOUNCEMENT_SIGNATURE), // preinit announcements (potentially) + "operation": "union" + }); + let fetch_txs = |filter: Value| -> Result> { let response = client.raw_call("mempool.get", [Param::Value(filter)])?; @@ -104,8 +114,9 @@ pub fn electrum_fetch_mempool(client: &Client) -> Result<(HashSet, HashSet defi_txs.extend(fetch_txs(tokentoken_filter)?); let mut oracle_txs = fetch_txs(oracle_v1_filter)?; oracle_txs.extend(fetch_txs(oracle_v2_filter)?); + let ido_txs = fetch_txs(ido_filter)?; - Ok((defi_txs, oracle_txs)) + Ok((defi_txs, oracle_txs, ido_txs)) } /// Fetch blockchain tip from electrum server diff --git a/src/index.rs b/src/index.rs index a2a5f9a..4ec99e7 100644 --- a/src/index.rs +++ b/src/index.rs @@ -26,6 +26,7 @@ use crate::{ crc20::index_crc20, db::{ self, + blob::ToBlob, cauldron::{ config::{config_get, config_set}, header::{db_get_header, store_headers}, @@ -67,18 +68,22 @@ fn moria_token_ids(network: Option) -> MoriaTokenIds { } } -pub async fn update_mempool(db: &DB, electrum: Arc>) -> Result<()> { +pub async fn update_mempool( + db: &DB, + electrum: Arc>, + network: Option, +) -> Result<()> { let our_mempool_txs: HashSet = db::cauldron::mempool::load_mempool(&db.cauldron_w).await?; let electrum_clone = electrum.clone(); - let (defi_txs, oracle_txs) = tokio::task::spawn_blocking(move || { + let (cauldron_txs, oracle_txs, ido_txs) = tokio::task::spawn_blocking(move || { electrum_fetch_mempool(&electrum_clone.lock().unwrap()) }) .await??; - let txs_to_delete: Vec = our_mempool_txs.difference(&defi_txs).cloned().collect(); - let txs_to_add: Vec<&Txid> = defi_txs.difference(&our_mempool_txs).collect(); + let txs_to_delete: Vec = our_mempool_txs.difference(&cauldron_txs).cloned().collect(); + let txs_to_add: Vec<&Txid> = cauldron_txs.difference(&our_mempool_txs).collect(); let electrum_for_fetch = electrum.clone(); let txids_to_fetch: Vec = txs_to_add.iter().map(|t| **t).collect(); @@ -182,6 +187,37 @@ pub async fn update_mempool(db: &DB, electrum: Arc>) -> Result<()> index_oracle(&db.oracle_w, &txs_to_add, &BlockHash::all_zeros()).await?; + // ido updates + let ido_electrum = electrum.clone(); + + // filter txs already recorded in an ido's state chain + let mut ido_to_add = Vec::new(); + for txid in ido_txs { + if !db::ido::has_indexed_tx(&db.ido_w, &txid.to_blob()).await? { + ido_to_add.push(txid); + } + } + + let txs_to_add: Vec = tokio::task::spawn_blocking(move || { + ido_to_add + .into_iter() + .filter_map( + |txid| match electrum_get_tx(&ido_electrum.lock().unwrap(), &txid) { + Ok(tx) => Some(tx), + Err(e) => { + info!("Failed to get mempool tx {txid}: {e}"); + None + } + }, + ) + .collect() + }) + .await?; + + // an ido chain can have several unconfirmed txs in flight; index parents first + let txs_to_add = ttor_sorted_kahn(txs_to_add); + db::ido::index_txs(network, &db.ido_w, &txs_to_add, None).await?; + Ok(()) } @@ -263,6 +299,14 @@ pub async fn index_blocks( } } + // Before applying confirmed blocks, drop any mempool-indexed IDO state + // (blockhash IS NULL). A mempool tx that never confirmed — e.g. one replaced + // by a different on-chain tx — must not leave a stale snapshot that wins + // MAX(seq) or resurfaces after the reorg undo above. Confirmed blocks + // re-create rows for the txs they contain, and the next mempool pass + // (update_mempool) rebuilds the rest. + db::ido::delete_entries(&db.ido_w, None).await?; + let db_cpy = db.clone(); let chain_cpy = chain.clone(); let client_cpy = client.clone(); @@ -444,6 +488,8 @@ pub async fn index_blocks( ) .await?; + db::ido::index_txs(network, &db.ido_w, &sorted_txs, Some(&blockhash)).await?; + let autheader_updates = if bcmr_enabled { let updates = index_bcmr(&db.bcmr_w, &blockhash, sorted_txs).await?; updates as i64 diff --git a/src/main.rs b/src/main.rs index 894f123..0245f82 100644 --- a/src/main.rs +++ b/src/main.rs @@ -169,6 +169,7 @@ async fn start_program( crc20: config.crc20_read_slots, oracle: config.oracle_read_slots, moria: config.moria_read_slots, + ido: config.ido_read_slots, }, ) .await?; @@ -337,7 +338,7 @@ async fn start_program( // Avoid overlapping writer while indexer is on if !indexing_in_progress_clone.load(Ordering::Relaxed) { - if let Err(e) = update_mempool(&db, client.clone()).await { + if let Err(e) = update_mempool(&db, client.clone(), Some(network)).await { error!("Failed to update mempool: {e}"); } } @@ -634,6 +635,15 @@ async fn launch() -> _ { rpc::tokentoken::list_tokens, ], ) + .mount( + "/ido", + routes![ + rpc::ido::list_idos, + rpc::ido::get_ido_by_id, + rpc::ido::get_ido_by_offering_token, + rpc::ido::list_ido_entries, + ], + ) .mount("/", routes![rpc::health::health]) .attach(cors) } diff --git a/src/rpc/err.rs b/src/rpc/err.rs index f24f97c..31b3ff8 100644 --- a/src/rpc/err.rs +++ b/src/rpc/err.rs @@ -35,6 +35,7 @@ pub enum ApiErrorCode { // 404 Not Found codes PriceNotFound, PoolNotFound, + IdoNotFound, // 500 Internal Server Error codes InternalError, @@ -61,6 +62,7 @@ impl fmt::Display for ApiErrorCode { // 404 codes Self::PriceNotFound => "PRICE_NOT_FOUND", Self::PoolNotFound => "POOL_NOT_FOUND", + Self::IdoNotFound => "IDO_NOT_FOUND", // 500 codes Self::InternalError => "INTERNAL_ERROR", // 503 codes diff --git a/src/rpc/ido.rs b/src/rpc/ido.rs new file mode 100644 index 0000000..c75baa1 --- /dev/null +++ b/src/rpc/ido.rs @@ -0,0 +1,214 @@ +// 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 + +use crate::db::blob::{blob_to_display_hex, display_hex_to_blob}; +use crate::db::DB; +use crate::rpc::err::{bad_request, db_error, not_found, ApiErrorCode, CachedApiResult}; +use crate::rpc::response::{cached_ok, CACHE_NONE}; +use bitcoincash::{TokenID, Txid}; +use rocket::{get, State}; +use serde_json::Value; + +const LIST_DEFAULT_LIMIT: i64 = 20; +const LIST_MAX_LIMIT: i64 = 100; + +/// Maximum number of comma-delimited owner_nfthash filters accepted per request. +const MAX_OWNER_NFTHASH_FILTERS: usize = 20; + +/// Length in bytes of an owner_nfthash. +const OWNER_NFTHASH_LEN: usize = 32; + +/// List IDOs with optional filters and pagination. +/// +/// Filters: +/// - `is_valid`: boolean filter on the is_valid flag +/// - `status`: one of PREINIT, ACTIVE, DISTRIBUTING, DISTRIBUTED +/// - `offered_token_id`: 64-char hex token ID (reversed display format) +/// +/// Pagination: `offset` (default 0), `limit` (default 20, max 100) +#[get("/list?&&&&")] +pub async fn list_idos( + is_valid: Option, + status: Option, + offered_token_id: Option<&str>, + offset: Option, + limit: Option, + db: &State, +) -> CachedApiResult { + let offset = offset.unwrap_or(0).max(0); + let limit = limit.unwrap_or(LIST_DEFAULT_LIMIT).clamp(1, LIST_MAX_LIMIT); + + let offered_token_id_blob = offered_token_id + .map(|s| { + display_hex_to_blob::(s).map_err(|e| { + bad_request( + ApiErrorCode::InvalidParameters, + &format!("Invalid offered_token_id: {e}"), + ) + }) + }) + .transpose()?; + + let items = crate::db::ido::list_idos( + &db.ido_r, + is_valid, + status, + offered_token_id_blob, + offset, + limit, + ) + .await + .map_err(db_error)?; + + Ok(cached_ok(serde_json::to_value(items).unwrap(), CACHE_NONE)) +} + +/// Get a single IDO by id token ID. Returns null if not found. +#[get("/by-id/")] +pub async fn get_ido_by_id(id: &str, db: &State) -> CachedApiResult { + let blob = display_hex_to_blob::(id).map_err(|e| { + bad_request( + ApiErrorCode::InvalidParameters, + &format!("Invalid offering_token_id: {e}"), + ) + })?; + + let item = crate::db::ido::get_ido_by_preinit_txid(&db.ido_r, blob) + .await + .map_err(db_error)?; + + Ok(cached_ok(serde_json::to_value(item).unwrap(), CACHE_NONE)) +} + +/// Get a single IDO by its offering token ID. Returns null if not found. +#[get("/by-offering-token/")] +pub async fn get_ido_by_offering_token( + offering_token_id: &str, + db: &State, +) -> CachedApiResult { + let blob = display_hex_to_blob::(offering_token_id).map_err(|e| { + bad_request( + ApiErrorCode::InvalidParameters, + &format!("Invalid offering_token_id: {e}"), + ) + })?; + + let item = crate::db::ido::get_ido_by_offering_token_id(&db.ido_r, blob) + .await + .map_err(db_error)?; + + Ok(cached_ok(serde_json::to_value(item).unwrap(), CACHE_NONE)) +} + +/// Parse the comma-delimited `owner_nfthash` query parameter into raw byte +/// filters. Returns an empty vec when the parameter is absent. Rejects more +/// than [`MAX_OWNER_NFTHASH_FILTERS`] values, blank entries, and invalid hex. +fn parse_owner_nfthash_filters( + owner_nfthash: Option<&str>, +) -> Result>, rocket::response::status::Custom>> { + let Some(raw) = owner_nfthash else { + return Ok(Vec::new()); + }; + + let parts: Vec<&str> = raw + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + if parts.len() > MAX_OWNER_NFTHASH_FILTERS { + return Err(bad_request( + ApiErrorCode::InvalidParameters, + &format!("Too many owner_nfthash filters (max {MAX_OWNER_NFTHASH_FILTERS})"), + )); + } + + parts + .into_iter() + .map(|s| { + let bytes = hex::decode(s).map_err(|e| { + bad_request( + ApiErrorCode::InvalidParameters, + &format!("Invalid owner_nfthash '{s}': {e}"), + ) + })?; + if bytes.len() != OWNER_NFTHASH_LEN { + return Err(bad_request( + ApiErrorCode::InvalidParameters, + &format!( + "Invalid owner_nfthash '{s}': expected {OWNER_NFTHASH_LEN} bytes, got {}", + bytes.len() + ), + )); + } + Ok(bytes) + }) + .collect() +} + +/// List entries for an IDO. +/// +/// - `id`: the IDO's public id (preinit txid, 64-char hex) +/// - `distributed`: optional boolean filter +/// - `owner_nfthash`: optional filter by owner nfthash (hex). Multiple hashes +/// may be supplied comma-delimited; up to 20 per request. An entry matches +/// if its owner_nfthash equals any of the supplied values. +/// +/// Pagination: `offset` (default 0), `limit` (default 20, max 100) +#[get("//entries?&&&")] +pub async fn list_ido_entries( + id: &str, + distributed: Option, + owner_nfthash: Option<&str>, + offset: Option, + limit: Option, + db: &State, +) -> CachedApiResult { + let offset = offset.unwrap_or(0).max(0); + let limit = limit.unwrap_or(LIST_DEFAULT_LIMIT).clamp(1, LIST_MAX_LIMIT); + + let owner_nfthashes = parse_owner_nfthash_filters(owner_nfthash)?; + + let (internal_id, preinit_txid_hex) = resolve_ido(id, &db.ido_r).await?; + + let items = crate::db::ido::list_ido_entries( + &db.ido_r, + internal_id, + &preinit_txid_hex, + distributed, + &owner_nfthashes, + offset, + limit, + ) + .await + .map_err(db_error)?; + + Ok(cached_ok(serde_json::to_value(items).unwrap(), CACHE_NONE)) +} + +async fn resolve_ido( + id: &str, + pool: &sqlx::SqlitePool, +) -> Result<(i64, String), rocket::response::status::Custom>> { + let preinit_blob = display_hex_to_blob::(id).map_err(|e| { + bad_request( + ApiErrorCode::InvalidParameters, + &format!("Invalid IDO id (expected preinit_txid hex): {e}"), + ) + })?; + + let internal_id = crate::db::ido::lookup_internal_id_by_preinit_txid(pool, &preinit_blob) + .await + .map_err(db_error)? + .ok_or_else(|| not_found(ApiErrorCode::IdoNotFound, "IDO not found"))?; + + let preinit_txid_hex = blob_to_display_hex::(&preinit_blob).map_err(|e| { + bad_request( + ApiErrorCode::InvalidParameters, + &format!("Invalid IDO id: {e}"), + ) + })?; + + Ok((internal_id, preinit_txid_hex)) +} diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 52d186f..364f589 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -17,6 +17,7 @@ pub mod candlesticks; pub mod contract; pub mod err; pub mod health; +pub mod ido; pub mod moria; pub mod oracle; pub mod pool; diff --git a/src/utiltest.rs b/src/utiltest.rs index 1545de0..c330ec5 100644 --- a/src/utiltest.rs +++ b/src/utiltest.rs @@ -43,6 +43,8 @@ mod test_utils { oracle_r: pool.clone(), moria_w: pool.clone(), moria_r: pool.clone(), + ido_w: pool.clone(), + ido_r: pool.clone(), } } }