libriften 00ff654 relaxed ido/preinit.cash's
`offeredTokenTotalSupply > fundingOTokenAmount` to `>=`, so a token can
be minted with its whole supply committed to the IDO. At a zero
remainder the oToken authbase cannot hold a zero-amount token output, so
it carries an immutable NFT with the OTOKEN_AUTHBASE_NFT_COMMITMENT
("BCMR") marker instead; preinit grows 1419 -> 1433 bytes.
- Swap the baked IDO_PREINIT_CONTRACT hex to the new bytecode from
templates/ido.json (contracts.IDOPreInit). The only bytecode changes
are a069 -> a269 and the output#0 commitment check becoming a branch
on the remainder; without the swap no new preinit's rebuilt p2sh32
would match and no IDO would be admitted.
- Relax the announcement-validation mirror of that require from `<=` to
`<`, so a whole-supply genesis is no longer marked invalid.
The state machine never reads output#0's commitment or amount, and
build_preinit_bytecode bakes no contract length (the size feeds only the
unlock bytecode, which the indexer does not build), so nothing else
moves. The other 14 baked contracts still match the current templates,
and the schema and announcement format are unchanged, so IDO_DB_VERSION
stays at 5.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4475 lines
220 KiB
Rust
4475 lines
220 KiB
Rust
// 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<Integer> for IntegerAsStr {
|
|
fn serialize_as<S>(value: &Integer, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
serializer.serialize_str(&value.to_string())
|
|
}
|
|
}
|
|
|
|
impl<'de> DeserializeAs<'de, Integer> for IntegerAsStr {
|
|
fn deserialize_as<D>(deserializer: D) -> Result<Integer, D::Error>
|
|
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;
|
|
// 0b00001000
|
|
const ITEM_TYPE_NFT_OWNER: u8 = 0x08;
|
|
// offering entry commitment flags
|
|
const OFFERING_ENTRY_FLAG_LUTV: u8 = 0x10; // has lockup timeval/discount
|
|
const OFFERING_ENTRY_FLAG_XWNT: u8 = 0x20; // exchange with native BCH (set iff the IDO's xTokenCategory is native)
|
|
|
|
static PERMANENT_LIQUIDITY_SHARE_DENOMINATOR: LazyLock<Integer> =
|
|
LazyLock::new(|| Integer::from(100_000_000i64));
|
|
|
|
// The minimum PLP share and minimum PLP-after-discount are no longer hardcoded:
|
|
// they are pinned per-IdoParams NFT (minPlpShare / minPlpAfterDiscount, numerators
|
|
// over PERMANENT_LIQUIDITY_SHARE_DENOMINATOR) and enforced against the offering.
|
|
|
|
// The ORB IdoParams NFT category (per network) lives in
|
|
// [`crate::db::orbconstants`]; every preinit must include this NFT (input #1,
|
|
// preserved at output #10). Its commitment carries the economic parameters
|
|
// (platform fee, execution fees, offering-duration window, delphi category,
|
|
// platform-fee nfth, permanent-pool platform nfth) that the announced IDO
|
|
// parameters are validated against.
|
|
|
|
// (OP_PUSH8 "CldIdo00" OP_DROP), found at the start of every ido
|
|
// state machine redeem script.
|
|
pub const IDO_SIGNATURE: &[u8] = &[0x08, 0x43, 0x6c, 0x64, 0x49, 0x64, 0x6f, 0x30, 0x30, 0x75];
|
|
|
|
static OFFERING_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
|
hex::decode("1178d100876478d101207f7578879169686d0089c0ce01207f75c0519c637600ce8800cf517f755f84518867c0009d00d000d394765479a269765579950500e876481796005c7900a063577900a069587900a0695c79587995048033e1015a7995a169785d7995587995048033e1010400e1f5059596776e947b757c7800a0696854790087535e7900a06376608577687863760120857768005f7900a0635f795680547958807e77686e7e51d28851d15779517e8851d356799d5800cf557f77547f757e557958807e567958807e547958807e607956807e5f7960798277009c637859797eaa776776827701209d680120787e5f797e02aa207caa7e01877e51cd8854796351cc5779a26960798277009c6352d159798852d3009d52d2527988535979008ac4549d67525979008ac4539d686752d15a798852d35779a269525152807e60797e52cd8860798277009c6353d159798853d3009d53d2527988545979008a555979008ac4569d67535979008a545979008ac4559d686800cf517f77547f758100cc00c6527993a26900cd00c78800cf557f77547f758100cf557f75788b54807e00d28800ce00d1886d6d6d6d6d686d6d6d6d6d51").unwrap()
|
|
});
|
|
static OFFERING_ENTRY_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
|
hex::decode("75c0519dc0ce01207f7500ce01207f758800cf517f755f845288c0cf517f7501208401008763c0c878c88876c9529d687551").unwrap()
|
|
});
|
|
static LAUNCHER_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
|
hex::decode("07cf557f77547f7500890902aa207caa7e01877e5189c0009d00c852c88852c9529d00c854c88854c9549d00c855c88855c9559d00ce01207f7551ce78527e8851cf517f755f8401008853ce01207f7555798853cf567f75817600a06953527978d100876478d101207f7578879169686d51d0547aa07651d0557aa09b63537952799f696851008a8100a16301205c797e56797e556085537956807e0058807e52d058807e0058807e00d28800d154798800d3009d00cd788851cd788851cc52c6a26951d152ce8851d352d09d51d2008852d10088c4549d75675279517e00d18800d3009d766355ca827755ca7853947f77527f758155ca527953945279947f77787f75526085557956807e51008a7e0058807e52d058807e0058807e00d288c15a7f755979827751807e59797e01207e5f797e787e518a00cd8801205f797e59797e52cd8852cc52c6a26952d152ce8852d352d09d52d2008854d10088c4559d6d756754ca827754ca7853947f77527f758154ca527953945279947f77787f7551d07600a06354d152ce8854d3789d54d20088012060797e5a797e54cd8855d10088c4569d6754d10088c4559d6852567956807e51008a7e0058807e7858807e0058807e00d28801205f797e5e7981009c6301007768c15a7f755d79827751807e5d797e5c79827751807e5c797e5b79827751807e5b797e01207e5a797e547e5f797e787e01207e0111797e53797e518a00cd8852d152ce8852d352d05379949d52cc52c6a269520052807e5d797e52cd8852d200886d6d756855798277518056797e59797e518a51cd88545b797e51d28851cc51c6a26951d153798851d3009d686d6d6d6d6d6d51").unwrap()
|
|
});
|
|
static DISTRIBUTOR_DEPLOY_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
|
hex::decode("07cf517f77567f75008908cf5b7f77587f7581518908cf01137f77587f75528909cf011b7f77587f75815389c0009d00c852c88852c9529d00008a8100ce01207f7551ce01207f75788851cf517f755f84538851cf517f755176ca7cca82770132940120947f7701207f750000537960840100876451cf517f77567f75817b757c51cf577f77587f758177687800a2697600a26952d352d051d0949d587a810000567901208401008764527900a26951c6765479950400e1f50596537a757c6b7c6c765379947b757c53cc5279a26953d1008854cc5379a26954d10088756753d05379950400e1f505967b757c53d0527994777600a06353d3789d53d153ce886753d10088687800a06354d352799d54d153ce886754d100886868547900a063012056797e5e797e51cd8851d1587988565551807e5c797e597956799356807e51d28851d3009d55d351d09d55d152ce88525152807e5f797e55cd8855d200886751d351d09d51d152ce88012056797e5d797e51cd8851d20088687600a06301205a797e5d797e53cd886753cd016a88687c00a06301205a797e5c797e54cd886754cd016a886800cf577f77547f75817651a06300cf517f7500008a7e788c54807e00518a53799358807e00528a7e00538a55799358807e00d28800ce00d18800c700cd8800d000d39d52cf52d28852ce52d18852c752cd88675500008a7e00518a53799358807e00528a7e00538a55799358807e00d28800d158798800d3009d01205a797e5d797e00cd8852d100885457790120840100876475536876ce59798876cf517f755f845488756855557a00a063755668c4789e6376d10088c4788b9d686d6d6d6d6d6d6d7551").unwrap()
|
|
});
|
|
static DISTRIBUTOR_REFUND_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
|
hex::decode("08cf01137f77587f75008908cf011b7f77587f755189c0009d00ce01207f7551ce01207f75788851cf517f755f8453885176ca7cca82770132940120947f7701207f7551cf517f75760120840100876451cc51c6a26951d100886751d352d09d51d152ce886801207b7e54797e51cd8800cf577f77547f75817651a06300cf517f7500cf517f77567f757e788c54807e00cf5b7f77587f757e00008a7e00518a7e00d28800ce00d18800c700cd88c4529e6352d10088c4539d686755608500cf517f77567f757e00cf5b7f77587f757e00008a7e00518a7e00d288527900d18800d3009d012054797e55797e00cd8852d100885352790120840100876475526876ce54798876cf517f755f845488c4539e6353d10088c4549d6875686d6d7551").unwrap()
|
|
});
|
|
static EXECUTION_FEE_PAYOUT_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
|
hex::decode("c0ce00ce01207f758800cf517f755f84528800cf577f77547f758151a169c0cf517f7701207f7501207c7e7c7e52cd8852ccc0c6a2").unwrap()
|
|
});
|
|
static OFFERING_INITIATOR_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
|
hex::decode("0902aa207caa7e01877e00891178d100876478d101207f7578879169686d51890ecd567f75066a0442434d528791695289c0c9009dc0c85b79c8885a79c9577a9d5979ce827701209d567900a263c0c85b79c8885a79c957799d68c0c800d1788800d2578800d3009d00cd5a7a88c08bc0c878c88876c9547a9d76ca827778ca7853947f77527f75817bca7b53945279947f777c7f75c05293c0c878c88876c9557a9d76ca827778ca7853947f77527f75817bca7b53945279947f777c7f75c05393c0c878c88876c9567a9d76ca827778ca7853947f77527f75815178529302ff00a063755367785293014ba063755268685379ca537a5394537a947b947f77525352807e5a797e7c7e008a54cd8854cc7cc6a26954d10088c05493c0c878c88876c9567a9d76ca827778ca7853947f77527f75815178529302ff00a063755367785293014ba063755268685379ca537a5394537a947b947f77525352807e59797e7c7e008a55cd8855cc7cc6a26955d100885779d07600a0690100557a7e04000000007e51d28851d15479527e8851d3789d51cd537a008a885153d28853d153798853d3009d53cd7b008a88525352807e557a7e52cd8852cc5579c6a26952d1557ace8852d39d52d20088c05593c0c878c88876c9537a9d76c7827778c77853947f77527f75817bc77b53945279947f777c7f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a776856cd8856d100885778518a57528ac4589e635878518a58528ac4599e635978518a59528ac45a9e635a78518a5a528ac45b9e635b78518a5b528ac45c9d686868686d7551").unwrap()
|
|
});
|
|
static OFFERING_INTEGRATED_TIMELOCKED_P2NFTH_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
|
hex::decode("c0cf527f7701207f75c0cf01227f77815479ce01207f757b88537acf567f75817600a0699f6978cf7bce7eaa88c0cf517f77517f7581c0c85279c8887cc99c").unwrap()
|
|
});
|
|
|
|
static IDO_INITIATOR_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
|
hex::decode("c0ce827701209dc0cf827700a0695579827701209dc0c85779c8885679c99dc0c85779c8885679c99d5479529376ca827778ca7853947f77527f75817bca7b53945279947f777c7f755b7f7701207f75c0cfc0ce7eaa88547ac852d1827701209d52d300a069c15a7f755379827751807e53797e5279827751807e7b7e01207e7c7e01207e52d17e01207e537a7e587e52d358807e7b7e02aa207caa7e01877e57cd8857ccc0c6a26957d1c0ce8857d2c0cf8857d3c0d09d525752807e7c7e7658cd8858cc02e803a26958d1008859cd8859cc78c6a26959d178ce8859d278cf8859d37cd09c").unwrap()
|
|
});
|
|
static IDO_POSTLAUNCH_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
|
hex::decode("0401207f7500895e79009c63c0009dc0c9009e69c0cdc0c788c0ccc0c6a269c0d1c0ce88c0d3c0d09dc0d2c0cf88520052807e5e7a7ec0c851c88851c9589d7651cd8851cc51c6a26951d151ce8851d351d09d7652cd8852d10088c0c852c88852c9599d53cd8853cc52c6a26953d152ce8853d352d09d54d10088c4559d006576c39f766b6376ce00876476ce008a5d7987916968768b77686c91666d6d6d6d6d6d6d7551675e79519c63c0009dc0c9009d57790087c0cdc0c788c0d1c0ce88c0d3c0d09dc0d2c0cf88c0c853c88853c9539d53cd53c78853cc53c6a26953d153ce8853d353d09d0120c0cfc0ce7eaa7e5e7a7e000000546576ce00876476ce008a0112798791696876c75579876355796376ce0111798763537978d093547a757c6b7c6b7c6c6c6ec6937b757c6776ce0088527978c693537a757c6b7c6c686776ce5e798763527978d093537a757c6b7c6c6776ce0111798763537978d093547a757c6b7c6b7c6c6c6776ce008868686ec6937b757c686776ce008868768b7776c3a266c0ccc0c6537a93a269c0ccc0c6577a8193a269c0c851c88851c9519d51cd51c78851cc51c6a26951d0537a937600a06351d15f798851d378a26951d200886751d1008868c0c852c88852c9529d52cd52c788547a6352cc52c6547993a26952d100886752cc52c6a26952d05379937600a06352d15c798852d378a26952d200886752d100886875686d6d6d6d6d6d6d6d7551675e7a529dc0009dc0c9009d5979827701209d5779008754ce5d7a8854cf517f755f845588c0c851c88851c9519d54cf5f7f77587f75817600a06351d078a26951ce5d79886851cf0088c0c852c88852c9529d54cf577f77587f75817600a06352796352c678a2696752d078a26952ce5b7988686852cf0088c0c853c88853c9539d53ce5e7988000054cf517f756084010087635d7981547994765d79950400e1f505967653d0a06353d07768765d79950500e87648179654cf01177f77587f758194760500e8764817955e79967800a07800a09a6378567a757c6b7c6b7c6b7c6b7c6c6c6c6c765379a16376557a757c6b7c6b7c6b7c6c6c6c675279557a757c6b7c6b7c6b7c6c6c6c68686d6d6852d052c656796352c67b75770068c0c651c6939353c6937c53799451d053d093537994012001127a7e0113797e5c7900a26952795d7a950400e1f50596537a7894567900a0567900a09a6359796300cc5779a26900d10113798800d356799d00d200884ce7c0c776517f77587f527f527f527f587f587f01207f77517f7501008791567a81567a81567a81567a81567a81567a81c0cec0d188c0c6c0ccc0d0c0d3557955795c7a63c0d0567a757c6b7c6b7c6b7c6b7c6c6c6c6cc0d3557a757c6b7c6b7c6b7c6c6c6cc0c6547a757c6b7c6b7c6c6cc0cc537a757c6b7c6c56797b757c5779776855795579949003a08601765a9552795e7a9578938c7c967b5c7a955279938c7b965b795279937b5279935b7aa269013f7858807e5c7a597f777ec0cd88567a7c947651a269547951a269567a597a94547993567a547993957c7b94537a93729395a17777205c797e4cab78cf7bce7eaac0c776517f77587f75817600a26978013f7f77517f75010087915379557987637663c0d3c0d05379949dc0ccc0c6a26967c0ccc0c6537994a269c0d3c0d09d68c0cec0d188c0cd597f77527f75768100a269013f0058807e787e54795b7f777ec0cd78886d675279011f7f7701207f7554797888527900a063c0cd012057797e0778cf7bce7eaa877e887863c0d1c0ce88c0d353799d67c0cc5379a269686875686d6d75517eaa07ca537f7776aa20787e0f8802e6007f7b63756777680089008a7e0800000000000000000200007e0200007e5f797e0800000000000000007e0800000000000000007e2000000000000000000000000000000000000000000000000000000000000000007e01007e00cd013f52797e01757e53797e8851d1008851cd016a886d756700d10111798800d357799d00d2008851d10113798851d356799d51d200884cbac0c8c08bc888c08bc9c0c98b9dc08bc7517f77587f527f527f527f587f587f75557a81557a81557a81557a81557a81557a81c0cec0d188c0c6c0cc9dc08bcec08bd188c08bc6c08bcc9dc0d0c0d3949003a08601765a955279587a9578938c7c967b567a955279938c7b9655795279937b527993557aa269c0cdc0c788013e7858807ec08bc7597f777ec08bcd88c0d37c947651a269c08bd351a269c0d0557a94547993c08bd0547993957c7b94537a93c08bd3537a9395a1205c797e4ca678cf7bce7eaac0c8c08bc888c08bc9c0c98b9dc08bc776517f77587f75817600a269708763c0d3c0d05279949dc08bd3c08bd09dc0cec0d188c0c6c0cc9dc08bcec08bd188c08bc6c08bcc9dc0cdc0c788c08bcd597f77527f75768100a269013e0058807e787e53795b7f777ec08bcd78886d6778011f7f7701207f75537978887800a063c0cd012056797e0778cf7bce7eaa877e88c0d1c0ce88c0d352799d6875686d6d517eaa00cd07ca537f7776aa2052797e0f8802b9007f7b63756777680089008a7e880800000000000000000200007e0200007e5e797e0800000000000000007e0800000000000000007e2000000000000000000000000000000000000000000000000000000000000000007e013e787e0e75c08c76c8c0c888c0c97cc98b9c7e51cd78886d75686700d1008800cd016a8851d1008851cd016a8868537900a063527952cd8852d35479a26952d10113798852d200886752cd016a8852d10088687600a063527953cd8859796353cc78a26953d100886753d3789d53d10111798853d20088686753cd016a8853d100886801205e7a7e01137a7e54cd88597a6354cc5579537993a26954d100886754cc5579a2697800a06354d35279a26954d15f798854d200886754d100886868556576c49f766b6376d100876476d1008a7654ce008a87916976c0ce008a8791697568768b77686c91666d6d6d6d6d6d6d6d6d75516868").unwrap()
|
|
});
|
|
static IDO_PREINIT_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
|
hex::decode("0902aa207caa7e01877e00890902a9147ca97e01877e5189c0519dc0c800c88800c9529dc0c852c88852c9539dc0c853c88853c9549dc0c854c88854c9559dc0c855c88855c9569dc0c856c88856c9579d5c79009e63c0c858c88858c9599d67c0c858c88858c9599d68c0c857c88857c9589d597981009c5d79009c9b5b7981009c9b63c0d1008852cd00c78852d1008853cd52c78853d1008854cd53c78854d1008855cd54c78855d1008856cd55c78856d1008857cd56c78857d100885c79009e6359cd58c78859cc58c6a26959d158ce8859d358d09d59d258cf8867597981009e5c79009e9a6459cd58c78859d10088686858cd57c78858d100880302010054797ec1014e7f775b7981009c6301005f79009e63015177685e79009c6300cd53798800d10088760251207e5e797e01207e5d797e52797e7b757c67c0c859c88859c9009d00cd016a8800d100885acd5c79885ad1c0c8885ad3009d5ad20100885bd10088c45c9d760200207e5e797e01207ec0c87e52797e7b757c6875675e79009c635d79009c6300cd52798800d10088030051205d797e01207e5c797e787e7767c0c859c88859c9009d53795579950400e1f50596547978935479789455795279a26901205f797e5c797e5b797e008a5c798277009c63012060797e5b797e518a776800cd788800d1c0c88800d352799d78009c6300d20442434d52886700d200886859cd56798859d1c0c88859d353799d59d2008858c7827758c77853947f77527f758158c7527953945279947f77787f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a77685acd78885ad100885bd10088c45c9d035100200114797e01207e0113797e58797e587a757c6b7c6b7c6b7c6b7c6b7c6b7c6c6c6c6c6c6c6d6d6d7568675d79009c6300cd52798800d10088035151205d797e01207e5c797e787e7767c0c859c88859c9009d00cd016a8800d100885acd5279885ad1c0c8885ad3009d5ad201ff885bd10088c45c9d03510020c0c87e01207e5c797e787e77686868c15a7f75787e008ac0cd886d67c0c859c88859c95a9d55ca827755ca7853947f77527f758155ca527953945279947f77787f75012301205e797e5c797e5a797e008a7e5b798277009c63011701205f797e5a797e518a7e776800cdc15a7f7552797e53797e008a8800d1008800ca827700ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686800ca537953945379945279947f77030200005c797e787e008a51cd8851d1008852ca827752ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686852ca537953945379945279947f770302000060797e7853795a937f757e01207e01ff0118797eaa7e785379012b937f777e008a52cd8852d1008853ca827753ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686853ca537953945379945279947f77030200000114797e787e008a53cd8853d1008854ca827754ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686854ca537953945379945279947f77030200000118797e787e008a54cd8854d1008857c7827757c77853947f77527f75815178529302ff00a063755367785293014ba0637552686857c7537953945379945279947f7755cd03020000011d797e52797e8855d1008803020000011c797e56cd8856cc58c6a26956d158ce8856d3011a799d56d2008856ca827756ca7853947f77527f758156ca527953945279947f77787f7557cdc15a7f7501207e01000127797eaa7e52797e008a8857cc59c6a26957d159ce8857d359d09d57d259cf88525752807e011f797e58cd8858d158ce8858d358d0011e79949d58d200886d6d6d6d6d6d6d6d6d6d6d6d6d75686d6d6d6d6d6d7551").unwrap()
|
|
});
|
|
|
|
static STORAGE_CONTRACT: LazyLock<Vec<u8>> =
|
|
LazyLock::new(|| hex::decode("8178c99dc8c0c887").unwrap());
|
|
static P2NFTH_CONTRACT: LazyLock<Vec<u8>> =
|
|
LazyLock::new(|| hex::decode("78cf7bce7eaa87").unwrap());
|
|
static CASHTOKENS_STUDIO_PAY2CATEGORY_CONTRACT: LazyLock<Vec<u8>> =
|
|
LazyLock::new(|| hex::decode("51ce8851d0009d6300cdc0c7886851").unwrap());
|
|
static REBUILD_IPFS_PLACEHOLDER_BCMR_FOR_GENESIS_WITH_AUTHGUARD_CONTRACT: LazyLock<Vec<u8>> =
|
|
LazyLock::new(|| {
|
|
hex::decode("0902a9147ca97e01877e57897c6b00c08851d100887c635ab2756d51cd016a88674d0301404142434445464748494a4b4c4d4e4f505152535455565758595a6162636465666768696a6b6c6d6e6f707172737475767778797a303132333435363738392d5f6b007c8253a26365537f7c76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7b7c7e7c82539f666882760087636d677d537c94007c807e76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7c51937f757e686c7554893a10303132333435363738396162636465667c006b65517f7c5279785f84817f77517f756c7e6b52797c5499817f77517f756c7e6b82009c666d6c5689097f7b827b7c7f777e7e538978a8886b518ac0ce568a65766c537a538a6b74519c66756c766ba804015512207c7e548a01757c7e6b528a6c7c6b65766c537a538a6b74519c66756c6ca87c7e076a0442434d52207c7e51cd886801206c0f51ce8851d0009d6300cdc0c78868517e7e578a00cd8800ce00d18800cf00d28800d000d38800c600cca26952d10088c45387").unwrap()
|
|
});
|
|
|
|
/// The 127-byte commitment of the ORB IdoParams NFT. Byte layout:
|
|
/// \[0\] version (must equal IDO_PARAMS_VERSION)
|
|
/// [1..33] permanentPoolPlatformNfth
|
|
/// [33..65] delphiCategory
|
|
/// [65..97] platformFeeNfth
|
|
/// [97..101] platformFee (pIntLE, raw-equal to platformFeeNumerator)
|
|
/// [101..105] minExpireDuration (pIntLE, seconds)
|
|
/// [105..109] maxExpireDuration (pIntLE, seconds)
|
|
/// [109..113] minPlpAfterDiscount (pIntLE, numerator / PERMANENT_LIQUIDITY_SHARE_DENOMINATOR)
|
|
/// [113..117] minPlpShare (pIntLE, numerator / PERMANENT_LIQUIDITY_SHARE_DENOMINATOR)
|
|
/// [117..121] entryExecutionFee (pIntLE, sats)
|
|
/// [121..125] createExecutionFee (pIntLE, sats)
|
|
/// [125..127] paramsUseFee (pIntLE, sats/100 — not parsed here)
|
|
/// Categories are carried on-chain in VM (little-endian) byte order and are
|
|
/// reversed to display/UI order on parse (matching the announcement parse), so
|
|
/// equality checks against announced parameters remain direct compares. The
|
|
/// nfths are hashes and are kept in their on-chain order.
|
|
const IDO_PARAMS_COMMITMENT_SIZE: usize = 127;
|
|
|
|
/// The only ORB IdoParams NFT commitment version this indexer understands.
|
|
/// Any other value means the params NFT was minted for a newer/incompatible
|
|
/// layout and the IDO is not indexed.
|
|
const IDO_PARAMS_VERSION: u8 = 0x00;
|
|
|
|
pub struct IdoParamsNftCommitment {
|
|
pub version: u8,
|
|
pub permanentPoolPlatformNfth: Vec<u8>,
|
|
pub delphiCategory: Vec<u8>,
|
|
pub platformFeeNfth: Vec<u8>,
|
|
pub platformFee: Integer,
|
|
pub minExpireDuration: Integer,
|
|
pub maxExpireDuration: Integer,
|
|
pub minPlpAfterDiscount: Integer,
|
|
pub minPlpShare: Integer,
|
|
pub entryExecutionFee: Integer,
|
|
pub createExecutionFee: Integer,
|
|
}
|
|
|
|
fn parse_ido_params_nft_commitment(commitment: &[u8]) -> Result<IdoParamsNftCommitment> {
|
|
if commitment.len() != IDO_PARAMS_COMMITMENT_SIZE {
|
|
return Err(anyhow::anyhow!(
|
|
"ido params nft commitment should be {} bytes, got {}",
|
|
IDO_PARAMS_COMMITMENT_SIZE,
|
|
commitment.len()
|
|
));
|
|
}
|
|
Ok(IdoParamsNftCommitment {
|
|
version: commitment[0],
|
|
// The nfths are hashes carried in their on-chain byte order; categories
|
|
// are stored in VM (little-endian) order and the indexer keeps them in
|
|
// display/UI order, so only those reverse on parse.
|
|
permanentPoolPlatformNfth: commitment[1..33].to_vec(),
|
|
delphiCategory: commitment[33..65].iter().copied().rev().collect(),
|
|
platformFeeNfth: commitment[65..97].to_vec(),
|
|
platformFee: vm_number_to_bigint(&commitment[97..101]),
|
|
minExpireDuration: vm_number_to_bigint(&commitment[101..105]),
|
|
maxExpireDuration: vm_number_to_bigint(&commitment[105..109]),
|
|
minPlpAfterDiscount: vm_number_to_bigint(&commitment[109..113]),
|
|
minPlpShare: vm_number_to_bigint(&commitment[113..117]),
|
|
entryExecutionFee: vm_number_to_bigint(&commitment[117..121]),
|
|
createExecutionFee: vm_number_to_bigint(&commitment[121..125]),
|
|
})
|
|
}
|
|
|
|
#[serde_as]
|
|
#[derive(Serialize, Deserialize, Clone)]
|
|
pub struct IpfsBcmrWithPlaceholderMetadata {
|
|
#[serde_as(as = "serde_with::hex::Hex")]
|
|
registryReplaceCalls: Vec<u8>,
|
|
#[serde_as(as = "serde_with::hex::Hex")]
|
|
bcmrUrisReplaceCalls: Vec<u8>,
|
|
}
|
|
|
|
#[serde_as]
|
|
#[derive(Serialize, Deserialize, Clone)]
|
|
pub struct IpfsBcmrWithPlaceholder {
|
|
metadata: IpfsBcmrWithPlaceholderMetadata,
|
|
#[serde_as(as = "serde_with::hex::Hex")]
|
|
contentHash: Vec<u8>,
|
|
#[serde_as(as = "serde_with::hex::Hex")]
|
|
urisBytecode: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone)]
|
|
pub struct IdoPreInitBcmrParameters {
|
|
oToken: Option<IpfsBcmrWithPlaceholder>,
|
|
offering: Option<IpfsBcmrWithPlaceholder>,
|
|
}
|
|
|
|
pub struct IdoPreInitLockParameters {
|
|
preInitBcmr: IdoPreInitBcmrParameters,
|
|
authguardLockingBytecode: Vec<u8>,
|
|
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,
|
|
// The token the offered tokens are exchanged for, display byte order.
|
|
// `None` (JSON null) = the IDO is priced/paid in NATIVE BCH: proceeds ride
|
|
// as satoshi value and the permanent pool is a single-UTXO tokenbch pool.
|
|
// An all-zero (or empty) on-chain category is the native sentinel and
|
|
// parses to `None`.
|
|
#[serde_as(as = "Option<serde_with::hex::Hex>")]
|
|
xTokenCategory: Option<Vec<u8>>,
|
|
}
|
|
|
|
#[serde_as]
|
|
#[derive(Serialize, Deserialize, Clone)]
|
|
pub struct IdoParametersOffering {
|
|
#[serde_as(as = "IntegerAsStr")]
|
|
platformFeeNumerator: Integer,
|
|
#[serde_as(as = "serde_with::hex::Hex")]
|
|
platformFeeNFTH: Vec<u8>,
|
|
#[serde_as(as = "serde_with::hex::Hex")]
|
|
delphiCategory: Vec<u8>,
|
|
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<u8>,
|
|
#[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,
|
|
// The delegation-pool platform NFTH baked into the postlaunch bytecode
|
|
// (on-chain byte order): the platform-fee settlement destination of the
|
|
// permanent pool, whose thin main the postlaunch run() reconstructs from
|
|
// it. Sourced from the IdoParams NFT commitment; the output #7 rebuild
|
|
// proves the postlaunch bytecode was baked with it.
|
|
#[serde_as(as = "serde_with::hex::Hex")]
|
|
permanentPoolPlatformNfth: Vec<u8>,
|
|
// Minimum swap fee of the permanent liquidity pool, baked into the
|
|
// postlaunch bytecode. Since the "CldIdo00" contract revision it is also
|
|
// carried in the preinit announcement; the two copies are cross-checked.
|
|
#[serde_as(as = "IntegerAsStr")]
|
|
permanentLiquidityMinFee: Integer,
|
|
}
|
|
|
|
// Default for `IdoActiveParameters::offeredTokenTotalSupply` so state rows
|
|
// written before the field existed still deserialize. `-1` is the
|
|
// pre-existing-token "unknown supply" sentinel.
|
|
fn default_offered_token_total_supply() -> Integer {
|
|
Integer::from(-1i64)
|
|
}
|
|
|
|
#[serde_as]
|
|
#[derive(Serialize, Deserialize, Clone)]
|
|
pub struct IdoActiveParameters {
|
|
preInitBcmr: IdoPreInitBcmrParameters,
|
|
#[serde_as(as = "serde_with::hex::Hex")]
|
|
collectorNFTH: Vec<u8>,
|
|
#[serde_as(as = "IntegerAsStr")]
|
|
permanentLiquidityShareNumerator: Integer,
|
|
// Carried forward from the preinit parameters so it survives the
|
|
// PreInit→Active transition.
|
|
#[serde_as(as = "IntegerAsStr")]
|
|
#[serde(default = "default_offered_token_total_supply")]
|
|
offeredTokenTotalSupply: Integer,
|
|
#[serde_as(as = "IntegerAsStr")]
|
|
offeredTokenAmount: Integer,
|
|
offering: IdoParametersOffering,
|
|
#[serde_as(as = "serde_with::hex::Hex")]
|
|
permanentPoolPlatformNfth: Vec<u8>,
|
|
#[serde_as(as = "IntegerAsStr")]
|
|
permanentLiquidityMinFee: Integer,
|
|
}
|
|
|
|
#[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<u8>,
|
|
#[serde_as(as = "serde_with::hex::Hex")]
|
|
idoCategory: Vec<u8>,
|
|
#[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<u8>,
|
|
#[serde_as(as = "serde_with::hex::Hex")]
|
|
idoCategory: Vec<u8>,
|
|
#[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<u8>,
|
|
#[serde_as(as = "serde_with::hex::Hex")]
|
|
idoCategory: Vec<u8>,
|
|
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<u8>,
|
|
#[serde_as(as = "serde_with::hex::Hex")]
|
|
idoCategory: Vec<u8>,
|
|
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,
|
|
}
|
|
|
|
// The permanent liquidity pool deployed by the final postlaunch tx. For a
|
|
// token/token IDO it is a two-leg tokentoken-delegation pool whose xToken leg
|
|
// is output #0 and oToken leg (the storage sibling) is output #1. For a NATIVE
|
|
// BCH IDO it is a single-UTXO tokenbch-delegation pool at output #0 (the BCH
|
|
// reserve in the value, the oToken as the CashToken; output #1 is an
|
|
// OP_RETURN) and `xTokenAmount` is then the BCH reserve in satoshis.
|
|
#[serde_as]
|
|
#[derive(Serialize, Deserialize, Clone)]
|
|
pub struct IdoPermanentPoolV0 {
|
|
/// Native BCH: the pool's BCH reserve in satoshis. Token/token: the xToken
|
|
/// leg amount.
|
|
#[serde_as(as = "IntegerAsStr")]
|
|
xTokenAmount: Integer,
|
|
#[serde_as(as = "IntegerAsStr")]
|
|
oTokenAmount: Integer,
|
|
}
|
|
|
|
#[serde_as]
|
|
#[derive(Serialize, Deserialize, Clone)]
|
|
pub struct IdoDistributedState {
|
|
#[serde_as(as = "serde_with::hex::Hex")]
|
|
authguardCategory: Vec<u8>,
|
|
#[serde_as(as = "serde_with::hex::Hex")]
|
|
idoCategory: Vec<u8>,
|
|
permanentPool: Option<IdoPermanentPoolV0>,
|
|
// The accumulated BCH pot (carrier + storages + fee deposits) paid to the
|
|
// platform fee p2nfth at output#4 of the final run tx.
|
|
#[serde_as(as = "IntegerAsStr")]
|
|
platformBchPayout: Integer,
|
|
#[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<PushBytes>` rather than a raw `&[u8]`.
|
|
fn pb(data: &[u8]) -> &PushBytes {
|
|
data.try_into().expect("push slice exceeds maximum length")
|
|
}
|
|
|
|
// P2NFTH outputs lock as bare-p2s: this script IS the locking bytecode (no
|
|
// p2sh32 wrap). nfthash = hash256(nftCommitment ‖ tokenCategory (le bytes)).
|
|
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<Vec<u8>> {
|
|
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<Vec<u8>> {
|
|
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"
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Decode an unlocking-bytecode argument push into a VM number: either a data
|
|
/// push (empty for zero) or an OP_1..OP_16 numeric opcode.
|
|
fn decode_unlock_arg_number(instruction: &Instruction) -> Result<Integer> {
|
|
match instruction {
|
|
Instruction::PushBytes(data) => Ok(vm_number_to_bigint(data.as_bytes())),
|
|
Instruction::Op(op)
|
|
if (opcodes::all::OP_PUSHNUM_1.to_u8()..=opcodes::all::OP_PUSHNUM_16.to_u8())
|
|
.contains(&op.to_u8()) =>
|
|
{
|
|
Ok(Integer::from(
|
|
op.to_u8() - opcodes::all::OP_PUSHNUM_1.to_u8() + 1,
|
|
))
|
|
}
|
|
_ => Err(anyhow::anyhow!(
|
|
"expecting a VM number push in the unlocking bytecode"
|
|
)),
|
|
}
|
|
}
|
|
|
|
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,
|
|
// display byte order; baked into the bytecode in VM (reversed) order. None
|
|
// is native BCH: the contract branches on an empty (0x) category, so an
|
|
// empty push is baked.
|
|
xTokenCategory: Option<&[u8]>,
|
|
) -> Vec<u8> {
|
|
let rev_xtoken_cat: Vec<u8> = xTokenCategory
|
|
.map(|cat| cat.iter().copied().rev().collect())
|
|
.unwrap_or_default();
|
|
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(&rev_xtoken_cat))
|
|
.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<u8> {
|
|
let rev_delphi_cat: Vec<u8> = 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<u8> {
|
|
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
|
|
}
|
|
|
|
// The partial postlaunch bytecode stored in preinit output #7 (via the partial
|
|
// ido initiator bytecode). Composition:
|
|
// <xTokenCategory rev> <permanentLiquidityShare> <price> <platformFeeNFTH>
|
|
// <platformFee> <permanentLiquidityMinFee 2B> <permanentPoolPlatformNfth 32B>
|
|
// <executionFee 4B> contracts.IDOPostLaunch
|
|
// Categories are display byte order here and reversed into VM order when baked
|
|
// (an empty push for a native-BCH xToken); nfths are hashes baked verbatim.
|
|
// platformFee is the real numerator (the offering layer's fee is zeroed for an
|
|
// IDO, but the postlaunch collects the real fee). executionFee is the
|
|
// offering's per-entry execution fee; postlaunch collect() uses it as the
|
|
// minimum the carrier must grow by on every call (spam protection).
|
|
struct PartialPostlaunchParameters<'a> {
|
|
xTokenCategory: Option<&'a [u8]>,
|
|
permanentLiquidityShare: &'a Integer,
|
|
price: &'a Integer,
|
|
platformFeeNFTH: &'a [u8],
|
|
platformFee: &'a Integer,
|
|
permanentLiquidityMinFee: &'a Integer,
|
|
permanentPoolPlatformNfth: &'a [u8],
|
|
executionFee: &'a Integer,
|
|
}
|
|
|
|
fn build_partial_postlaunch_bytecode(params: &PartialPostlaunchParameters) -> Vec<u8> {
|
|
let rev_xtoken_cat: Vec<u8> = params
|
|
.xTokenCategory
|
|
.map(|cat| cat.iter().copied().rev().collect())
|
|
.unwrap_or_default();
|
|
let mut bytecode = Builder::new()
|
|
.push_slice(pb(&rev_xtoken_cat))
|
|
.into_script()
|
|
.to_bytes();
|
|
bytecode.extend_from_slice(&bigint_to_push_opcode(params.permanentLiquidityShare));
|
|
bytecode.extend_from_slice(&bigint_to_push_opcode(params.price));
|
|
bytecode.extend_from_slice(
|
|
&Builder::new()
|
|
.push_slice(pb(params.platformFeeNFTH))
|
|
.into_script()
|
|
.to_bytes(),
|
|
);
|
|
bytecode.extend_from_slice(&bigint_to_push_opcode(params.platformFee));
|
|
// permanentLiquidityMinFee is a 2-byte announcement field, so it always fits
|
|
// this 2-byte push.
|
|
let min_fee_bytes = encode_padded_vm_number(params.permanentLiquidityMinFee, 2).unwrap();
|
|
let execution_fee_bytes = encode_padded_vm_number(params.executionFee, 4).unwrap();
|
|
bytecode.extend_from_slice(
|
|
&Builder::new()
|
|
.push_slice(pb(&min_fee_bytes))
|
|
.push_slice(pb(params.permanentPoolPlatformNfth))
|
|
.push_slice(pb(&execution_fee_bytes))
|
|
.into_script()
|
|
.to_bytes(),
|
|
);
|
|
bytecode.extend_from_slice(&IDO_POSTLAUNCH_CONTRACT);
|
|
bytecode
|
|
}
|
|
|
|
fn build_partial_ido_initiator_bytecode(
|
|
postLaunchPartialBytecode: &[u8],
|
|
permanentPoolOTokenReserveOutpointIndex: &Integer,
|
|
offeringInitiatorOutpointIndex: &Integer,
|
|
) -> Vec<u8> {
|
|
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<u8> {
|
|
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<u8> {
|
|
let rev_ido: Vec<u8> = state.idoCategory.iter().copied().rev().collect();
|
|
let rev_authguard: Vec<u8> = 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<u8> {
|
|
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<Vec<u8>> {
|
|
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<u8> {
|
|
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<u8> = 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<u8> {
|
|
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
|
|
}
|
|
|
|
/// Schema/format version of the ido database, stored in SQLite's
|
|
/// `PRAGMA user_version` (0 on databases created before versioning existed).
|
|
/// Version 2: the ORB IdoParams NFT generation — non-native xToken, parameters
|
|
/// pinned by the params NFT, token/token permanent pool.
|
|
/// Version 3: the postlaunch collect/run lifecycle rework (conf NFT copied
|
|
/// through collect io#4, run(altPPOut) pool-deploy-failure fallback, BCH pot
|
|
/// paid to the platform, executionFee baked into the postlaunch bytecode) and
|
|
/// the bare-p2s p2nfth/storage locks with the flipped nfthash preimage order.
|
|
/// Version 4: the ORB PoolParams removal (the IdoParams commitment carries
|
|
/// permanentPoolPlatformNfth instead of orbPoolParamsCategory; run() drops the
|
|
/// PoolParams io#4 and the altPPOut fallback — the conf NFT is consumed once,
|
|
/// at run() input#4, and is forbidden in init/collect), plus native-BCH
|
|
/// xToken IDOs (nullable xTokenCategory, single-UTXO tokenbch permanent pool).
|
|
/// Version 5: `ido_entry.first_seen` — the unix time a purchase was first
|
|
/// indexed (block MTP for block-first entries, wall clock for mempool-first),
|
|
/// exposed through the entries RPC.
|
|
/// There is no migration from earlier data; delete ido.db and re-index from
|
|
/// scratch.
|
|
const IDO_DB_VERSION: i64 = 5;
|
|
|
|
pub async fn set_db_version(pool: &SqlitePool) -> Result<()> {
|
|
sqlx::query(&format!("PRAGMA user_version = {IDO_DB_VERSION}"))
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn check_db_version(pool: &SqlitePool) -> Result<()> {
|
|
let version: i64 = sqlx::query_scalar("PRAGMA user_version")
|
|
.fetch_one(pool)
|
|
.await?;
|
|
if version != IDO_DB_VERSION {
|
|
return Err(anyhow::anyhow!(
|
|
"ido database version mismatch (expected {IDO_DB_VERSION}, got {version}); \
|
|
there is no migration — delete ido.db to re-index from scratch"
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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,
|
|
-- the xToken amount the buyer paid (token units of xTokenCategory)
|
|
supply_amount INTEGER NOT NULL,
|
|
-- the offering-token amount the buyer purchased
|
|
demand_amount INTEGER NOT NULL,
|
|
lockup_timeval INTEGER NOT NULL,
|
|
discount INTEGER NOT NULL,
|
|
-- Unix timestamp (seconds) the entry was first indexed: the
|
|
-- block's MTP when first seen in a confirmed block, wall-clock
|
|
-- time when first seen in the mempool. Written once (the insert is
|
|
-- ON CONFLICT DO NOTHING), so a mempool-seen purchase keeps its
|
|
-- mempool timestamp when the tx later confirms.
|
|
first_seen INTEGER NOT NULL DEFAULT 0,
|
|
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<u8>,
|
|
init_txid: Option<Vec<u8>>,
|
|
launch_txid: Option<Vec<u8>>,
|
|
otoken_genesis_txid: Option<Vec<u8>>,
|
|
offering_token_id: Option<Vec<u8>>,
|
|
offered_token_id: Option<Vec<u8>>,
|
|
status: String,
|
|
parameters: Vec<u8>,
|
|
state: Vec<u8>,
|
|
// txid of the head tx of the txchain at the current state
|
|
txchain_head: Option<Vec<u8>>,
|
|
is_valid: bool,
|
|
is_token_created_at_preinit: bool,
|
|
created_at: i64,
|
|
launched_at: Option<i64>,
|
|
}
|
|
impl IdoDBRecord {
|
|
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Self> {
|
|
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<Option<IdoDBRecord>> {
|
|
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<bool> {
|
|
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; 11] = &[
|
|
0x6a, 0x4c, 0xbd, // OP_RETURN OP_PUSHDATA1 (189)
|
|
0x43, 0x6c, 0x64, 0x49, 0x64, 0x6f, 0x30, 0x30, // CldIdo00
|
|
];
|
|
|
|
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();
|
|
let sig = IDO_PREINIT_ANNOUNCEMENT_SIGNATURE;
|
|
bytes.len() >= sig.len() && &bytes[0..sig.len()] == sig
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
fn deserialize_ipfs_bcmr_with_placeholder(
|
|
bcmr_data: &[u8],
|
|
) -> Result<Option<IpfsBcmrWithPlaceholder>> {
|
|
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<IpfsBcmrWithPlaceholder>) -> Vec<u8> {
|
|
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<Option<IpfsBcmrWithPlaceholder>> {
|
|
deserialize_ipfs_bcmr_with_placeholder(&extract_data_from_storage_script_with_data_and_size(
|
|
script,
|
|
)?)
|
|
}
|
|
|
|
struct IdoContext {
|
|
preinit_txid: Vec<u8>,
|
|
init_txid: Option<Vec<u8>>,
|
|
launch_txid: Option<Vec<u8>>,
|
|
otoken_genesis_txid: Option<Vec<u8>>,
|
|
status: String,
|
|
parameters: IdoParameters,
|
|
state: IdoState,
|
|
is_valid_ido: bool,
|
|
is_token_created_at_preinit: bool,
|
|
offered_token_id: Option<Vec<u8>>,
|
|
offering_token_id: Option<Vec<u8>>,
|
|
head_txid: Vec<u8>,
|
|
created_at: i64,
|
|
launched_at: Option<i64>,
|
|
}
|
|
|
|
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,
|
|
/// Parsed commitment of the ORB IdoParams NFT preserved at output #10;
|
|
/// None when the NFT is missing or unparseable (the ido is then invalid).
|
|
ido_params_commitment: Option<IdoParamsNftCommitment>,
|
|
}
|
|
|
|
fn parse_ido_preinit_tx_params(
|
|
tx: &Transaction,
|
|
ido_params_category: &[u8],
|
|
errors: &mut Vec<Error>,
|
|
invalid_ido_reasons: &mut Vec<Error>,
|
|
) -> Option<IdoPreinitParseParamsResult> {
|
|
// 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() < 189 {
|
|
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[172..180]) < 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<IpfsBcmrWithPlaceholder> = 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// The ORB IdoParams NFT is spent via use() at input #1 and
|
|
// preserved at output #10. Its commitment is the only readable
|
|
// on-chain source for permanentPoolPlatformNfth: that value is
|
|
// baked into the partial postlaunch bytecode, but the postlaunch
|
|
// lives in output #7 as a P2SH32 hash, so it cannot be read back
|
|
// out.
|
|
// permanentLiquidityMinFee, by contrast, is carried in the
|
|
// announcement (data[170..172]). Both feed the output #7 rebuild
|
|
// in parse_ido_preinit_tx, which is what actually proves the ido
|
|
// initiator storage was built with these inputs. The preinit
|
|
// covenant does not reference the NFT on-chain, so enforcing the
|
|
// match is entirely the indexer's job; a missing NFT or any
|
|
// mismatched parameter means this is not an ORB IDO.
|
|
let mut ido_params_commitment: Option<IdoParamsNftCommitment> = None;
|
|
match tx.output.get(10).and_then(|out| out.token.as_ref()) {
|
|
Some(token) if token.has_nft() => {
|
|
let category: Vec<u8> = token.id.to_blob().iter().copied().rev().collect();
|
|
if category != ido_params_category {
|
|
invalid_ido_reasons.push(anyhow::anyhow!(
|
|
"output#10 nft category != IDO_PARAMS_NFT_CATEGORY"
|
|
));
|
|
is_valid_ido = false;
|
|
} else {
|
|
match parse_ido_params_nft_commitment(&token.commitment) {
|
|
Ok(value) => {
|
|
ido_params_commitment = Some(value);
|
|
}
|
|
Err(err) => {
|
|
invalid_ido_reasons.push(anyhow::anyhow!(
|
|
"failed to parse the ido params nft commitment: {}",
|
|
err
|
|
));
|
|
is_valid_ido = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
invalid_ido_reasons
|
|
.push(anyhow::anyhow!("ido params nft is missing at output#10"));
|
|
is_valid_ido = false;
|
|
}
|
|
}
|
|
|
|
let preinit_parameters = IdoPreInitParameters {
|
|
preInitBcmr: IdoPreInitBcmrParameters {
|
|
offering: offeringBcmr,
|
|
oToken: oTokenBcmr,
|
|
},
|
|
authguardLockingBytecode,
|
|
offering: IdoParametersOffering {
|
|
platformFeeNFTH: data[8..40].to_vec(),
|
|
platformFeeNumerator: vm_number_to_bigint(&data[40..44]),
|
|
// On-chain in VM order; kept in display/UI order.
|
|
delphiCategory: data[44..76].iter().copied().rev().collect(),
|
|
launchConditions: IdoParametersOfferingLaunchConditions {
|
|
expiresAt: vm_number_to_bigint(&data[76..82]),
|
|
deployThreshold: vm_number_to_bigint(&data[82..90]),
|
|
immediateDeployThreshold: vm_number_to_bigint(&data[90..98]),
|
|
},
|
|
offer: IdoParametersOfferingOffer {
|
|
maxDiscountRateNumerator: vm_number_to_bigint(&data[98..102]),
|
|
discountAnnualRateNumerator: vm_number_to_bigint(&data[102..106]),
|
|
priceNumerator: vm_number_to_bigint(&data[106..122]),
|
|
minOffer: vm_number_to_bigint(&data[122..130]),
|
|
// On-chain in VM order; kept in display/UI order. An
|
|
// all-zero category is the native-BCH sentinel and
|
|
// normalizes to None.
|
|
xTokenCategory: if data[130..162].iter().all(|&b| b == 0) {
|
|
None
|
|
} else {
|
|
Some(data[130..162].iter().copied().rev().collect())
|
|
},
|
|
},
|
|
executionFee: vm_number_to_bigint(&data[162..166]),
|
|
},
|
|
permanentLiquidityShareNumerator: vm_number_to_bigint(&data[166..170]),
|
|
offeredTokenTotalSupply: vm_number_to_bigint(&data[172..180]),
|
|
offeredTokenAmount: vm_number_to_bigint(&data[180..188]),
|
|
authguardNftOutputIndex: vm_number_to_bigint(&data[188..189]),
|
|
// From the ORB IdoParams NFT commitment (output #10). Absent
|
|
// when the NFT is missing/invalid, in which case is_valid_ido
|
|
// is already false and the output #7 rebuild will not match.
|
|
permanentPoolPlatformNfth: ido_params_commitment
|
|
.as_ref()
|
|
.map(|c| c.permanentPoolPlatformNfth.clone())
|
|
.unwrap_or_default(),
|
|
// Carried in the announcement OP_RETURN (2 bytes).
|
|
permanentLiquidityMinFee: vm_number_to_bigint(&data[170..172]),
|
|
};
|
|
|
|
// The xToken may be a real token or native BCH (`None`, the
|
|
// all-zero on-chain sentinel): a native IDO takes payment via
|
|
// the offering's XWNT path and its permanent pool is a
|
|
// single-UTXO tokenbch pool. No rejection here.
|
|
// The genesis transaction mints the whole supply: the funded
|
|
// amount (offeredTokenAmount + the permanent liquidity reserve)
|
|
// plus the remainder held by the oToken authbase. Equal is
|
|
// allowed — the authbase then carries no tokens and holds an
|
|
// immutable NFT with the OTOKEN_AUTHBASE_NFT_COMMITMENT ("BCMR")
|
|
// marker instead, since a token output cannot carry zero
|
|
// fungible tokens and no NFT. Below the funded amount the
|
|
// preinit contract rejects the genesis step.
|
|
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
|
|
.offering
|
|
.offer
|
|
.discountAnnualRateNumerator
|
|
< 0
|
|
{
|
|
invalid_ido_reasons.push(anyhow::anyhow!(
|
|
"preinit_parameters.offering.offer.discountAnnualRateNumerator < 0"
|
|
));
|
|
is_valid_ido = false;
|
|
}
|
|
|
|
if preinit_parameters.offering.offer.maxDiscountRateNumerator < 0 {
|
|
invalid_ido_reasons.push(anyhow::anyhow!(
|
|
"preinit_parameters.offering.offer.maxDiscountRateNumerator < 0"
|
|
));
|
|
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;
|
|
}
|
|
// The IdoParams NFT commitment (parsed above) pins the economic
|
|
// parameters the announcement must agree with.
|
|
if let Some(ref ido_params) = ido_params_commitment {
|
|
// The remaining fields are read at v0 commitment offsets, so a
|
|
// different version could mean a different layout: reject on the
|
|
// version alone and skip the parameter comparisons.
|
|
if ido_params.version != IDO_PARAMS_VERSION {
|
|
invalid_ido_reasons.push(anyhow::anyhow!(
|
|
"ido params nft version {} is incompatible with the indexer (expected {})",
|
|
ido_params.version,
|
|
IDO_PARAMS_VERSION
|
|
));
|
|
is_valid_ido = false;
|
|
} else {
|
|
if preinit_parameters.offering.delphiCategory != ido_params.delphiCategory {
|
|
invalid_ido_reasons.push(anyhow::anyhow!(
|
|
"offering.delphiCategory != ido_params.delphiCategory"
|
|
));
|
|
is_valid_ido = false;
|
|
}
|
|
if preinit_parameters.offering.platformFeeNFTH != ido_params.platformFeeNfth
|
|
{
|
|
invalid_ido_reasons.push(anyhow::anyhow!(
|
|
"offering.platformFeeNFTH != ido_params.platformFeeNfth"
|
|
));
|
|
is_valid_ido = false;
|
|
}
|
|
if preinit_parameters.offering.platformFeeNumerator
|
|
!= ido_params.platformFee
|
|
{
|
|
invalid_ido_reasons.push(anyhow::anyhow!(
|
|
"offering.platformFeeNumerator != ido_params.platformFee"
|
|
));
|
|
is_valid_ido = false;
|
|
}
|
|
if preinit_parameters.offering.executionFee != ido_params.entryExecutionFee
|
|
{
|
|
invalid_ido_reasons.push(anyhow::anyhow!(
|
|
"offering.executionFee != ido_params.entryExecutionFee"
|
|
));
|
|
is_valid_ido = false;
|
|
}
|
|
// permanentPoolPlatformNfth is taken directly from this
|
|
// commitment (see preinit_parameters above), so there is
|
|
// nothing to compare here — the output #7 rebuild in
|
|
// parse_ido_preinit_tx proves the postlaunch bytecode was
|
|
// baked with it.
|
|
// The permanent-liquidity minimums are pinned by the IdoParams
|
|
// NFT (numerators over PERMANENT_LIQUIDITY_SHARE_DENOMINATOR,
|
|
// directly comparable to permanentLiquidityShareNumerator).
|
|
if preinit_parameters.permanentLiquidityShareNumerator
|
|
< ido_params.minPlpShare
|
|
{
|
|
invalid_ido_reasons.push(anyhow::anyhow!(
|
|
"preinit_parameters.permanentLiquidityShareNumerator < ido_params.minPlpShare"
|
|
));
|
|
is_valid_ido = false;
|
|
}
|
|
if &preinit_parameters.offering.offer.maxDiscountRateNumerator
|
|
+ &ido_params.minPlpAfterDiscount
|
|
> preinit_parameters.permanentLiquidityShareNumerator
|
|
{
|
|
invalid_ido_reasons.push(anyhow::anyhow!(
|
|
"preinit_parameters.offering.offer.maxDiscountRateNumerator + ido_params.minPlpAfterDiscount > preinit_parameters.permanentLiquidityShareNumerator"
|
|
));
|
|
is_valid_ido = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// The first output must be the Delphi NFT. Its category must match the
|
|
// announcement's delphiCategory (itself pinned to the IdoParams NFT
|
|
// commitment above), and the commitment timestamp (the current time)
|
|
// must place launchConditions.expiresAt within the offering window the
|
|
// IdoParams NFT allows. 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 delphiTokenCategory: Vec<u8> =
|
|
delphiToken.id.to_blob().iter().copied().rev().collect();
|
|
if delphiTokenCategory != preinit_parameters.offering.delphiCategory {
|
|
invalid_ido_reasons.push(anyhow::anyhow!("delphi nft category in first output != offering.delphiCategory"));
|
|
is_valid_ido = false;
|
|
}
|
|
if delphiToken.commitment.len() < 6 {
|
|
invalid_ido_reasons.push(anyhow::anyhow!(
|
|
"delphi nft commitment too short to contain a timestamp"
|
|
));
|
|
is_valid_ido = false;
|
|
} else {
|
|
let c = &delphiToken.commitment;
|
|
// 48-bit little-endian timestamp (see riftenlabs_defi::delphi::parse_delphi_update).
|
|
let delphi_timestamp =
|
|
u64::from_le_bytes([c[0], c[1], c[2], c[3], c[4], c[5], 0, 0]);
|
|
created_at = delphi_timestamp as i64;
|
|
if let Some(ido_params) = ido_params_commitment
|
|
.as_ref()
|
|
.filter(|p| p.version == IDO_PARAMS_VERSION)
|
|
{
|
|
let offering_duration =
|
|
&preinit_parameters.offering.launchConditions.expiresAt
|
|
- Integer::from(delphi_timestamp);
|
|
if offering_duration < ido_params.minExpireDuration {
|
|
invalid_ido_reasons.push(anyhow::anyhow!("expiresAt - delphi timestamp < ido_params.minExpireDuration"));
|
|
is_valid_ido = false;
|
|
}
|
|
if offering_duration > ido_params.maxExpireDuration {
|
|
invalid_ido_reasons.push(anyhow::anyhow!("expiresAt - delphi timestamp > ido_params.maxExpireDuration"));
|
|
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,
|
|
ido_params_commitment,
|
|
})
|
|
}
|
|
_ => {
|
|
errors.push(anyhow::anyhow!("invalid announcement (2)"));
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct IdoPreinitParseResult {
|
|
parameters: Option<IdoPreInitParameters>,
|
|
state: Option<IdoPreInitState>,
|
|
is_valid_ido: bool,
|
|
offered_token_is_in_supply: bool,
|
|
offered_token_id: Option<Vec<u8>>,
|
|
created_at: i64,
|
|
}
|
|
|
|
fn parse_ido_preinit_tx(
|
|
network: Option<Network>,
|
|
tx: &Transaction,
|
|
errors: &mut Vec<Error>,
|
|
invalid_ido_reasons: &mut Vec<Error>,
|
|
) -> IdoPreinitParseResult {
|
|
let ido_params_category = crate::db::orbconstants::ido_params_nft_category(network);
|
|
let offered_token_is_in_supply: bool;
|
|
let mut is_valid_ido: bool;
|
|
let nullable_preinit_parameters: Option<IdoPreInitParameters>;
|
|
let created_at: i64;
|
|
let ido_params_commitment: Option<IdoParamsNftCommitment>;
|
|
// preinit announcement
|
|
match parse_ido_preinit_tx_params(tx, &ido_params_category, 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;
|
|
ido_params_commitment = output.ido_params_commitment;
|
|
}
|
|
None => {
|
|
offered_token_is_in_supply = false;
|
|
is_valid_ido = false;
|
|
nullable_preinit_parameters = None;
|
|
created_at = 0;
|
|
ido_params_commitment = None;
|
|
}
|
|
}
|
|
|
|
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(),
|
|
),
|
|
)
|
|
.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,
|
|
// In an IDO the offering layer never charges a platform fee;
|
|
// the fee is collected by the postlaunch covenant instead, so
|
|
// every offering-layer contract is instantiated with a zero
|
|
// platformFee.
|
|
&Integer::from(0i64),
|
|
¶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) {
|
|
// Rebuild output #7 from the announcement values plus the NFT-sourced
|
|
// permanentPoolPlatformNfth and compare the P2SH32 hash. This is the
|
|
// only way to verify the postlaunch bytecode baked into output #7
|
|
// (which is stored as a hash) was built with the right inputs.
|
|
let bytecode = build_storage_script_with_data_and_size(
|
|
1,
|
|
&build_partial_ido_initiator_bytecode(
|
|
&build_partial_postlaunch_bytecode(&PartialPostlaunchParameters {
|
|
xTokenCategory: params.offering.offer.xTokenCategory.as_deref(),
|
|
permanentLiquidityShare: ¶ms.permanentLiquidityShareNumerator,
|
|
price: ¶ms.offering.offer.priceNumerator,
|
|
platformFeeNFTH: ¶ms.offering.platformFeeNFTH,
|
|
platformFee: ¶ms.offering.platformFeeNumerator,
|
|
permanentLiquidityMinFee: ¶ms.permanentLiquidityMinFee,
|
|
permanentPoolPlatformNfth: ¶ms.permanentPoolPlatformNfth,
|
|
executionFee: ¶ms.offering.executionFee,
|
|
}),
|
|
&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<Vec<u8>> = 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() {
|
|
// plain storages lock as bare-p2s: the locking bytecode IS
|
|
// the script (no p2sh32 wrap)
|
|
let script = build_storage_script(1u32);
|
|
if script != offeredTokenStorageOut.script_pubkey {
|
|
errors.push(anyhow::anyhow!(
|
|
"offered token locking bytecode does not match, expected bytecode: {}",
|
|
hex::encode(script.to_bytes())
|
|
));
|
|
}
|
|
let token_amount = Integer::from(token.amount.to_int());
|
|
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 the preinit (create) execution fee: sats paid to bare-p2s
|
|
// p2nfth(platformFeeNFTH) outputs, which must cover the
|
|
// createExecutionFee pinned by the IdoParams NFT commitment.
|
|
if let Some(ido_params) = ido_params_commitment
|
|
.as_ref()
|
|
.filter(|p| p.version == IDO_PARAMS_VERSION)
|
|
{
|
|
let mut preinit_paid_fee: u64 = 0;
|
|
let fee_script = build_p2nfth_script(¶ms.offering.platformFeeNFTH);
|
|
for output in &tx.output {
|
|
if fee_script == output.script_pubkey {
|
|
preinit_paid_fee += output.value.to_sat();
|
|
}
|
|
}
|
|
if ido_params.createExecutionFee > 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<Network>,
|
|
tx: &Transaction,
|
|
errors: &mut Vec<Error>,
|
|
invalid_ido_reasons: &mut Vec<Error>,
|
|
) -> Result<IdoContext> {
|
|
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<Network>,
|
|
pool: &SqlitePool,
|
|
tx: &Transaction,
|
|
blockhash: Option<&BlockHash>,
|
|
) -> Result<()> {
|
|
debug!(
|
|
"IDO on_create_ido: {}",
|
|
blob_to_display_hex::<Txid>(&tx.compute_txid().to_blob())?
|
|
);
|
|
let mut errors: Vec<Error> = Vec::new();
|
|
let mut invalid_ido_reasons: Vec<Error> = 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):",
|
|
blob_to_display_hex::<Txid>(&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):",
|
|
blob_to_display_hex::<Txid>(&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<u8>,
|
|
owner_nfthash: Vec<u8>,
|
|
supply_amount: u64,
|
|
demand_amount: u64,
|
|
lockup_timeval: u64,
|
|
discount: u64,
|
|
commitment: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
enum IdoUpdate {
|
|
InitTxId(Vec<u8>),
|
|
LaunchTxId {
|
|
txid: Vec<u8>,
|
|
launched_at: Option<i64>,
|
|
},
|
|
OTokenGenesisTxId(Vec<u8>),
|
|
Status(String),
|
|
// boxed to keep the enum small (clippy::large_enum_variant)
|
|
State(Box<IdoState>),
|
|
Parameters(Box<IdoParameters>),
|
|
OfferedTokenId(Vec<u8>),
|
|
OfferingTokenId(Vec<u8>),
|
|
Entry(IdoUpdateEntry),
|
|
EntryDistributed {
|
|
entry_txid: Vec<u8>,
|
|
},
|
|
}
|
|
|
|
struct IdoAddResult {
|
|
updates: Vec<IdoUpdate>,
|
|
next_output_index: i32,
|
|
}
|
|
|
|
fn rev_blob(v: &[u8]) -> Vec<u8> {
|
|
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<i64> {
|
|
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<i64> {
|
|
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<IdoAddResult> {
|
|
// inputs to check #0, #1, #3
|
|
let mut updates: Vec<IdoUpdate> = 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<u8>;
|
|
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(),
|
|
offeredTokenTotalSupply: preinit_params.offeredTokenTotalSupply.clone(),
|
|
offeredTokenAmount: preinit_params.offeredTokenAmount.clone(),
|
|
offering: preinit_params.offering.clone(),
|
|
permanentPoolPlatformNfth: preinit_params
|
|
.permanentPoolPlatformNfth
|
|
.clone(),
|
|
permanentLiquidityMinFee: preinit_params
|
|
.permanentLiquidityMinFee
|
|
.clone(),
|
|
},
|
|
))));
|
|
updates.push(IdoUpdate::State(Box::new(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: 1, // 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(Box::new(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
|
|
{
|
|
// input#0 is the offering, add entry
|
|
let xTokenCategory = match &context.parameters {
|
|
IdoParameters::Active(active_params) => {
|
|
active_params.offering.offer.xTokenCategory.clone()
|
|
}
|
|
_ => return Err(anyhow::anyhow!("expecting active parameters")),
|
|
};
|
|
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!"));
|
|
}
|
|
if first_output.token.as_ref().unwrap().commitment.len() < 9 {
|
|
return Err(anyhow::anyhow!("Incorrect commitment size at output#0"));
|
|
}
|
|
// entry commitment: [0] type/flags; when the LUTV flag is set
|
|
// it is followed by lockupTimeval (6B) and lockupDiscount (8B)
|
|
let entry_commitment = second_output.token.as_ref().unwrap().commitment.clone();
|
|
if entry_commitment.is_empty() {
|
|
return Err(anyhow::anyhow!("Incorrect commitment size at output#1"));
|
|
}
|
|
// Native BCH (xTokenCategory == None): buyers pay via the
|
|
// offering's XWNT path — the payment rides in the entry's
|
|
// value (there is no xToken storage output) and a freshly
|
|
// minted owner nft sits at output#2 instead of #3. The
|
|
// entry's XWNT flag must agree with the IDO's xToken: it is
|
|
// set iff the xToken is native BCH.
|
|
let is_native = xTokenCategory.is_none();
|
|
if (entry_commitment[0] & OFFERING_ENTRY_FLAG_XWNT != 0) != is_native {
|
|
return Err(anyhow::anyhow!(
|
|
"entry XWNT flag ({}) does not match the IDO xToken (native={})",
|
|
entry_commitment[0] & OFFERING_ENTRY_FLAG_XWNT != 0,
|
|
is_native
|
|
));
|
|
}
|
|
let has_lockup = entry_commitment[0] & OFFERING_ENTRY_FLAG_LUTV != 0;
|
|
if has_lockup && entry_commitment.len() < 15 {
|
|
return Err(anyhow::anyhow!("Incorrect commitment size at output#1"));
|
|
}
|
|
// The offering unlocking bytecode pushes
|
|
// <lockupTimeval> <ownerNFTH> <redeem bytecode>; an empty
|
|
// ownerNFTH signals the contract to mint a fresh owner nft,
|
|
// which then sits at output#3 — its nfthash identifies the
|
|
// entry's owner.
|
|
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.len() < 3 {
|
|
return Err(anyhow::anyhow!(
|
|
"unexpected unlocking bytecode of the offering utxo!"
|
|
));
|
|
}
|
|
let unlock_owner_nfthash = match &instructions[1] {
|
|
Ok(Instruction::PushBytes(data)) => data.as_bytes().to_vec(),
|
|
_ => {
|
|
return Err(anyhow::anyhow!(
|
|
"OP_PUSH expected in the unlocking bytecode of the offering utxo!"
|
|
))
|
|
}
|
|
};
|
|
let owner_nfthash = if unlock_owner_nfthash.len() == 32 {
|
|
unlock_owner_nfthash
|
|
} else if unlock_owner_nfthash.is_empty() {
|
|
// freshly minted owner nft right after the entry's
|
|
// xToken storage (non-native, output#3) or right after
|
|
// the entry itself (native, output#2);
|
|
// nfthash = hash256(commitment ‖ category (le bytes))
|
|
let owner_output = tx
|
|
.output
|
|
.get(if is_native { 2 } else { 3 })
|
|
.ok_or_else(|| anyhow::anyhow!("owner nft output does not exist!"))?;
|
|
let owner_token = owner_output
|
|
.token
|
|
.as_ref()
|
|
.filter(|token| token.has_nft())
|
|
.ok_or_else(|| anyhow::anyhow!("output#3 is not an nft!"))?;
|
|
if owner_token.id.to_blob()
|
|
!= context.offering_token_id.clone().unwrap_or_default()
|
|
{
|
|
return Err(anyhow::anyhow!("owner nft category != offering category"));
|
|
}
|
|
if owner_token.commitment.first() != Some(&ITEM_TYPE_NFT_OWNER) {
|
|
return Err(anyhow::anyhow!(
|
|
"output#3 does not carry an owner nft commitment"
|
|
));
|
|
}
|
|
let mut hash_preimage = owner_token.commitment.to_vec();
|
|
hash_preimage.extend_from_slice(&owner_token.id.to_blob());
|
|
sha256d::Hash::hash(&hash_preimage).as_byte_array().to_vec()
|
|
} else {
|
|
return Err(anyhow::anyhow!(
|
|
"invalid ownerNFTH push in the unlocking bytecode of the offering utxo!"
|
|
));
|
|
};
|
|
// The payment: for a token xToken it sits in the entry's
|
|
// xToken storage at output#2 as a token amount; for native
|
|
// BCH it rides in the entry output's satoshi value (there
|
|
// is no xToken storage output).
|
|
let supply_amount = if is_native {
|
|
second_output.value.to_sat()
|
|
} else {
|
|
let xtoken_storage_output = tx.output.get(2).ok_or_else(|| {
|
|
anyhow::anyhow!("xToken storage output does not exist!")
|
|
})?;
|
|
let xtoken = xtoken_storage_output
|
|
.token
|
|
.as_ref()
|
|
.ok_or_else(|| anyhow::anyhow!("output#2 should carry the xToken!"))?;
|
|
let xtoken_category: Vec<u8> =
|
|
xtoken.id.to_blob().iter().copied().rev().collect();
|
|
if Some(xtoken_category) != xTokenCategory {
|
|
return Err(anyhow::anyhow!(
|
|
"output#2 token category != xTokenCategory"
|
|
));
|
|
}
|
|
xtoken.amount.to_int() as u64
|
|
};
|
|
let demand_amount =
|
|
second_output.token.as_ref().unwrap().amount.to_int() as u64;
|
|
let lockup_timeval = if has_lockup {
|
|
u64::try_from(&decode_padded_vm_number(&entry_commitment[1..7]))
|
|
.unwrap_or(0)
|
|
} else {
|
|
0
|
|
};
|
|
let discount = if has_lockup {
|
|
u64::try_from(&decode_padded_vm_number(&entry_commitment[7..15]))
|
|
.unwrap_or(0)
|
|
} else {
|
|
0
|
|
};
|
|
updates.push(IdoUpdate::Entry(IdoUpdateEntry {
|
|
txid: tx.compute_txid().to_blob(),
|
|
owner_nfthash,
|
|
supply_amount,
|
|
demand_amount,
|
|
lockup_timeval,
|
|
discount,
|
|
commitment: entry_commitment,
|
|
}));
|
|
updates.push(IdoUpdate::State(Box::new(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(Box::new(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(Box::new(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" => {
|
|
// 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")),
|
|
};
|
|
// The offering layer of an IDO charges no platform fee (its platform
|
|
// share output is an OP_RETURN); the platform fee is collected once,
|
|
// by the final postlaunch tx.
|
|
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(Box::new(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.clone(),
|
|
},
|
|
))));
|
|
} 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(Box::new(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.clone(),
|
|
},
|
|
))));
|
|
} 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 prev_state: &IdoPostLaunchState = match &context.state {
|
|
IdoState::PostLaunch(value) => value,
|
|
_ => return Err(anyhow::anyhow!("expecting postlaunch state")),
|
|
};
|
|
// Since the PoolParams removal the confirmation NFT is consumed
|
|
// exactly once, at run() input#4 — init() and collect() forbid any
|
|
// offering-category input, so the tracked chain (headed by the conf
|
|
// NFT) only ever advances with the final run tx. Collects meanwhile
|
|
// only move proceeds into the covenant storages and are not
|
|
// state-relevant. The called method is read from the postlaunch
|
|
// carrier's (input#0) unlocking bytecode, <args...> <functionIndex>
|
|
// <redeem script> — the function-index push sits right before the
|
|
// redeem-script push; run() takes no args: <2> <redeem>.
|
|
let first_input = tx
|
|
.input
|
|
.first()
|
|
.ok_or_else(|| anyhow::anyhow!("Should have first input!"))?;
|
|
let unlock_args: Vec<Instruction> = first_input
|
|
.script_sig
|
|
.instructions()
|
|
.collect::<Result<_, _>>()
|
|
.map_err(|e| anyhow::anyhow!("postlaunch unlocking bytecode: {e}"))?;
|
|
if unlock_args.len() < 2 {
|
|
return Err(anyhow::anyhow!(
|
|
"postlaunch unlocking bytecode has too few pushes"
|
|
));
|
|
}
|
|
let function_index = decode_unlock_arg_number(&unlock_args[unlock_args.len() - 2])?;
|
|
if function_index != 2i64 {
|
|
// The conf NFT cannot be spent by init/collect (the covenant
|
|
// forbids offering-category inputs there), so any other method
|
|
// touching the tracked head is not a valid continuation.
|
|
return Err(anyhow::anyhow!(
|
|
"unexpected postlaunch function index: {}",
|
|
function_index
|
|
));
|
|
}
|
|
// Whether this IDO's xToken is native BCH (single-UTXO tokenbch
|
|
// permanent pool) or a token (two-leg tokentoken pool).
|
|
let is_native = match &context.parameters {
|
|
IdoParameters::Active(active_params) => {
|
|
active_params.offering.offer.xTokenCategory.is_none()
|
|
}
|
|
_ => return Err(anyhow::anyhow!("expecting active parameters")),
|
|
};
|
|
// Final postlaunch run tx output layout (carrier at input#0, the
|
|
// storages at inputs #1..#3, conf NFT consumed at input#4):
|
|
// #0 permanent pool: the tokentoken thin main holding the xToken
|
|
// leg (token amount) — or, for a NATIVE BCH IDO, the whole
|
|
// single-UTXO tokenbch pool (BCH reserve in the value, oToken
|
|
// as the CashToken); OP_RETURN when refunding or when the
|
|
// share computation produced no pool
|
|
// #1 permanent pool oToken leg (the storage sibling; token
|
|
// amount) — OP_RETURN for a native IDO (no sibling) and when
|
|
// no pool was created
|
|
// #2 collector p2nfth: unsold oToken remainder — OP_RETURN when zero
|
|
// #3 collector p2nfth: the collector's xToken share after the
|
|
// platform fee (token amount; BCH value for native) —
|
|
// OP_RETURN when zero
|
|
// #4 platform fee p2nfth: the accumulated BCH pot (carrier +
|
|
// storages + fee deposits), plus the platform's xToken share
|
|
// (token when non-zero; folded into the BCH value for native)
|
|
// #5.. change (no offering/ido category tokens)
|
|
let pool_output = tx
|
|
.output
|
|
.first()
|
|
.ok_or_else(|| anyhow::anyhow!("Should have first output!"))?;
|
|
let collector_otoken_output = tx
|
|
.output
|
|
.get(2)
|
|
.ok_or_else(|| anyhow::anyhow!("Should have third output!"))?;
|
|
updates.push(IdoUpdate::Status("DISTRIBUTED".to_string()));
|
|
let permanent_pool = match pool_output.token.as_ref() {
|
|
// Native BCH: output#0 IS the pool — its value is the BCH
|
|
// reserve (xTokenAmount) and its CashToken is the oToken.
|
|
Some(o_leg) if is_native => Some(IdoPermanentPoolV0 {
|
|
xTokenAmount: Integer::from(pool_output.value.to_sat()),
|
|
oTokenAmount: Integer::from(o_leg.amount.to_int()),
|
|
}),
|
|
Some(x_leg) => {
|
|
let o_leg = tx
|
|
.output
|
|
.get(1)
|
|
.and_then(|output| output.token.as_ref())
|
|
.ok_or_else(|| {
|
|
anyhow::anyhow!("permanent pool oToken output (output#1) is missing!")
|
|
})?;
|
|
Some(IdoPermanentPoolV0 {
|
|
xTokenAmount: Integer::from(x_leg.amount.to_int()),
|
|
oTokenAmount: Integer::from(o_leg.amount.to_int()),
|
|
})
|
|
}
|
|
None => None,
|
|
};
|
|
// The collector's xToken share: a token amount, or (native) the
|
|
// BCH value of the p2nfth output — zero when the slot is an
|
|
// OP_RETURN (no token either way).
|
|
let collector_output = tx.output.get(3);
|
|
let collector_xtoken_amount = if is_native {
|
|
collector_output
|
|
.filter(|output| output.script_pubkey.as_bytes() != [0x6a])
|
|
.map(|output| Integer::from(output.value.to_sat()))
|
|
.unwrap_or(Integer::ZERO)
|
|
} else {
|
|
collector_output
|
|
.and_then(|output| output.token.as_ref())
|
|
.map(|token| Integer::from(token.amount.to_int()))
|
|
.unwrap_or(Integer::ZERO)
|
|
};
|
|
let platform_output = tx.output.get(4);
|
|
// The platform's xToken share: a token amount for a token xToken.
|
|
// For native BCH it is folded into the platform output's value
|
|
// (indistinguishable from the BCH pot on-chain), so it is recovered
|
|
// from the share arithmetic instead: earnedAmount is the full
|
|
// xToken proceeds (the offering layer charges no IDO fee) and the
|
|
// pool + collector legs account for the rest.
|
|
let platform_xtoken_amount = if is_native {
|
|
let pool_x = permanent_pool
|
|
.as_ref()
|
|
.map(|pool| pool.xTokenAmount.clone())
|
|
.unwrap_or(Integer::ZERO);
|
|
let residual = &prev_state.earnedAmount - &pool_x - collector_xtoken_amount.clone();
|
|
if residual > 0 {
|
|
residual
|
|
} else {
|
|
Integer::ZERO
|
|
}
|
|
} else {
|
|
platform_output
|
|
.and_then(|output| output.token.as_ref())
|
|
.map(|token| Integer::from(token.amount.to_int()))
|
|
.unwrap_or(Integer::ZERO)
|
|
};
|
|
let platform_bch_payout = platform_output
|
|
.map(|output| Integer::from(output.value.to_sat()))
|
|
.unwrap_or(Integer::ZERO);
|
|
updates.push(IdoUpdate::State(Box::new(IdoState::Distributed(
|
|
IdoDistributedState {
|
|
authguardCategory: prev_state.authguardCategory.clone(),
|
|
idoCategory: prev_state.idoCategory.clone(),
|
|
permanentPool: permanent_pool,
|
|
platformBchPayout: platform_bch_payout,
|
|
refundAmount: collector_otoken_output
|
|
.token
|
|
.as_ref()
|
|
.map(|token| Integer::from(token.amount.to_int()))
|
|
.unwrap_or(Integer::ZERO),
|
|
discountAmount: prev_state.discountAmount.clone(),
|
|
collectorEarnedAmount: collector_xtoken_amount,
|
|
platformEarnedAmount: &prev_state.platformEarnedAmount + platform_xtoken_amount,
|
|
},
|
|
))));
|
|
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 and first_seen are 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), `blockhash` is None while the tx is only seen in the
|
|
/// mempool, and `first_seen` is the unix time stamped onto new entries (block
|
|
/// MTP or mempool wall clock, see index_txs).
|
|
async fn upsert_updates_to_ido_entries(
|
|
dbtx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
|
internal_id: i64,
|
|
updates: &[IdoUpdate],
|
|
tx_txid: &[u8],
|
|
blockhash: Option<&BlockHash>,
|
|
first_seen: i64,
|
|
) -> 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, first_seen)
|
|
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)
|
|
.bind(first_seen)
|
|
.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. `first_seen` stamps any
|
|
/// purchase entries the tx introduces.
|
|
async fn on_add_ido_tx(
|
|
pool: &SqlitePool,
|
|
prev: &IdoDBRecord,
|
|
tx: &Transaction,
|
|
blockhash: Option<&BlockHash>,
|
|
first_seen: i64,
|
|
) -> Result<()> {
|
|
let tx_txid = tx.compute_txid().to_blob();
|
|
debug!(
|
|
"IDO on_add_ido_tx: {}",
|
|
blob_to_display_hex::<Txid>(&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,
|
|
first_seen,
|
|
)
|
|
.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).
|
|
///
|
|
/// `mtp` is the block's median-time-past when indexing a confirmed block and
|
|
/// None for the mempool; it becomes new purchase entries' `first_seen` (the
|
|
/// mempool falls back to wall-clock time, matching the cauldron pool-history
|
|
/// first_seen_timestamp convention).
|
|
pub async fn index_txs(
|
|
network: Option<Network>,
|
|
pool: &SqlitePool,
|
|
sorted_txs: &[Transaction],
|
|
blockhash: Option<&BlockHash>,
|
|
mtp: Option<i64>,
|
|
) -> Result<()> {
|
|
let first_seen = mtp.unwrap_or_else(crate::timeutil::time_now);
|
|
for tx in sorted_txs {
|
|
// detect a new ido
|
|
if is_preinit_broadcast(tx) {
|
|
debug!(
|
|
"IDO index preinit: {}",
|
|
blob_to_display_hex::<Txid>(&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: {}, {}",
|
|
blob_to_display_hex::<Txid>(&tx.compute_txid().to_blob())?,
|
|
err
|
|
)
|
|
}
|
|
} else if is_ido_sig_in_tx_inputs(tx) {
|
|
debug!(
|
|
"IDO tx to index: {}",
|
|
blob_to_display_hex::<Txid>(&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).
|
|
// Inputs 0/1/3 cover the pre-postlaunch chain; the final postlaunch
|
|
// run consumes the tracked confirmation NFT at input#4 (collects
|
|
// never touch it — the covenant forbids offering-category inputs
|
|
// outside run()).
|
|
for input_index in [0, 1, 3, 4] {
|
|
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, first_seen).await
|
|
{
|
|
info!(
|
|
"add tx to an ido failed, txid: {}, {}",
|
|
blob_to_display_hex::<Txid>(&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<usize> {
|
|
let bh: Option<Vec<u8>> = 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<String>,
|
|
pub launch_txid: Option<String>,
|
|
pub otoken_genesis_txid: Option<String>,
|
|
pub offering_token_id: Option<String>,
|
|
pub offered_token_id: Option<String>,
|
|
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<String>,
|
|
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<i64>,
|
|
}
|
|
|
|
#[derive(Serialize, Clone)]
|
|
pub struct IdoEntryRpcRecord {
|
|
pub ido_id: String,
|
|
pub txid: String,
|
|
pub owner_nfthash: String,
|
|
pub commitment: Option<String>,
|
|
pub supply_amount: i64,
|
|
pub demand_amount: i64,
|
|
pub lockup_timeval: i64,
|
|
pub discount: i64,
|
|
pub distributed: bool,
|
|
/// Unix timestamp (seconds) the purchase was first indexed: the block MTP
|
|
/// when first seen in a confirmed block, wall-clock time when first seen
|
|
/// in the mempool. 0 for rows written before the field existed.
|
|
pub first_seen: i64,
|
|
}
|
|
|
|
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<IdoRpcRecord> {
|
|
let preinit_txid_hex = blob_to_display_hex::<Txid>(&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::<Txid>)
|
|
.transpose()?,
|
|
launch_txid: self
|
|
.launch_txid
|
|
.as_deref()
|
|
.map(blob_to_display_hex::<Txid>)
|
|
.transpose()?,
|
|
otoken_genesis_txid: self
|
|
.otoken_genesis_txid
|
|
.as_deref()
|
|
.map(blob_to_display_hex::<Txid>)
|
|
.transpose()?,
|
|
offering_token_id: self
|
|
.offering_token_id
|
|
.as_deref()
|
|
.map(blob_to_display_hex::<TokenID>)
|
|
.transpose()?,
|
|
offered_token_id: self
|
|
.offered_token_id
|
|
.as_deref()
|
|
.map(blob_to_display_hex::<TokenID>)
|
|
.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::<Txid>)
|
|
.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<Vec<u8>> = LazyLock::new(|| {
|
|
hex::decode("02000000045059b688113f77c743def28cb0b2e77b0606756dabd5242e45d5b8b39f41010e00000000fd1101514d0d012082fa7e456ada87ddd4ff6195ca1284d8ab09e5b55caf73ddfb7dca8faf9620e620b757276de32bb650f7969569f685b1616473f5d02b75655c26c030facc190a4e209110b4023c0864f684eda7f6e84ae8016a3d3ced6c2240274d9a8aa02fc432235379009c6300ce01207f7588c0d276827760a269c0cf78587f77547f758178587f77547f7581a0697c567f75817c567f7581a069c0ccc0c6a269c0cdc0c788c0d1c0ce87777777675379519c63c0cf567f77527f75817600a269016495c0ccc0c67b93a269c0cdc0c788c0d1c0ce88c0d2c0cf886d6d51675379529c6300ce01207f757b88c0cdc0c788c0d1c0ce88c0d2c0cf8777777767537a539d00ce01207f75537a877777686868ffffffff122f683dab81d08fb1836e6f79f316c03ecfc156be963feb8824105d1b4980e90a0000006441cef894bb26f25d47c7664d7f25f0cd1d7733686b284515c3b79d5cf4b25c3ce71825c117ae61ceeb531b7e2298cc27aa297e65d33251f9d95f96f61cd8e7656161210305b3cadf2e6dab4f81e9b0b5b59cd25ad8884ed6d612dd26fb1d2d72be9cca42000000001ec4faa4b89a4caf90379084766821745d687b878059d44f81fa5294a09121d90a0000006441e5f0170ad2e31b09705095ca631191cb7b5f28c12ed65291f80152e85f5389885c3e9bd6da68bacaab4548336baf757390cedca5dd3a55c3294ded656ac7e2a7612102c1547ca5906ae616000ff9e82a8808ae72e3b10280ea388bc4c53c698e1b72a700000000b9d2700ae10121f034a23429f63651083066ec372cf055749bb5e708896daa2c0c0000006441af3bbf102a27a3f69f198b3cd3f818db4ba13ee48566c3f15c46ba7212b07eac32f7b6a2906bff0a0698f416e4524af29e3f1b249036627d46a2b849d65a4b9461210325b6de70e3fea68d754d4d8d6a04434f284cf3ad73ee18a5c2654eeb29a6083c000000000ee80300000000000056ef9a66f2918e9a845e263d71d126949ca0ef35476eb13d382482aacfc94082941b6110ec5a316a0000000075131a0064540000aa206024ca62d059de8235ec20424a8ac4d0b36845a7c373bc480004d3758092177087e80300000000000023aa20511b5d54255cc74610d067f6e51e307635f2f3da17015c10b26ff0d478f7052587e80300000000000023aa20dbe66aae53f9d2b52dee51819dfa0cad8c6bcb43886d567e26673f6bfd7144c987e80300000000000023aa20f1894b74076a9ba9c505cf8aa130903ebfa4961074c7e4880b57c59b65557a0d87e80300000000000023aa20f69ff67df71b9ecd4b6b39af393551468bb2a46fb261e2e5a61cfb27dbcbb25087e80300000000000023aa20e5b1d9865bf78f7cfc02e60ba0bcf327e0d3949f455a37dd8870979c3fc0f42d87e80300000000000023aa208b3c520007a78d00dcebfd2e8bcda6020ed7f7ed7d5f0d687ac325b0fc3e309187e80300000000000023aa20d75abe1601065e05b96055cc9e56c5110b40b31d46b5a87c57ea078fdc3156df87b0040000000000000f0201008178c99dc8c0c88702000075b004000000000000c70201008178c99dc8c0c8874cb90c0602c100026a0104011901526a0442434d52203d044d71cdb71f9a8874469de3b0b3f6542fc5e9d185de1c9f12a5469165a8794968747470733a2f2f697066732e72696674656e2e6e65742f7541565553494430455458484e74782d61694852476e654f77735f5a554c38587030595865484a3853705561525a61683538697066733a2f2f7541565553494430455458484e74782d61694852476e654f77735f5a554c38587030595865484a3853705561525a616835b70075e8030000000000003def600ea0227e6fdc0066b6b18c4d7b2677e49771f0603a0d039b4088da8063a5b260010076a914e2302c4bbaa4a890dd8923ff1c5980af46fe81db88ac809698000000000023aa20629ccbca71e4c6b24e02bcad1ad2f8136a904b55526e83d5b2612c89b6a49d8187ff917b1c000000001976a914e398bcb344046f6a79ade0b0badc34522b1cd06488ac0000000000000000bf6a4cbb4361756c64726f6e49646f30ab3aba01311b672808aa06f5e7f332d930e4ed0c99407343f7fc5d743d15ff55809698001b948240c9cfaa8224383db16e4735efa09c9426d1713d265e849a8e91f2669aeccf436a000048b400000000000048b4000000000000002d310180969800b92ddb7232000000000000000000000048b40000000000000000000000000000000000000000000000000000000000000000000050c3000080c3c90100ca9a3b00000000c0b49504000000000a0000000000").unwrap()
|
|
});
|
|
|
|
static PREINIT_STEP_TEST_TX01: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
|
hex::decode("020000000a4416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a402000000fddb01514dd7010201008178c99dc8c0c8874dc801124361756c64726f6e49646f2d32303236513275088178c99dc8c0c88736827701209dc0519dc0ce01207f7500ce01207f758800cf517f755f845288c0cf517f7501208401008763c0c878c88876c9529d68755104002d310103404b4c059f06241f7e021c4800c0ce01207f75c0519c637600ce8800cf517f755f845188675879827701209dc0009d00d000d394765479a269765579950500e876481796005c7900a063577900a069587900a0695c79587995048033e1015a7995a169785d7995587995048033e1010400e1f5059596776e947b757c7800a0696854790087535e7900a06376608577687863760120857768005f7900a0635f795680547958807e77686e7e51d28851d15779517e8851d356799d02aa20012060797e5e797eaa7e01877e51cd8852796351cc5579a26952d100876452d101207f75577987916968c4539d6752d158798852d35579a26902aa20525152807e5f797eaa7e01877e52cd8853d100876453d101207f7557798791696854d100876454d101207f75577987916968c4559d6800cf517f77547f758100cc00c6527993a26900cd00c78800cf557f77547f758100cf557f75788b54807e00d28800ce00d1886d6d6d6d686d6d6d6d6d51c60175000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a401000000fd0a094d0709124361756c64726f6e49646f2d32303236513275000020000000000000000000000000000000000000000000000000000000000000000020bfa57ca201b401db5496995a69a333e9354e8782fbf176eabec3f891a29aa94700004cb520314e518b4207e9252edc443abea7dede08a93165975a451fd802a847aee86f1c0602c400024a0151894c87011701504c814768747470733a2f2f7733732e6c696e6b2f697066732f75415655534944464f55597443422d6b6c4c7478454f72366e337434497154466c6c31704648396743714565753647386338697066733a2f2f75415655534944464f55597443422d6b6c4c7478454f72366e337434497154466c6c31704648396743714565753647386352894df5010902a9147ca97e01877e57897c6b00c08851d100887c635ab2756d51cd016a88674d0301404142434445464748494a4b4c4d4e4f505152535455565758595a6162636465666768696a6b6c6d6e6f707172737475767778797a303132333435363738392d5f6b007c8253a26365537f7c76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7b7c7e7c82539f666882760087636d677d537c94007c807e76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7c51937f757e686c7554893a10303132333435363738396162636465667c006b65517f7c5279785f84817f77517f756c7e6b52797c5499817f77517f756c7e6b82009c666d6c5689097f7b827b7c7f777e7e538978a8886b518ac0ce568a65766c537a538a6b74519c66756c766ba804015512207c7e548a01757c7e6b528a6c7c6b65766c537a538a6b74519c66756c6ca87c7e076a0442434d52207c7e51cd886801206c0f51ce8851d0009d6300cdc0c78868517e7e578a00cd8800ce00d18800d2008800d000d38800c600cca26952d10088c453870f51ce8851d0009d6300cdc0c7886851088178c99dc8c0c8870480c3c90104800fd92d0500e40b5402c0519dc0c800c88800c9529dc0c852c88852c9539dc0c853c88853c9549dc0c854c88854c9559dc0c855c88855c9569dc0c856c88856c9579d5c79009e63c0c858c88858c9599d67c0c858c88858c9599d68c0c857c88857c9589d597981009c5d79009c9b5b7981009c9b63c0d1008852cd00c78852d1008853cd52c78853d1008854cd53c78854d1008855cd54c78855d1008856cd55c78856d1008857cd56c78857d100885c79009e6359cd58c78859cc58c6a26959d158ce8859d358d09d59d258cf8867597981009e5c79009e9a6459cd58c78859d10088686858cd57c78858d1008802aa200302010055797eaa7e01877ec101587f775b7981009c6301005f79009e63015177685e79009c6300cd53798800d10088760251207e5e797e01207e5d797e52797e7b757c67c0c859c88859c9009d00cd016a8800d100885acd5c79885ad1c0c8885ad3009d5ad20100885bd10088c45c9d760200207e5e797e01207ec0c87e52797e7b757c6875675e79009c635d79009c6300cd52798800d10088030051205d797e01207e5c797e787e7767c0c859c88859c9009d53795579950400e1f50596547978935479789455795279a06902aa20012060797e5d797e5c797eaa7e01877e5c798277009c6302a91401200111797e5c797ea97e01877e776800cd788800d1c0c88800d352799d00d2008859cd56798859d1c0c88859d353799d59d2008858c7827758c77853947f77527f758158c7527953945279947f77787f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a77685acd78885ad100885bd10088c45c9d035100200114797e01207e0113797e58797e587a757c6b7c6b7c6b7c6b7c6b7c6b7c6c6c6c6c6c6c6d6d6d7568675d79009c6300cd52798800d10088035151205d797e01207e5c797e787e7767c0c859c88859c9009d00cd016a8800d100885acd5279885ad1c0c8885ad3009d5ad201ff885bd10088c45c9d03510020c0c87e01207e5c797e787e7768686802aa20c101147f7552797eaa7e01877ec0cd886d67c0c859c88859c95a9d55ca827755ca7853947f77527f758155ca527953945279947f77787f75012302aa2001205f797e5d797e5b797eaa7e01877e7e5b798277009c63011702a914012060797e5b797ea97e01877e7e776800cd02aa20c101147f7553797e54797eaa7e01877e8800d1008800ca827700ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686800ca537953945379945279947f7702aa20030200005d797e52797eaa7e01877e51cd8851d1008852ca827752ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686852ca537953945379945279947f7702aa20030200000111797e707c0114937f757e01207e01187901ff7eaa7e707c0135937f777eaa7e01877e52cd8852d1008853ca827753ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686853ca537953945379945279947f7702aa20030200000115797e52797eaa7e01877e53cd8853d1008854ca827754ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686854ca537953945379945279947f7702aa20030200000119797e52797eaa7e01877e54cd8854d1008857c7827757c77853947f77527f75815178529302ff00a063755367785293014ba0637552686857c7537953945379945279947f7755cd03020000011d797e52797e8855d1008802aa2003020000011d797eaa7e01877e56cd8856cc58c6a26956d158ce8856d3011a799d56d2008856ca827756ca7853947f77527f758156ca527953945279947f77787f7557cd02aa20c101147f7501207e01277901007eaa7e53797eaa7e01877e8857cc59c6a26957d159ce8857d359d09d57d259cf8802aa20525752807e0120797eaa7e01877e58cd8858d158ce8858d358d0011e79949d58d200886d6d6d6d6d6d6d6d6d6d6d6d6d75686d6d6d6d6d6d7551000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a403000000fdb503514db1030201008178c99dc8c0c8874da203124361756c64726f6e49646f2d3230323651327520000000000000000000000000000000000000000000000000000000000000000020ab3aba01311b672808aa06f5e7f332d930e4ed0c99407343f7fc5d743d15ff5504809698003dc0ce00ce01207f758800cf517f755f84528800cf577f77547f758151a169c0cf517f7701207f7502aa2001207b7e7b7eaa7e01877e52cd8852ccc0c6a2088178c99dc8c0c8873fc0cf527f7701207f75c0cf01227f77815479ce01207f757b88537acf567f75817600a0699f6978ce7bcf7eaa88c0cf517f77517f7581c0c85279c8887cc99c0778ce7bcf7eaa87209a66f2918e9a845e263d71d126949ca0ef35476eb13d382482aacfc94082941b046cac326a021c48021c48c0009d00c852c88852c9529d00c854c88854c9549d00c855c88855c9559d00ce01207f7551ce78527e8851cf517f755f8401008853ce01207f7555798853cf567f75817600a06953d100876453d101207f7552798791696851d0547aa07651d0557aa09b63537952799f696851cf557f77547f758100a16302aa2001205d797e57797eaa7e01877e556085537956807e0058807e52d058807e0058807e00d28800d154798800d3009d00cd788851cd788851cc52c6a26951d152ce8851d352d09d51d2008852d10088c4549d75675279517e00d18800d3009d766355ca827755ca7853947f77527f758155ca527953945279947f77787f75526085557956807e51cf557f77547f757e0058807e52d058807e0058807e00d28802aa20c101147f755a79827751807e5a797e01207e60797e52797eaa7e01877e00cd8802aa20012060797e5a797eaa7e01877e52cd8852cc52c6a26952d152ce8852d352d09d52d2008854d10088c4559d6d756754ca827754ca7853947f77527f758154ca527953945279947f77787f7551d07600a06354d152ce8854d3789d54d2008802aa2001200111797e5b797eaa7e01877e54cd8855d10088c4569d6754d10088c4559d6852567956807e51cf557f77547f757e0058807e7858807e0058807e00d28802aa20c101147f755d79827751807e5d797e5c79827751807e5c797e5b79827751807e5b797e01207e5a797e547e5f797e01207e60797e01207e0111797e53797eaa7e01877e00cd8852d152ce8852d352d05279949d52cc52c6a26902aa20520052807e5d797eaa7e01877e52cd8852d200886d6d6802aa2056798277518057797e5a797eaa7e01877e51cd88545b797e51d28851cc51c6a26951d153798851d3009d686d6d6d6d6d6d51a00375000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a404000000fdb902514db5020201008178c99dc8c0c8874da602c0009d00c852c88852c9529d00cf517f77567f758100ce01207f7551ce01207f75788851cf517f755f84538851cf517f7551ca51ca82770136940120947f7701207f750000537960840100876451cf517f77567f75817b757c51cf577f77587f758177687800a2697600a26952d352d051d0949d587a810000567901208401008764527900a26951c6765479950400e1f50596537a757c6b7c6c765379947b757c53cc5279a26953d1008854cc5379a26954d10088756753d05379950400e1f505967b757c53d0527994777600a06353d3789d53d153ce886753d10088687800a06354d352799d54d153ce886754d100886868547900a06302aa20012057797e5f797eaa7e01877e51cd8851d1587988565551807e5c797e597956799356807e51d28851d3009d55d351d09d55d152ce8802aa20525152807e60797eaa7e01877e55cd8855d200886751d351d09d51d152ce8802aa20012057797e5e797eaa7e01877e51cd8851d20088687600a06302aa2001205b797e5e797eaa7e01877e53cd886753cd016a88687c00a06302aa2001205b797e5d797eaa7e01877e54cd886754cd016a886800cf577f77547f75817651a06300cf517f7500cf517f77567f757e788c54807e00cf5b7f77587f758153799358807e00cf01137f77587f757e00cf011b7f77587f758155799358807e00d28800ce00d18800c700cd8800d000d39d52cf52d28852ce52d18852c752cd88675500cf517f77567f757e00cf5b7f77587f758153799358807e00cf01137f77587f757e00cf011b7f77587f758155799358807e00d28800d158798800d3009d02aa2001205b797e5e797eaa7e01877e00cd8852d100885457790120840100876475536876ce59798876cf517f755f845488756855557a00a063755668c4789e6376d10088c4788b9d686d6d6d6d6d6d6d7551a40275000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a405000000fd4701514d43010201008178c99dc8c0c8874d3401c0009d00ce01207f7551ce01207f75788851cf517f755f84538851ca51ca82770136940120947f7701207f7551cf517f75760120840100876451cc51c6a26951d100886751d352d09d51d152ce886802aa200120537a7e55797eaa7e01877e51cd8800cf577f77547f75817651a06300cf517f7500cf517f77567f757e788c54807e00cf5b7f77587f757e00cf01137f77587f757e00cf011b7f77587f757e00d28800ce00d18800c700cd88c4529e6352d10088c4539d686755608500cf517f77567f757e00cf5b7f77587f757e00cf01137f77587f757e00cf011b7f77587f757e00d288527900d18800d3009d02aa20012055797e56797eaa7e01877e00cd8852d100885352790120840100876475526876ce54798876cf517f755f845488c4539e6353d10088c4549d6875686d6d7551320175000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a406000000fd0f03514d0b030201008178c99dc8c0c8874dfc02088178c99dc8c0c887575655545352510450c30000c0c9009dc0c85b79c8885a79c9577a9d5979ce827701209d567900a263c0c85b79c8885a79c957799d68c0c800d1788800d2578800d3009d00cd5a7a88c08bc0c878c88876c9547a9d76ca827778ca7853947f77527f75817bca7b53945279947f777c7f75c05293c0c878c88876c9557a9d76ca827778ca7853947f77527f75817bca7b53945279947f777c7f75c05393c0c878c88876c9567a9d76ca827778ca7853947f77527f75815178529302ff00a063755367785293014ba063755268685379ca537a5394537a947b947f7702aa20525352807e5b797e7b7eaa7e01877e54cd8854cc7cc6a26954d10088c05493c0c878c88876c9567a9d76ca827778ca7853947f77527f75815178529302ff00a063755367785293014ba063755268685379ca537a5394537a947b947f7702aa20525352807e5a797e7b7eaa7e01877e55cd8855cc7cc6a26955d100885779d07600a0690100557a7e04000000007e51d28851d15479527e8851d3789d51cd02aa20547aaa7e01877e885153d28853d153798853d3009d53cd02aa20537aaa7e01877e8802aa20525352807e567a7eaa7e01877e52cd8852cc5579c6a26952d1557ace8852d39d52d20088c05593c0c878c88876c9537a9d76c7827778c77853947f77527f75817bc77b53945279947f777c7f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a776856cd8856d1008857d100876457d101207f75788791696857cd567f75066a0442434d52879169c4589e6358d100876458d101207f75788791696858cd567f75066a0442434d52879169c4599e6359d100876459d101207f75788791696859cd567f75066a0442434d52879169c45a9e635ad10087645ad101207f7578879169685acd567f75066a0442434d52879169c45b9e635bd10087645bd101207f7578879169685bcd567f75066a0442434d52879169c45c9d686868686d7551fa0275000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a407000000fd2506514d21060201008178c99dc8c0c8874d12064d09050480c3c901059f06241f7e5879009c63c0009dc0c9009e69c0cdc0c788c0ccc0c6a269c0d1c0ce88c0d3c0d09dc0d2c0cf8802aa20520052807e597a7eaa7e01877ec0c851c88851c9589d7651cd8851cc51c6a26951d151ce8851d351d09dc0c852c88852c9599d52cd8852cc52c6a26952d152ce8852d352d09dc353a06353ce0088c3549d6853d10088c4549d6d6d6d6d51675879519c63c0009dc0c9009dc0cdc0c788c0d1c0ce88c0d3c0d09dc0d2c0cf88c0c852c88852c9529d52cd52c78852cc52c6a26952d152ce8852d352d09d02aa200120c0cec0cf7eaa7e587a7eaa7e01877e00005376c75479876376ce59798763527978d093537a757c6b7c6c6776ce0088686ec6937b757c6776ce008868768bc378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c378a06376c75579876376ce5a798763537978d093547a757c6b7c6b7c6c6c6776ce008868527978c693537a757c6b7c6c6776ce008868768b77c3789d6868686868686868c0ccc0c6547a93a269c0c851c88851c9519d51cd51c78851cc51c6a26951d0537a937600a06351d159798851d378a26951d200886751d100886853d10088c4549d6d6d6d6d6d6d5167587a529dc0009dc0c9009d5379827701209d53ce567a8853cf517f755f845588c0c653cf577f77587f7581a269c0c851c88851c9519d53cf5f7f77587f75817600a06351d078a2696851d000a06351ce5679886851cf0088c0c852c88852c9529d52ce567988000053cf517f75608401008763557981537994765679950400e1f505967652d0a06352d07768765679950500e87648179653cf01177f77587f758194760500e8764817955779967802e803a27800a09a6378567a757c6b7c6b7c6b7c6b7c6c6c6c6c765379a16376557a757c6b7c6b7c6b7c6c6c6c675279557a757c6b7c6b7c6b7c6c6c6c68686d6d68c0c651c69352c69352799451d052d093527994537900a0537900a09a6302aa2005746376a914000114807e2b88ac67c0d1c0ce88c25288c0cdc0c788c0c6c0d095c0c6c0cc9490539502e80396c0cc7c94c0d3957ca2687eaa7e01877e00cd8800cc54799d00d15a798800d353799d00d20088096a0653554d4d4f4e14000114807e51cd8851cc009d51d100886700d1008800cd016a8851d1008851cd016a886802aa2001205a7a7e5b7a7eaa7e01877e52cd8852cc7ba2697600a06352d378a26952d158798852d200886752d100886853d10088c4549d6d6d6d6d75516868088178c99dc8c0c8870778ce7bcf7eaa875800c0ce827701209dc0cf827700a0695579827701209dc0c85779c8885679c99dc0c85779c8885679c99d5479529376ca827778ca7853947f77527f75817bca7b53945279947f777c7f7501157f7701207f75c0cec0cf7eaa88547ac852d1827701209d52d300a06902aa20c101147f755479827751807e54797e5379827751807e537a7e01207e7b7e01207e52d17e01207e547a7e587e52d358807e537a7eaa7e01877e57cd8857ccc0c6a26957d1c0ce8857d2c0cf8857d3c0d09d02aa20525752807e7b7eaa7e01877e7658cd8858cc02e803a26958d1008859cd8859cc78c6a26959d178ce8859d278cf8859d37cd09c100675000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a4080000000151000000004416b98e44076018160375d7b46f243739c5eb945bfa5d0a6b97332a7da4b7a409000000015100000000d9d78914b49e4313f22b27e1ea03dd7c2963775ce944e0b81c1ad71490e865710100000064411de3a0757e6c479e5c20ca63f5d7f6e2029f27d7349d61be689a99a655afd2024b4d23d1e736d7119ed226dbd66597521e2fd2b8be6f640e3e0b51cc2ce0a1fe612102c1547ca5906ae616000ff9e82a8808ae72e3b10280ea388bc4c53c698e1b72a7ffffffff0be80300000000000023aa204358e719285ca59fffedcff34a5d1a39b32711ec2ad21c51765aeaa79d60409887e80300000000000023aa20890580b8a7a29eca5c414ddf82057cb65924950acb189661cf2727136eb4a49887e80300000000000023aa20a8e6aed82ff79e824aa86d14cc469704bda8e8c20057ff06d4948004e6ade0d287e80300000000000023aa20de0a3c5fe9a1d0fea9ca1d0e82628ab14c61214d17d3f9df4b175fec46a52b5e87e80300000000000023aa20f69ff67df71b9ecd4b6b39af393551468bb2a46fb261e2e5a61cfb27dbcbb25087e80300000000000023aa20e5b1d9865bf78f7cfc02e60ba0bcf327e0d3949f455a37dd8870979c3fc0f42d87e80300000000000023aa208b3c520007a78d00dcebfd2e8bcda6020ed7f7ed7d5f0d687ac325b0fc3e309187e80300000000000023aa20beafa293c3d712876b389da252b1f7f584a8f8b10da2e526e38a34856f1c15e487b0040000000000000f0201008178c99dc8c0c88702000075b004000000000000c50201008178c99dc8c0c8874cb70c0602c400024a0104011701506a0442434d5220314e518b4207e9252edc443abea7dede08a93165975a451fd802a847aee86f1c4768747470733a2f2f7733732e6c696e6b2f697066732f75415655534944464f55597443422d6b6c4c7478454f72366e337434497154466c6c31704648396743714565753647386338697066733a2f2f75415655534944464f55597443422d6b6c4c7478454f72366e337434497154466c6c317046483967437145657536473863b50075586e0400000000001976a91495570c60bbdeaf648a259bb177911169e060a76788ac00000000").unwrap()
|
|
});
|
|
|
|
// The fixture carries the legacy "CauldronIdo0" announcement signature. The
|
|
// "CldIdo00" contract revision shortened it, so the detector must no longer
|
|
// recognize the old announcement. There is no backward compatibility.
|
|
#[test]
|
|
fn legacy_preinit_announcement_not_detected() {
|
|
let tx: Transaction =
|
|
bitcoincash::consensus::deserialize(&PREINIT_TEST_TX01).expect("should be valid");
|
|
assert!(!is_preinit_broadcast(&tx));
|
|
}
|
|
|
|
// The fixture predates the ORB IdoParams generation (native-BCH xToken, no
|
|
// params NFT, old partial postlaunch bytecode); it must not parse as a
|
|
// valid ido anymore. There is no backward compatibility.
|
|
#[test]
|
|
#[ignore]
|
|
fn parse_legacy_preinit_is_rejected() {
|
|
let tx: Transaction =
|
|
bitcoincash::consensus::deserialize(&PREINIT_TEST_TX01).expect("should be valid");
|
|
let mut errors: Vec<Error> = Vec::new();
|
|
let mut invalid_ido_reasons: Vec<Error> = Vec::new();
|
|
let network = Some(Network::Chipnet);
|
|
let result = parse_ido_preinit_tx(network, &tx, &mut errors, &mut invalid_ido_reasons);
|
|
assert!(!result.is_valid_ido);
|
|
assert!(!errors.is_empty() || !invalid_ido_reasons.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn ido_params_nft_commitment_roundtrip() {
|
|
let mut commitment = vec![0u8; IDO_PARAMS_COMMITMENT_SIZE];
|
|
commitment[0] = IDO_PARAMS_VERSION; // version
|
|
commitment[1..33].copy_from_slice(&[0xAA; 32]); // permanentPoolPlatformNfth (kept verbatim)
|
|
commitment[33..65].copy_from_slice(&[0xBB; 32]); // delphiCategory
|
|
commitment[65..97].copy_from_slice(&[0xCC; 32]); // platformFeeNfth
|
|
commitment[97..101]
|
|
.copy_from_slice(&encode_padded_vm_number(&Integer::from(5000i64), 4).unwrap());
|
|
commitment[101..105]
|
|
.copy_from_slice(&encode_padded_vm_number(&Integer::from(3600i64), 4).unwrap());
|
|
commitment[105..109]
|
|
.copy_from_slice(&encode_padded_vm_number(&Integer::from(2_592_000i64), 4).unwrap());
|
|
commitment[109..113]
|
|
.copy_from_slice(&encode_padded_vm_number(&Integer::from(1000i64), 4).unwrap());
|
|
commitment[113..117]
|
|
.copy_from_slice(&encode_padded_vm_number(&Integer::from(2000i64), 4).unwrap());
|
|
commitment[117..121]
|
|
.copy_from_slice(&encode_padded_vm_number(&Integer::from(1000i64), 4).unwrap());
|
|
commitment[121..125]
|
|
.copy_from_slice(&encode_padded_vm_number(&Integer::from(2000i64), 4).unwrap());
|
|
let parsed = parse_ido_params_nft_commitment(&commitment).expect("should parse");
|
|
assert_eq!(parsed.version, IDO_PARAMS_VERSION);
|
|
assert_eq!(parsed.permanentPoolPlatformNfth, vec![0xAA; 32]);
|
|
assert_eq!(parsed.delphiCategory, vec![0xBB; 32]);
|
|
assert_eq!(parsed.platformFeeNfth, vec![0xCC; 32]);
|
|
assert_eq!(parsed.platformFee, Integer::from(5000i64));
|
|
assert_eq!(parsed.minExpireDuration, Integer::from(3600i64));
|
|
assert_eq!(parsed.maxExpireDuration, Integer::from(2_592_000i64));
|
|
assert_eq!(parsed.minPlpAfterDiscount, Integer::from(1000i64));
|
|
assert_eq!(parsed.minPlpShare, Integer::from(2000i64));
|
|
assert_eq!(parsed.entryExecutionFee, Integer::from(1000i64));
|
|
assert_eq!(parsed.createExecutionFee, Integer::from(2000i64));
|
|
// wrong size is rejected
|
|
assert!(parse_ido_params_nft_commitment(&commitment[..89]).is_err());
|
|
}
|
|
|
|
// build_partial_postlaunch_bytecode bakes permanentLiquidityMinFee as a
|
|
// 2-byte push and permanentPoolPlatformNfth as a verbatim 32-byte push (a
|
|
// hash, NOT byte-reversed), in the positions the postlaunch covenant
|
|
// expects. This is the layout the output #7 rebuild relies on to prove the
|
|
// storage was built with the announcement's minFee and the NFT's
|
|
// permanentPoolPlatformNfth.
|
|
#[test]
|
|
fn partial_postlaunch_bakes_min_fee_and_platform_nfth() {
|
|
let x_token_category = vec![0x11u8; 32];
|
|
let permanent_pool_platform_nfth: Vec<u8> = (0u8..32).collect();
|
|
let min_fee = Integer::from(1000i64);
|
|
let partial = build_partial_postlaunch_bytecode(&PartialPostlaunchParameters {
|
|
xTokenCategory: Some(&x_token_category),
|
|
permanentLiquidityShare: &Integer::from(20_000_000i64),
|
|
price: &Integer::from(1_700_000_000_000i64),
|
|
platformFeeNFTH: &[0xCC; 32],
|
|
platformFee: &Integer::from(5000i64),
|
|
permanentLiquidityMinFee: &min_fee,
|
|
permanentPoolPlatformNfth: &permanent_pool_platform_nfth,
|
|
executionFee: &Integer::from(100_000i64),
|
|
});
|
|
let script = ScriptBuf::from(partial);
|
|
let inst: Vec<_> = script.instructions().take(8).collect();
|
|
// [0] xTokenCategory: 32 bytes, reversed into VM order.
|
|
match &inst[0] {
|
|
Ok(Instruction::PushBytes(d)) => {
|
|
let restored: Vec<u8> = d.as_bytes().iter().copied().rev().collect();
|
|
assert_eq!(restored, x_token_category);
|
|
}
|
|
_ => panic!("expected a 32-byte xTokenCategory push"),
|
|
}
|
|
// [5] permanentLiquidityMinFee: a 2-byte push carrying the fee value.
|
|
match &inst[5] {
|
|
Ok(Instruction::PushBytes(d)) => {
|
|
assert_eq!(d.as_bytes().len(), 2);
|
|
assert_eq!(vm_number_to_bigint(d.as_bytes()), min_fee);
|
|
}
|
|
_ => panic!("expected a 2-byte permanentLiquidityMinFee push"),
|
|
}
|
|
// [6] permanentPoolPlatformNfth: 32 bytes, verbatim.
|
|
match &inst[6] {
|
|
Ok(Instruction::PushBytes(d)) => {
|
|
assert_eq!(d.as_bytes(), &permanent_pool_platform_nfth[..]);
|
|
}
|
|
_ => panic!("expected a 32-byte permanentPoolPlatformNfth push"),
|
|
}
|
|
}
|
|
|
|
// A native-BCH xToken bakes an EMPTY push (the contract branches on
|
|
// xTokenCategory == 0x).
|
|
#[test]
|
|
fn partial_postlaunch_bakes_empty_push_for_native_x_token() {
|
|
let partial = build_partial_postlaunch_bytecode(&PartialPostlaunchParameters {
|
|
xTokenCategory: None,
|
|
permanentLiquidityShare: &Integer::from(20_000_000i64),
|
|
price: &Integer::from(1_700_000_000_000i64),
|
|
platformFeeNFTH: &[0xCC; 32],
|
|
platformFee: &Integer::from(5000i64),
|
|
permanentLiquidityMinFee: &Integer::from(1000i64),
|
|
permanentPoolPlatformNfth: &[0xAB; 32],
|
|
executionFee: &Integer::from(100_000i64),
|
|
});
|
|
// An empty push is the single opcode OP_0 (0x00).
|
|
assert_eq!(partial[0], 0x00, "native xToken must bake OP_0");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore]
|
|
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<Option<i64>> {
|
|
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<bool>,
|
|
status: Option<String>,
|
|
offered_token_id_blob: Option<Vec<u8>>,
|
|
offset: i64,
|
|
limit: i64,
|
|
) -> Result<Vec<IdoRpcRecord>> {
|
|
let base = format!("{IDO_RECORD_SELECT}{IDO_CURRENT_STATE_JOIN} WHERE 1=1");
|
|
let mut qb: sqlx::QueryBuilder<sqlx::Sqlite> = 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<u8>,
|
|
) -> Result<Option<IdoRpcRecord>> {
|
|
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<u8>,
|
|
) -> Result<Option<IdoRpcRecord>> {
|
|
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<bool>,
|
|
owner_nfthashes: &[Vec<u8>],
|
|
offset: i64,
|
|
limit: i64,
|
|
) -> Result<Vec<IdoEntryRpcRecord>> {
|
|
// 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::Sqlite> = 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}, e.first_seen 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<u8> = row.get(0);
|
|
let owner_nfthash: Vec<u8> = row.get(1);
|
|
let commitment: Option<Vec<u8>> = 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>(&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,
|
|
first_seen: row.get(8),
|
|
})
|
|
})
|
|
.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<u8> {
|
|
vec![n; 32]
|
|
}
|
|
|
|
async fn insert_test_ido(
|
|
pool: &SqlitePool,
|
|
preinit_txid: Vec<u8>,
|
|
status: &str,
|
|
is_valid: bool,
|
|
is_token_created_at_preinit: bool,
|
|
offered_token_id: Option<Vec<u8>>,
|
|
offering_token_id: Option<Vec<u8>>,
|
|
) -> 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::<u8>::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<u8>,
|
|
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_carries_first_seen() {
|
|
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));
|
|
sqlx::query(
|
|
"INSERT INTO ido_entry (ido_id, txid, owner_nfthash, commitment,
|
|
supply_amount, demand_amount, lockup_timeval, discount, first_seen)
|
|
VALUES (?, ?, ?, NULL, 0, 0, 0, 0, 1700000123)",
|
|
)
|
|
.bind(ido_id)
|
|
.bind(txid(10))
|
|
.bind(vec![0u8; 32])
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
// A row written without the column (pre-upgrade shape) defaults to 0.
|
|
insert_test_entry(&pool, ido_id, txid(11), false).await;
|
|
let all = list_ido_entries(&pool, ido_id, &preinit_hex, None, &[], 0, 100)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(all.len(), 2);
|
|
assert_eq!(all[0].first_seen, 1_700_000_123);
|
|
assert_eq!(all[1].first_seen, 0);
|
|
}
|
|
|
|
#[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<u8>,
|
|
status: &str,
|
|
offering_token_id: Option<Vec<u8>>,
|
|
) {
|
|
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<u8>,
|
|
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);
|
|
}
|
|
}
|