feat: index token-token & token-BCH delegation pools with ORB admission

Re-index tokentoken for the delegation design and add a tokenbch
single-UTXO token-to-BCH pool indexer: mempool + block ingestion, reorg
undo, RPC (/tokenbch), and cauldron.db table migrations.

ORB PoolParams admission (src/db/cauldron/orb.rs): a pool creation is
admitted only if its tx carried the live PoolParams NFT and the pool
matches the pinned platformFeeRate + platformFeeNfth (logicHash derived
from the NFTH at admission, not a constant). Per-network params-NFT
categories are centralized in src/db/orbconstants.rs (pool-params
categories placeholder + fail-closed). Store platform_fee_nfth on both
pool tables and the tokenbch fee_paid_in_bch variant.

Cargo.toml: riftenlabs-defi 0.4.5 (published, no longer a local path),
bitcoincash 0.32.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hossein Zoda 2026-07-19 14:30:12 +00:00
parent 0a566e9ee6
commit cab3f49c35
15 changed files with 1565 additions and 194 deletions

View file

@ -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"

View file

@ -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?
{

View file

@ -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);",

286
src/db/cauldron/orb.rs Normal file
View file

@ -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<PoolParamsCommitment> {
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<PoolParamsCommitment> {
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<u8> = 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(&params.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<TokenBchFeeSide> {
let params = params?;
if state.platform_fee_rate != params.platform_fee_rate {
return None;
}
let [bch, token] = expected_logic_hashes(&params.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(&params)
));
// 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(&params)));
// Wrong logicHash (fees settle to a different collector) ⇒ reject.
assert!(!admit_tokentoken_pool(&tt_state(300, bad), Some(&params)));
// 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(&params)),
Some(TokenBchFeeSide::Bch)
);
assert_eq!(
admit_tokenbch_pool(&tb_state(300, token_lh), Some(&params)),
Some(TokenBchFeeSide::Token)
);
assert!(admit_tokenbch_pool(&tb_state(300, bad), Some(&params)).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,
}
}
}

769
src/db/cauldron/tokenbch.rs Normal file
View file

