ido: native BCH xToken — nullable xTokenCategory + single-UTXO tokenbch pool

Reflect libriften 9e3456d4 (feat(ido): native BCH xToken) in the indexer. An
IDO priced in native BCH sets offer.xTokenCategory = null: proceeds ride as
satoshi value and the permanent pool is a single-UTXO tokenbch-delegation pool
(BCH reserve in value + oToken as the CashToken) instead of a two-leg tokentoken
pool. The sibling tokenbch-delegation AMM (libriften 7b7cd569) is NOT indexed
here — deferred per Hossein.

- xTokenCategory is now Option<Vec<u8>> (serde Option<Hex>): native = None ->
  JSON null, matching libriften indexer-types (Hex | null). Old rows (always a
  hex string) deserialize to Some, so no migration.
- Announcement parse: an all-zero category normalizes to None; dropped the
  now-obsolete "must be non-native" rejection.
- build_offering_bytecode + PartialPostlaunchParameters take Option<&[u8]> and
  bake an empty (0x -> OP_0) push for native, matching encodeIdoXTokenCategory.
- Recompiled IDO_POSTLAUNCH_CONTRACT 1627 -> 2309 bytes (native collect/run
  branches); all three IDO contract hexes now byte-match ido.json.
- ACTIVE buy: native uses the offering's XWNT path — the entry XWNT flag is now
  required-iff-native (was always rejected); supply_amount is the entry output's
  value (no xToken storage output); a freshly minted owner nft shifts #3 -> #2.
- POSTLAUNCH run: native single-UTXO pool leg read from output#0 (xTokenAmount =
  its value in sats, oTokenAmount = its token); collector xToken share read from
  output#3 value. The platform xToken share is BCH folded into output#5's value,
  so it is recovered as earnedAmount - output#0.value - output#3.value (the
  offering layer charges no IDO fee, so earnedAmount is the full xToken proceeds
  entering run); platformBchPayout excludes that share, leaving just the pot.

No cargo this session (project constraint) — verified via rustfmt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hossein Zoda 2026-07-18 14:43:51 +00:00
parent 0eb86131f4
commit 0a566e9ee6

View file

