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:
parent
0a566e9ee6
commit
cab3f49c35
15 changed files with 1565 additions and 194 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
286
src/db/cauldron/orb.rs
Normal 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(¶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<TokenBchFeeSide> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
769
src/db/cauldron/tokenbch.rs
Normal file
769
src/db/cauldron/tokenbch.rs
Normal 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, ¤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<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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ¤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<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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) =
|
||||
|
|
|
|||
|
|
@ -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
72
src/db/orbconstants.rs
Normal 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]
|
||||
}
|
||||
|
|
@ -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)?;
|
||||
|
|
|
|||
55
src/index.rs
55
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<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;
|
||||
|
|
|
|||
|
|
@ -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![
|
||||
|
|
|
|||
|
|
@ -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
63
src/rpc/tokenbch.rs
Normal 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))
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue