List every active TokenToken pool (no pair filter) so the frontend can present the set of pooled token pairs without probing each candidate pair. Mirrors db_active_pools_for_pair minus the pair WHERE clause; served at a distinct /pools path (the param'd /pool/active already matches param-less requests via its Option guards, so reusing it would collide). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
720 lines
26 KiB
Rust
720 lines
26 KiB
Rust
// Copyright (C) 2024-2026 Whiterun LLC
|
|
//
|
|
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
|
|
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
|
|
|
|
//! Indexing of native token-A <-> token-B (`TokenToken`) 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.
|
|
//!
|
|
//! 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.
|
|
|
|
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, tokentoken::ParsedTokenToken};
|
|
use sqlx::{Row, SqliteConnection, SqlitePool};
|
|
|
|
use crate::db::blob::{blob_to_display_hex, FromBlob, ToBlob};
|
|
|
|
/// Idempotent: also run as an always-on migration for existing databases
|
|
/// (the tables were added after `DB_VERSION` 5 without a version bump).
|
|
pub async fn create_table(pool: &SqlitePool) {
|
|
sqlx::query(
|
|
"CREATE TABLE IF NOT EXISTS tokentoken_pool (
|
|
creation_utxo BLOB PRIMARY KEY,
|
|
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,
|
|
withdrawn_in_txid BLOB
|
|
)",
|
|
)
|
|
.execute(pool)
|
|
.await
|
|
.expect("failed to create table tokentoken_pool");
|
|
|
|
sqlx::query(
|
|
"CREATE TABLE IF NOT EXISTS tokentoken_pool_history_entry (
|
|
utxo BLOB PRIMARY KEY,
|
|
pool BLOB NOT NULL REFERENCES tokentoken_pool(creation_utxo) ON DELETE CASCADE,
|
|
storage_utxo BLOB NOT NULL,
|
|
token_a_id BLOB NOT NULL,
|
|
token_b_id BLOB NOT NULL,
|
|
txid BLOB NOT NULL,
|
|
tx_pos INT NOT NULL,
|
|
storage_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_a BIGINT NOT NULL,
|
|
reserve_b BIGINT NOT NULL,
|
|
main_sats BIGINT NOT NULL,
|
|
storage_sats BIGINT NOT NULL,
|
|
reserve_a_delta BIGINT NOT NULL,
|
|
reserve_b_delta BIGINT NOT NULL
|
|
)",
|
|
)
|
|
.execute(pool)
|
|
.await
|
|
.expect("failed to create table tokentoken_pool_history_entry");
|
|
|
|
sqlx::query(
|
|
"CREATE INDEX IF NOT EXISTS idx_tt_history_pool_sequence ON tokentoken_pool_history_entry(pool, sequence)",
|
|
)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"CREATE INDEX IF NOT EXISTS idx_tt_pool_pair ON tokentoken_pool(token_a_id, token_b_id)",
|
|
)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"CREATE INDEX IF NOT EXISTS idx_tt_history_txid ON tokentoken_pool_history_entry(txid)",
|
|
)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
/// Next sequence number in the `tokentoken_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 tokentoken_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_main_utxo(
|
|
conn: &mut SqliteConnection,
|
|
utxo_hash: &OutPointHash,
|
|
) -> Result<Option<OutPointHash>> {
|
|
let row: Option<(Vec<u8>,)> =
|
|
sqlx::query_as("SELECT pool FROM tokentoken_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_a, reserve_b) 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_a, reserve_b FROM tokentoken_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)),
|
|
}
|
|
}
|
|
|
|
pub async fn insert_new_pool(conn: &mut SqliteConnection, pool: &ParsedTokenToken) -> 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)
|
|
VALUES (?, ?, ?, ?, ?, ?, NULL)",
|
|
)
|
|
.bind(
|
|
pool.new_main_utxo_hash
|
|
.expect("new pool main utxo missing")
|
|
.to_blob(),
|
|
)
|
|
.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)
|
|
.execute(&mut *conn)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("failed to insert tokentoken pool: {e:?}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn flag_as_withdrawn(
|
|
conn: &mut SqliteConnection,
|
|
creation_utxo: &OutPointHash,
|
|
pool: &ParsedTokenToken,
|
|
) -> Result<()> {
|
|
let txid = pool
|
|
.new_main_utxo_txid
|
|
.expect("withdraw must carry the withdrawing txid");
|
|
sqlx::query("UPDATE tokentoken_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 tokentoken pool withdrawn: {e:?}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn insert_history_entry(
|
|
conn: &mut SqliteConnection,
|
|
creation_utxo: &OutPointHash,
|
|
pool: &ParsedTokenToken,
|
|
mtp_timestamp: Option<u64>,
|
|
first_seen_timestamp: Option<u64>,
|
|
reserve_a_delta: i64,
|
|
reserve_b_delta: i64,
|
|
) -> Result<()> {
|
|
let next_seq = NEXT_SEQUENCE.fetch_add(1, Ordering::SeqCst);
|
|
assert!(next_seq >= 0, "tokentoken sequence not initialized");
|
|
|
|
sqlx::query(
|
|
"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,
|
|
main_sats, storage_sats, reserve_a_delta, reserve_b_delta)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(utxo) DO UPDATE SET
|
|
pool = excluded.pool,
|
|
storage_utxo = excluded.storage_utxo,
|
|
txid = excluded.txid,
|
|
tx_pos = excluded.tx_pos,
|
|
storage_tx_pos = excluded.storage_tx_pos,
|
|
mtp_timestamp = COALESCE(excluded.mtp_timestamp, tokentoken_pool_history_entry.mtp_timestamp),
|
|
first_seen_timestamp = COALESCE(excluded.first_seen_timestamp, tokentoken_pool_history_entry.first_seen_timestamp),
|
|
sequence = excluded.sequence",
|
|
)
|
|
.bind(pool.new_main_utxo_hash.expect("new main utxo hash missing").to_blob())
|
|
.bind(creation_utxo.to_blob())
|
|
.bind(pool.new_storage_utxo_hash.expect("new storage utxo hash missing").to_blob())
|
|
.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.new_main_utxo_txid.expect("main txid missing").to_blob())
|
|
.bind(pool.new_main_utxo_n.expect("main vout missing") as i64)
|
|
.bind(pool.new_storage_utxo_n.expect("storage 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.token_a_amount.expect("reserve a missing"))
|
|
.bind(pool.token_b_amount.expect("reserve b missing"))
|
|
.bind(pool.main_sats.expect("main sats missing") as i64)
|
|
.bind(pool.storage_sats.expect("storage sats missing") as i64)
|
|
.bind(reserve_a_delta)
|
|
.bind(reserve_b_delta)
|
|
.execute(&mut *conn)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("failed to insert tokentoken history entry: {e:?}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// 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.
|
|
pub async fn update_tokentoken_pool_history(
|
|
conn: &mut SqliteConnection,
|
|
pools: Vec<ParsedTokenToken>,
|
|
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) = 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);
|
|
if has_parent {
|
|
queue.push_back(current);
|
|
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.
|
|
_ => continue,
|
|
}
|
|
}
|
|
};
|
|
|
|
if current.is_withdrawn {
|
|
info!("TokenToken pool {} withdrawn", creation_utxo);
|
|
flag_as_withdrawn(&mut *conn, &creation_utxo, ¤t).await?;
|
|
} else if is_new {
|
|
info!(
|
|
"TokenToken pool created in tx {}",
|
|
current.new_main_utxo_txid.expect("new pool txid")
|
|
);
|
|
insert_new_pool(&mut *conn, ¤t).await?;
|
|
insert_history_entry(
|
|
&mut *conn,
|
|
&creation_utxo,
|
|
¤t,
|
|
mtp_timestamp,
|
|
first_seen_timestamp,
|
|
0,
|
|
0,
|
|
)
|
|
.await?;
|
|
} else {
|
|
let (prev_a, prev_b) =
|
|
get_reserves_at(&mut *conn, ¤t.spent_main_utxo_hash).await?;
|
|
let reserve_a_delta = current.token_a_amount.unwrap_or(0) - prev_a;
|
|
let reserve_b_delta = current.token_b_amount.unwrap_or(0) - prev_b;
|
|
insert_history_entry(
|
|
&mut *conn,
|
|
&creation_utxo,
|
|
¤t,
|
|
mtp_timestamp,
|
|
first_seen_timestamp,
|
|
reserve_a_delta,
|
|
reserve_b_delta,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove TokenToken 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 tokentoken_pool_history_entry
|
|
WHERE txid IN (SELECT txid FROM tx WHERE blockhash = ?)",
|
|
)
|
|
.bind(blockhash.to_blob())
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
sqlx::query(
|
|
"DELETE FROM tokentoken_pool
|
|
WHERE creation_utxo NOT IN (SELECT DISTINCT pool FROM tokentoken_pool_history_entry)",
|
|
)
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
let r3 = sqlx::query(
|
|
"UPDATE tokentoken_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 TokenToken pool at its latest state, for the RPC layer.
|
|
#[derive(serde::Serialize)]
|
|
pub struct ActiveTokenTokenPool {
|
|
pub pool_id: String,
|
|
pub nft_owner: String,
|
|
pub token_a_id: String,
|
|
pub token_b_id: 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,
|
|
pub min_fee: u64,
|
|
/// Satoshis locked in the main UTXO (preserved across swaps).
|
|
pub main_sats: u64,
|
|
/// Satoshis locked in the storage UTXO (preserved across swaps).
|
|
pub storage_sats: u64,
|
|
/// Transaction that created the pool's current state.
|
|
pub txid: String,
|
|
/// Output index of the main UTXO (token A).
|
|
pub main_vout: u32,
|
|
/// Output index of the storage UTXO (token B); also `otherTokenOutpointIndex`.
|
|
pub storage_vout: 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(
|
|
pool: &SqlitePool,
|
|
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
|
|
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)
|
|
.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,
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Every active pool (`withdrawn_in_txid IS NULL`) at its latest state, with no
|
|
/// pair filter. Drives the frontend pooled-pair selector, which needs the full
|
|
/// 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 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,
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Distinct token ids that appear in at least one active TokenToken pool.
|
|
pub async fn db_pair_tokens(pool: &SqlitePool) -> Result<Vec<String>> {
|
|
let rows = sqlx::query(
|
|
"SELECT token_a_id AS tid FROM tokentoken_pool WHERE withdrawn_in_txid IS NULL
|
|
UNION
|
|
SELECT token_b_id AS tid FROM tokentoken_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 TT_TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
async fn test_db() -> SqlitePool {
|
|
let id = TT_TEST_COUNTER.fetch_add(1, AOrdering::SeqCst);
|
|
let uri = format!("file:tt_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
|
|
}
|
|
|
|
/// The always-run startup migration calls `create_table` on databases
|
|
/// that already have the tables; it must be a no-op then.
|
|
#[tokio::test]
|
|
async fn create_table_is_idempotent() {
|
|
let pool = test_db().await;
|
|
create_table(&pool).await;
|
|
}
|
|
|
|
/// A state first seen in the mempool (first_seen only) and later confirmed
|
|
/// in a block (mtp only) must keep the mempool first_seen timestamp.
|
|
#[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, 1_000_000, 2_000_000);
|
|
seed_tx(&pool, &c, &block).await;
|
|
|
|
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();
|
|
// Block confirmation of the same state: mtp only.
|
|
update_tokentoken_pool_history(&mut conn, vec![c], Some(1_700_000_500), None)
|
|
.await
|
|
.unwrap();
|
|
|
|
let row = sqlx::query(
|
|
"SELECT mtp_timestamp, first_seen_timestamp, effective_timestamp
|
|
FROM tokentoken_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");
|
|
}
|
|
|
|
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])
|
|
}
|
|
|
|
fn creation(main: u8, txid_b: u8, ra: i64, rb: i64) -> ParsedTokenToken {
|
|
ParsedTokenToken {
|
|
nft_owner: [0x11; 32],
|
|
fee_rate: 300,
|
|
min_fee: 1,
|
|
other_token_outpoint_index: 1,
|
|
is_withdrawn: false,
|
|
spent_main_utxo_hash: OutPointHash::all_zeros(),
|
|
spent_storage_utxo_hash: Some(OutPointHash::all_zeros()),
|
|
new_main_utxo_hash: Some(oph(main)),
|
|
new_main_utxo_txid: Some(txid(txid_b)),
|
|
new_main_utxo_n: Some(0),
|
|
new_storage_utxo_hash: Some(oph(main.wrapping_add(100))),
|
|
new_storage_utxo_txid: Some(txid(txid_b)),
|
|
new_storage_utxo_n: Some(1),
|
|
token_a_id: Some(tid(0xAA)),
|
|
token_b_id: Some(tid(0xBB)),
|
|
token_a_amount: Some(ra),
|
|
token_b_amount: Some(rb),
|
|
main_sats: Some(800),
|
|
storage_sats: Some(800),
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
let mut p = creation(new_main, txid_b, ra, rb);
|
|
p.spent_main_utxo_hash = oph(prev_main);
|
|
p
|
|
}
|
|
|
|
async fn seed_tx(pool: &SqlitePool, t: &ParsedTokenToken, blockhash: &BlockHash) {
|
|
let mut conn = pool.acquire().await.unwrap();
|
|
insert_block_tx(
|
|
&mut conn,
|
|
&t.new_main_utxo_txid.unwrap(),
|
|
blockhash,
|
|
1_700_000_000,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn creation_then_swap_tracks_reserves_and_pair_query() {
|
|
let pool = test_db().await;
|
|
let block = BlockHash::all_zeros();
|
|
|
|
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);
|
|
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();
|
|
drop(conn);
|
|
|
|
// Order-insensitive pair lookup returns the latest state.
|
|
let pools = db_active_pools_for_pair(&pool, &tid(0xBB), &tid(0xAA))
|
|
.await
|
|
.unwrap();
|
|
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].main_vout, 0);
|
|
assert_eq!(pools[0].storage_vout, 1);
|
|
|
|
let tokens = db_pair_tokens(&pool).await.unwrap();
|
|
assert!(tokens.contains(&tid(0xAA).to_string()) && 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, 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;
|
|
// carry the withdrawing txid (as the parser does)
|
|
w.new_main_utxo_txid = Some(txid(20));
|
|
seed_tx(&pool, &w, &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)
|
|
.await
|
|
.unwrap();
|
|
drop(conn);
|
|
|
|
let a = tid(0xAA);
|
|
let b = tid(0xBB);
|
|
assert!(
|
|
db_active_pools_for_pair(&pool, &a, &b)
|
|
.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_pair(&pool, &a, &b).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_pair(&pool, &a, &b)
|
|
.await
|
|
.unwrap()
|
|
.is_empty());
|
|
assert!(db_pair_tokens(&pool).await.unwrap().is_empty());
|
|
}
|
|
}
|