@ -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<Option<OutPointHash>> {
let row: Option<(Vec<u8>,)> =
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<u64>,
first_seen_timestamp: Option<u64>,
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<PoolParamsCommitment>)>,
mtp_timestamp: Option<u64>,
first_seen_timestamp: Option<u64>,
) -> 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, &current.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, &current).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(&current, 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,
&current,
&commitment.platform_fee_nfth,
matches!(fee_side, TokenBchFeeSide::Bch),
)
.await?;
insert_history_entry(
&mut *conn,
&creation_utxo,
&current,
mtp_timestamp,
first_seen_timestamp,
0,
0,
)
.await?;
} else {
let (prev_bch, prev_token) =
get_reserves_at(&mut *conn, &current.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,
&current,
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<bool> {
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<S>(value: &u64, serializer: S) -> Result<S::Ok, S::Error>
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<ActiveTokenBchPool> {
let pool_blob: Vec<u8> = row.get(0);
let nft_owner_blob: Vec<u8> = row.get(1);
let token_blob: Vec<u8> = row.get(2);
let platform_fee_nfth_blob: Vec<u8> = 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<u8> = row.get(13);
let tx_pos: i64 = row.get(14);
Ok(ActiveTokenBchPool {
pool_id: blob_to_display_hex::<OutPointHash>(&pool_blob)?,
nft_owner: hex::encode(&nft_owner_blob),
token_id: blob_to_display_hex::<TokenID>(&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::<bitcoincash::Txid>(&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<Vec<ActiveTokenBchPool>> {
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<Vec<ActiveTokenBchPool>> {
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<Vec<String>> {
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<u8> = row.get(0);
out.push(blob_to_display_hex::<TokenID>(&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<i64> = row.get(0);
let first_seen: Option<i64> = row.get(1);
let effective: Option<i64> = 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());
}
}

View file

@ -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
//! `<main, sibling>` 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<ParsedTokenToken>,
pools: Vec<(ParsedTokenToken, Option<PoolParamsCommitment>)>,
mtp_timestamp: Option<u64>,
first_seen_timestamp: Option<u64>,
) -> 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, &current.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, &current).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(&current, 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, &current).await?;
insert_new_pool(&mut *conn, &current, &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<ActiveTokenTokenPool> {
let pool_blob: Vec<u8> = row.get(0);
let nft_owner_blob: Vec<u8> = row.get(1);
let token_a_blob: Vec<u8> = row.get(2);
let token_b_blob: Vec<u8> = row.get(3);
let platform_fee_nfth_blob: Vec<u8> = 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<u8> = 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::<OutPointHash>(&pool_blob)?,
nft_owner: hex::encode(&nft_owner_blob),
token_a_id: blob_to_display_hex::<TokenID>(&token_a_blob)?,
token_b_id: blob_to_display_hex::<TokenID>(&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::<bitcoincash::Txid>(&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<Vec<ActiveTokenTokenPool>> {
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<u8> = row.get(0);
let nft_owner_blob: Vec<u8> = row.get(1);
let token_a_blob: Vec<u8> = row.get(2);
let token_b_blob: Vec<u8> = 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<u8> = 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::<OutPointHash>(&pool_blob)?,
nft_owner: hex::encode(&nft_owner_blob),
token_a_id: blob_to_display_hex::<TokenID>(&token_a_blob)?,
token_b_id: blob_to_display_hex::<TokenID>(&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::<bitcoincash::Txid>(&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<Vec<ActiveTokenTokenPool>> {
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<u8> = row.get(0);
let nft_owner_blob: Vec<u8> = row.get(1);
let token_a_blob: Vec<u8> = row.get(2);
let token_b_blob: Vec<u8> = 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<u8> = 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::<OutPointHash>(&pool_blob)?,
nft_owner: hex::encode(&nft_owner_blob),
token_a_id: blob_to_display_hex::<TokenID>(&token_a_blob)?,
token_b_id: blob_to_display_hex::<TokenID>(&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::<bitcoincash::Txid>(&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);

View file

@ -74,17 +74,11 @@ static PERMANENT_LIQUIDITY_SHARE_DENOMINATOR: LazyLock<Integer> =
// 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<Error>,
invalid_ido_reasons: &mut Vec<Error>,
) -> 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<IdoPreInitParameters>;
@ -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<IdoAddResult> {
"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<IdoAddResult> {
// 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<IdoAddResult> {
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<IdoAddResult> {
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<IdoAddResult> {
} 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<IdoAddResult> {
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,

View file

@ -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) =

View file

@ -13,6 +13,7 @@ pub mod ido;
pub mod init;
pub mod moria;
pub mod oracle;
pub mod orbconstants;
pub mod search;
#[derive(Clone)]

72
src/db/orbconstants.rs Normal file
View file

@ -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<Network>) -> [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<Network>) -> [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]
}

View file

@ -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<Txid>, HashSet<Txid>, HashSet<Txid>, HashSet<Txid>)> {
/// Mempool transaction-id sets returned by [`electrum_fetch_mempool`], in order:
/// defi (cauldron + tokentoken + tokenbch), oracle, ido, bcmr.
type MempoolTxSets = (HashSet<Txid>, HashSet<Txid>, HashSet<Txid>, HashSet<Txid>);
/// Fetch defi (cauldron + tokentoken + tokenbch), oracle, ido and bcmr mempool transactions
pub fn electrum_fetch_mempool(client: &Client) -> Result<MempoolTxSets> {
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<u8> = [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 <main,
// sibling> 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)?;

View file

@ -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<ParsedContract> = 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;

View file

@ -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![

View file

@ -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;

63
src/rpc/tokenbch.rs Normal file
View file

@ -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?<token>")]
pub async fn list_active_pools(token: Option<&str>, conn: &State<DB>) -> CachedApiResult<Value> {
let token = match token {
Some(t) => t,
None => {
return Err(bad_request(
ApiErrorCode::MissingParameters,
"Provide token",
))
}
};
let token = token.parse::<TokenID>().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<DB>) -> CachedApiResult<Value> {
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<DB>) -> CachedApiResult<Value> {
let active = db_all_active_pools(&conn.cauldron_r)
.await
.map_err(db_error)?;
Ok(cached_ok(json!({ "active": active }), CACHE_NONE))
}