diff --git a/Cargo.toml b/Cargo.toml index 6ebbcda..3c19fa2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ build = "build.rs" [dependencies] anyhow = { version = "1.0.94", features = ["backtrace"] } -bitcoincash = "0.32.2" +bitcoincash = "0.32.4" bitcoin_hashes = { version = "0.14.100", default-features = false } # Same version as used by bitcoincash electrum-client-netagnostic = { version = "0.21.2", features = ["use-rustls", "use-websocket"] } hex = { version = "0.4.3", features = ["serde"] } @@ -22,7 +22,7 @@ serde_json = "1.0.133" rocket = { version = "0.5.1", features = ["json"] } sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } rayon = "1.10.0" -riftenlabs-defi = "0.4.1" +riftenlabs-defi = "0.4.5" rocket_cors = "0.6.0" log = "0.4" stderrlog = "0.6.0" diff --git a/src/chain.rs b/src/chain.rs index 93fbf5d..ba11537 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -71,6 +71,14 @@ impl BlockUndoer for StoreBlockUndoer { { was_indexed = true; } + if db::cauldron::tokenbch::delete_entries_for_block( + &self.db.cauldron_w, + &blockheader.block_hash(), + ) + .await? + { + was_indexed = true; + } if db::bcmr::delete_entries_for_block(&self.db.bcmr_w, &blockheader.block_hash()) .await? { diff --git a/src/db/cauldron/mod.rs b/src/db/cauldron/mod.rs index c7127ff..b8af25d 100644 --- a/src/db/cauldron/mod.rs +++ b/src/db/cauldron/mod.rs @@ -18,8 +18,10 @@ pub mod config; pub mod header; pub mod mempool; pub mod ohlcv; +pub mod orb; pub mod pool; pub mod poolvisitor; +pub mod tokenbch; pub mod tokenlist; pub mod tokentoken; pub mod tx; @@ -48,6 +50,7 @@ pub async fn prepare_tables(pool: &SqlitePool) { pool::create_table(pool).await; tokentoken::create_table(pool).await; + tokenbch::create_table(pool).await; sqlx::query( "CREATE INDEX idx_utxo_funding_join ON utxo_funding(new_utxo_hash, sats, token_id);", diff --git a/src/db/cauldron/orb.rs b/src/db/cauldron/orb.rs new file mode 100644 index 0000000..043f2f3 --- /dev/null +++ b/src/db/cauldron/orb.rs @@ -0,0 +1,286 @@ +// 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 + +//! ORB DAO admission for delegation pools (see libriften `docs/orb.md`). +//! +//! A token-token or token-BCH delegation pool is only an **ORB-protocol** pool if +//! its creation transaction spends ORB's live `PoolParams` NFT via `use()` — the +//! covenant preserves that NFT (unchanged commitment, increased BCH value) at its +//! same-index output, so the params it pins are visible on the created tx. The +//! commitment carries the canonical fee parameters: +//! +//! ```text +//! version(1) ‖ platformFeeNfth(32) ‖ platformFeeRate(2) ‖ paramsUseFee(4) (39 bytes) +//! ``` +//! +//! Admission (`docs/orb.md` §4.2) requires, for a pool being created: +//! 1. the live `PoolParams` NFT is present (its category is ORB's — pinned here +//! per network), carried forward by `use()`; +//! 2. the pool's config `platformFeeRate` equals the commitment's; and +//! 3. the pool's thin-main `logicHash` is the one derived from the commitment's +//! `platformFeeNfth` (so the pool provably settles its platform fee to the +//! collector ORB controls). +//! +//! The on-chain covenant enforces (2)/(3) against the pair at `tx.outputs[0..2]`; +//! the indexer re-confirms them against the pool it actually indexes, and treats a +//! creation that fails any rule as **not an ORB pool** (ignored). There is no +//! fallback rate/collector — an unparseable or absent commitment rejects the +//! creation (money-safety: fail closed). +//! +//! Admission is by **pattern, not by creator** (`docs/orb.md` never singles out a +//! particular tx shape): the rule is "a pool-creating tx that carries the matching +//! `PoolParams` NFT". That uniformly covers both a user directly creating a pool +//! (`PoolParams.use()`) and the pool the IDO governor deploys inside its +//! `ido/postlaunch.cash` `run()` tx (which spends the same `PoolParams` NFT — at +//! input #4, preserved at output #4 — in the very tx that also distributes the +//! sale and ends the IDO). So this gate needs **no IDO awareness**: the pool +//! indexer and the IDO indexer independently observe that one `run` tx. (The +//! `altPPOut` timeout branch of `run` deploys no pool, so there is nothing here to +//! admit.) + +use bitcoincash::Transaction; +use riftenlabs_defi::{ + tokenbch::{expected_logic_hashes, ParsedTokenBch, TokenBchFeeSide}, + tokentoken::{expected_logic_hash, ParsedTokenToken}, +}; + +use crate::db::blob::ToBlob; +use crate::db::orbconstants::is_configured; + +/// Byte length of the encoded `PoolParams` NFT commitment. +pub const POOL_PARAMS_COMMITMENT_SIZE: usize = 39; +const OFFSET_PLATFORM_FEE_NFTH: usize = 1; +const OFFSET_PLATFORM_FEE_RATE: usize = 33; +const OFFSET_PARAMS_USE_FEE: usize = 35; + +/// The canonical pool fee parameters read from a `PoolParams` NFT commitment. +#[derive(Clone, Debug)] +pub struct PoolParamsCommitment { + /// `hash256(commitment ‖ category)` of the platform-fee collector NFT — the + /// pool's thin-main `logicHash` is derived from this. + pub platform_fee_nfth: [u8; 32], + /// Platform-fee numerator; must equal the pool config's `platformFeeRate`. + pub platform_fee_rate: i64, +} + +/// Decode a Bitcoin script number (little-endian, sign-magnitude) — the codec ORB +/// uses for the commitment's padded VM numbers. Matches the delegation parsers' +/// `decode_script_int`. +fn decode_padded_vm_number(bytes: &[u8]) -> i64 { + if bytes.is_empty() || bytes.len() > 8 { + return 0; + } + let mut result: i64 = 0; + for (i, b) in bytes.iter().enumerate() { + result |= (*b as i64) << (8 * i); + } + let last = bytes[bytes.len() - 1]; + if last & 0x80 != 0 { + let sign_bit = 0x80i64 << (8 * (bytes.len() - 1)); + result &= !sign_bit; + result = -result; + } + result +} + +/// Decode a 39-byte `PoolParams` NFT commitment; `None` if the length is wrong. +fn decode_pool_params_commitment(commitment: &[u8]) -> Option { + if commitment.len() != POOL_PARAMS_COMMITMENT_SIZE { + return None; + } + let mut platform_fee_nfth = [0u8; 32]; + platform_fee_nfth.copy_from_slice(&commitment[OFFSET_PLATFORM_FEE_NFTH..OFFSET_PLATFORM_FEE_RATE]); + Some(PoolParamsCommitment { + platform_fee_nfth, + platform_fee_rate: decode_padded_vm_number( + &commitment[OFFSET_PLATFORM_FEE_RATE..OFFSET_PARAMS_USE_FEE], + ), + }) +} + +/// Find the live `PoolParams` NFT carried forward by a `use()` spend in this tx: +/// an output holding an NFT of `category` with a genuine 39-byte commitment, and +/// return its decoded parameters. `None` if the tx does not carry the params NFT +/// (⇒ any pool it creates is not an ORB pool). +/// +/// The category is compared in display byte order (`token.id` reversed), matching +/// the convention used elsewhere in the indexer. When `category` is the all-zero +/// placeholder this never matches a real NFT, so admission fails closed. +pub fn find_pool_params_commitment( + tx: &Transaction, + category: &[u8; 32], +) -> Option { + if !is_configured(category) { + return None; + } + for out in &tx.output { + let token = match out.token.as_ref() { + Some(t) if t.has_nft() => t, + _ => continue, + }; + let disp: Vec = token.id.to_blob().iter().copied().rev().collect(); + if disp.as_slice() != category.as_slice() { + continue; + } + if let Some(c) = decode_pool_params_commitment(&token.commitment) { + return Some(c); + } + } + None +} + +/// ORB admission of a token-token pool creation against the `PoolParams` the +/// creating tx carried (`None` = no params NFT ⇒ reject). Confirms the config's +/// `platformFeeRate` and the thin-main `logicHash` match the commitment. +pub fn admit_tokentoken_pool( + state: &ParsedTokenToken, + params: Option<&PoolParamsCommitment>, +) -> bool { + let params = match params { + Some(p) => p, + None => return false, + }; + state.platform_fee_rate == params.platform_fee_rate + && state.logic_hash == expected_logic_hash(¶ms.platform_fee_nfth) +} + +/// ORB admission of a token-BCH pool creation. Like [`admit_tokentoken_pool`], but +/// returns **which fee variant** the pool pins (BCH-side or token-side accrual), +/// derived by matching the pool's `logicHash` against the two variants' hashes for +/// the admitted `platformFeeNfth` — the variant is *not* readable from the hash +/// alone. `None` = rejected (no params NFT, fee-rate mismatch, or logicHash matches +/// neither variant of the pinned NFTH). +pub fn admit_tokenbch_pool( + state: &ParsedTokenBch, + params: Option<&PoolParamsCommitment>, +) -> Option { + let params = params?; + if state.platform_fee_rate != params.platform_fee_rate { + return None; + } + let [bch, token] = expected_logic_hashes(¶ms.platform_fee_nfth); + if state.logic_hash == bch { + Some(TokenBchFeeSide::Bch) + } else if state.logic_hash == token { + Some(TokenBchFeeSide::Token) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin_hashes::Hash; + + #[test] + fn unconfigured_category_fails_closed() { + // The placeholder (all-zero) category is "not configured", so + // find_pool_params_commitment short-circuits and no pool is admitted. + assert!(!is_configured(&[0u8; 32])); + assert!(is_configured(&[0x11u8; 32])); + } + + #[test] + fn commitment_decodes_platform_fields() { + let mut c = [0u8; POOL_PARAMS_COMMITMENT_SIZE]; + c[0] = 0; // version + c[OFFSET_PLATFORM_FEE_NFTH..OFFSET_PLATFORM_FEE_RATE].fill(0xab); + c[OFFSET_PLATFORM_FEE_RATE..OFFSET_PARAMS_USE_FEE].copy_from_slice(&300u16.to_le_bytes()); + let d = decode_pool_params_commitment(&c).unwrap(); + assert_eq!(d.platform_fee_nfth, [0xab; 32]); + assert_eq!(d.platform_fee_rate, 300); + // Wrong length is rejected. + assert!(decode_pool_params_commitment(&c[..38]).is_none()); + } + + #[test] + fn admission_requires_matching_params() { + // A commitment whose platformFeeNfth is the placeholder; the pool's + // logicHash must be the one derived from it. + let nfth = [0xab; 32]; + let params = PoolParamsCommitment { + platform_fee_nfth: nfth, + platform_fee_rate: 300, + }; + let good_tt = expected_logic_hash(&nfth); + let bad = [0u8; 32]; + + assert!(admit_tokentoken_pool( + &tt_state(300, good_tt), + Some(¶ms) + )); + // No params NFT ⇒ reject. + assert!(!admit_tokentoken_pool(&tt_state(300, good_tt), None)); + // Wrong fee rate ⇒ reject. + assert!(!admit_tokentoken_pool(&tt_state(301, good_tt), Some(¶ms))); + // Wrong logicHash (fees settle to a different collector) ⇒ reject. + assert!(!admit_tokentoken_pool(&tt_state(300, bad), Some(¶ms))); + + // TokenBch admits either fee variant, returning which one the pool pins. + let [bch_lh, token_lh] = expected_logic_hashes(&nfth); + assert_eq!( + admit_tokenbch_pool(&tb_state(300, bch_lh), Some(¶ms)), + Some(TokenBchFeeSide::Bch) + ); + assert_eq!( + admit_tokenbch_pool(&tb_state(300, token_lh), Some(¶ms)), + Some(TokenBchFeeSide::Token) + ); + assert!(admit_tokenbch_pool(&tb_state(300, bad), Some(¶ms)).is_none()); + assert!(admit_tokenbch_pool(&tb_state(300, bch_lh), None).is_none()); + } + + fn tt_state(platform_fee_rate: i64, logic_hash: [u8; 32]) -> ParsedTokenToken { + use riftenlabs_defi::chainutil::OutPointHash; + ParsedTokenToken { + nft_owner: [0; 32], + owed_a: 0, + platform_fee_rate, + pool_fee_rate: 0, + min_fee: 0, + virtual_x: 0, + virtual_y: 0, + logic_hash, + is_withdrawn: false, + spent_main_utxo_hash: OutPointHash::from_byte_array([0; 32]), + spent_storage_utxo_hash: None, + new_main_utxo_hash: None, + new_main_utxo_txid: None, + new_main_utxo_n: None, + new_storage_utxo_hash: None, + new_storage_utxo_txid: None, + new_storage_utxo_n: None, + token_a_id: None, + token_a_amount: None, + token_b_id: None, + token_b_amount: None, + main_sats: None, + storage_sats: None, + } + } + + fn tb_state(platform_fee_rate: i64, logic_hash: [u8; 32]) -> ParsedTokenBch { + use riftenlabs_defi::chainutil::OutPointHash; + ParsedTokenBch { + nft_owner: [0; 32], + owed: 0, + platform_fee_rate, + pool_fee_rate: 0, + min_fee: 0, + virtual_x: 0, + virtual_y: 0, + logic_hash, + is_withdrawn: false, + spent_utxo_hash: OutPointHash::from_byte_array([0; 32]), + new_utxo_hash: None, + new_utxo_txid: None, + new_utxo_n: None, + token_id: None, + token_amount: None, + sats: None, + } + } +} diff --git a/src/db/cauldron/tokenbch.rs b/src/db/cauldron/tokenbch.rs new file mode 100644 index 0000000..19336f1 --- /dev/null +++ b/src/db/cauldron/tokenbch.rs @@ -0,0 +1,769 @@ +// 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 + +//! Indexing of single-UTXO token ⇄ BCH (`TokenBch`) delegation AMM pools. +//! +//! Mirrors [`crate::db::cauldron::tokentoken`], but a TokenBch pool is a single +//! self-contained UTXO: the BCH reserve rides in the output's satoshi value and +//! the token-B reserve in its CashToken. There is no storage sibling. Tables +//! live in `cauldron.db` so ingestion shares the block write-transaction and +//! `KEY_LAST_INDEXED` checkpoint. +//! +//! States are indexed both from confirmed blocks (with `mtp_timestamp`) and from +//! the mempool (with `first_seen_timestamp`); the history upsert reconciles the +//! two when a mempool tx confirms. +//! +//! The pool locking is bare-P2S, so a spend never reveals the pool script: +//! creations, swaps, and platform-fee sweeps are recognised from the recreated +//! pool output, while an LP teardown (no pool output) is surfaced by the parser +//! as withdrawal candidates and closed here by matching the spent outpoint +//! against the indexed live pools. See [`riftenlabs_defi::tokenbch`]. + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicI64, Ordering}; + +use anyhow::Result; + +use bitcoincash::{BlockHash, TokenID}; +use log::info; +use riftenlabs_defi::{ + chainutil::OutPointHash, + tokenbch::{ParsedTokenBch, TokenBchFeeSide}, +}; +use sqlx::{Row, SqliteConnection, SqlitePool}; + +use crate::db::blob::{blob_to_display_hex, FromBlob, ToBlob}; +use crate::db::cauldron::orb::{admit_tokenbch_pool, PoolParamsCommitment}; + +/// Idempotent: also run as an always-on migration for existing databases. +pub async fn create_table(pool: &SqlitePool) { + sqlx::query( + "CREATE TABLE IF NOT EXISTS tokenbch_pool ( + creation_utxo BLOB PRIMARY KEY, + nft_owner BLOB NOT NULL, + token_id BLOB NOT NULL, + logic_hash BLOB NOT NULL, + platform_fee_nfth BLOB NOT NULL, + fee_paid_in_bch TINYINT NOT NULL, + withdrawn_in_txid BLOB + )", + ) + .execute(pool) + .await + .expect("failed to create table tokenbch_pool"); + + sqlx::query( + "CREATE TABLE IF NOT EXISTS tokenbch_pool_history_entry ( + utxo BLOB PRIMARY KEY, + pool BLOB NOT NULL REFERENCES tokenbch_pool(creation_utxo) ON DELETE CASCADE, + token_id BLOB NOT NULL, + txid BLOB NOT NULL, + tx_pos INT NOT NULL, + mtp_timestamp BIGINT, + first_seen_timestamp BIGINT, + effective_timestamp BIGINT GENERATED ALWAYS AS (COALESCE(first_seen_timestamp, mtp_timestamp)), + sequence BIGINT NOT NULL, + reserve_bch BIGINT NOT NULL, + reserve_token BIGINT NOT NULL, + owed BIGINT NOT NULL, + platform_fee_rate BIGINT NOT NULL, + pool_fee_rate BIGINT NOT NULL, + min_fee BIGINT NOT NULL, + virtual_x BIGINT NOT NULL, + virtual_y BIGINT NOT NULL, + reserve_bch_delta BIGINT NOT NULL, + reserve_token_delta BIGINT NOT NULL + )", + ) + .execute(pool) + .await + .expect("failed to create table tokenbch_pool_history_entry"); + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_tb_history_pool_sequence ON tokenbch_pool_history_entry(pool, sequence)", + ) + .execute(pool) + .await + .unwrap(); + sqlx::query("CREATE INDEX IF NOT EXISTS idx_tb_pool_token ON tokenbch_pool(token_id)") + .execute(pool) + .await + .unwrap(); + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_tb_history_txid ON tokenbch_pool_history_entry(txid)", + ) + .execute(pool) + .await + .unwrap(); +} + +/// Next sequence number in the `tokenbch_pool_history_entry` table. +static NEXT_SEQUENCE: AtomicI64 = AtomicI64::new(-10); + +pub async fn initialize_seq(pool: &SqlitePool) { + let row: (i64,) = + sqlx::query_as("SELECT IFNULL(MAX(sequence), 0) + 1 FROM tokenbch_pool_history_entry") + .fetch_one(pool) + .await + .unwrap(); + NEXT_SEQUENCE.store(row.0, Ordering::SeqCst); +} + +#[allow(dead_code)] +pub fn dummy_init_seq() { + if NEXT_SEQUENCE.load(Ordering::SeqCst) < 0 { + NEXT_SEQUENCE.store(42, Ordering::SeqCst); + } +} + +async fn get_pool_by_utxo( + conn: &mut SqliteConnection, + utxo_hash: &OutPointHash, +) -> Result> { + let row: Option<(Vec,)> = + sqlx::query_as("SELECT pool FROM tokenbch_pool_history_entry WHERE utxo = ?") + .bind(utxo_hash.to_blob()) + .fetch_optional(&mut *conn) + .await?; + match row { + Some((blob,)) => Ok(Some(OutPointHash::from_blob(&blob)?)), + None => Ok(None), + } +} + +/// Latest (reserve_bch, reserve_token) for the pool state recorded at `utxo_hash`. +async fn get_reserves_at( + conn: &mut SqliteConnection, + utxo_hash: &OutPointHash, +) -> Result<(i64, i64)> { + let row = sqlx::query( + "SELECT reserve_bch, reserve_token FROM tokenbch_pool_history_entry WHERE utxo = ?", + ) + .bind(utxo_hash.to_blob()) + .fetch_optional(&mut *conn) + .await?; + match row { + Some(r) => Ok((r.get(0), r.get(1))), + None => Ok((0, 0)), + } +} + +/// Insert the pool row at creation. `platform_fee_nfth` (the platform-fee +/// collector this pool settles `owed` to) and `fee_paid_in_bch` (the fee variant) +/// come from the ORB admission of the creating tx — see +/// [`crate::db::cauldron::orb`]. +pub async fn insert_new_pool( + conn: &mut SqliteConnection, + pool: &ParsedTokenBch, + platform_fee_nfth: &[u8; 32], + fee_paid_in_bch: bool, +) -> Result<()> { + sqlx::query( + "INSERT OR REPLACE INTO tokenbch_pool + (creation_utxo, nft_owner, token_id, logic_hash, platform_fee_nfth, fee_paid_in_bch, withdrawn_in_txid) + VALUES (?, ?, ?, ?, ?, ?, NULL)", + ) + .bind(pool.new_utxo_hash.expect("new pool utxo missing").to_blob()) + .bind(pool.nft_owner.to_vec()) + .bind(pool.token_id.expect("token id missing").to_blob()) + .bind(pool.logic_hash.to_vec()) + .bind(platform_fee_nfth.to_vec()) + .bind(fee_paid_in_bch as i64) + .execute(&mut *conn) + .await + .map_err(|e| anyhow::anyhow!("failed to insert tokenbch pool: {e:?}"))?; + Ok(()) +} + +pub async fn flag_as_withdrawn( + conn: &mut SqliteConnection, + creation_utxo: &OutPointHash, + pool: &ParsedTokenBch, +) -> Result<()> { + let txid = pool + .new_utxo_txid + .expect("withdraw must carry the withdrawing txid"); + sqlx::query("UPDATE tokenbch_pool SET withdrawn_in_txid = ? WHERE creation_utxo = ?") + .bind(txid.to_blob()) + .bind(creation_utxo.to_blob()) + .execute(&mut *conn) + .await + .map_err(|e| anyhow::anyhow!("failed to flag tokenbch pool withdrawn: {e:?}"))?; + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub async fn insert_history_entry( + conn: &mut SqliteConnection, + creation_utxo: &OutPointHash, + pool: &ParsedTokenBch, + mtp_timestamp: Option, + first_seen_timestamp: Option, + reserve_bch_delta: i64, + reserve_token_delta: i64, +) -> Result<()> { + let next_seq = NEXT_SEQUENCE.fetch_add(1, Ordering::SeqCst); + assert!(next_seq >= 0, "tokenbch sequence not initialized"); + + sqlx::query( + "INSERT INTO tokenbch_pool_history_entry + (utxo, pool, token_id, txid, tx_pos, mtp_timestamp, first_seen_timestamp, sequence, + reserve_bch, reserve_token, owed, platform_fee_rate, pool_fee_rate, min_fee, + virtual_x, virtual_y, reserve_bch_delta, reserve_token_delta) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(utxo) DO UPDATE SET + pool = excluded.pool, + txid = excluded.txid, + tx_pos = excluded.tx_pos, + mtp_timestamp = COALESCE(excluded.mtp_timestamp, tokenbch_pool_history_entry.mtp_timestamp), + first_seen_timestamp = COALESCE(excluded.first_seen_timestamp, tokenbch_pool_history_entry.first_seen_timestamp), + sequence = excluded.sequence", + ) + .bind(pool.new_utxo_hash.expect("new utxo hash missing").to_blob()) + .bind(creation_utxo.to_blob()) + .bind(pool.token_id.expect("token id missing").to_blob()) + .bind(pool.new_utxo_txid.expect("txid missing").to_blob()) + .bind(pool.new_utxo_n.expect("vout missing") as i64) + .bind(mtp_timestamp.map(|t| t as i64)) + .bind(first_seen_timestamp.map(|t| t as i64)) + .bind(next_seq) + .bind(pool.sats.expect("reserve bch missing") as i64) + .bind(pool.token_amount.expect("reserve token missing")) + .bind(pool.owed) + .bind(pool.platform_fee_rate) + .bind(pool.pool_fee_rate) + .bind(pool.min_fee) + .bind(pool.virtual_x) + .bind(pool.virtual_y) + .bind(reserve_bch_delta) + .bind(reserve_token_delta) + .execute(&mut *conn) + .await + .map_err(|e| anyhow::anyhow!("failed to insert tokenbch history entry: {e:?}"))?; + Ok(()) +} + +/// Apply a batch of parsed TokenBch states to the database. +/// +/// Mirrors [`crate::db::cauldron::tokentoken::update_tokentoken_pool_history`]: a +/// swap/sweep is linked to its pool by the spent pool UTXO; a creation (no known +/// parent) opens a new pool — but only if [ORB-admitted](crate::db::cauldron::orb) +/// against the `PoolParams` NFT its creating tx carried; a withdrawal candidate +/// that matches a live pool flags it closed. The queue defers states whose parent +/// is created later in the same block. +/// +/// Each state is paired with the ORB `PoolParams` commitment carried by its own +/// transaction (`None` if that tx spent no live params NFT); only a creation +/// consults it (the ORB admission gate, `docs/orb.md` §4.2). +pub async fn update_tokenbch_pool_history( + conn: &mut SqliteConnection, + pools: Vec<(ParsedTokenBch, Option)>, + mtp_timestamp: Option, + first_seen_timestamp: Option, +) -> Result<()> { + if pools.is_empty() { + return Ok(()); + } + + let mut queue = VecDeque::from(pools); + + while let Some((current, params)) = queue.pop_front() { + let (creation_utxo, is_new) = + match get_pool_by_utxo(&mut *conn, ¤t.spent_utxo_hash).await? { + Some(c) => (c, false), + None => { + let has_parent = queue + .iter() + .any(|(p, _)| Some(current.spent_utxo_hash) == p.new_utxo_hash); + if has_parent { + queue.push_back((current, params)); + continue; + } + match current.new_utxo_hash { + Some(utxo) if !current.is_withdrawn => (utxo, true), + // A withdrawal candidate that matches no indexed pool, or + // a malformed state — nothing to update. + _ => continue, + } + } + }; + + if current.is_withdrawn { + info!("TokenBch pool {} withdrawn", creation_utxo); + flag_as_withdrawn(&mut *conn, &creation_utxo, ¤t).await?; + } else if is_new { + // ORB admission: a new pool is part of the protocol only if its + // creation tx spent the live PoolParams NFT and the pool matches the + // parameters that NFT pins. Admission also tells us the fee variant. + // Otherwise it is not an ORB pool — ignore. + let fee_side = match admit_tokenbch_pool(¤t, params.as_ref()) { + Some(fee_side) => fee_side, + None => { + info!( + "TokenBch pool creation in tx {} not ORB-admitted; ignoring", + current.new_utxo_txid.expect("new pool txid") + ); + continue; + } + }; + // Admission guarantees params is Some. + let commitment = params.as_ref().expect("admitted pool ⇒ PoolParams present"); + info!( + "TokenBch pool created in tx {}", + current.new_utxo_txid.expect("new pool txid") + ); + insert_new_pool( + &mut *conn, + ¤t, + &commitment.platform_fee_nfth, + matches!(fee_side, TokenBchFeeSide::Bch), + ) + .await?; + insert_history_entry( + &mut *conn, + &creation_utxo, + ¤t, + mtp_timestamp, + first_seen_timestamp, + 0, + 0, + ) + .await?; + } else { + let (prev_bch, prev_token) = + get_reserves_at(&mut *conn, ¤t.spent_utxo_hash).await?; + let reserve_bch_delta = current.sats.map(|s| s as i64).unwrap_or(0) - prev_bch; + let reserve_token_delta = current.token_amount.unwrap_or(0) - prev_token; + insert_history_entry( + &mut *conn, + &creation_utxo, + ¤t, + mtp_timestamp, + first_seen_timestamp, + reserve_bch_delta, + reserve_token_delta, + ) + .await?; + } + } + + Ok(()) +} + +/// Remove TokenBch state recorded in the given block (chain reorg). +/// +/// Order-independent w.r.t. how reorged blocks are replayed: deletes the block's +/// history rows, drops pools left with no history (their creation was undone), +/// and reverts withdrawals made in the block. +pub async fn delete_entries_for_block(pool: &SqlitePool, blockhash: &BlockHash) -> Result { + let r1 = sqlx::query( + "DELETE FROM tokenbch_pool_history_entry + WHERE txid IN (SELECT txid FROM tx WHERE blockhash = ?)", + ) + .bind(blockhash.to_blob()) + .execute(pool) + .await?; + + sqlx::query( + "DELETE FROM tokenbch_pool + WHERE creation_utxo NOT IN (SELECT DISTINCT pool FROM tokenbch_pool_history_entry)", + ) + .execute(pool) + .await?; + + let r3 = sqlx::query( + "UPDATE tokenbch_pool SET withdrawn_in_txid = NULL + WHERE withdrawn_in_txid IN (SELECT txid FROM tx WHERE blockhash = ?)", + ) + .bind(blockhash.to_blob()) + .execute(pool) + .await?; + + Ok(r1.rows_affected() + r3.rows_affected() != 0) +} + +fn serialize_u64_as_string(value: &u64, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +/// A single active TokenBch pool at its latest state, for the RPC layer. +#[derive(serde::Serialize)] +pub struct ActiveTokenBchPool { + pub pool_id: String, + pub nft_owner: String, + /// Token-B category id (the other side is native BCH). + pub token_id: String, + /// ORB platform-fee collector this pool settles `owed` to + /// (`hash256(commitment ‖ category)`, hex) — from the admitted PoolParams NFT. + pub platform_fee_nfth: String, + /// `1` if the platform fee accrues on the BCH side, `0` if on token B. + pub fee_paid_in_bch: u8, + /// BCH reserve in satoshis (decimal string; avoids f64 precision loss). + #[serde(serialize_with = "serialize_u64_as_string")] + pub reserve_bch: u64, + /// Token-B reserve in base units (decimal string). + #[serde(serialize_with = "serialize_u64_as_string")] + pub reserve_token: u64, + /// Accrued platform fee on the fee side (fee-side base units). + #[serde(serialize_with = "serialize_u64_as_string")] + pub owed: u64, + /// Platform's cut, parts-per-1000000. + pub platform_fee_rate: u64, + /// LP pool fee, parts-per-100000. + pub pool_fee_rate: u64, + /// Minimum combined fee, fee-side base units. + pub min_fee: u64, + /// Virtual reserve offset on the BCH side. + #[serde(serialize_with = "serialize_u64_as_string")] + pub virtual_x: u64, + /// Virtual reserve offset on the token side. + #[serde(serialize_with = "serialize_u64_as_string")] + pub virtual_y: u64, + /// Transaction that created the pool's current state. + pub txid: String, + /// Output index of the pool UTXO. + pub vout: u32, +} + +/// Column list + join shared by the active-pool queries: each pool's latest state. +const ACTIVE_POOL_SELECT: &str = "SELECT p.creation_utxo, p.nft_owner, p.token_id, + p.platform_fee_nfth, p.fee_paid_in_bch, + phe.reserve_bch, phe.reserve_token, phe.owed, phe.platform_fee_rate, phe.pool_fee_rate, + phe.min_fee, phe.virtual_x, phe.virtual_y, phe.txid, phe.tx_pos + FROM tokenbch_pool p + JOIN tokenbch_pool_history_entry phe ON phe.pool = p.creation_utxo + AND phe.sequence = ( + SELECT MAX(sequence) FROM tokenbch_pool_history_entry WHERE pool = p.creation_utxo + ) + WHERE p.withdrawn_in_txid IS NULL"; + +fn row_to_active_pool(row: &sqlx::sqlite::SqliteRow) -> Result { + let pool_blob: Vec = row.get(0); + let nft_owner_blob: Vec = row.get(1); + let token_blob: Vec = row.get(2); + let platform_fee_nfth_blob: Vec = row.get(3); + let fee_paid_in_bch: i64 = row.get(4); + let reserve_bch: i64 = row.get(5); + let reserve_token: i64 = row.get(6); + let owed: i64 = row.get(7); + let platform_fee_rate: i64 = row.get(8); + let pool_fee_rate: i64 = row.get(9); + let min_fee: i64 = row.get(10); + let virtual_x: i64 = row.get(11); + let virtual_y: i64 = row.get(12); + let txid_blob: Vec = row.get(13); + let tx_pos: i64 = row.get(14); + + Ok(ActiveTokenBchPool { + pool_id: blob_to_display_hex::(&pool_blob)?, + nft_owner: hex::encode(&nft_owner_blob), + token_id: blob_to_display_hex::(&token_blob)?, + platform_fee_nfth: hex::encode(&platform_fee_nfth_blob), + fee_paid_in_bch: fee_paid_in_bch as u8, + reserve_bch: reserve_bch as u64, + reserve_token: reserve_token as u64, + owed: owed as u64, + platform_fee_rate: platform_fee_rate as u64, + pool_fee_rate: pool_fee_rate as u64, + min_fee: min_fee as u64, + virtual_x: virtual_x as u64, + virtual_y: virtual_y as u64, + txid: blob_to_display_hex::(&txid_blob)?, + vout: tx_pos as u32, + }) +} + +/// Active pools trading the given token against BCH, each at its latest state. +pub async fn db_active_pools_for_token( + pool: &SqlitePool, + token: &TokenID, +) -> Result> { + let rows = sqlx::query(&format!("{ACTIVE_POOL_SELECT} AND p.token_id = ?")) + .bind(token.to_blob()) + .fetch_all(pool) + .await?; + + let mut out = Vec::with_capacity(rows.len()); + for row in &rows { + out.push(row_to_active_pool(row)?); + } + Ok(out) +} + +/// Every active pool at its latest state, with no token filter. Drives the +/// frontend selector so it can show which tokens have BCH liquidity. +pub async fn db_all_active_pools(pool: &SqlitePool) -> Result> { + let rows = sqlx::query(ACTIVE_POOL_SELECT).fetch_all(pool).await?; + + let mut out = Vec::with_capacity(rows.len()); + for row in &rows { + out.push(row_to_active_pool(row)?); + } + Ok(out) +} + +/// Distinct token ids that appear in at least one active TokenBch pool. +pub async fn db_pool_tokens(pool: &SqlitePool) -> Result> { + let rows = + sqlx::query("SELECT DISTINCT token_id FROM tokenbch_pool WHERE withdrawn_in_txid IS NULL") + .fetch_all(pool) + .await?; + + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + let blob: Vec = row.get(0); + out.push(blob_to_display_hex::(&blob)?); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::cauldron::prepare_tables; + use crate::db::cauldron::tx::insert_block_tx; + + use bitcoin_hashes::Hash; + use bitcoincash::{BlockHash, Txid}; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use std::sync::atomic::{AtomicU64, Ordering as AOrdering}; + + static TB_TEST_COUNTER: AtomicU64 = AtomicU64::new(0); + + async fn test_db() -> SqlitePool { + let id = TB_TEST_COUNTER.fetch_add(1, AOrdering::SeqCst); + let uri = format!("file:tb_test_{}?mode=memory&cache=shared", id); + let opts = SqliteConnectOptions::new() + .filename(&uri) + .foreign_keys(false); + let pool = SqlitePoolOptions::new().connect_with(opts).await.unwrap(); + prepare_tables(&pool).await; + dummy_init_seq(); + pool + } + + fn oph(b: u8) -> OutPointHash { + OutPointHash::from_byte_array([b; 32]) + } + fn tid(b: u8) -> TokenID { + TokenID::from_byte_array([b; 32]) + } + fn txid(b: u8) -> Txid { + Txid::from_byte_array([b; 32]) + } + + /// Platform-fee NFTH the test pools pin; the params commitment carries it. + fn test_platform_nfth() -> [u8; 32] { + [0xcd; 32] + } + + /// A `PoolParams` commitment matching the `creation()` fixture, so the ORB + /// admission gate accepts it. + fn params() -> PoolParamsCommitment { + PoolParamsCommitment { + platform_fee_nfth: test_platform_nfth(), + platform_fee_rate: 300, + } + } + + fn creation(utxo: u8, txid_b: u8, bch: u64, tok: i64) -> ParsedTokenBch { + ParsedTokenBch { + nft_owner: [0x11; 32], + owed: 0, + platform_fee_rate: 300, + pool_fee_rate: 100, + min_fee: 1, + virtual_x: 0, + virtual_y: 0, + // The BCH-fee variant logicHash for the pinned platform NFTH, so the + // pool matches the params commitment and admits as the BCH fee variant. + logic_hash: riftenlabs_defi::tokenbch::expected_logic_hashes(&test_platform_nfth())[0], + is_withdrawn: false, + spent_utxo_hash: OutPointHash::all_zeros(), + new_utxo_hash: Some(oph(utxo)), + new_utxo_txid: Some(txid(txid_b)), + new_utxo_n: Some(0), + token_id: Some(tid(0xBB)), + token_amount: Some(tok), + sats: Some(bch), + } + } + + /// A swap spending `prev_utxo`, producing `new_utxo` in tx `txid_b`. + fn swap(prev_utxo: u8, new_utxo: u8, txid_b: u8, bch: u64, tok: i64, owed: i64) -> ParsedTokenBch { + let mut p = creation(new_utxo, txid_b, bch, tok); + p.spent_utxo_hash = oph(prev_utxo); + p.owed = owed; + p + } + + async fn seed_tx(pool: &SqlitePool, t: &ParsedTokenBch, blockhash: &BlockHash) { + let mut conn = pool.acquire().await.unwrap(); + insert_block_tx(&mut conn, &t.new_utxo_txid.unwrap(), blockhash, 1_700_000_000) + .await + .unwrap(); + } + + #[tokio::test] + async fn create_table_is_idempotent() { + let pool = test_db().await; + create_table(&pool).await; + } + + #[tokio::test] + async fn mempool_then_block_keeps_first_seen() { + let pool = test_db().await; + let block = BlockHash::all_zeros(); + + let c = creation(1, 10, 5_000, 1_000_000); + seed_tx(&pool, &c, &block).await; + + let mut conn = pool.acquire().await.unwrap(); + update_tokenbch_pool_history( + &mut conn, + vec![(c.clone(), Some(params()))], + None, + Some(1_700_000_100), + ) + .await + .unwrap(); + update_tokenbch_pool_history( + &mut conn, + vec![(c, Some(params()))], + Some(1_700_000_500), + None, + ) + .await + .unwrap(); + + let row = sqlx::query( + "SELECT mtp_timestamp, first_seen_timestamp, effective_timestamp + FROM tokenbch_pool_history_entry", + ) + .fetch_one(&pool) + .await + .unwrap(); + let mtp: Option = row.get(0); + let first_seen: Option = row.get(1); + let effective: Option = row.get(2); + assert_eq!(mtp, Some(1_700_000_500)); + assert_eq!(first_seen, Some(1_700_000_100)); + assert_eq!(effective, Some(1_700_000_100), "first_seen wins"); + } + + #[tokio::test] + async fn creation_then_swap_tracks_reserves_and_token_query() { + let pool = test_db().await; + let block = BlockHash::all_zeros(); + + let c = creation(1, 10, 5_000, 1_000_000); + seed_tx(&pool, &c, &block).await; + let s = swap(1, 2, 11, 6_000, 990_000, 3); + seed_tx(&pool, &s, &block).await; + + let mut conn = pool.acquire().await.unwrap(); + update_tokenbch_pool_history( + &mut conn, + vec![(c, Some(params())), (s, None)], + Some(1_700_000_000), + None, + ) + .await + .unwrap(); + drop(conn); + + let pools = db_active_pools_for_token(&pool, &tid(0xBB)).await.unwrap(); + assert_eq!(pools.len(), 1); + assert_eq!(pools[0].reserve_bch, 6_000); + assert_eq!(pools[0].reserve_token, 990_000); + assert_eq!(pools[0].owed, 3); + assert_eq!(pools[0].platform_fee_rate, 300); + assert_eq!(pools[0].pool_fee_rate, 100); + assert_eq!(pools[0].fee_paid_in_bch, 1); + assert_eq!(pools[0].platform_fee_nfth, hex::encode(test_platform_nfth())); + assert_eq!(pools[0].vout, 0); + + // The swap delta is recorded against the previous reserves. + let deltas = sqlx::query( + "SELECT reserve_bch_delta, reserve_token_delta FROM tokenbch_pool_history_entry + ORDER BY sequence DESC LIMIT 1", + ) + .fetch_one(&pool) + .await + .unwrap(); + let bch_delta: i64 = deltas.get(0); + let token_delta: i64 = deltas.get(1); + assert_eq!(bch_delta, 1_000); + assert_eq!(token_delta, -10_000); + + let tokens = db_pool_tokens(&pool).await.unwrap(); + assert!(tokens.contains(&tid(0xBB).to_string())); + } + + #[tokio::test] + async fn withdraw_hides_pool_and_reorg_restores_it() { + let pool = test_db().await; + let block_a = BlockHash::from_byte_array([0xA1; 32]); + let block_b = BlockHash::from_byte_array([0xB2; 32]); + + let c = creation(1, 10, 5_000, 1_000_000); + seed_tx(&pool, &c, &block_a).await; + + // Teardown in a later block: the parser emits withdrawal candidates, one + // of which matches the live pool UTXO (oph(1)); the others don't. + let mut w_match = creation(0, 20, 0, 0); + w_match.is_withdrawn = true; + w_match.spent_utxo_hash = oph(1); + w_match.new_utxo_hash = None; + w_match.new_utxo_n = None; + w_match.token_id = None; + w_match.token_amount = None; + w_match.sats = None; + w_match.new_utxo_txid = Some(txid(20)); + let mut w_miss = w_match.clone(); + w_miss.spent_utxo_hash = oph(200); // unrelated input, matches no pool + seed_tx(&pool, &w_match, &block_b).await; + + let mut conn = pool.acquire().await.unwrap(); + update_tokenbch_pool_history(&mut conn, vec![(c, Some(params()))], Some(1), None) + .await + .unwrap(); + // Teardown txs carry no params NFT; withdrawals are not admission-gated. + update_tokenbch_pool_history(&mut conn, vec![(w_miss, None), (w_match, None)], Some(2), None) + .await + .unwrap(); + drop(conn); + + assert!( + db_active_pools_for_token(&pool, &tid(0xBB)) + .await + .unwrap() + .is_empty(), + "withdrawn pool should not be active" + ); + + // Reorg the withdrawal block: pool becomes active again. + delete_entries_for_block(&pool, &block_b).await.unwrap(); + assert_eq!( + db_active_pools_for_token(&pool, &tid(0xBB)) + .await + .unwrap() + .len(), + 1, + "reorg of the withdrawal should restore the pool" + ); + + // Reorg the creation block: pool disappears entirely. + delete_entries_for_block(&pool, &block_a).await.unwrap(); + assert!(db_active_pools_for_token(&pool, &tid(0xBB)) + .await + .unwrap() + .is_empty()); + assert!(db_pool_tokens(&pool).await.unwrap().is_empty()); + } +} diff --git a/src/db/cauldron/tokentoken.rs b/src/db/cauldron/tokentoken.rs index 473c8f0..9b6d929 100644 --- a/src/db/cauldron/tokentoken.rs +++ b/src/db/cauldron/tokentoken.rs @@ -3,16 +3,24 @@ // 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 -//! Indexing of native token-A <-> token-B (`TokenToken`) AMM pools. +//! Indexing of native token-A ⇄ token-B (`TokenToken`) **delegation** AMM pools. //! -//! Mirrors [`crate::db::cauldron::pool`] but tracks both token reserves of a -//! pool (token A in the main UTXO, token B in the linked storage UTXO). Tables -//! live in `cauldron.db` so ingestion shares the block write-transaction and -//! `KEY_LAST_INDEXED` checkpoint. +//! A pool is two co-created bare-P2S UTXOs: a generic *thin main* (token A) and a +//! per-pool *storage sibling* (token B + the 62-byte config). On a swap both are +//! spent and recreated at the same input/output indices, the sibling immediately +//! after its main. Tables live in `cauldron.db` so ingestion shares the block +//! write-transaction and `KEY_LAST_INDEXED` checkpoint. //! //! Like cauldron pools, states are indexed both from confirmed blocks (with //! `mtp_timestamp`) and from the mempool (with `first_seen_timestamp`); the //! history upsert reconciles the two when a mempool tx confirms. +//! +//! The pool locking is bare-P2S, so a spend never reveals the pool script: +//! creations, swaps, and platform-fee sweeps are recognised from the recreated +//! `` output pair, while an LP teardown (no pool output) is +//! surfaced by the parser as withdrawal candidates and closed here by matching +//! the spent outpoint against the indexed live pools. See +//! [`riftenlabs_defi::tokentoken`]. use std::collections::VecDeque; use std::sync::atomic::{AtomicI64, Ordering}; @@ -25,6 +33,7 @@ use riftenlabs_defi::{chainutil::OutPointHash, tokentoken::ParsedTokenToken}; use sqlx::{Row, SqliteConnection, SqlitePool}; use crate::db::blob::{blob_to_display_hex, FromBlob, ToBlob}; +use crate::db::cauldron::orb::{admit_tokentoken_pool, PoolParamsCommitment}; /// Idempotent: also run as an always-on migration for existing databases /// (the tables were added after `DB_VERSION` 5 without a version bump). @@ -35,8 +44,8 @@ pub async fn create_table(pool: &SqlitePool) { nft_owner BLOB NOT NULL, token_a_id BLOB NOT NULL, token_b_id BLOB NOT NULL, - fee_rate INT NOT NULL, - min_fee INT NOT NULL, + logic_hash BLOB NOT NULL, + platform_fee_nfth BLOB NOT NULL, withdrawn_in_txid BLOB )", ) @@ -60,6 +69,12 @@ pub async fn create_table(pool: &SqlitePool) { sequence BIGINT NOT NULL, reserve_a BIGINT NOT NULL, reserve_b BIGINT NOT NULL, + owed_a BIGINT NOT NULL, + platform_fee_rate BIGINT NOT NULL, + pool_fee_rate BIGINT NOT NULL, + min_fee BIGINT NOT NULL, + virtual_x BIGINT NOT NULL, + virtual_y BIGINT NOT NULL, main_sats BIGINT NOT NULL, storage_sats BIGINT NOT NULL, reserve_a_delta BIGINT NOT NULL, @@ -141,10 +156,17 @@ async fn get_reserves_at( } } -pub async fn insert_new_pool(conn: &mut SqliteConnection, pool: &ParsedTokenToken) -> Result<()> { +/// Insert the pool row at creation. `platform_fee_nfth` (the platform-fee +/// collector this pool settles `owed_a` to) comes from the ORB admission of the +/// creating tx — see [`crate::db::cauldron::orb`]. +pub async fn insert_new_pool( + conn: &mut SqliteConnection, + pool: &ParsedTokenToken, + platform_fee_nfth: &[u8; 32], +) -> Result<()> { sqlx::query( "INSERT OR REPLACE INTO tokentoken_pool - (creation_utxo, nft_owner, token_a_id, token_b_id, fee_rate, min_fee, withdrawn_in_txid) + (creation_utxo, nft_owner, token_a_id, token_b_id, logic_hash, platform_fee_nfth, withdrawn_in_txid) VALUES (?, ?, ?, ?, ?, ?, NULL)", ) .bind( @@ -155,8 +177,8 @@ pub async fn insert_new_pool(conn: &mut SqliteConnection, pool: &ParsedTokenToke .bind(pool.nft_owner.to_vec()) .bind(pool.token_a_id.expect("token a id missing").to_blob()) .bind(pool.token_b_id.expect("token b id missing").to_blob()) - .bind(pool.fee_rate as i64) - .bind(pool.min_fee as i64) + .bind(pool.logic_hash.to_vec()) + .bind(platform_fee_nfth.to_vec()) .execute(&mut *conn) .await .map_err(|e| anyhow::anyhow!("failed to insert tokentoken pool: {e:?}"))?; @@ -197,8 +219,9 @@ pub async fn insert_history_entry( "INSERT INTO tokentoken_pool_history_entry (utxo, pool, storage_utxo, token_a_id, token_b_id, txid, tx_pos, storage_tx_pos, mtp_timestamp, first_seen_timestamp, sequence, reserve_a, reserve_b, + owed_a, platform_fee_rate, pool_fee_rate, min_fee, virtual_x, virtual_y, main_sats, storage_sats, reserve_a_delta, reserve_b_delta) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(utxo) DO UPDATE SET pool = excluded.pool, storage_utxo = excluded.storage_utxo, @@ -222,6 +245,12 @@ pub async fn insert_history_entry( .bind(next_seq) .bind(pool.token_a_amount.expect("reserve a missing")) .bind(pool.token_b_amount.expect("reserve b missing")) + .bind(pool.owed_a) + .bind(pool.platform_fee_rate) + .bind(pool.pool_fee_rate) + .bind(pool.min_fee) + .bind(pool.virtual_x) + .bind(pool.virtual_y) .bind(pool.main_sats.expect("main sats missing") as i64) .bind(pool.storage_sats.expect("storage sats missing") as i64) .bind(reserve_a_delta) @@ -234,13 +263,19 @@ pub async fn insert_history_entry( /// Apply a batch of parsed TokenToken states to the database. /// -/// Mirrors [`crate::db::cauldron::pool::update_pool_history`]: a swap is linked -/// to its pool by the spent main UTXO; a creation (no known parent) opens a new -/// pool; a withdrawal flags the pool closed. The queue defers states whose -/// parent is created later in the same block. +/// Mirrors [`crate::db::cauldron::tokenbch::update_tokenbch_pool_history`]: a +/// swap/sweep is linked to its pool by the spent main UTXO; a creation (no known +/// parent) opens a new pool — but only if [ORB-admitted](crate::db::cauldron::orb) +/// against the `PoolParams` NFT its creating tx carried; a withdrawal candidate +/// that matches a live pool flags it closed. The queue defers states whose parent +/// is created later in the same block. +/// +/// Each state is paired with the ORB `PoolParams` commitment carried by its own +/// transaction (`None` if that tx spent no live params NFT); only a creation +/// consults it (the ORB admission gate, `docs/orb.md` §4.2). pub async fn update_tokentoken_pool_history( conn: &mut SqliteConnection, - pools: Vec, + pools: Vec<(ParsedTokenToken, Option)>, mtp_timestamp: Option, first_seen_timestamp: Option, ) -> Result<()> { @@ -250,22 +285,22 @@ pub async fn update_tokentoken_pool_history( let mut queue = VecDeque::from(pools); - while let Some(current) = queue.pop_front() { + while let Some((current, params)) = queue.pop_front() { let (creation_utxo, is_new) = match get_pool_by_main_utxo(&mut *conn, ¤t.spent_main_utxo_hash).await? { Some(c) => (c, false), None => { let has_parent = queue .iter() - .any(|p| Some(current.spent_main_utxo_hash) == p.new_main_utxo_hash); + .any(|(p, _)| Some(current.spent_main_utxo_hash) == p.new_main_utxo_hash); if has_parent { - queue.push_back(current); + queue.push_back((current, params)); continue; } match current.new_main_utxo_hash { Some(utxo) if !current.is_withdrawn => (utxo, true), - // A withdrawal of a pool we never indexed, or a malformed - // state — nothing to update. + // A withdrawal candidate that matches no indexed pool, or + // a malformed state — nothing to update. _ => continue, } } @@ -275,11 +310,23 @@ pub async fn update_tokentoken_pool_history( info!("TokenToken pool {} withdrawn", creation_utxo); flag_as_withdrawn(&mut *conn, &creation_utxo, ¤t).await?; } else if is_new { + // ORB admission: a new pool is part of the protocol only if its + // creation tx spent the live PoolParams NFT and the pool matches the + // parameters that NFT pins. Otherwise it is not an ORB pool — ignore. + if !admit_tokentoken_pool(¤t, params.as_ref()) { + info!( + "TokenToken pool creation in tx {} not ORB-admitted; ignoring", + current.new_main_utxo_txid.expect("new pool txid") + ); + continue; + } + // Admission guarantees params is Some. + let commitment = params.as_ref().expect("admitted pool ⇒ PoolParams present"); info!( "TokenToken pool created in tx {}", current.new_main_utxo_txid.expect("new pool txid") ); - insert_new_pool(&mut *conn, ¤t).await?; + insert_new_pool(&mut *conn, ¤t, &commitment.platform_fee_nfth).await?; insert_history_entry( &mut *conn, &creation_utxo, @@ -357,13 +404,29 @@ pub struct ActiveTokenTokenPool { pub nft_owner: String, pub token_a_id: String, pub token_b_id: String, + /// ORB platform-fee collector this pool settles `owed_a` to + /// (`hash256(commitment ‖ category)`, hex) — from the admitted PoolParams NFT. + pub platform_fee_nfth: String, /// Decimal string (base units) — avoids f64 precision loss for large token amounts. #[serde(serialize_with = "serialize_u64_as_string")] pub reserve_a: u64, #[serde(serialize_with = "serialize_u64_as_string")] pub reserve_b: u64, - pub fee_rate: u32, + /// Accrued platform fee in token A (token-A base units). + #[serde(serialize_with = "serialize_u64_as_string")] + pub owed_a: u64, + /// Platform's cut, parts-per-1000000. + pub platform_fee_rate: u64, + /// LP pool fee, parts-per-100000. + pub pool_fee_rate: u64, + /// Minimum combined fee, token-A base units. pub min_fee: u64, + /// Virtual reserve offset on the token-A side. + #[serde(serialize_with = "serialize_u64_as_string")] + pub virtual_x: u64, + /// Virtual reserve offset on the token-B side. + #[serde(serialize_with = "serialize_u64_as_string")] + pub virtual_y: u64, /// Satoshis locked in the main UTXO (preserved across swaps). pub main_sats: u64, /// Satoshis locked in the storage UTXO (preserved across swaps). @@ -376,6 +439,61 @@ pub struct ActiveTokenTokenPool { pub storage_vout: u32, } +/// Column list + join shared by the active-pool queries: each pool's latest state. +const ACTIVE_POOL_SELECT: &str = "SELECT p.creation_utxo, p.nft_owner, p.token_a_id, p.token_b_id, + p.platform_fee_nfth, + phe.reserve_a, phe.reserve_b, phe.owed_a, phe.platform_fee_rate, phe.pool_fee_rate, + phe.min_fee, phe.virtual_x, phe.virtual_y, phe.main_sats, phe.storage_sats, + phe.txid, phe.tx_pos, phe.storage_tx_pos + FROM tokentoken_pool p + JOIN tokentoken_pool_history_entry phe ON phe.pool = p.creation_utxo + AND phe.sequence = ( + SELECT MAX(sequence) FROM tokentoken_pool_history_entry WHERE pool = p.creation_utxo + ) + WHERE p.withdrawn_in_txid IS NULL"; + +fn row_to_active_pool(row: &sqlx::sqlite::SqliteRow) -> Result { + let pool_blob: Vec = row.get(0); + let nft_owner_blob: Vec = row.get(1); + let token_a_blob: Vec = row.get(2); + let token_b_blob: Vec = row.get(3); + let platform_fee_nfth_blob: Vec = row.get(4); + let reserve_a: i64 = row.get(5); + let reserve_b: i64 = row.get(6); + let owed_a: i64 = row.get(7); + let platform_fee_rate: i64 = row.get(8); + let pool_fee_rate: i64 = row.get(9); + let min_fee: i64 = row.get(10); + let virtual_x: i64 = row.get(11); + let virtual_y: i64 = row.get(12); + let main_sats: i64 = row.get(13); + let storage_sats: i64 = row.get(14); + let txid_blob: Vec = row.get(15); + let tx_pos: i64 = row.get(16); + let storage_tx_pos: i64 = row.get(17); + + Ok(ActiveTokenTokenPool { + pool_id: blob_to_display_hex::(&pool_blob)?, + nft_owner: hex::encode(&nft_owner_blob), + token_a_id: blob_to_display_hex::(&token_a_blob)?, + token_b_id: blob_to_display_hex::(&token_b_blob)?, + platform_fee_nfth: hex::encode(&platform_fee_nfth_blob), + reserve_a: reserve_a as u64, + reserve_b: reserve_b as u64, + owed_a: owed_a as u64, + platform_fee_rate: platform_fee_rate as u64, + pool_fee_rate: pool_fee_rate as u64, + min_fee: min_fee as u64, + virtual_x: virtual_x as u64, + virtual_y: virtual_y as u64, + main_sats: main_sats as u64, + storage_sats: storage_sats as u64, + txid: blob_to_display_hex::(&txid_blob)?, + main_vout: tx_pos as u32, + storage_vout: storage_tx_pos as u32, + }) +} + /// Active pools containing both tokens, regardless of which is A (main) or B /// (storage). Returns each pool's latest state. pub async fn db_active_pools_for_pair( @@ -383,58 +501,19 @@ pub async fn db_active_pools_for_pair( token_a: &TokenID, token_b: &TokenID, ) -> Result> { - let a = token_a.to_blob(); - let b = token_b.to_blob(); - - let rows = sqlx::query( - "SELECT p.creation_utxo, p.nft_owner, p.token_a_id, p.token_b_id, p.fee_rate, p.min_fee, - phe.reserve_a, phe.reserve_b, phe.main_sats, phe.storage_sats, - phe.txid, phe.tx_pos, phe.storage_tx_pos - FROM tokentoken_pool p - JOIN tokentoken_pool_history_entry phe ON phe.pool = p.creation_utxo - AND phe.sequence = ( - SELECT MAX(sequence) FROM tokentoken_pool_history_entry WHERE pool = p.creation_utxo - ) - WHERE p.withdrawn_in_txid IS NULL + let rows = sqlx::query(&format!( + "{ACTIVE_POOL_SELECT} AND ((p.token_a_id = ?1 AND p.token_b_id = ?2) - OR (p.token_a_id = ?2 AND p.token_b_id = ?1))", - ) - .bind(&a) - .bind(&b) + OR (p.token_a_id = ?2 AND p.token_b_id = ?1))" + )) + .bind(token_a.to_blob()) + .bind(token_b.to_blob()) .fetch_all(pool) .await?; let mut out = Vec::with_capacity(rows.len()); - for row in rows { - let pool_blob: Vec = row.get(0); - let nft_owner_blob: Vec = row.get(1); - let token_a_blob: Vec = row.get(2); - let token_b_blob: Vec = row.get(3); - let fee_rate: i64 = row.get(4); - let min_fee: i64 = row.get(5); - let reserve_a: i64 = row.get(6); - let reserve_b: i64 = row.get(7); - let main_sats: i64 = row.get(8); - let storage_sats: i64 = row.get(9); - let txid_blob: Vec = row.get(10); - let tx_pos: i64 = row.get(11); - let storage_tx_pos: i64 = row.get(12); - - out.push(ActiveTokenTokenPool { - pool_id: blob_to_display_hex::(&pool_blob)?, - nft_owner: hex::encode(&nft_owner_blob), - token_a_id: blob_to_display_hex::(&token_a_blob)?, - token_b_id: blob_to_display_hex::(&token_b_blob)?, - reserve_a: reserve_a as u64, - reserve_b: reserve_b as u64, - fee_rate: fee_rate as u32, - min_fee: min_fee as u64, - main_sats: main_sats as u64, - storage_sats: storage_sats as u64, - txid: blob_to_display_hex::(&txid_blob)?, - main_vout: tx_pos as u32, - storage_vout: storage_tx_pos as u32, - }); + for row in &rows { + out.push(row_to_active_pool(row)?); } Ok(out) } @@ -444,51 +523,11 @@ pub async fn db_active_pools_for_pair( /// set of pools to show which token pairs have liquidity (without probing each /// candidate pair individually). pub async fn db_all_active_pools(pool: &SqlitePool) -> Result> { - let rows = sqlx::query( - "SELECT p.creation_utxo, p.nft_owner, p.token_a_id, p.token_b_id, p.fee_rate, p.min_fee, - phe.reserve_a, phe.reserve_b, phe.main_sats, phe.storage_sats, - phe.txid, phe.tx_pos, phe.storage_tx_pos - FROM tokentoken_pool p - JOIN tokentoken_pool_history_entry phe ON phe.pool = p.creation_utxo - AND phe.sequence = ( - SELECT MAX(sequence) FROM tokentoken_pool_history_entry WHERE pool = p.creation_utxo - ) - WHERE p.withdrawn_in_txid IS NULL", - ) - .fetch_all(pool) - .await?; + let rows = sqlx::query(ACTIVE_POOL_SELECT).fetch_all(pool).await?; let mut out = Vec::with_capacity(rows.len()); - for row in rows { - let pool_blob: Vec = row.get(0); - let nft_owner_blob: Vec = row.get(1); - let token_a_blob: Vec = row.get(2); - let token_b_blob: Vec = row.get(3); - let fee_rate: i64 = row.get(4); - let min_fee: i64 = row.get(5); - let reserve_a: i64 = row.get(6); - let reserve_b: i64 = row.get(7); - let main_sats: i64 = row.get(8); - let storage_sats: i64 = row.get(9); - let txid_blob: Vec = row.get(10); - let tx_pos: i64 = row.get(11); - let storage_tx_pos: i64 = row.get(12); - - out.push(ActiveTokenTokenPool { - pool_id: blob_to_display_hex::(&pool_blob)?, - nft_owner: hex::encode(&nft_owner_blob), - token_a_id: blob_to_display_hex::(&token_a_blob)?, - token_b_id: blob_to_display_hex::(&token_b_blob)?, - reserve_a: reserve_a as u64, - reserve_b: reserve_b as u64, - fee_rate: fee_rate as u32, - min_fee: min_fee as u64, - main_sats: main_sats as u64, - storage_sats: storage_sats as u64, - txid: blob_to_display_hex::(&txid_blob)?, - main_vout: tx_pos as u32, - storage_vout: storage_tx_pos as u32, - }); + for row in &rows { + out.push(row_to_active_pool(row)?); } Ok(out) } @@ -556,13 +595,23 @@ mod tests { let mut conn = pool.acquire().await.unwrap(); // Mempool: first_seen only. - update_tokentoken_pool_history(&mut conn, vec![c.clone()], None, Some(1_700_000_100)) - .await - .unwrap(); + update_tokentoken_pool_history( + &mut conn, + vec![(c.clone(), Some(params()))], + None, + Some(1_700_000_100), + ) + .await + .unwrap(); // Block confirmation of the same state: mtp only. - update_tokentoken_pool_history(&mut conn, vec![c], Some(1_700_000_500), None) - .await - .unwrap(); + update_tokentoken_pool_history( + &mut conn, + vec![(c, Some(params()))], + Some(1_700_000_500), + None, + ) + .await + .unwrap(); let row = sqlx::query( "SELECT mtp_timestamp, first_seen_timestamp, effective_timestamp @@ -589,12 +638,32 @@ mod tests { Txid::from_byte_array([b; 32]) } + /// Platform-fee NFTH the test pools pin; the params commitment carries it. + fn test_platform_nfth() -> [u8; 32] { + [0xcd; 32] + } + + /// A `PoolParams` commitment matching the `creation()` fixture, so the ORB + /// admission gate accepts it. + fn params() -> PoolParamsCommitment { + PoolParamsCommitment { + platform_fee_nfth: test_platform_nfth(), + platform_fee_rate: 300, + } + } + fn creation(main: u8, txid_b: u8, ra: i64, rb: i64) -> ParsedTokenToken { ParsedTokenToken { nft_owner: [0x11; 32], - fee_rate: 300, + owed_a: 0, + platform_fee_rate: 300, + pool_fee_rate: 100, min_fee: 1, - other_token_outpoint_index: 1, + virtual_x: 0, + virtual_y: 0, + // The thin-main logicHash for the pinned platform NFTH, so the pool + // matches the params commitment (ORB admission). + logic_hash: riftenlabs_defi::tokentoken::expected_logic_hash(&test_platform_nfth()), is_withdrawn: false, spent_main_utxo_hash: OutPointHash::all_zeros(), spent_storage_utxo_hash: Some(OutPointHash::all_zeros()), @@ -614,9 +683,10 @@ mod tests { } /// A swap spending `prev_main`, producing `new_main` in tx `txid_b`. - fn swap(prev_main: u8, new_main: u8, txid_b: u8, ra: i64, rb: i64) -> ParsedTokenToken { + fn swap(prev_main: u8, new_main: u8, txid_b: u8, ra: i64, rb: i64, owed_a: i64) -> ParsedTokenToken { let mut p = creation(new_main, txid_b, ra, rb); p.spent_main_utxo_hash = oph(prev_main); + p.owed_a = owed_a; p } @@ -639,13 +709,18 @@ mod tests { let c = creation(1, 10, 1_000_000, 2_000_000); seed_tx(&pool, &c, &block).await; - let s = swap(1, 2, 11, 1_010_000, 1_980_000); + let s = swap(1, 2, 11, 1_010_000, 1_980_000, 3); seed_tx(&pool, &s, &block).await; let mut conn = pool.acquire().await.unwrap(); - update_tokentoken_pool_history(&mut conn, vec![c, s], Some(1_700_000_000), None) - .await - .unwrap(); + update_tokentoken_pool_history( + &mut conn, + vec![(c, Some(params())), (s, None)], + Some(1_700_000_000), + None, + ) + .await + .unwrap(); drop(conn); // Order-insensitive pair lookup returns the latest state. @@ -655,10 +730,26 @@ mod tests { assert_eq!(pools.len(), 1); assert_eq!(pools[0].reserve_a, 1_010_000); assert_eq!(pools[0].reserve_b, 1_980_000); - assert_eq!(pools[0].fee_rate, 300); + assert_eq!(pools[0].owed_a, 3); + assert_eq!(pools[0].platform_fee_rate, 300); + assert_eq!(pools[0].pool_fee_rate, 100); + assert_eq!(pools[0].platform_fee_nfth, hex::encode(test_platform_nfth())); assert_eq!(pools[0].main_vout, 0); assert_eq!(pools[0].storage_vout, 1); + // The swap delta is recorded against the previous reserves. + let deltas = sqlx::query( + "SELECT reserve_a_delta, reserve_b_delta FROM tokentoken_pool_history_entry + ORDER BY sequence DESC LIMIT 1", + ) + .fetch_one(&pool) + .await + .unwrap(); + let a_delta: i64 = deltas.get(0); + let b_delta: i64 = deltas.get(1); + assert_eq!(a_delta, 10_000); + assert_eq!(b_delta, -20_000); + let tokens = db_pair_tokens(&pool).await.unwrap(); assert!(tokens.contains(&tid(0xAA).to_string()) && tokens.contains(&tid(0xBB).to_string())); } @@ -672,23 +763,41 @@ mod tests { let c = creation(1, 10, 1_000_000, 2_000_000); seed_tx(&pool, &c, &block_a).await; - // Withdrawal in a later block. - let mut w = creation(0, 20, 0, 0); - w.is_withdrawn = true; - w.spent_main_utxo_hash = oph(1); - w.new_main_utxo_hash = None; - w.new_storage_utxo_hash = None; + // Teardown in a later block: the parser emits withdrawal candidates, one + // of which matches the live pool's main UTXO (oph(1)); the others don't. + let mut w_match = creation(0, 20, 0, 0); + w_match.is_withdrawn = true; + w_match.spent_main_utxo_hash = oph(1); + w_match.spent_storage_utxo_hash = None; + w_match.new_main_utxo_hash = None; + w_match.new_main_utxo_n = None; + w_match.new_storage_utxo_hash = None; + w_match.new_storage_utxo_n = None; + w_match.token_a_id = None; + w_match.token_b_id = None; + w_match.token_a_amount = None; + w_match.token_b_amount = None; + w_match.main_sats = None; + w_match.storage_sats = None; // carry the withdrawing txid (as the parser does) - w.new_main_utxo_txid = Some(txid(20)); - seed_tx(&pool, &w, &block_b).await; + w_match.new_main_utxo_txid = Some(txid(20)); + let mut w_miss = w_match.clone(); + w_miss.spent_main_utxo_hash = oph(200); // unrelated input, matches no pool + seed_tx(&pool, &w_match, &block_b).await; let mut conn = pool.acquire().await.unwrap(); - update_tokentoken_pool_history(&mut conn, vec![c], Some(1), None) - .await - .unwrap(); - update_tokentoken_pool_history(&mut conn, vec![w], Some(2), None) + update_tokentoken_pool_history(&mut conn, vec![(c, Some(params()))], Some(1), None) .await .unwrap(); + // Teardown txs carry no params NFT; withdrawals are not admission-gated. + update_tokentoken_pool_history( + &mut conn, + vec![(w_miss, None), (w_match, None)], + Some(2), + None, + ) + .await + .unwrap(); drop(conn); let a = tid(0xAA); diff --git a/src/db/ido/mod.rs b/src/db/ido/mod.rs index e43aea1..011e1c0 100644 --- a/src/db/ido/mod.rs +++ b/src/db/ido/mod.rs @@ -74,17 +74,11 @@ static PERMANENT_LIQUIDITY_SHARE_DENOMINATOR: LazyLock = // they are pinned per-IdoParams NFT (minPlpShare / minPlpAfterDiscount, numerators // over PERMANENT_LIQUIDITY_SHARE_DENOMINATOR) and enforced against the offering. -// The ORB IdoParams NFT category (display byte order, like a token id). 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) that the -// announced IDO parameters are validated against. -const CHIPNET_IDO_PARAMS_NFT_CATEGORY: &[u8; 32] = &[ - 0xc9, 0x16, 0x7d, 0x12, 0x39, 0x46, 0x27, 0xba, 0x28, 0x30, 0x70, 0x5f, 0xb2, 0xb0, 0xf0, 0x0b, - 0x49, 0xeb, 0x28, 0x46, 0x9e, 0x65, 0xda, 0x53, 0x69, 0x12, 0xd0, 0x50, 0x75, 0x89, 0x08, 0x00, -]; -// TODO:: set the ido params nft category for mainnet -const MAINNET_IDO_PARAMS_NFT_CATEGORY: &[u8; 32] = &[0u8; 32]; +// The ORB IdoParams NFT category (per network) now 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) that the announced IDO parameters are validated against. // (OP_PUSH8 "CldIdo00" OP_DROP), found at the start of every ido // state machine redeem script. @@ -1735,11 +1729,7 @@ fn parse_ido_preinit_tx( errors: &mut Vec, invalid_ido_reasons: &mut Vec, ) -> IdoPreinitParseResult { - let ido_params_category = match network { - Some(Network::Chipnet) => *CHIPNET_IDO_PARAMS_NFT_CATEGORY, - Some(Network::Bitcoin) => *MAINNET_IDO_PARAMS_NFT_CATEGORY, - _ => *MAINNET_IDO_PARAMS_NFT_CATEGORY, - }; + 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; @@ -2005,7 +1995,7 @@ fn parse_ido_preinit_tx( hex::encode(script.to_bytes()) )); } - let token_amount = Integer::from(token.amount); + let token_amount = Integer::from(token.amount.to_int()); if token_amount < requiredTokens { errors.push(anyhow::anyhow!("invalid offered token amount!")); } @@ -2566,9 +2556,10 @@ fn ido_add_tx(context: &IdoContext, tx: &Transaction) -> Result { "output#2 token category != xTokenCategory" )); } - xtoken.amount as u64 + xtoken.amount.to_int() as u64 }; - let demand_amount = second_output.token.as_ref().unwrap().amount 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) @@ -2861,7 +2852,7 @@ fn ido_add_tx(context: &IdoContext, tx: &Transaction) -> Result { // 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), + oTokenAmount: Integer::from(o_leg.amount.to_int()), }), Some(x_leg) => { let o_leg = tx @@ -2872,8 +2863,8 @@ fn ido_add_tx(context: &IdoContext, tx: &Transaction) -> Result { anyhow::anyhow!("permanent pool oToken output (output#1) is missing!") })?; Some(IdoPermanentPoolV0 { - xTokenAmount: Integer::from(x_leg.amount), - oTokenAmount: Integer::from(o_leg.amount), + xTokenAmount: Integer::from(x_leg.amount.to_int()), + oTokenAmount: Integer::from(o_leg.amount.to_int()), }) } None => None, @@ -2905,7 +2896,7 @@ fn ido_add_tx(context: &IdoContext, tx: &Transaction) -> Result { tx.output .get(3) .and_then(|output| output.token.as_ref()) - .map(|token| Integer::from(token.amount)) + .map(|token| Integer::from(token.amount.to_int())) .unwrap_or(Integer::ZERO) }; let platform_output = tx.output.get(5); @@ -2929,7 +2920,7 @@ fn ido_add_tx(context: &IdoContext, tx: &Transaction) -> Result { } else { platform_output .and_then(|output| output.token.as_ref()) - .map(|token| Integer::from(token.amount)) + .map(|token| Integer::from(token.amount.to_int())) .unwrap_or(Integer::ZERO) }; // The BCH pot paid to the platform (carrier + storages + fee @@ -2954,7 +2945,7 @@ fn ido_add_tx(context: &IdoContext, tx: &Transaction) -> Result { refundAmount: collector_otoken_output .token .as_ref() - .map(|token| Integer::from(token.amount)) + .map(|token| Integer::from(token.amount.to_int())) .unwrap_or(Integer::ZERO), discountAmount: prev_state.discountAmount.clone(), collectorEarnedAmount: collector_xtoken_amount, diff --git a/src/db/init.rs b/src/db/init.rs index 440b955..0aac7d1 100644 --- a/src/db/init.rs +++ b/src/db/init.rs @@ -14,6 +14,7 @@ use super::DB; use crate::db::bcmr::prepare_tables as bcmr_prepare_tables; use crate::db::cauldron::config::check_db_version; use crate::db::cauldron::prepare_tables as cauldron_prepare_tables; +use crate::db::cauldron::tokenbch::create_table as tokenbch_prepare_tables; use crate::db::cauldron::tokentoken::create_table as tokentoken_prepare_tables; use crate::db::crc20::prepare_tables as crc20_prepare_tables; use crate::db::ido::prepare_tables as ido_prepare_tables; @@ -108,6 +109,7 @@ pub async fn initialize_databases(network: &str, read_slots: ReadSlots) -> Resul } // Always-run migration: safe on both new and existing cauldron.db tokentoken_prepare_tables(&cauldron_db_write).await; + tokenbch_prepare_tables(&cauldron_db_write).await; // Initialize BCMR database let (db_exists, bcmr_db_write, bcmr_db_read) = diff --git a/src/db/mod.rs b/src/db/mod.rs index 352b173..80358f0 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -13,6 +13,7 @@ pub mod ido; pub mod init; pub mod moria; pub mod oracle; +pub mod orbconstants; pub mod search; #[derive(Clone)] diff --git a/src/db/orbconstants.rs b/src/db/orbconstants.rs new file mode 100644 index 0000000..de4ac5e --- /dev/null +++ b/src/db/orbconstants.rs @@ -0,0 +1,72 @@ +// 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 + +//! Per-network ORB DAO parameter-NFT categories — the single place to set them. +//! +//! ORB governs protocol revenue through two parameter NFTs (see libriften +//! `docs/orb.md`): a `PoolParams` NFT whose commitment pins delegation-pool fee +//! parameters, and an `IdoParams` NFT whose commitment pins IDO parameters. Each +//! NFT is identified by its **token category**, which is a per-network deployment +//! constant. A pool/IDO is only an ORB-protocol member if its init transaction +//! spends the network's live params NFT (see the `orb` / `ido` admission code). +//! +//! Categories are stored in **display byte order** (like a token id string), the +//! convention the indexer compares against (`token.id` reversed). Until a real +//! category is set, its placeholder is the all-zero sentinel — [`is_configured`] +//! is false and admission fails **closed** (no pools/IDOs admitted), never open. + +use bitcoincash::Network; + +// --- IdoParams NFT category --------------------------------------------------- + +/// Chipnet `IdoParams` NFT category. Every IDO preinit must spend this NFT +/// (input #1, preserved at output #10); its commitment carries the economic +/// parameters the announced IDO is validated against. +pub const CHIPNET_IDO_PARAMS_NFT_CATEGORY: &[u8; 32] = &[ + 0xc9, 0x16, 0x7d, 0x12, 0x39, 0x46, 0x27, 0xba, 0x28, 0x30, 0x70, 0x5f, 0xb2, 0xb0, 0xf0, 0x0b, + 0x49, 0xeb, 0x28, 0x46, 0x9e, 0x65, 0xda, 0x53, 0x69, 0x12, 0xd0, 0x50, 0x75, 0x89, 0x08, 0x00, +]; +// TODO:: set the ido params nft category for mainnet +pub const MAINNET_IDO_PARAMS_NFT_CATEGORY: &[u8; 32] = &[0u8; 32]; + +// --- PoolParams NFT category -------------------------------------------------- + +// Every delegation-pool creation (token-token and token-BCH) must spend the +// `PoolParams` NFT; its commitment pins the platform fee rate + collector the +// pool is validated against. +// +pub const CHIPNET_POOL_PARAMS_NFT_CATEGORY: &[u8; 32] = &[ + 0x04, 0xb7, 0x36, 0xed, 0x20, 0x6d, 0x34, 0xfa, 0xe2, 0xd2, 0xfb, 0x4b, 0x95, 0x28, 0x54, 0xc9, + 0xd5, 0xcf, 0x07, 0x38, 0x73, 0x2e, 0xe3, 0xaa, 0x61, 0xdb, 0x17, 0xe2, 0x3e, 0xaf, 0x2d, 0x12, +]; +// TODO:: set the pool params nft category for mainnet +pub const MAINNET_POOL_PARAMS_NFT_CATEGORY: &[u8; 32] = &[0u8; 32]; + +/// The ORB `IdoParams` NFT category for the given network (display byte order). +pub fn ido_params_nft_category(network: Option) -> [u8; 32] { + match network { + Some(Network::Chipnet) => *CHIPNET_IDO_PARAMS_NFT_CATEGORY, + Some(Network::Bitcoin) => *MAINNET_IDO_PARAMS_NFT_CATEGORY, + _ => *MAINNET_IDO_PARAMS_NFT_CATEGORY, + } +} + +/// The ORB `PoolParams` NFT category for the given network (display byte order). +/// While a network's category is the all-zero placeholder, no delegation pool is +/// admitted on it (fail closed) — see [`is_configured`]. +pub fn pool_params_nft_category(network: Option) -> [u8; 32] { + match network { + Some(Network::Chipnet) => *CHIPNET_POOL_PARAMS_NFT_CATEGORY, + Some(Network::Bitcoin) => *MAINNET_POOL_PARAMS_NFT_CATEGORY, + _ => *MAINNET_POOL_PARAMS_NFT_CATEGORY, + } +} + +/// True once a real (non-placeholder) category is configured. A caller can log a +/// one-time warning when this is false so the inert admission gate (nothing +/// indexed) is not mistaken for "no pools/IDOs exist". +pub fn is_configured(category: &[u8; 32]) -> bool { + category != &[0u8; 32] +} diff --git a/src/electrum.rs b/src/electrum.rs index 0ed3853..3784ab4 100644 --- a/src/electrum.rs +++ b/src/electrum.rs @@ -14,7 +14,14 @@ use log::info; use riftenlabs_defi::{ cauldron::V2_CONTRACT_TEMPLATE, delphi::{v2::DELPHI_V2_REDEEM_SCRIPT_BODY, DELPHI_REDEEM_SCRIPT}, - tokentoken::{CONJURE_HINT_PREFIX, TOKENTOKEN_CONTRACT_CODE}, + tokenbch::{ + CARRIER_UNLOCK_PREFIX_HEX as TOKENBCH_CARRIER_UNLOCK_PREFIX_HEX, + THIN_MAIN_SUFFIX as TOKENBCH_THIN_MAIN_SUFFIX, + }, + tokentoken::{ + CARRIER_UNLOCK_PREFIX_HEX as TOKENTOKEN_CARRIER_UNLOCK_PREFIX_HEX, + SIBCODE as TOKENTOKEN_SIBCODE, + }, }; use serde_json::{json, Value}; @@ -41,27 +48,41 @@ pub fn electrum_get_tip(client: &Client) -> Result<(BlockHeader, u64)> { Ok((deserialize(&hex::decode(header)?)?, height as u64)) } -/// Fetch defi (cauldron + tokentoken), oracle, ido and bcmr mempool transactions -pub fn electrum_fetch_mempool( - client: &Client, -) -> Result<(HashSet, HashSet, HashSet, HashSet)> { +/// Mempool transaction-id sets returned by [`electrum_fetch_mempool`], in order: +/// defi (cauldron + tokentoken + tokenbch), oracle, ido, bcmr. +type MempoolTxSets = (HashSet, HashSet, HashSet, HashSet); + +/// Fetch defi (cauldron + tokentoken + tokenbch), oracle, ido and bcmr mempool transactions +pub fn electrum_fetch_mempool(client: &Client) -> Result { let cauldron_filter = json!({ "scriptsig": hex::encode(&V2_CONTRACT_TEMPLATE[(V2_CONTRACT_TEMPLATE.len() - 43)..]), // cauldron spends "scriptpubkey": hex::encode([0x6a /* op_return */, 0x06 /* push */, b'S', b'U', b'M', b'M', b'O', b'N']), // new pools (potentially) "operation": "union" }); - // The contract code is the constant tail of every tokentoken redeem - // script, pushed in full in spending scriptsigs; creations carry an - // `OP_RETURN "CONJURE"` hint output. - let conjure_hint_prefix: Vec = [0x6a /* op_return */, 0x07 /* push */] - .iter() - .chain(CONJURE_HINT_PREFIX) - .copied() - .collect(); + // TokenToken (token-A ⇄ token-B delegation) pools are bare-P2S, so a spend + // never reveals the pool script; recognition is output-driven. Every + // creation, swap, and platform-fee sweep recreates the pool at a output pair; the per-pool storage sibling's locking ends in the + // fixed StorageSibling code — the scriptpubkey side catches those. An LP + // teardown recreates no pool output but presents the carrier covenant's logic + // blob in an input; its shared leading bytes (already hex) match on the + // scriptsig side. let tokentoken_filter = json!({ - "scriptsig": hex::encode(TOKENTOKEN_CONTRACT_CODE), // pool spends - "scriptpubkey": hex::encode(&conjure_hint_prefix), // new pools (potentially) + "scriptsig": TOKENTOKEN_CARRIER_UNLOCK_PREFIX_HEX, // teardowns (carrier logic blob) + "scriptpubkey": hex::encode(TOKENTOKEN_SIBCODE), // pool siblings (create/swap/sweep) + "operation": "union" + }); + + // TokenBch (token ⇄ BCH delegation) pools are the single-UTXO sibling of the + // above; same bare-P2S, output-driven recognition. Every creation, swap, and + // platform-fee sweep recreates the pool output, whose locking ends in the + // fixed dispatcher suffix (variant- and NFTH-independent) — the scriptpubkey + // side catches those. An LP teardown presents the carrier logic blob in an + // input; its shared leading bytes match on the scriptsig side. + let tokenbch_filter = json!({ + "scriptsig": TOKENBCH_CARRIER_UNLOCK_PREFIX_HEX, // teardowns (carrier logic blob) + "scriptpubkey": hex::encode(TOKENBCH_THIN_MAIN_SUFFIX), // pool outputs (create/swap/sweep) "operation": "union" }); @@ -116,10 +137,11 @@ pub fn electrum_fetch_mempool( .collect()) }; - // Cauldron and tokentoken txs share one set: update_mempool diffs it - // against the stored mempool (tx table) to decide adds and deletes. + // Cauldron, tokentoken and tokenbch txs share one set: update_mempool diffs + // it against the stored mempool (tx table) to decide adds and deletes. let mut defi_txs = fetch_txs(cauldron_filter)?; defi_txs.extend(fetch_txs(tokentoken_filter)?); + defi_txs.extend(fetch_txs(tokenbch_filter)?); let mut oracle_txs = fetch_txs(oracle_v1_filter)?; oracle_txs.extend(fetch_txs(oracle_v2_filter)?); let ido_txs = fetch_txs(ido_filter)?; diff --git a/src/index.rs b/src/index.rs index 1fa66e0..a9d2c56 100644 --- a/src/index.rs +++ b/src/index.rs @@ -18,6 +18,7 @@ use electrum_client_netagnostic::{Client, ElectrumApi, Param}; use log::{debug, info, warn}; use riftenlabs_defi::cauldron::{parse_cauldrons_from_tx, ParsedContract}; use riftenlabs_defi::moria::MoriaTokenIds; +use riftenlabs_defi::tokenbch::parse_tokenbch_from_tx; use riftenlabs_defi::tokentoken::parse_tokentoken_from_tx; use crate::{ @@ -112,23 +113,29 @@ pub async fn update_mempool( let mut all_cauldrons = vec![]; let mut all_tokentokens = vec![]; + let mut all_tokenbch = vec![]; let current_timestamp = time_now() as u64; + // ORB PoolParams NFT category for this network — the delegation-pool + // admission gate (docs/orb.md §4). Placeholder categories admit nothing. + let pool_params_category = db::orbconstants::pool_params_nft_category(network); for btx in txs_to_add { let cauldrons: Vec = parse_cauldrons_from_tx(&btx); let tokentokens = parse_tokentoken_from_tx(&btx); + let tokenbch = parse_tokenbch_from_tx(&btx); - if cauldrons.is_empty() && tokentokens.is_empty() { + if cauldrons.is_empty() && tokentokens.is_empty() && tokenbch.is_empty() { info!("ignoring non-defi tx {}", btx.compute_txid()); continue; } let txid = btx.compute_txid(); db::cauldron::tx::insert_mempool_tx(&mut db_tx, &txid, current_timestamp).await?; info!( - "mempool add {} with {} cauldrons, {} tokentoken states", + "mempool add {} with {} cauldrons, {} tokentoken states, {} tokenbch states", txid, cauldrons.len(), - tokentokens.len() + tokentokens.len(), + tokenbch.len() ); if !cauldrons.is_empty() { @@ -138,7 +145,12 @@ pub async fn update_mempool( all_cauldrons.extend(cauldrons); } - all_tokentokens.extend(tokentokens); + // The PoolParams NFT (if any) this tx spent, carried alongside every + // pool state it produced so a creation can be ORB-admitted. + let pool_params = + db::cauldron::orb::find_pool_params_commitment(&btx, &pool_params_category); + all_tokentokens.extend(tokentokens.into_iter().map(|s| (s, pool_params.clone()))); + all_tokenbch.extend(tokenbch.into_iter().map(|s| (s, pool_params.clone()))); } db::cauldron::pool::update_pool_history( @@ -155,6 +167,13 @@ pub async fn update_mempool( Some(current_timestamp), ) .await?; + db::cauldron::tokenbch::update_tokenbch_pool_history( + &mut db_tx, + all_tokenbch, + None, + Some(current_timestamp), + ) + .await?; db_tx.commit().await?; } @@ -464,17 +483,21 @@ pub async fn index_blocks( let mut all_cauldrons = vec![]; let mut all_tokentokens = vec![]; + let mut all_tokenbch = vec![]; + // ORB PoolParams NFT category for this network — the delegation-pool + // admission gate (docs/orb.md §4). Placeholder categories admit nothing. + let pool_params_category = db::orbconstants::pool_params_nft_category(network); let sorted_txs = ttor_sorted_kahn(block.txdata); for tx in &sorted_txs { let cauldrons = parse_cauldrons_from_tx(tx); - // Note: parse_tokentoken_from_tx skips the pool-creation check when - // any input spends an existing pool, so a single tx that both swaps - // pool P and creates pool Q drops Q (upstream riftenlabs-defi - // limitation, shared with parse_cauldrons_from_tx). + // The delegation parsers are output-driven: they recognise every + // recreated pool leg, so a tx that both swaps one pool and creates + // another yields states for both. let tokentokens = parse_tokentoken_from_tx(tx); - if cauldrons.is_empty() && tokentokens.is_empty() { + let tokenbch = parse_tokenbch_from_tx(tx); + if cauldrons.is_empty() && tokentokens.is_empty() && tokenbch.is_empty() { continue; } @@ -497,7 +520,12 @@ pub async fn index_blocks( all_cauldrons.extend(cauldrons); } - all_tokentokens.extend(tokentokens); + // The PoolParams NFT (if any) this tx spent, carried alongside every + // pool state it produced so a creation can be ORB-admitted. + let pool_params = + db::cauldron::orb::find_pool_params_commitment(tx, &pool_params_category); + all_tokentokens.extend(tokentokens.into_iter().map(|s| (s, pool_params.clone()))); + all_tokenbch.extend(tokenbch.into_iter().map(|s| (s, pool_params.clone()))); } // Figuring out initial utxo needs to be done on all cauldrons in a block. @@ -509,6 +537,13 @@ pub async fn index_blocks( None, ) .await?; + db::cauldron::tokenbch::update_tokenbch_pool_history( + &mut db_tx, + all_tokenbch, + Some(mtp), + None, + ) + .await?; // config_set must be inside the cauldron transaction, as it tracks what the // last successful block indexed was. It must commit atomically with block data. config_set(&mut *db_tx, KEY_LAST_INDEXED, &blockhash.to_string()).await; diff --git a/src/main.rs b/src/main.rs index 952ea37..84fc1df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -213,6 +213,7 @@ async fn start_program( db::cauldron::pool::initialize_seq(&db.cauldron_r).await; db::cauldron::tokentoken::initialize_seq(&db.cauldron_r).await; + db::cauldron::tokenbch::initialize_seq(&db.cauldron_r).await; info!("Loading block headers..."); let all_headers = load_all_headers(&db.cauldron_r).await.unwrap(); @@ -641,6 +642,14 @@ async fn launch() -> _ { rpc::tokentoken::list_tokens, ], ) + .mount( + "/tokenbch", + routes![ + rpc::tokenbch::list_active_pools, + rpc::tokenbch::list_all_pools, + rpc::tokenbch::list_tokens, + ], + ) .mount( "/ido", routes![ diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 364f589..14d188b 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -23,6 +23,7 @@ pub mod oracle; pub mod pool; pub mod price; pub mod response; +pub mod tokenbch; pub mod tokens; pub mod tokentoken; pub mod tvl; diff --git a/src/rpc/tokenbch.rs b/src/rpc/tokenbch.rs new file mode 100644 index 0000000..29b55b0 --- /dev/null +++ b/src/rpc/tokenbch.rs @@ -0,0 +1,63 @@ +// Copyright (C) 2025-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 + +//! RPC endpoints for single-UTXO token ⇄ BCH (`TokenBch`) delegation pools. + +use crate::{ + db::{ + cauldron::tokenbch::{db_active_pools_for_token, db_all_active_pools, db_pool_tokens}, + DB, + }, + rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult}, + rpc::response::{cached_ok, CACHE_AGGREGATE, CACHE_NONE}, +}; +use bitcoincash::TokenID; +use rocket::{get, State}; +use serde_json::{json, Value}; + +/// List active TokenBch pools that trade the given token against native BCH. +/// +/// `token` is required (a token id in display hex). Each pool trades that token +/// against BCH, so a single token id fully identifies the pair. +#[get("/pool/active?")] +pub async fn list_active_pools(token: Option<&str>, conn: &State) -> CachedApiResult { + let token = match token { + Some(t) => t, + None => { + return Err(bad_request( + ApiErrorCode::MissingParameters, + "Provide token", + )) + } + }; + let token = token.parse::().map_err(|e| { + bad_request(ApiErrorCode::InvalidTokenId, &format!("Invalid token: {e}")) + })?; + + let active = db_active_pools_for_token(&conn.cauldron_r, &token) + .await + .map_err(db_error)?; + + Ok(cached_ok(json!({ "active": active }), CACHE_NONE)) +} + +/// List the token ids that appear in at least one active TokenBch pool. +/// Drives the frontend token selector and the "no route" decision. +#[get("/tokens")] +pub async fn list_tokens(conn: &State) -> CachedApiResult { + let tokens = db_pool_tokens(&conn.cauldron_r).await.map_err(db_error)?; + Ok(cached_ok(json!({ "tokens": tokens }), CACHE_AGGREGATE)) +} + +/// List ALL active TokenBch pools, regardless of token — every pool at its +/// latest state. Drives the frontend pooled-token selector so it can present the +/// tokens that have BCH liquidity without probing each candidate individually. +#[get("/pools")] +pub async fn list_all_pools(conn: &State) -> CachedApiResult { + let active = db_all_active_pools(&conn.cauldron_r) + .await + .map_err(db_error)?; + Ok(cached_ok(json!({ "active": active }), CACHE_NONE)) +}