riftenlabs-indexer/src/db/cauldron/tokentoken.rs
Dagur Valberg Johannsson 9d0eaff22c
tokentoken: add GET /tokentoken/pools (list all active pools)
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>
2026-06-17 13:48:45 +02:00

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, &current.spent_main_utxo_hash).await? {
Some(c) => (c, false),
None => {
let has_parent = queue
.iter()
.any(|p| Some(current.spent_main_utxo_hash) == p.new_main_utxo_hash);
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, &current).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, &current).await?;
insert_history_entry(
&mut *conn,
&creation_utxo,
&current,
mtp_timestamp,
first_seen_timestamp,
0,
0,
)
.await?;
} else {
let (prev_a, prev_b) =
get_reserves_at(&mut *conn, &current.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,
&current,
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());
}
}