@ -65,7 +65,7 @@ const ITEM_TYPE_CONFIRMATION_NFT: u8 = 0x05;
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 token (legacy, invalid)
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));
@ -121,7 +121,7 @@ static IDO_INITIATOR_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
hex::decode("c0ce827701209dc0cf827700a0695579827701209dc0c85779c8885679c99dc0c85779c8885679c99d5479529376ca827778ca7853947f77527f75817bca7b53945279947f777c7f755b7f7701207f75c0cfc0ce7eaa88547ac852d1827701209d52d300a06902aa20c15a7f755479827751807e54797e5379827751807e537a7e01207e7b7e01207e52d17e01207e547a7e587e52d358807e537a7eaa7e01877e57cd8857ccc0c6a26957d1c0ce8857d2c0cf8857d3c0d09d525752807e7c7e7658cd8858cc02e803a26958d1008859cd8859cc78c6a26959d178ce8859d278cf8859d37cd09c").unwrap()
});
static IDO_POSTLAUNCH_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
hex::decode("5e79009c63c0009dc0c9009e69c0cdc0c788c0ccc0c6a269c0d1c0ce88c0d3c0d09dc0d2c0cf88520052807e5e7a7ec0c851c88851c9589d7651cd8851cc51c6a26951d151ce8851d351d09d7652cd8852d10088c0c852c88852c9599d53cd8853cc52c6a26953d152ce8853d352d09d54d10088c4559d6d6d6d6d6d6d6d51675e79519c63c0009dc0c9009dc0cdc0c788c0d1c0ce88c0d3c0d09dc0d2c0cf88c0c853c88853c9539d53cd53c78853cc53c6a26953d153ce8853d353d09d54ce5c7a8854cf517f755f84558854cd54c78854cc54c6a26954d154ce8854d354d09d54d254cf8854cf5f7f77587f758154cf577f77587f758151d07b9f52d07b9f9b690120c0cfc0ce7eaa7e5c7a7e000000556576c75579876376ce5d798763527978d093537a757c6b7c6c6776ce60798763537978d093547a757c6b7c6b7c6c6c6776ce008868686ec6937b757c6776ce008868768b7776c3a266c0ccc0c6537a93a269c0ccc0c6567a8193a269c0c851c88851c9519d51cd51c78851cc51c6a26951d0537a937600a06351d15e798851d378a26951d200886751d1008868c0c852c88852c9529d52cd52c78852cc52c6a26952d0537a937600a06352d15b798852d378a26952d200886752d10088686d6d6d6d6d6d6d6d51675e7a529d5e7a92c0009dc0c9009d5a79827701209d55ce5d7a8855cf517f755f845588c0c851c88851c9519d55cf5f7f77587f75817600a06351d078a26951ce5d79886851cf0088c0c852c88852c9529d55cf577f77587f75817600a06352d078a26952ce5b79886852cf0088c0c853c88853c9539d53ce5e7988000055cf517f756084010087635d7981547994765d79950400e1f505967653d0a06353d07768765d79950500e87648179655cf01177f77587f758194760500e8764817955e79967800a07800a09a6378567a757c6b7c6b7c6b7c6b7c6c6c6c6c765379a16376557a757c6b7c6b7c6b7c6c6c6c675279557a757c6b7c6b7c6b7c6c6c6c68686d6d68c0c651c69352c69353c69352d053799451d053d093537994012001127a7e0113797e5c7900a26952795d7a950400e1f50596537a7894567900a0567900a09a6300d10111798800d357799d00d2008851d10113798851d356799d51d2008859796302e007b27554cd016a8854d1008800cd53798851cd5379886754ce01207f755c798854cf827701279d54cf517f7501008854ce54d18854cf517f7701207f7554cf01217f77527f754cc5c0768b78c878c88876c95279c98b9d76c7517f77587f527f527f527f587f587f75557a81557a81557a81557a81557a81557a815779ce5879d1885779c65879cc9d5679ce5779d1885679c65779cc9d5779d05879d3949003a08601765a955279587a9578938c7c967b567a955279938c7b9655795279937b527993557aa2695679cd5779c788013e7858807e5679c7597f777e5679cd885679d37c947651a2695579d351a269567ad0557a945479935579d0547993957c7b94537a93537ad3537a9395a12052797e4cb778cf7bce7eaac0768b78c878c88876c95279c98b9d76c776517f77587f75817600a2695479567987635379d35479d05279949d5279d35379d09d5379ce5479d1885379c65479cc9d5279ce5379d1885279c65379cc9d5379cd5479c7885279cd597f77527f75768100a269013e0058807e787e53795b7f777e5479cd78886d6778011f7f7701207f75557978887800a0635479cd012058797e0778cf7bce7eaa877e885479d15579ce885479d352799d6875686d6d6d517eaa00cd07ca537f7776aa2052797e0f8802c4007f7b63756777680089008a7e8808000000000000000052797e0200007e60797e0800000000000000007e0800000000000000007e2000000000000000000000000000000000000000000000000000000000000000007e013e787e0e75c08c76c8c0c888c0c97cc98b9c7e51cd78886d6d75686700d1008800cd016a8851d1008851cd016a8854d1008854cd016a8868537900a063527952cd8852d35479a26952d10113798852d200886752cd016a8852d10088687600a063527953cd8853d3789d53d10111798853d200886753cd016a8853d100886801205e7a7e01137a7e55cd8855cc557aa2697800a06355d35279a26955d15f798855d200886755d1008868566576c49f766b6376d100876476d101207f757655ce01207f7587916976c0ce01207f758791697568768b77686c91666d6d6d6d6d6d6d6d6d75516868").unwrap()
hex::decode("5e79009c63c0009dc0c9009e69c0cdc0c788c0ccc0c6a269c0d1c0ce88c0d3c0d09dc0d2c0cf88520052807e5e7a7ec0c851c88851c9589d7651cd8851cc51c6a26951d151ce8851d351d09d7652cd8852d10088c0c852c88852c9599d53cd8853cc52c6a26953d152ce8853d352d09d54d10088c4559d6d6d6d6d6d6d6d51675e79519c63c0009dc0c9009d57790087c0cdc0c788c0d1c0ce88c0d3c0d09dc0d2c0cf88c0c853c88853c9539d53cd53c78853cc53c6a26953d153ce8853d353d09d54ce5d7a8854cf517f755f84558854cd54c78854cc54c6a26954d154ce8854d354d09d54d254cf8854cf5f7f77587f758154cf577f77587f758152796351d052799f52c652799f9b696751d052799f52d052799f9b69680120c0cfc0ce7eaa7e5f7a7e000000556576c75579876357796376ce0113798763537978d093547a757c6b7c6b7c6c6c6ec6937b757c6776ce0088527978c693537a757c6b7c6c686776ce60798763527978d093537a757c6b7c6c6776ce0113798763537978d093547a757c6b7c6b7c6c6c6776ce008868686ec6937b757c686776ce008868768b7776c3a266c0ccc0c6537a93a269c0ccc0c6597a8193a269c0c851c88851c9519d51cd51c78851cc51c6a26951d0537a937600a06351d10111798851d378a26951d200886751d1008868c0c852c88852c9529d52cd52c788567a6352cc52c6547993a26952d100886752cc52c6a26952d05379937600a06352d15e798852d378a26952d200886752d100886875686d6d6d6d6d6d6d6d6d51675e7a529d5e7a92c0009dc0c9009d5a79827701209d5879008755ce5e7a8855cf517f755f845588c0c851c88851c9519d55cf5f7f77587f75817600a06351d078a26951ce5e79886851cf0088c0c852c88852c9529d55cf577f77587f75817600a06352796352c678a2696752d078a26952ce5c7988686852cf0088c0c853c88853c9539d53ce5f7988000055cf517f756084010087635e7981547994765e79950400e1f505967653d0a06353d07768765e79950500e87648179655cf01177f77587f758194760500e8764817955f79967800a07800a09a6378567a757c6b7c6b7c6b7c6b7c6c6c6c6c765379a16376557a757c6b7c6b7c6b7c6c6c6c675279557a757c6b7c6b7c6b7c6c6c6c68686d6d6852d052c656796352c67b75770068c0c651c6939353c6937c53799451d053d093537994012001137a7e0114797e5d7900a26952795e7a950400e1f50596537a7894567900a0567900a09a6359796300cc5779a26900d10114798800d356799d00d2008851d100885a796302e007b27554cd016a8854d1008800cd53798851cd016a886754ce01207f755d798854cf827701279d54cf517f7501008854ce54d18854cf517f7701207f7554cf01217f77527f754c9bc076c776517f77587f527f527f527f587f587f75557a81557a81557a81557a81557a81557a815779ce5879d1885779c65879cc949003a08601765a955279587a9578938c7c967b567a955279938c7b9655795279937b527993557aa269013e7858807e567a597f777e5679cd885579cc7c947651a2695579d351a2695579c6557a945479935579d0547993957c7b94537a93537ad3537a9395a12052797e4c8778cf7bce7eaac076c776517f77587f75817600a2695379557987635279cc5379c65279949d5279d35379d09d5279ce5379d1885279cd597f77527f75768100a269013e0058807e787e53795b7f777e5479cd78886d6778011f7f7701207f75547978887800a0635379cd012057797e0778cf7bce7eaa877e885379cc5279a2696875686d6d75517eaa07ca537f7776aa20787e0f88029a007f7b63756777680089008a7e08000000000000000053797e0200007e0112797e0800000000000000007e0800000000000000007e2000000000000000000000000000000000000000000000000000000000000000007e00cd013e52797e01757e53797e8851cd016a886d6d75686700d10112798800d357799d00d2008851d10114798851d356799d51d200885a796302e007b27554cd016a8854d1008800cd53798851cd5379886754ce01207f755d798854cf827701279d54cf517f7501008854ce54d18854cf517f7701207f7554cf01217f77527f754cc5c0768b78c878c88876c95279c98b9d76c7517f77587f527f527f527f587f587f75557a81557a81557a81557a81557a81557a815779ce5879d1885779c65879cc9d5679ce5779d1885679c65779cc9d5779d05879d3949003a08601765a955279587a9578938c7c967b567a955279938c7b9655795279937b527993557aa2695679cd5779c788013e7858807e5679c7597f777e5679cd885679d37c947651a2695579d351a269567ad0557a945479935579d0547993957c7b94537a93537ad3537a9395a12052797e4cb778cf7bce7eaac0768b78c878c88876c95279c98b9d76c776517f77587f75817600a2695479567987635379d35479d05279949d5279d35379d09d5379ce5479d1885379c65479cc9d5279ce5379d1885279c65379cc9d5379cd5479c7885279cd597f77527f75768100a269013e0058807e787e53795b7f777e5479cd78886d6778011f7f7701207f75557978887800a0635479cd012058797e0778cf7bce7eaa877e885479d15579ce885479d352799d6875686d6d6d517eaa00cd07ca537f7776aa2052797e0f8802c4007f7b63756777680089008a7e8808000000000000000052797e0200007e0111797e0800000000000000007e0800000000000000007e2000000000000000000000000000000000000000000000000000000000000000007e013e787e0e75c08c76c8c0c888c0c97cc98b9c7e51cd78886d6d7568686700d1008800cd016a8851d1008851cd016a8854d1008854cd016a8868537900a063527952cd8852d35479a26952d10114798852d200886752cd016a8852d10088687600a063527953cd8859796353cc78a26953d100886753d3789d53d10112798853d20088686753cd016a8853d100886801205f7a7e01147a7e55cd88597a6355cc5579537993a26955d100886755cc5579a2697800a06355d35279a26955d160798855d200886755d100886868566576c49f766b6376d100876476d101207f757655ce01207f7587916976c0ce01207f758791697568768b77686c91666d6d6d6d6d6d6d6d6d6d516868").unwrap()
});
static IDO_PREINIT_CONTRACT: LazyLock<Vec<u8>> = LazyLock::new(|| {
hex::decode("c0519dc0c800c88800c9529dc0c852c88852c9539dc0c853c88853c9549dc0c854c88854c9559dc0c855c88855c9569dc0c856c88856c9579d5c79009e63c0c858c88858c9599d67c0c858c88858c9599d68c0c857c88857c9589d597981009c5d79009c9b5b7981009c9b63c0d1008852cd00c78852d1008853cd52c78853d1008854cd53c78854d1008855cd54c78855d1008856cd55c78856d1008857cd56c78857d100885c79009e6359cd58c78859cc58c6a26959d158ce8859d358d09d59d258cf8867597981009e5c79009e9a6459cd58c78859d10088686858cd57c78858d100880302010054797ec1014e7f775b7981009c6301005f79009e63015177685e79009c6300cd53798800d10088760251207e5e797e01207e5d797e52797e7b757c67c0c859c88859c9009d00cd016a8800d100885acd5c79885ad1c0c8885ad3009d5ad20100885bd10088c45c9d760200207e5e797e01207ec0c87e52797e7b757c6875675e79009c635d79009c6300cd52798800d10088030051205d797e01207e5c797e787e7767c0c859c88859c9009d53795579950400e1f50596547978935479789455795279a06902aa20012060797e5d797e5c797eaa7e01877e5c798277009c6302a91401200111797e5c797ea97e01877e776800cd788800d1c0c88800d352799d00d2008859cd56798859d1c0c88859d353799d59d2008858c7827758c77853947f77527f758158c7527953945279947f77787f7576827700a06376517f75768176014b9f788b547982779f9a635279788b7f77537a757c6b7c6c5279517f757b757c6878016a8764016a537a757c6b7c6c686d67016a77685acd78885ad100885bd10088c45c9d035100200114797e01207e0113797e58797e587a757c6b7c6b7c6b7c6b7c6b7c6b7c6c6c6c6c6c6c6d6d6d7568675d79009c6300cd52798800d10088035151205d797e01207e5c797e787e7767c0c859c88859c9009d00cd016a8800d100885acd5279885ad1c0c8885ad3009d5ad201ff885bd10088c45c9d03510020c0c87e01207e5c797e787e7768686802aa20c15a7f7552797eaa7e01877ec0cd886d67c0c859c88859c95a9d55ca827755ca7853947f77527f758155ca527953945279947f77787f75012302aa2001205f797e5d797e5b797eaa7e01877e7e5b798277009c63011702a914012060797e5b797ea97e01877e7e776800cd02aa20c15a7f7553797e54797eaa7e01877e8800d1008800ca827700ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686800ca537953945379945279947f7702aa20030200005d797e52797eaa7e01877e51cd8851d1008852ca827752ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686852ca537953945379945279947f7702aa20030200000111797e707c5a937f757e01207e01ff0119797eaa7e707c012b937f777eaa7e01877e52cd8852d1008853ca827753ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686853ca537953945379945279947f7702aa20030200000115797e52797eaa7e01877e53cd8853d1008854ca827754ca7853947f77527f75815178529302ff00a063755367785293014ba0637552686854ca537953945379945279947f7702aa20030200000119797e52797eaa7e01877e54cd8854d1008857c7827757c77853947f77527f75815178529302ff00a063755367785293014ba0637552686857c7537953945379945279947f7755cd03020000011d797e52797e8855d1008803020000011c797e56cd8856cc58c6a26956d158ce8856d3011a799d56d2008856ca827756ca7853947f77527f758156ca527953945279947f77787f7557cd02aa20c15a7f7501207e01000128797eaa7e53797eaa7e01877e8857cc59c6a26957d159ce8857d359d09d57d259cf88525752807e011f797e58cd8858d158ce8858d358d0011e79949d58d200886d6d6d6d6d6d6d6d6d6d6d6d6d75686d6d6d6d6d6d7551").unwrap()
@ -257,10 +257,13 @@ pub struct IdoParametersOfferingOffer {
priceNumerator: Integer,
#[serde_as(as = "IntegerAsStr")]
minOffer: Integer,
// The (non-native) token the offered tokens are exchanged for, display byte
// order. All-zero is the legacy "native BCH" sentinel and is invalid now.
#[serde_as(as = "serde_with::hex::Hex")]
xTokenCategory: Vec<u8>,
// The token the offered tokens are exchanged for, display byte order.
// `None` means NATIVE BCH (the offering's XWNT path): 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]
@ -418,13 +421,17 @@ pub struct IdoPostLaunchState {
platformEarnedAmount: Integer,
}
// The permanent liquidity pool deployed by the final postlaunch tx: a
// token/token pool whose xToken leg is output #0 and oToken leg is output #1.
// Also carries the altPPOut payback amounts (same share arithmetic, but the
// legs are paid to the collector p2nfth instead of deploying the pool).
// The permanent liquidity pool deployed by the final postlaunch tx. For a
// token/token IDO it is a two-leg pool whose xToken leg is output #0 and oToken
// leg is output #1. For a NATIVE BCH IDO it is a single-UTXO tokenbch pool at
// output #0 (BCH reserve in the value + oToken as the CashToken); there is no
// sibling and `xTokenAmount` is then the BCH reserve in satoshis. Also carries
// the altPPOut payback amounts (same share arithmetic, but the legs are paid to
// the collector p2nfth instead of deploying the pool).
#[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")]
@ -598,10 +605,14 @@ fn build_offering_bytecode(
discountAnnualRate: &Integer,
price: &Integer,
minOffer: &Integer,
// display byte order; baked into the bytecode in VM (reversed) order
xTokenCategory: &[u8],
// 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 in (matching encodeIdoXTokenCategory in libriften).
xTokenCategory: Option<&[u8]>,
) -> Vec<u8> {
let rev_xtoken_cat: Vec<u8> = xTokenCategory.iter().copied().rev().collect();
let rev_xtoken_cat: Vec<u8> = xTokenCategory
.map(|c| c.iter().copied().rev().collect())
.unwrap_or_default();
let mut input_bytecode = Builder::new()
.push_slice(pb(&STORAGE_CONTRACT))
.push_slice(pb(&OFFERING_ENTRY_CONTRACT))
@ -698,7 +709,8 @@ fn build_partial_offering_initiator_bytecode(
// 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: &'a [u8],
// display byte order; `None` is native BCH and bakes an empty (0x) push.
xTokenCategory: Option<&'a [u8]>,
permanentLiquidityShare: &'a Integer,
price: &'a Integer,
platformFeeNFTH: &'a [u8],
@ -709,7 +721,10 @@ struct PartialPostlaunchParameters<'a> {
}
fn build_partial_postlaunch_bytecode(params: &PartialPostlaunchParameters) -> Vec<u8> {
let rev_xtoken_cat: Vec<u8> = params.xTokenCategory.iter().copied().rev().collect();
let rev_xtoken_cat: Vec<u8> = params
.xTokenCategory
.map(|c| c.iter().copied().rev().collect())
.unwrap_or_default();
let rev_orb_pool_cat: Vec<u8> = params.orbPoolParamsCategory.iter().copied().rev().collect();
let mut bytecode = Builder::new()
.push_slice(pb(&rev_xtoken_cat))
@ -1500,8 +1515,14 @@ fn parse_ido_preinit_tx_params(
discountAnnualRateNumerator: vm_number_to_bigint(&data[102..106]),
priceNumerator: vm_number_to_bigint(&data[106..122]),
minOffer: vm_number_to_bigint(&data[122..126]),
// On-chain in VM order; kept in display/UI order.
xTokenCategory: data[126..158].iter().copied().rev().collect(),
// On-chain in VM order; kept in display/UI order. An
// all-zero category is the native-BCH sentinel and
// normalizes to `None` (matching the libriften SDK).
xTokenCategory: if data[126..158].iter().all(|&b| b == 0) {
None
} else {
Some(data[126..158].iter().copied().rev().collect())
},
},
executionFee: vm_number_to_bigint(&data[158..162]),
},
@ -1520,14 +1541,9 @@ fn parse_ido_preinit_tx_params(
permanentLiquidityMinFee: vm_number_to_bigint(&data[166..168]),
};
// xToken must be a non-native token; an all-zero category (the
// legacy native-BCH sentinel) is not accepted anymore.
if vm_number_to_bigint(&preinit_parameters.offering.offer.xTokenCategory) == 0 {
invalid_ido_reasons.push(anyhow::anyhow!(
"xToken should be a non-native token (zero xTokenCategory)"
));
is_valid_ido = false;
}
// The xToken may be a real token or native BCH (`None`, the
// all-zero on-chain sentinel). Both are valid; native rides the
// offering's XWNT path and lands in a single-UTXO tokenbch pool.
if !offered_token_is_in_supply {
let permanentLiquidityOTokenReserve: Integer = &preinit_parameters
.offeredTokenAmount
@ -1823,7 +1839,7 @@ fn parse_ido_preinit_tx(
&params.offering.offer.discountAnnualRateNumerator,
&params.offering.offer.priceNumerator,
&params.offering.offer.minOffer,
&params.offering.offer.xTokenCategory,
params.offering.offer.xTokenCategory.as_deref(),
),
)
.to_bytes();
@ -1929,7 +1945,7 @@ fn parse_ido_preinit_tx(
1,
&build_partial_ido_initiator_bytecode(
&build_partial_postlaunch_bytecode(&PartialPostlaunchParameters {
xTokenCategory: &params.offering.offer.xTokenCategory,
xTokenCategory: params.offering.offer.xTokenCategory.as_deref(),
permanentLiquidityShare: &params.permanentLiquidityShareNumerator,
price: &params.offering.offer.priceNumerator,
platformFeeNFTH: &params.offering.platformFeeNFTH,
@ -2430,6 +2446,11 @@ fn ido_add_tx(context: &IdoContext, tx: &Transaction) -> Result<IdoAddResult> {
}
_ => return Err(anyhow::anyhow!("expecting active parameters")),
};
// Native BCH (xTokenCategory == None): buyers pay via the
// offering's XWNT path. The paid BCH is the entry output's
// value (there is no xToken storage output) and a freshly
// minted owner nft sits one slot earlier (output#2).
let is_native = xTokenCategory.is_none();
let second_output = tx
.output
.get(1)
@ -2450,9 +2471,14 @@ fn ido_add_tx(context: &IdoContext, tx: &Transaction) -> Result<IdoAddResult> {
if entry_commitment.is_empty() {
return Err(anyhow::anyhow!("Incorrect commitment size at output#1"));
}
if entry_commitment[0] & OFFERING_ENTRY_FLAG_XWNT != 0 {
// The entry's XWNT flag must agree with the IDO's xToken: it
// is set iff the xToken is native BCH.
let entry_is_xwnt = entry_commitment[0] & OFFERING_ENTRY_FLAG_XWNT != 0;
if entry_is_xwnt != is_native {
return Err(anyhow::anyhow!(
"exchange-with-native-token entries are not supported"
"entry XWNT flag ({}) does not match the IDO xToken (native={})",
entry_is_xwnt,
is_native
));
}
let has_lockup = entry_commitment[0] & OFFERING_ENTRY_FLAG_LUTV != 0;
@ -2482,20 +2508,25 @@ fn ido_add_tx(context: &IdoContext, tx: &Transaction) -> Result<IdoAddResult> {
))
}
};
// The freshly minted owner nft sits right after the entry's
// xToken storage (non-native, output#3) or right after the
// entry itself when there is no storage (native, output#2).
let owner_nft_index = if is_native { 2 } else { 3 };
let owner_nfthash = if unlock_owner_nfthash.len() == 32 {
unlock_owner_nfthash
} else if unlock_owner_nfthash.is_empty() {
// freshly minted owner nft at output#3;
// freshly minted owner nft at output#owner_nft_index;
// nfthash = hash256(commitment ‖ category (le bytes))
let owner_output = tx
.output
.get(3)
.ok_or_else(|| anyhow::anyhow!("owner nft output does not exist!"))?;
let owner_output = tx.output.get(owner_nft_index).ok_or_else(|| {
anyhow::anyhow!("owner nft output#{owner_nft_index} 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!"))?;
.ok_or_else(|| {
anyhow::anyhow!("output#{owner_nft_index} is not an nft!")
})?;
if owner_token.id.to_blob()
!= context.offering_token_id.clone().unwrap_or_default()
{
@ -2503,7 +2534,7 @@ fn ido_add_tx(context: &IdoContext, tx: &Transaction) -> Result<IdoAddResult> {
}
if owner_token.commitment.first() != Some(&ITEM_TYPE_NFT_OWNER) {
return Err(anyhow::anyhow!(
"output#3 does not carry an owner nft commitment"
"output#{owner_nft_index} does not carry an owner nft commitment"
));
}
let mut hash_preimage = owner_token.commitment.to_vec();
@ -2514,22 +2545,29 @@ fn ido_add_tx(context: &IdoContext, tx: &Transaction) -> Result<IdoAddResult> {
"invalid ownerNFTH push in the unlocking bytecode of the offering utxo!"
));
};
// The paid xToken sits in the entry's xToken storage at
// output#2 as a token amount.
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 xtoken_category != xTokenCategory {
return Err(anyhow::anyhow!("output#2 token category != xTokenCategory"));
}
let supply_amount = xtoken.amount as u64;
// The paid xToken (supply). Native BCH: the entry output#1
// carries the paid BCH as its value (no xToken storage
// output). Token xToken: it sits in the entry's xToken
// storage at output#2 as a token amount.
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.as_ref() {
return Err(anyhow::anyhow!(
"output#2 token category != xTokenCategory"
));
}
xtoken.amount as u64
};
let demand_amount = second_output.token.as_ref().unwrap().amount as u64;
let lockup_timeval = if has_lockup {
u64::try_from(&decode_padded_vm_number(&entry_commitment[1..7]))
@ -2798,6 +2836,17 @@ fn ido_add_tx(context: &IdoContext, tx: &Transaction) -> Result<IdoAddResult> {
// #5 platform fee p2nfth: the accumulated BCH pot (carrier +
// storages + fee deposits), plus the platform's xToken share
// when non-zero
//
// Native BCH (xTokenCategory == None) reshapes the pool/xToken legs:
// #0 is the SINGLE-UTXO tokenbch pool — the BCH reserve rides as
// the output's value and the oToken is the CashToken; #1 is an
// unused OP_RETURN sibling slot. The collector xToken share (#3)
// and the platform xToken share (folded into #5's value) are BCH
// value, not tokens. #2 (oToken remainder) is unchanged.
let is_native = matches!(
&context.parameters,
IdoParameters::Active(p) if p.offering.offer.xTokenCategory.is_none()
);
let pool_x_output = tx
.output
.first()
@ -2808,6 +2857,12 @@ fn ido_add_tx(context: &IdoContext, tx: &Transaction) -> Result<IdoAddResult> {
.ok_or_else(|| anyhow::anyhow!("Should have third output!"))?;
updates.push(IdoUpdate::Status("DISTRIBUTED".to_string()));
let pool_legs = match pool_x_output.token.as_ref() {
// Native: output#0 is the single tokenbch 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_x_output.value.to_sat()),
oTokenAmount: Integer::from(o_leg.amount),
}),
Some(x_leg) => {
let o_leg = tx
.output
@ -2838,20 +2893,57 @@ fn ido_add_tx(context: &IdoContext, tx: &Transaction) -> Result<IdoAddResult> {
Some(legs) => (Some(legs), None),
None => (None, None),
};
let collector_xtoken_amount = tx
.output
.get(3)
.and_then(|output| output.token.as_ref())
.map(|token| Integer::from(token.amount))
.unwrap_or(Integer::ZERO);
// The collector's xToken share at output#3. Native BCH: it is paid
// as the output's value (OP_RETURN carries 0 when the share is zero);
// token xToken: it is the output's token amount.
let collector_xtoken_amount = if is_native {
tx.output
.get(3)
.map(|output| Integer::from(output.value.to_sat()))
.unwrap_or(Integer::ZERO)
} else {
tx.output
.get(3)
.and_then(|output| output.token.as_ref())
.map(|token| Integer::from(token.amount))
.unwrap_or(Integer::ZERO)
};
let platform_output = tx.output.get(5);
let platform_xtoken_amount = platform_output
.and_then(|output| output.token.as_ref())
.map(|token| Integer::from(token.amount))
.unwrap_or(Integer::ZERO);
let platform_bch_payout = platform_output
// The platform's xToken fee share at output#5. Token xToken: the
// output's token amount. Native BCH: the share is BCH folded into
// output#5's value and not separable there, so recover it from the
// xToken proceeds. In an IDO the offering layer charges no fee, so
// the confirmation NFT's earnedAmount is exactly the total xToken
// proceeds entering run; they split into the pool reserve (output#0
// value), the collector share (output#3 value), and this platform
// share. Clamp at zero to stay robust against a refund/edge tx.
let platform_xtoken_amount = if is_native {
let share = prev_state.earnedAmount.clone()
- Integer::from(pool_x_output.value.to_sat())
- collector_xtoken_amount.clone();
if share > 0 {
share
} else {
Integer::ZERO
}
} else {
platform_output
.and_then(|output| output.token.as_ref())
.map(|token| Integer::from(token.amount))
.unwrap_or(Integer::ZERO)
};
// The BCH pot paid to the platform (carrier + storages + fee
// deposits). Native BCH: output#5's value also carries the platform's
// xToken share (above), so subtract it to leave just the pot — keeping
// platformBchPayout the same "unrelated BCH" quantity as token/token.
let platform_output_value = platform_output
.map(|output| Integer::from(output.value.to_sat()))
.unwrap_or(Integer::ZERO);
let platform_bch_payout = if is_native {
platform_output_value - platform_xtoken_amount.clone()
} else {
platform_output_value
};
updates.push(IdoUpdate::State(Box::new(IdoState::Distributed(
IdoDistributedState {
authguardCategory: prev_state.authguardCategory.clone(),
@ -3417,7 +3509,7 @@ mod tests {
let orb_pool_params_category: Vec<u8> = (0u8..32).collect();
let min_fee = Integer::from(1000i64);
let partial = build_partial_postlaunch_bytecode(&PartialPostlaunchParameters {
xTokenCategory: &x_token_category,
xTokenCategory: Some(x_token_category.as_slice()),
permanentLiquidityShare: &Integer::from(20_000_000i64),
price: &Integer::from(1_700_000_000_000i64),
platformFeeNFTH: &[0xCC; 32],