diff --git a/Cargo.lock b/Cargo.lock index 02011ac..d136a9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2283,7 +2283,9 @@ dependencies = [ [[package]] name = "riftenlabs-defi" -version = "0.4.5" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcf89dd770556a74ecd88b6971e7fa151f58bcee5b1a400b836c485c668973b" dependencies = [ "anyhow", "bitcoin_hashes", diff --git a/Cargo.toml b/Cargo.toml index 3c19fa2..62fc4dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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.5" +riftenlabs-defi = "0.4.6" rocket_cors = "0.6.0" log = "0.4" stderrlog = "0.6.0" diff --git a/src/bcmr/bcmrdownloader.rs b/src/bcmr/bcmrdownloader.rs index c6f7034..2b32949 100644 --- a/src/bcmr/bcmrdownloader.rs +++ b/src/bcmr/bcmrdownloader.rs @@ -422,7 +422,7 @@ mod tests { #[test] fn ipfs_path_validation_accepts_real_cids_and_rejects_injection() { - // Real shapes seen on-chain: bare CIDv0/v1, directory + sub-path, and the libriften + // Real shapes seen on-chain: bare CIDv0/v1, directory + sub-path, and a // base64url CID with a filename extension appended directly. assert!(is_safe_ipfs_path("QmAbc123")); assert!(is_safe_ipfs_path("bafybeig4n5ut/icon.png")); diff --git a/src/chain.rs b/src/chain.rs index ba11537..2988104 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -90,7 +90,7 @@ impl BlockUndoer for StoreBlockUndoer { { was_indexed = true; } - if db::moria::delete_entries_for_block(&self.db.moria_w, &blockheader.block_hash()) + if db::moria_v11::delete_entries_for_block(&self.db.moria_w, &blockheader.block_hash()) .await? > 0 { @@ -277,7 +277,12 @@ impl Chain { let rewind = match new_headers.first() { Some(first) => first.height, - None => tip_height.expect("empty new_headers and no tip height") + 1, + None => { + let Some(h) = tip_height else { + bail!("empty new_headers and no tip height"); + }; + h + 1 + } }; for (hash, header) in headers.drain(rewind as usize..) { @@ -575,4 +580,46 @@ mod tests { "0e16637fe0700a7c52e9a6eaa58bd6ac7202652103be8f778680c66f51ad2e9b" ); } + + #[test] + fn empty_headers_without_tip_height_is_error() { + let regtest = Chain::new_regtest(); + let err = regtest + .update(DummyBlockUndoer::new(true), vec![], None) + .unwrap_err(); + assert!(err + .to_string() + .contains("empty new_headers and no tip height")); + } + + #[test] + fn empty_headers_with_tip_height_shrinks() { + let hex_headers = ["0000002006226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f1d14d3c7ff12d6adf494ebbcfba69baa915a066358b68a2b8c37126f74de396b1d61cc60ffff7f2000000000", +"00000020d700ae5d3c705702e0a5d9ababd22ded079f8a63b880b1866321d6bfcb028c3fc816efcf0e84ccafa1dda26be337f58d41b438170c357cda33a68af5550590bc1e61cc60ffff7f2004000000"]; + let headers: Vec<(BlockHeader, u64)> = hex_headers + .iter() + .enumerate() + .map(|(height, hex_header)| { + ( + deserialize(&Vec::from_hex(hex_header).unwrap()).unwrap(), + 1 + height as u64, + ) + }) + .collect(); + let mut regtest = Chain::new_regtest(); + let genesis = Chain::new_regtest().get_block_header(0).unwrap(); + let mut chain = headers.clone(); + chain.push((genesis, 0_u64)); + regtest.load(chain).unwrap(); + assert_eq!(regtest.height(), 2); + + let keep = headers[0].0.block_hash(); + regtest + .update(DummyBlockUndoer::new(true), vec![], Some(1)) + .unwrap(); + assert_eq!(regtest.height(), 1); + assert_eq!(regtest.tip_hash(), keep); + assert!(regtest.contains(&keep)); + assert!(!regtest.contains(&headers[1].0.block_hash())); + } } diff --git a/src/db/ido/mod.rs b/src/db/ido/mod.rs index 3157680..fb3df05 100644 --- a/src/db/ido/mod.rs +++ b/src/db/ido/mod.rs @@ -131,8 +131,7 @@ static REBUILD_IPFS_PLACEHOLDER_BCMR_FOR_GENESIS_WITH_AUTHGUARD_CONTRACT: LazyLo hex::decode("0902a9147ca97e01877e57897c6b00c08851d100887c635ab2756d51cd016a88674d0301404142434445464748494a4b4c4d4e4f505152535455565758595a6162636465666768696a6b6c6d6e6f707172737475767778797a303132333435363738392d5f6b007c8253a26365537f7c76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7b7c7e7c82539f666882760087636d677d537c94007c807e76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7c51937f757e686c7554893a10303132333435363738396162636465667c006b65517f7c5279785f84817f77517f756c7e6b52797c5499817f77517f756c7e6b82009c666d6c5689097f7b827b7c7f777e7e538978a8886b518ac0ce568a65766c537a538a6b74519c66756c766ba804015512207c7e548a01757c7e6b528a6c7c6b65766c537a538a6b74519c66756c6ca87c7e076a0442434d52207c7e51cd886801206c0f51ce8851d0009d6300cdc0c78868517e7e578a00cd8800ce00d18800cf00d28800d000d38800c600cca26952d10088c45387").unwrap() }); -/// The 127-byte commitment of the ORB IdoParams NFT (libriften -/// packages/cauldron/src/orb/v0). Byte layout: +/// The 127-byte commitment of the ORB IdoParams NFT. Byte layout: /// \[0\] version (must equal IDO_PARAMS_VERSION) /// [1..33] permanentPoolPlatformNfth /// [33..65] delphiCategory @@ -151,9 +150,9 @@ static REBUILD_IPFS_PLACEHOLDER_BCMR_FOR_GENESIS_WITH_AUTHGUARD_CONTRACT: LazyLo /// nfths are hashes and are kept in their on-chain order. const IDO_PARAMS_COMMITMENT_SIZE: usize = 127; -/// The only ORB IdoParams NFT commitment version this indexer understands -/// (libriften `ORB_IDO_PARAMS_VERSION`). Any other value means the params NFT -/// was minted for a newer/incompatible layout and the IDO is not indexed. +/// The only ORB IdoParams NFT commitment version this indexer understands. +/// Any other value means the params NFT was minted for a newer/incompatible +/// layout and the IDO is not indexed. const IDO_PARAMS_VERSION: u8 = 0x00; pub struct IdoParamsNftCommitment { @@ -597,7 +596,7 @@ fn build_offering_bytecode( minOffer: &Integer, // display byte order; baked into the bytecode in VM (reversed) order. None // is native BCH: the contract branches on an empty (0x) category, so an - // empty push is baked (matching libriften encodeIdoXTokenCategory). + // empty push is baked. xTokenCategory: Option<&[u8]>, ) -> Vec { let rev_xtoken_cat: Vec = xTokenCategory @@ -688,8 +687,7 @@ fn build_partial_offering_initiator_bytecode( } // The partial postlaunch bytecode stored in preinit output #7 (via the partial -// ido initiator bytecode). Composition per libriften templates/ido.json -// `postLaunchPartialBytecode`: +// ido initiator bytecode). Composition: // // // contracts.IDOPostLaunch @@ -1521,8 +1519,7 @@ fn parse_ido_preinit_tx_params( minOffer: vm_number_to_bigint(&data[122..130]), // On-chain in VM order; kept in display/UI order. An // all-zero category is the native-BCH sentinel and - // normalizes to None (libriften - // isNativeXTokenCategory). + // normalizes to None. xTokenCategory: if data[130..162].iter().all(|&b| b == 0) { None } else { @@ -1870,7 +1867,7 @@ fn parse_ido_preinit_tx( // In an IDO the offering layer never charges a platform fee; // the fee is collected by the postlaunch covenant instead, so // every offering-layer contract is instantiated with a zero - // platformFee (libriften idoOfferingLayerParameters). + // platformFee. &Integer::from(0i64), ¶ms.offering.delphiCategory, ¶ms.offering.launchConditions.expiresAt, @@ -3526,7 +3523,7 @@ mod tests { } // A native-BCH xToken bakes an EMPTY push (the contract branches on - // xTokenCategory == 0x), matching libriften encodeIdoXTokenCategory. + // xTokenCategory == 0x). #[test] fn partial_postlaunch_bakes_empty_push_for_native_x_token() { let partial = build_partial_postlaunch_bytecode(&PartialPostlaunchParameters { diff --git a/src/db/init.rs b/src/db/init.rs index b9ec664..54dec05 100644 --- a/src/db/init.rs +++ b/src/db/init.rs @@ -18,7 +18,7 @@ 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; -use crate::db::moria::prepare_tables as moria_prepare_tables; +use crate::db::moria_v11::prepare_tables as moria_prepare_tables; use crate::db::oracle::prepare_tables as oracle_prepare_tables; /// Create read and write database pools for a given database path @@ -134,12 +134,12 @@ pub async fn initialize_databases(network: &str, read_slots: ReadSlots) -> Resul oracle_prepare_tables(&oracle_db_write).await; } - // Initialize moria lending database + // Initialize moria lending database (v1-1 tables; IF NOT EXISTS so existing + // moria.db files pick up the cutover schema without a full wipe). let (db_exists, moria_db_write, moria_db_read) = create_db_pool(&db_path(db_dir, "moria.db"), read_slots.moria).await; - if !db_exists { - moria_prepare_tables(&moria_db_write).await; - } + let _ = db_exists; // schema is additive via IF NOT EXISTS + moria_prepare_tables(&moria_db_write).await; // Initialize ido database let (db_exists, ido_db_write, ido_db_read) = diff --git a/src/db/mod.rs b/src/db/mod.rs index 80358f0..09107a2 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -11,7 +11,7 @@ pub mod cauldron; pub mod crc20; pub mod ido; pub mod init; -pub mod moria; +pub mod moria_v11; pub mod oracle; pub mod orbconstants; pub mod search; diff --git a/src/db/moria/mod.rs b/src/db/moria/mod.rs deleted file mode 100644 index 07e4fc7..0000000 --- a/src/db/moria/mod.rs +++ /dev/null @@ -1,821 +0,0 @@ -// 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 - -use anyhow::Result; - -use bitcoin_hashes::Hash; -use bitcoincash::{BlockHash, Transaction, Txid}; -use log::debug; -use riftenlabs_defi::moria::{ - parse_moria_from_tx, MoriaActionType, MoriaTokenIds, ParsedMoriaAction, -}; -use sqlx::{Row, SqlitePool}; - -use crate::db::blob::{blob_to_display_hex, ToBlob}; - -#[derive(Debug, Clone, serde::Serialize)] -pub struct MoriaEntry { - pub txid: String, - pub blockhash: String, - pub action_type: &'static str, - pub borrower_hash: Option, - pub principal: Option, - pub interest_rate: Option, - pub loan_timestamp: Option, - pub collateral_sats: Option, - pub tokens_amount: Option, - pub mtp_timestamp: i64, - // Refinance new terms - pub new_principal: Option, - pub new_interest_rate: Option, - pub new_loan_timestamp: Option, - pub new_collateral_sats: Option, -} - -fn action_type_to_str(action: MoriaActionType) -> &'static str { - match action { - MoriaActionType::Borrow => "borrow", - MoriaActionType::Repay => "repay", - MoriaActionType::Redeem => "redeem", - MoriaActionType::Refinance => "refinance", - MoriaActionType::AddCollateral => "add_collateral", - } -} - -fn action_type_to_int(action: MoriaActionType) -> i64 { - action as i64 -} - -fn int_to_action_type(val: i64) -> &'static str { - match val { - 0 => "borrow", - 1 => "repay", - 2 => "redeem", - 3 => "refinance", - 4 => "add_collateral", - _ => "unknown", - } -} - -impl MoriaEntry { - fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result { - let txid_blob: Vec = row.get("txid"); - let blockhash_blob: Vec = row.get("blockhash"); - let action_type: i64 = row.get("action_type"); - let borrower_blob: Option> = row.get("borrower_hash"); - - Ok(Self { - txid: blob_to_display_hex::(&txid_blob)?, - blockhash: blob_to_display_hex::(&blockhash_blob)?, - action_type: int_to_action_type(action_type), - borrower_hash: borrower_blob.map(hex::encode), - principal: row.get("principal"), - interest_rate: row.get("interest_rate"), - loan_timestamp: row.get("loan_timestamp"), - collateral_sats: row.get("collateral_sats"), - tokens_amount: row.get("tokens_amount"), - mtp_timestamp: row.get("mtp_timestamp"), - new_principal: row.get("new_principal"), - new_interest_rate: row.get("new_interest_rate"), - new_loan_timestamp: row.get("new_loan_timestamp"), - new_collateral_sats: row.get("new_collateral_sats"), - }) - } -} - -pub async fn prepare_tables(pool: &SqlitePool) { - sqlx::query( - "CREATE TABLE moria_action ( - txid BLOB PRIMARY KEY, - blockhash BLOB NOT NULL, - action_type INTEGER NOT NULL, - borrower_hash BLOB, - principal INTEGER, - interest_rate INTEGER, - loan_timestamp INTEGER, - collateral_sats BIGINT, - tokens_amount BIGINT, - mtp_timestamp BIGINT NOT NULL, - first_seen_timestamp BIGINT, - new_principal INTEGER, - new_interest_rate INTEGER, - new_loan_timestamp INTEGER, - new_collateral_sats BIGINT - )", - ) - .execute(pool) - .await - .expect("failed to create moria_action table"); - - sqlx::query("CREATE INDEX idx_moria_borrower_hash ON moria_action(borrower_hash)") - .execute(pool) - .await - .expect("failed to create borrower_hash index"); - - sqlx::query("CREATE INDEX idx_moria_blockhash ON moria_action(blockhash)") - .execute(pool) - .await - .expect("failed to create blockhash index"); - - sqlx::query("CREATE INDEX idx_moria_timestamp ON moria_action(mtp_timestamp)") - .execute(pool) - .await - .expect("failed to create timestamp index"); - - // Track loan UTXOs for looking up borrower_hash when a loan is spent - sqlx::query( - "CREATE TABLE moria_loan_utxo ( - txid BLOB NOT NULL, - vout INTEGER NOT NULL, - borrower_hash BLOB NOT NULL, - principal INTEGER NOT NULL, - interest_rate INTEGER NOT NULL, - loan_timestamp INTEGER NOT NULL, - collateral_sats BIGINT NOT NULL, - blockhash BLOB NOT NULL, - PRIMARY KEY (txid, vout) - )", - ) - .execute(pool) - .await - .expect("failed to create moria_loan_utxo table"); -} - -#[allow(clippy::too_many_arguments)] -async fn insert_loan_utxo( - pool: &SqlitePool, - txid: &Txid, - vout: u32, - borrower_hash: &[u8; 32], - principal: u16, - interest_rate: u16, - loan_timestamp: u32, - collateral_sats: u64, - blockhash: &BlockHash, -) -> Result<()> { - sqlx::query( - "INSERT OR REPLACE INTO moria_loan_utxo (txid, vout, borrower_hash, principal, interest_rate, loan_timestamp, collateral_sats, blockhash) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(txid.to_blob()) - .bind(vout as i64) - .bind(borrower_hash.as_slice()) - .bind(principal as i64) - .bind(interest_rate as i64) - .bind(loan_timestamp as i64) - .bind(collateral_sats as i64) - .bind(blockhash.to_blob()) - .execute(pool) - .await - .map_err(|e| anyhow::anyhow!("failed to insert loan utxo: {}", e))?; - Ok(()) -} - -async fn delete_loan_utxo(pool: &SqlitePool, txid: &Txid, vout: u32) -> Result<()> { - sqlx::query("DELETE FROM moria_loan_utxo WHERE txid = ? AND vout = ?") - .bind(txid.to_blob()) - .bind(vout as i64) - .execute(pool) - .await - .map_err(|e| anyhow::anyhow!("failed to delete loan utxo: {}", e))?; - Ok(()) -} - -/// Look up the borrower_hash for a spent loan UTXO -async fn lookup_loan_utxo( - pool: &SqlitePool, - txid: &Txid, - vout: u32, -) -> Result, i64, i64, i64, i64)>> { - let row: Option<(Vec, i64, i64, i64, i64)> = sqlx::query_as( - "SELECT borrower_hash, principal, interest_rate, loan_timestamp, collateral_sats - FROM moria_loan_utxo WHERE txid = ? AND vout = ?", - ) - .bind(txid.to_blob()) - .bind(vout as i64) - .fetch_optional(pool) - .await?; - Ok(row) -} - -async fn insert_moria_entry( - pool: &SqlitePool, - txid: &Txid, - blockhash: &BlockHash, - mtp: i64, - first_seen: Option, - action: &ParsedMoriaAction, - borrower_hash: Option<&[u8]>, -) -> Result<()> { - let commitment = action.loan_commitment.as_ref(); - let bh = borrower_hash.or_else(|| commitment.map(|c| c.borrower_nft_hash.as_slice())); - - sqlx::query( - "INSERT OR REPLACE INTO moria_action - (txid, blockhash, action_type, borrower_hash, principal, interest_rate, - loan_timestamp, collateral_sats, tokens_amount, mtp_timestamp, first_seen_timestamp, - new_principal, new_interest_rate, new_loan_timestamp, new_collateral_sats) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(txid.to_blob()) - .bind(blockhash.to_blob()) - .bind(action_type_to_int(action.action_type)) - .bind(bh) - .bind(commitment.map(|c| c.principal as i64)) - .bind(commitment.map(|c| c.annual_interest_rate_bp as i64)) - .bind(commitment.map(|c| c.timestamp as i64)) - .bind(action.collateral_sats.map(|s| s as i64)) - .bind(action.tokens_amount) - .bind(mtp) - .bind(first_seen) - .bind( - action - .new_loan_commitment - .as_ref() - .map(|c| c.principal as i64), - ) - .bind( - action - .new_loan_commitment - .as_ref() - .map(|c| c.annual_interest_rate_bp as i64), - ) - .bind( - action - .new_loan_commitment - .as_ref() - .map(|c| c.timestamp as i64), - ) - .bind( - action - .new_loan_commitment - .as_ref() - .and_then(|_| action.collateral_sats.map(|s| s as i64)), - ) - .execute(pool) - .await - .map_err(|e| anyhow::anyhow!("failed to insert moria_action: {}", e))?; - Ok(()) -} - -/// Index moria transactions from a block -pub async fn index_moria( - pool: &SqlitePool, - txs: &[Transaction], - blockhash: &BlockHash, - mtp: i64, - token_ids: &MoriaTokenIds, -) -> Result { - let mut count = 0; - for tx in txs { - let mut action = match parse_moria_from_tx(tx, token_ids) { - Some(a) => a, - None => continue, - }; - - let txid = tx.compute_txid(); - - // If the parser returned Borrow but the spent outpoint is a known loan UTXO, - // reclassify as Refinance - let mut looked_up_borrower: Option> = None; - - if let Some((spent_txid, spent_vout)) = &action.spent_loan_outpoint { - if let Some((bh, principal, interest_rate, loan_ts, collateral)) = - lookup_loan_utxo(pool, spent_txid, *spent_vout).await? - { - // This outpoint is a known loan UTXO - if action.action_type == MoriaActionType::Borrow { - // Reclassify: a Borrow that spends a known loan is actually a Refinance - action.action_type = MoriaActionType::Refinance; - action.new_loan_commitment = action.loan_commitment.clone(); - action.loan_commitment = Some(riftenlabs_defi::moria::LoanCommitment { - borrower_nft_hash: bh.as_slice().try_into().unwrap_or([0u8; 32]), - principal: principal as u16, - annual_interest_rate_bp: interest_rate as u16, - timestamp: loan_ts as u32, - }); - } - - if action.action_type == MoriaActionType::Repay - || action.action_type == MoriaActionType::Redeem - { - // Fill in the loan commitment from UTXO lookup - action.loan_commitment = Some(riftenlabs_defi::moria::LoanCommitment { - borrower_nft_hash: bh.as_slice().try_into().unwrap_or([0u8; 32]), - principal: principal as u16, - annual_interest_rate_bp: interest_rate as u16, - timestamp: loan_ts as u32, - }); - action.collateral_sats = Some(collateral as u64); - } - - looked_up_borrower = Some(bh); - - // Remove the spent loan UTXO - delete_loan_utxo(pool, spent_txid, *spent_vout).await?; - } - } - - // Store new loan UTXO if one was created - if let Some(output_idx) = action.loan_output_index { - if let Some(commitment) = &action - .new_loan_commitment - .as_ref() - .or(action.loan_commitment.as_ref()) - { - insert_loan_utxo( - pool, - &txid, - output_idx, - &commitment.borrower_nft_hash, - commitment.principal, - commitment.annual_interest_rate_bp, - commitment.timestamp, - action.collateral_sats.unwrap_or(0), - blockhash, - ) - .await?; - } - } - - debug!( - "moria: {} {} (borrower: {})", - action_type_to_str(action.action_type), - txid, - action - .loan_commitment - .as_ref() - .map(|c| hex::encode(c.borrower_nft_hash)) - .unwrap_or_else(|| "n/a".to_string()), - ); - - insert_moria_entry( - pool, - &txid, - blockhash, - mtp, - None, - &action, - looked_up_borrower.as_deref(), - ) - .await?; - count += 1; - } - Ok(count) -} - -pub async fn delete_entries_for_block(pool: &SqlitePool, blockhash: &BlockHash) -> Result { - // Also clean up loan UTXOs from this block - sqlx::query("DELETE FROM moria_loan_utxo WHERE blockhash = ?") - .bind(blockhash.to_blob()) - .execute(pool) - .await - .map_err(|e| anyhow::anyhow!("failed to delete loan utxos for block: {}", e))?; - - let r = sqlx::query("DELETE FROM moria_action WHERE blockhash = ?") - .bind(blockhash.to_blob()) - .execute(pool) - .await - .map_err(|e| { - anyhow::anyhow!( - "failed to delete moria_action for block {}: {}", - blockhash, - e - ) - })?; - - Ok(r.rows_affected() as usize) -} - -#[allow(dead_code)] // Will be used for mempool support -pub async fn has_entry(pool: &SqlitePool, txid: &Txid) -> Result { - let row: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM moria_action WHERE txid = ?") - .bind(txid.to_blob()) - .fetch_optional(pool) - .await?; - Ok(row.is_some()) -} - -pub async fn clear_mempool(pool: &SqlitePool) -> Result { - let r = sqlx::query("DELETE FROM moria_action WHERE blockhash = ?") - .bind(BlockHash::all_zeros().to_blob()) - .execute(pool) - .await - .map_err(|e| anyhow::anyhow!("failed to clear mempool entries: {}", e))?; - Ok(r.rows_affected() as usize) -} - -/// Get full loan history for a borrower_hash -pub async fn get_loan_history(pool: &SqlitePool, borrower_hash: &[u8]) -> Result> { - let rows = sqlx::query( - "SELECT txid, blockhash, action_type, borrower_hash, principal, interest_rate, - loan_timestamp, collateral_sats, tokens_amount, mtp_timestamp, - new_principal, new_interest_rate, new_loan_timestamp, new_collateral_sats - FROM moria_action - WHERE borrower_hash = ? - ORDER BY mtp_timestamp ASC, rowid ASC", - ) - .bind(borrower_hash) - .fetch_all(pool) - .await?; - - let mut entries = Vec::new(); - for row in rows { - entries.push(MoriaEntry::from_row(&row)?); - } - Ok(entries) -} - -/// Get all active loans (borrowed but not yet repaid/redeemed) -pub async fn get_active_loans(pool: &SqlitePool) -> Result> { - let rows = sqlx::query( - "SELECT m.txid, m.blockhash, m.action_type, m.borrower_hash, m.principal, m.interest_rate, - m.loan_timestamp, m.collateral_sats, m.tokens_amount, m.mtp_timestamp, - m.new_principal, m.new_interest_rate, m.new_loan_timestamp, m.new_collateral_sats - FROM moria_action m - WHERE m.action_type IN (0, 3) - AND m.borrower_hash IS NOT NULL - AND m.borrower_hash NOT IN ( - SELECT borrower_hash FROM moria_action - WHERE action_type IN (1, 2) AND borrower_hash IS NOT NULL - ) - ORDER BY m.mtp_timestamp DESC", - ) - .fetch_all(pool) - .await?; - - let mut entries = Vec::new(); - for row in rows { - entries.push(MoriaEntry::from_row(&row)?); - } - Ok(entries) -} - -/// Get global history with pagination and optional nfth filter -pub async fn get_global_history( - pool: &SqlitePool, - nfth_filter: &[Vec], - offset: i64, - limit: i64, -) -> Result> { - let rows = if nfth_filter.is_empty() { - sqlx::query( - "SELECT txid, blockhash, action_type, borrower_hash, principal, interest_rate, - loan_timestamp, collateral_sats, tokens_amount, mtp_timestamp, - new_principal, new_interest_rate, new_loan_timestamp, new_collateral_sats - FROM moria_action - ORDER BY mtp_timestamp DESC, rowid DESC - LIMIT ? OFFSET ?", - ) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await? - } else { - // Build query with IN clause for nfth filter - let placeholders: Vec<&str> = nfth_filter.iter().map(|_| "?").collect(); - let sql = format!( - "SELECT txid, blockhash, action_type, borrower_hash, principal, interest_rate, - loan_timestamp, collateral_sats, tokens_amount, mtp_timestamp, - new_principal, new_interest_rate, new_loan_timestamp, new_collateral_sats - FROM moria_action - WHERE borrower_hash IN ({}) - ORDER BY mtp_timestamp DESC, rowid DESC - LIMIT ? OFFSET ?", - placeholders.join(",") - ); - let mut query = sqlx::query(&sql); - for nfth in nfth_filter { - query = query.bind(nfth); - } - query = query.bind(limit).bind(offset); - query.fetch_all(pool).await? - }; - - let mut entries = Vec::new(); - for row in rows { - entries.push(MoriaEntry::from_row(&row)?); - } - Ok(entries) -} - -/// Get protocol statistics -pub async fn get_stats(pool: &SqlitePool) -> Result { - let total_loans: (i64,) = - sqlx::query_as("SELECT COUNT(*) FROM moria_action WHERE action_type = 0") - .fetch_one(pool) - .await?; - - let active_loans: (i64,) = sqlx::query_as( - "SELECT COUNT(DISTINCT borrower_hash) FROM moria_action - WHERE action_type IN (0, 3) AND borrower_hash IS NOT NULL - AND borrower_hash NOT IN ( - SELECT borrower_hash FROM moria_action - WHERE action_type IN (1, 2) AND borrower_hash IS NOT NULL - )", - ) - .fetch_one(pool) - .await?; - - let total_actions: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM moria_action") - .fetch_one(pool) - .await?; - - Ok(serde_json::json!({ - "total_borrows": total_loans.0, - "active_loans": active_loans.0, - "total_actions": total_actions.0, - })) -} - -#[cfg(test)] -mod tests { - use super::*; - use bitcoin_hashes::Hash; - use bitcoincash::BlockHash; - - async fn setup_moria_db(pool: SqlitePool) { - prepare_tables(&pool).await; - } - - fn test_blockhash() -> BlockHash { - BlockHash::from_raw_hash(bitcoin_hashes::sha256d::Hash::from_slice(&[0xAA; 32]).unwrap()) - } - - fn test_txid(n: u8) -> Txid { - Txid::from_raw_hash(bitcoin_hashes::sha256d::Hash::from_slice(&[n; 32]).unwrap()) - } - - fn borrower_a() -> [u8; 32] { - [0x01; 32] - } - - fn borrower_b() -> [u8; 32] { - [0x02; 32] - } - - async fn insert_test_action( - pool: &SqlitePool, - txid: &Txid, - action_type: MoriaActionType, - borrower: &[u8; 32], - principal: i64, - mtp: i64, - ) { - let action = ParsedMoriaAction { - action_type, - loan_commitment: Some(riftenlabs_defi::moria::LoanCommitment { - borrower_nft_hash: *borrower, - principal: principal as u16, - annual_interest_rate_bp: 500, - timestamp: mtp as u32, - }), - new_loan_commitment: None, - collateral_sats: Some(1_000_000), - tokens_amount: Some(principal * 100), - spent_loan_outpoint: None, - loan_output_index: Some(2), - }; - insert_moria_entry(pool, txid, &test_blockhash(), mtp, None, &action, None) - .await - .unwrap(); - } - - #[rocket::async_test] - async fn test_loan_history() { - let db = crate::utiltest::mock_db_pool(setup_moria_db).await; - let pool = &db.moria_w; - - let txid1 = test_txid(1); - let txid2 = test_txid(2); - insert_test_action( - pool, - &txid1, - MoriaActionType::Borrow, - &borrower_a(), - 1000, - 100, - ) - .await; - insert_test_action( - pool, - &txid2, - MoriaActionType::Repay, - &borrower_a(), - 1000, - 200, - ) - .await; - - let history = get_loan_history(pool, &borrower_a()).await.unwrap(); - assert_eq!(history.len(), 2); - assert_eq!(history[0].action_type, "borrow"); - assert_eq!(history[0].principal, Some(1000)); - assert_eq!(history[1].action_type, "repay"); - assert_eq!(history[1].mtp_timestamp, 200); - } - - #[rocket::async_test] - async fn test_loan_history_filters_by_borrower() { - let db = crate::utiltest::mock_db_pool(setup_moria_db).await; - let pool = &db.moria_w; - - insert_test_action( - pool, - &test_txid(1), - MoriaActionType::Borrow, - &borrower_a(), - 1000, - 100, - ) - .await; - insert_test_action( - pool, - &test_txid(2), - MoriaActionType::Borrow, - &borrower_b(), - 500, - 200, - ) - .await; - - let history_a = get_loan_history(pool, &borrower_a()).await.unwrap(); - assert_eq!(history_a.len(), 1); - assert_eq!(history_a[0].principal, Some(1000)); - - let history_b = get_loan_history(pool, &borrower_b()).await.unwrap(); - assert_eq!(history_b.len(), 1); - assert_eq!(history_b[0].principal, Some(500)); - } - - #[rocket::async_test] - async fn test_active_loans() { - let db = crate::utiltest::mock_db_pool(setup_moria_db).await; - let pool = &db.moria_w; - - // Borrower A borrows and repays - insert_test_action( - pool, - &test_txid(1), - MoriaActionType::Borrow, - &borrower_a(), - 1000, - 100, - ) - .await; - insert_test_action( - pool, - &test_txid(2), - MoriaActionType::Repay, - &borrower_a(), - 1000, - 200, - ) - .await; - - // Borrower B borrows and stays active - insert_test_action( - pool, - &test_txid(3), - MoriaActionType::Borrow, - &borrower_b(), - 500, - 150, - ) - .await; - - let active = get_active_loans(pool).await.unwrap(); - assert_eq!(active.len(), 1); - assert_eq!( - active[0].borrower_hash.as_ref().unwrap(), - &hex::encode(borrower_b()) - ); - } - - #[rocket::async_test] - async fn test_global_history_pagination() { - let db = crate::utiltest::mock_db_pool(setup_moria_db).await; - let pool = &db.moria_w; - - for i in 0..5 { - insert_test_action( - pool, - &test_txid(i), - MoriaActionType::Borrow, - &borrower_a(), - 1000, - (i as i64) * 100, - ) - .await; - } - - // limit - let page = get_global_history(pool, &[], 0, 2).await.unwrap(); - assert_eq!(page.len(), 2); - - // offset - let page2 = get_global_history(pool, &[], 2, 2).await.unwrap(); - assert_eq!(page2.len(), 2); - assert_ne!(page[0].txid, page2[0].txid); - - // all - let all = get_global_history(pool, &[], 0, 100).await.unwrap(); - assert_eq!(all.len(), 5); - } - - #[rocket::async_test] - async fn test_global_history_nfth_filter() { - let db = crate::utiltest::mock_db_pool(setup_moria_db).await; - let pool = &db.moria_w; - - insert_test_action( - pool, - &test_txid(1), - MoriaActionType::Borrow, - &borrower_a(), - 1000, - 100, - ) - .await; - insert_test_action( - pool, - &test_txid(2), - MoriaActionType::Borrow, - &borrower_b(), - 500, - 200, - ) - .await; - insert_test_action( - pool, - &test_txid(3), - MoriaActionType::Repay, - &borrower_a(), - 1000, - 300, - ) - .await; - - // Filter to borrower_a only - let filtered = get_global_history(pool, &[borrower_a().to_vec()], 0, 100) - .await - .unwrap(); - assert_eq!(filtered.len(), 2); - - // Filter to both - let both = get_global_history( - pool, - &[borrower_a().to_vec(), borrower_b().to_vec()], - 0, - 100, - ) - .await - .unwrap(); - assert_eq!(both.len(), 3); - - // No filter - let all = get_global_history(pool, &[], 0, 100).await.unwrap(); - assert_eq!(all.len(), 3); - } - - #[rocket::async_test] - async fn test_stats() { - let db = crate::utiltest::mock_db_pool(setup_moria_db).await; - let pool = &db.moria_w; - - insert_test_action( - pool, - &test_txid(1), - MoriaActionType::Borrow, - &borrower_a(), - 1000, - 100, - ) - .await; - insert_test_action( - pool, - &test_txid(2), - MoriaActionType::Borrow, - &borrower_b(), - 500, - 200, - ) - .await; - insert_test_action( - pool, - &test_txid(3), - MoriaActionType::Repay, - &borrower_a(), - 1000, - 300, - ) - .await; - - let stats = get_stats(pool).await.unwrap(); - assert_eq!(stats["total_borrows"], 2); - assert_eq!(stats["active_loans"], 1); - assert_eq!(stats["total_actions"], 3); - } -} diff --git a/src/db/moria_v11/deploy.rs b/src/db/moria_v11/deploy.rs new file mode 100644 index 0000000..6568098 --- /dev/null +++ b/src/db/moria_v11/deploy.rs @@ -0,0 +1,53 @@ +// 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 Moria v1-1 deployment constants. +//! +//! Mainnet is unconfigured until a v1-1 deployment exists (fail-closed). + +use bitcoincash::{Network, TokenID}; +use riftenlabs_defi::moria_v1_1::MoriaV11TokenIds; + +/// Unique tail of the moria v1-1 redeem script. Partial scriptsig match — +/// every moria method spends this body. +const REDEEM_TAIL: [u8; 20] = [ + 0x51, 0xd2, 0x51, 0xcf, 0x88, 0x52, 0xd1, 0x00, 0x88, 0xc4, 0x53, 0x9c, 0x77, 0x77, 0x77, 0x68, + 0x68, 0x68, 0x68, 0x68, +]; + +/// Chipnet v1-1 token categories and loan P2SH32, or `None` on other networks. +pub fn token_ids(network: Option) -> Option { + match network { + Some(Network::Chipnet) => Some(MoriaV11TokenIds { + moria: "56d909e16cd3b4cecae685a7ca45baa292bf7550205c2ad59320a342c73754de" + .parse::() + .expect("valid chipnet moria v1-1 token_id"), + bp_oracle: "a0628bc21d10211a23f986fd12770e836bae43878db93db8b164ab8a5ab33c96" + .parse::() + .expect("valid chipnet bp_oracle v1-1 token_id"), + pool: "a533b7e96435af18136b534b43e465dd759db3ac33e8490b9bfd254b8c27ae48" + .parse::() + .expect("valid chipnet pool v1-1 token_id"), + bar: "bc98b2733ca1c2070586cce1a4216418830ccd9b19ff77b5d43d47542bb3b50d" + .parse::() + .expect("valid chipnet bar v1-1 token_id"), + loan_locking_bytecode: hex::decode( + "aa20415812e068086c90686635a813d77b6c412427e574e89b16e79feeaa48cf0f0787", + ) + .expect("valid chipnet loan locking bytecode"), + }), + _ => None, + } +} + +/// Electrum `mempool.get` filter for this network's loan lock + redeem tail. +pub fn mempool_filter(network: Option) -> Option { + let ids = token_ids(network)?; + Some(serde_json::json!({ + "scriptsig": hex::encode(REDEEM_TAIL), + "scriptpubkey": hex::encode(&ids.loan_locking_bytecode), + "operation": "union", + })) +} diff --git a/src/db/moria_v11/ingest.rs b/src/db/moria_v11/ingest.rs new file mode 100644 index 0000000..2c8d864 --- /dev/null +++ b/src/db/moria_v11/ingest.rs @@ -0,0 +1,867 @@ +// 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 + +//! Write path: index a block or mempool batch, mutate UTXO tables, roll back. + +use anyhow::Result; +use bitcoin_hashes::Hash; +use bitcoincash::{BlockHash, TokenID, Transaction, Txid}; +use log::debug; +use riftenlabs_defi::moria_v1_1::{ + crank_allowance_locking_bytecode, crank_hint_to_policy, decode_crank_hint, + parse_moria_v11_from_tx, CrankPolicy, LoanCommitmentV11, MoriaV11ActionType, MoriaV11TokenIds, + ParsedMoriaV11Action, +}; +use sqlx::{Row, SqlitePool}; + +use crate::db::blob::ToBlob; + +use super::{action_type_to_int, action_type_to_str, staking}; + +const BP_MESSAGE_SIZE: usize = 26; +/// `BP_TIMESTAMP_OFFSET` — u48 LE seconds. +const BP_TIMESTAMP_OFFSET: usize = 0; +/// `BP_BP_OFFSET` — u16 LE threshold in basis points. +const BP_BP_OFFSET: usize = 8; + +/// Decode threshold_bp + oracle_timestamp from a 26-byte BP oracle commitment. +/// +/// Layout: timestamp u48 LE @ 0, threshold u16 LE @ 8. +pub fn parse_bp_commitment(commitment: &[u8]) -> Option<(u16, u64)> { + if commitment.len() != BP_MESSAGE_SIZE { + return None; + } + let mut ts_bytes = [0u8; 8]; + ts_bytes[..6].copy_from_slice(&commitment[BP_TIMESTAMP_OFFSET..BP_TIMESTAMP_OFFSET + 6]); + let oracle_timestamp = u64::from_le_bytes(ts_bytes); + let threshold_bp = u16::from_le_bytes([commitment[BP_BP_OFFSET], commitment[BP_BP_OFFSET + 1]]); + Some((threshold_bp, oracle_timestamp)) +} + +/// Build a minimal 26-byte BP commitment for tests (only timestamp + threshold filled). +#[cfg(test)] +pub fn encode_bp_commitment(threshold_bp: u16, oracle_timestamp: u64) -> Vec { + let mut out = vec![0u8; BP_MESSAGE_SIZE]; + out[BP_TIMESTAMP_OFFSET..BP_TIMESTAMP_OFFSET + 6] + .copy_from_slice(&oracle_timestamp.to_le_bytes()[..6]); + out[BP_BP_OFFSET..BP_BP_OFFSET + 2].copy_from_slice(&threshold_bp.to_le_bytes()); + out +} + +fn commitment_fields(c: &LoanCommitmentV11) -> (i64, i64, i64) { + ( + c.principal as i64, + c.annual_interest_rate_bp as i64, + c.timestamp as i64, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn insert_loan_utxo( + pool: &SqlitePool, + txid: &Txid, + vout: u32, + commitment: &LoanCommitmentV11, + collateral_sats: u64, + blockhash: &BlockHash, +) -> Result<()> { + sqlx::query( + "INSERT OR REPLACE INTO moria_v11_loan_utxo + (txid, vout, borrower_hash, delegate_hash, principal, interest_rate, + loan_timestamp, collateral_sats, blockhash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(txid.to_blob()) + .bind(vout as i64) + .bind(commitment.borrower_nft_hash.as_slice()) + .bind(commitment.delegate_nft_hash.as_slice()) + .bind(commitment.principal as i64) + .bind(commitment.annual_interest_rate_bp as i64) + .bind(commitment.timestamp as i64) + .bind(collateral_sats as i64) + .bind(blockhash.to_blob()) + .execute(pool) + .await + .map_err(|e| anyhow::anyhow!("failed to insert v11 loan utxo: {}", e))?; + Ok(()) +} + +async fn delete_loan_utxo(pool: &SqlitePool, txid: &Txid, vout: u32) -> Result<()> { + sqlx::query("DELETE FROM moria_v11_loan_utxo WHERE txid = ? AND vout = ?") + .bind(txid.to_blob()) + .bind(vout as i64) + .execute(pool) + .await + .map_err(|e| anyhow::anyhow!("failed to delete v11 loan utxo: {}", e))?; + Ok(()) +} + +/// Look up a spent loan UTXO. +type LoanUtxoRow = (Vec, Vec, i64, i64, i64, i64); + +async fn lookup_loan_utxo( + pool: &SqlitePool, + txid: &Txid, + vout: u32, +) -> Result> { + let row: Option = sqlx::query_as( + "SELECT borrower_hash, delegate_hash, principal, interest_rate, loan_timestamp, + collateral_sats + FROM moria_v11_loan_utxo WHERE txid = ? AND vout = ?", + ) + .bind(txid.to_blob()) + .bind(vout as i64) + .fetch_optional(pool) + .await?; + Ok(row) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn insert_allowance_utxo( + pool: &SqlitePool, + txid: &Txid, + vout: u32, + owner_hash: &[u8], + delegate_hash: &[u8], + locking_bytecode: &[u8], + token_amount: i64, + sats: u64, + blockhash: &BlockHash, + policy: Option<&CrankPolicy>, + fee_sats: Option, +) -> Result<()> { + sqlx::query( + "INSERT OR REPLACE INTO moria_v11_allowance_utxo + (txid, vout, owner_hash, delegate_hash, locking_bytecode, token_amount, sats, blockhash, + policy_delta, policy_max_bp, policy_rescue_lead_bp, + policy_up_leg_seconds, policy_down_leg_seconds, fee_sats) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(txid.to_blob()) + .bind(vout as i64) + .bind(owner_hash) + .bind(delegate_hash) + .bind(locking_bytecode) + .bind(token_amount) + .bind(sats as i64) + .bind(blockhash.to_blob()) + .bind(policy.map(|p| p.delta as i64)) + .bind(policy.map(|p| p.max_bp as i64)) + .bind(policy.map(|p| p.rescue_lead_bp as i64)) + .bind(policy.map(|p| p.up_leg_seconds as i64)) + .bind(policy.map(|p| p.down_leg_seconds as i64)) + .bind(fee_sats) + .execute(pool) + .await + .map_err(|e| anyhow::anyhow!("failed to insert v11 allowance utxo: {}", e))?; + Ok(()) +} + +async fn delete_allowance_utxo(pool: &SqlitePool, txid: &Txid, vout: u32) -> Result<()> { + sqlx::query("DELETE FROM moria_v11_allowance_utxo WHERE txid = ? AND vout = ?") + .bind(txid.to_blob()) + .bind(vout as i64) + .execute(pool) + .await + .map_err(|e| anyhow::anyhow!("failed to delete v11 allowance utxo: {}", e))?; + Ok(()) +} + +struct AllowanceUtxoRow { + owner_hash: Vec, + delegate_hash: Vec, + policy: Option, + fee_sats: Option, +} + +fn crank_policy_from_cols( + delta: Option, + max_bp: Option, + rescue_lead_bp: Option, + up_leg_seconds: Option, + down_leg_seconds: Option, +) -> Option { + Some(CrankPolicy { + delta: u32::try_from(delta?).ok()?, + max_bp: u32::try_from(max_bp?).ok()?, + rescue_lead_bp: u32::try_from(rescue_lead_bp?).ok()?, + up_leg_seconds: u32::try_from(up_leg_seconds?).ok()?, + down_leg_seconds: u32::try_from(down_leg_seconds?).ok()?, + }) +} + +async fn lookup_allowance_utxo( + pool: &SqlitePool, + txid: &Txid, + vout: u32, +) -> Result> { + let row = sqlx::query( + "SELECT owner_hash, delegate_hash, locking_bytecode, token_amount, sats, + policy_delta, policy_max_bp, policy_rescue_lead_bp, + policy_up_leg_seconds, policy_down_leg_seconds, fee_sats + FROM moria_v11_allowance_utxo WHERE txid = ? AND vout = ?", + ) + .bind(txid.to_blob()) + .bind(vout as i64) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| AllowanceUtxoRow { + owner_hash: r.get("owner_hash"), + delegate_hash: r.get("delegate_hash"), + policy: crank_policy_from_cols( + r.get("policy_delta"), + r.get("policy_max_bp"), + r.get("policy_rescue_lead_bp"), + r.get("policy_up_leg_seconds"), + r.get("policy_down_leg_seconds"), + ), + fee_sats: r.get("fee_sats"), + })) +} + +/// Known allowance locking scripts already seen as live UTXOs (first-spend +/// or borrow deposit). Used so later top-ups to the same P2SH32 are indexed. +/// Policy+fee ride along so a top-up does not wipe a verified hint. +struct KnownAllowanceScript { + locking_bytecode: Vec, + owner_hash: Vec, + delegate_hash: Vec, + policy: Option, + fee_sats: Option, +} + +async fn known_allowance_scripts(pool: &SqlitePool) -> Result> { + let mut out: Vec = Vec::new(); + let mut seen: std::collections::HashMap, usize> = std::collections::HashMap::new(); + + let utxo_rows = sqlx::query( + "SELECT locking_bytecode, owner_hash, delegate_hash, + policy_delta, policy_max_bp, policy_rescue_lead_bp, + policy_up_leg_seconds, policy_down_leg_seconds, fee_sats + FROM moria_v11_allowance_utxo", + ) + .fetch_all(pool) + .await?; + for row in utxo_rows { + let script: Vec = row.get("locking_bytecode"); + let policy = crank_policy_from_cols( + row.get("policy_delta"), + row.get("policy_max_bp"), + row.get("policy_rescue_lead_bp"), + row.get("policy_up_leg_seconds"), + row.get("policy_down_leg_seconds"), + ); + let fee_sats: Option = row.get("fee_sats"); + if let Some(&idx) = seen.get(&script) { + if out[idx].policy.is_none() && policy.is_some() { + out[idx].policy = policy; + } + if out[idx].fee_sats.is_none() && fee_sats.is_some() { + out[idx].fee_sats = fee_sats; + } + continue; + } + seen.insert(script.clone(), out.len()); + out.push(KnownAllowanceScript { + locking_bytecode: script, + owner_hash: row.get("owner_hash"), + delegate_hash: row.get("delegate_hash"), + policy, + fee_sats, + }); + } + Ok(out) +} + +async fn insert_bp_threshold( + pool: &SqlitePool, + txid: &Txid, + blockhash: &BlockHash, + threshold_bp: u16, + oracle_timestamp: u64, +) -> Result<()> { + sqlx::query( + "INSERT OR REPLACE INTO moria_v11_bp_threshold + (txid, blockhash, threshold_bp, oracle_timestamp) + VALUES (?, ?, ?, ?)", + ) + .bind(txid.to_blob()) + .bind(blockhash.to_blob()) + .bind(threshold_bp as i64) + .bind(oracle_timestamp as i64) + .execute(pool) + .await + .map_err(|e| anyhow::anyhow!("failed to insert v11 bp threshold: {}", e))?; + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn insert_action( + pool: &SqlitePool, + txid: &Txid, + blockhash: &BlockHash, + mtp: i64, + first_seen: Option, + action: &ParsedMoriaV11Action, + borrower_hash: Option<&[u8]>, + delegate_hash: Option<&[u8]>, +) -> Result<()> { + let old_c = action.loan_commitment.as_ref(); + let new_c = action.new_loan_commitment.as_ref(); + + let bh = borrower_hash + .or_else(|| old_c.map(|c| c.borrower_nft_hash.as_slice())) + .or_else(|| new_c.map(|c| c.borrower_nft_hash.as_slice())); + let dh = delegate_hash + .or_else(|| old_c.map(|c| c.delegate_nft_hash.as_slice())) + .or_else(|| new_c.map(|c| c.delegate_nft_hash.as_slice())); + + let has_old = old_c.is_some(); + let (principal, interest_rate, loan_timestamp) = + old_c.map(commitment_fields).unwrap_or((0, 0, 0)); + + let has_new = new_c.is_some(); + let (new_principal, new_interest_rate, new_loan_timestamp) = + new_c.map(commitment_fields).unwrap_or((0, 0, 0)); + + let allowance_token_amount = action + .allowance_deposit + .as_ref() + .map(|d| d.token_amount) + .or_else(|| { + action + .crank_allowance + .as_ref() + .map(|c| c.output_token_amount) + }); + let allowance_sats = action + .allowance_deposit + .as_ref() + .map(|d| d.sats as i64) + .or_else(|| { + action + .crank_allowance + .as_ref() + .map(|c| c.output_sats as i64) + }); + + let fee_take_sats = action.crank_fee_sats.map(|s| s as i64); + let recovered_token_amount = action.allowance_close.as_ref().map(|c| c.token_amount); + let recovered_sats = action.allowance_close.as_ref().map(|c| c.sats as i64); + + let new_collateral_sats = if has_new { + action.collateral_sats.map(|s| s as i64) + } else { + None + }; + + sqlx::query( + "INSERT INTO moria_v11_action + (txid, blockhash, action_type, borrower_hash, delegate_hash, + principal, interest_rate, loan_timestamp, + collateral_sats, tokens_amount, mtp_timestamp, first_seen_timestamp, + new_principal, new_interest_rate, new_loan_timestamp, new_collateral_sats, + allowance_token_amount, allowance_sats, fee_take_sats, + recovered_token_amount, recovered_sats, loan_output_index) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(txid.to_blob()) + .bind(blockhash.to_blob()) + .bind(action_type_to_int(action.action_type)) + .bind(bh) + .bind(dh) + .bind(if has_old { Some(principal) } else { None }) + .bind(if has_old { Some(interest_rate) } else { None }) + .bind(if has_old { Some(loan_timestamp) } else { None }) + .bind(action.collateral_sats.map(|s| s as i64)) + .bind(action.tokens_amount) + .bind(mtp) + .bind(first_seen) + .bind(if has_new { Some(new_principal) } else { None }) + .bind(if has_new { + Some(new_interest_rate) + } else { + None + }) + .bind(if has_new { + Some(new_loan_timestamp) + } else { + None + }) + .bind(new_collateral_sats) + .bind(allowance_token_amount) + .bind(allowance_sats) + .bind(fee_take_sats) + .bind(recovered_token_amount) + .bind(recovered_sats) + .bind(action.loan_output_index.map(|i| i as i64)) + .execute(pool) + .await + .map_err(|e| anyhow::anyhow!("failed to insert moria_v11_action: {}", e))?; + Ok(()) +} + +fn index_bp_oracle_outputs(tx: &Transaction, bp_oracle: &TokenID) -> Option<(u16, u64)> { + for out in &tx.output { + let Some(tok) = out.token.as_ref() else { + continue; + }; + if &tok.id != bp_oracle { + continue; + } + if let Some(parsed) = parse_bp_commitment(&tok.commitment) { + return Some(parsed); + } + } + None +} + +fn verified_borrow_hint_policy( + tx: &Transaction, + token_ids: &MoriaV11TokenIds, + commitment: &LoanCommitmentV11, +) -> Option<(CrankPolicy, u64)> { + let seq = tx.input.get(4)?.sequence.to_consensus_u32(); + let hint = decode_crank_hint(seq)?; + if token_ids.loan_locking_bytecode.is_empty() { + return None; + } + let script = tx.output.get(8)?.script_pubkey.as_bytes(); + let policy = crank_hint_to_policy(&hint); + let bp = token_ids.bp_oracle.to_byte_array(); + let mut bp_rev = bp; + bp_rev.reverse(); + for cat in [&bp, &bp_rev] { + let derived = crank_allowance_locking_bytecode( + &token_ids.loan_locking_bytecode, + &commitment.borrower_nft_hash, + &commitment.delegate_nft_hash, + hint.fee_sats, + cat, + &policy, + ); + if script == derived.as_slice() || script.ends_with(derived.as_slice()) { + return Some((policy, hint.fee_sats)); + } + } + None +} + +/// Index moria v1-1 transactions from a block (or mempool batch). +/// +/// Returns the number of loan/allowance actions recorded (BP threshold rows +/// are not included in the count). +pub async fn index_moria_v11( + pool: &SqlitePool, + txs: &[Transaction], + blockhash: &BlockHash, + mtp: i64, + token_ids: &MoriaV11TokenIds, +) -> Result { + let mut count = 0; + + for tx in txs { + let txid = tx.compute_txid(); + if *blockhash != BlockHash::all_zeros() { + delete_unconfirmed_tx(pool, &txid).await?; + } + + // BP oracle threshold updates (independent of loan actions). + if let Some((threshold_bp, oracle_ts)) = index_bp_oracle_outputs(tx, &token_ids.bp_oracle) { + insert_bp_threshold(pool, &txid, blockhash, threshold_bp, oracle_ts).await?; + debug!( + "moria_v11 bp threshold: {} bp={} ts={}", + txid, threshold_bp, oracle_ts + ); + } + + // Discover allowances from borrow optional-MUSD or first spend. + // Previously-seen UTXO scripts remain recognizable for top-ups. + let known_scripts = known_allowance_scripts(pool).await?; + if !known_scripts.is_empty() { + for (vout, out) in tx.output.iter().enumerate() { + let script = out.script_pubkey.as_bytes(); + let Some(known) = known_scripts + .iter() + .find(|k| k.locking_bytecode.as_slice() == script) + else { + continue; + }; + let Some(tok) = out.token.as_ref() else { + continue; + }; + if tok.id != token_ids.moria || tok.has_nft() { + continue; + } + let amount = tok.amount.to_int(); + if amount <= 0 { + continue; + } + insert_allowance_utxo( + pool, + &txid, + vout as u32, + &known.owner_hash, + &known.delegate_hash, + script, + amount, + out.value.to_sat(), + blockhash, + known.policy.as_ref(), + known.fee_sats, + ) + .await?; + } + } + + let actions = parse_moria_v11_from_tx(tx, token_ids); + + // Track which allowance outpoints this tx's actions explicitly handle, + // so the generic spend cleanup does not double-delete mid-action. + let mut handled_allowance_spends: Vec<(Txid, u32)> = Vec::new(); + + for mut action in actions { + let mut looked_up_borrower: Option> = None; + let mut looked_up_delegate: Option> = None; + + // Fill prior commitment from the live loan UTXO set when the parser + // could not recover it (bare txs lack input token payloads). + if let Some((spent_txid, spent_vout)) = &action.spent_loan_outpoint { + if let Some((bh, dh, principal, interest_rate, loan_ts, collateral)) = + lookup_loan_utxo(pool, spent_txid, *spent_vout).await? + { + if action.loan_commitment.is_none() { + let mut borrower = [0u8; 32]; + let mut delegate = [0u8; 32]; + if bh.len() == 32 { + borrower.copy_from_slice(&bh); + } + if dh.len() == 32 { + delegate.copy_from_slice(&dh); + } + action.loan_commitment = Some(LoanCommitmentV11 { + borrower_nft_hash: borrower, + delegate_nft_hash: delegate, + principal: principal as u16, + annual_interest_rate_bp: interest_rate as u16, + timestamp: loan_ts as u64, + }); + } + if action.collateral_sats.is_none() + && matches!( + action.action_type, + MoriaV11ActionType::Repay + | MoriaV11ActionType::Redeem + | MoriaV11ActionType::Liquidate + ) + { + action.collateral_sats = Some(collateral as u64); + } + looked_up_borrower = Some(bh); + looked_up_delegate = Some(dh); + delete_loan_utxo(pool, spent_txid, *spent_vout).await?; + } + } + + // Create / replace continuing loan UTXO. + if let Some(output_idx) = action.loan_output_index { + if let Some(commitment) = action + .new_loan_commitment + .as_ref() + .or(action.loan_commitment.as_ref()) + { + insert_loan_utxo( + pool, + &txid, + output_idx, + commitment, + action.collateral_sats.unwrap_or(0), + blockhash, + ) + .await?; + // Policy lives on the allowance, revealed at first spend. + } + } + + // Allowance deposit on borrow (parser: optional MUSD at out 8/9). + if let Some(dep) = &action.allowance_deposit { + if let Some(c) = action + .loan_commitment + .as_ref() + .or(action.new_loan_commitment.as_ref()) + { + let verified = verified_borrow_hint_policy(tx, token_ids, c); + let fee_sats = verified.as_ref().map(|(_, f)| *f as i64); + + insert_allowance_utxo( + pool, + &txid, + dep.output_index, + &c.borrower_nft_hash, + &c.delegate_nft_hash, + &dep.locking_bytecode, + dep.token_amount, + dep.sats, + blockhash, + verified.as_ref().map(|(p, _)| p), + fee_sats, + ) + .await?; + } + } + + // Crank: spend prior allowance + insert the purse continuation + // (not same-index as the METHOD_CRANK input). + if let Some(info) = &action.crank_allowance { + let cont_vout = tx + .output + .iter() + .enumerate() + .find(|(_, o)| o.script_pubkey.as_bytes() == info.locking_bytecode.as_slice()) + .map(|(i, _)| i as u32) + .unwrap_or(info.io_index); + if let Some(inp) = tx.input.get(info.io_index as usize) { + let prev = &inp.previous_output; + if let Some(spent) = lookup_allowance_utxo(pool, &prev.txid, prev.vout).await? { + delete_allowance_utxo(pool, &prev.txid, prev.vout).await?; + handled_allowance_spends.push((prev.txid, prev.vout)); + insert_allowance_utxo( + pool, + &txid, + cont_vout, + &spent.owner_hash, + &spent.delegate_hash, + &info.locking_bytecode, + info.output_token_amount, + info.output_sats, + blockhash, + spent.policy.as_ref(), + spent.fee_sats, + ) + .await?; + } else if let Some(c) = action + .new_loan_commitment + .as_ref() + .or(action.loan_commitment.as_ref()) + { + // First spend of an unregistered allowance — classify now. + // Policy/fee stay null until a verified borrow hint (or a + // later spend of a row that already had them). + insert_allowance_utxo( + pool, + &txid, + cont_vout, + &c.borrower_nft_hash, + &c.delegate_nft_hash, + &info.locking_bytecode, + info.output_token_amount, + info.output_sats, + blockhash, + None, + None, + ) + .await?; + } else if let (Some(owner), Some(delegate)) = + (&looked_up_borrower, &looked_up_delegate) + { + insert_allowance_utxo( + pool, + &txid, + cont_vout, + owner, + delegate, + &info.locking_bytecode, + info.output_token_amount, + info.output_sats, + blockhash, + None, + None, + ) + .await?; + } + } + } + + // AllowanceClose: look up owner before deleting the spent UTXO. + if let Some(close) = &action.allowance_close { + if let Some(inp) = tx.input.get(close.input_index as usize) { + let prev = &inp.previous_output; + if let Some(spent) = lookup_allowance_utxo(pool, &prev.txid, prev.vout).await? { + looked_up_borrower = Some(spent.owner_hash); + looked_up_delegate = Some(spent.delegate_hash); + delete_allowance_utxo(pool, &prev.txid, prev.vout).await?; + handled_allowance_spends.push((prev.txid, prev.vout)); + } + } + } + + debug!( + "moria_v11: {} {} (borrower: {})", + action_type_to_str(action.action_type), + txid, + action + .loan_commitment + .as_ref() + .or(action.new_loan_commitment.as_ref()) + .map(|c| hex::encode(c.borrower_nft_hash)) + .or_else(|| looked_up_borrower.as_ref().map(hex::encode)) + .unwrap_or_else(|| "n/a".to_string()), + ); + + insert_action( + pool, + &txid, + blockhash, + mtp, + None, + &action, + looked_up_borrower.as_deref(), + looked_up_delegate.as_deref(), + ) + .await?; + count += 1; + } + + // Remove any remaining spent known-allowance UTXOs (e.g. closed without + // parser match, or reorg-safe cleanup of pure spends). + for inp in &tx.input { + let prev = &inp.previous_output; + if handled_allowance_spends + .iter() + .any(|(t, v)| t == &prev.txid && *v == prev.vout) + { + continue; + } + if lookup_allowance_utxo(pool, &prev.txid, prev.vout) + .await? + .is_some() + { + delete_allowance_utxo(pool, &prev.txid, prev.vout).await?; + } + } + } + + Ok(count) +} + +pub async fn delete_entries_for_block(pool: &SqlitePool, blockhash: &BlockHash) -> Result { + sqlx::query("DELETE FROM moria_v11_loan_utxo WHERE blockhash = ?") + .bind(blockhash.to_blob()) + .execute(pool) + .await + .map_err(|e| anyhow::anyhow!("failed to delete v11 loan utxos for block: {}", e))?; + + sqlx::query("DELETE FROM moria_v11_allowance_utxo WHERE blockhash = ?") + .bind(blockhash.to_blob()) + .execute(pool) + .await + .map_err(|e| anyhow::anyhow!("failed to delete v11 allowance utxos for block: {}", e))?; + + sqlx::query("DELETE FROM moria_v11_bp_threshold WHERE blockhash = ?") + .bind(blockhash.to_blob()) + .execute(pool) + .await + .map_err(|e| anyhow::anyhow!("failed to delete v11 bp thresholds for block: {}", e))?; + + let r = sqlx::query("DELETE FROM moria_v11_action WHERE blockhash = ?") + .bind(blockhash.to_blob()) + .execute(pool) + .await + .map_err(|e| { + anyhow::anyhow!( + "failed to delete moria_v11_action for block {}: {}", + blockhash, + e + ) + })?; + + let staking_deleted = staking::delete_entries_for_block(pool, blockhash).await?; + + Ok(r.rows_affected() as usize + staking_deleted) +} + +pub async fn has_entry(pool: &SqlitePool, txid: &Txid) -> Result { + let row: Option<(i64,)> = + sqlx::query_as("SELECT 1 FROM moria_v11_action WHERE txid = ? LIMIT 1") + .bind(txid.to_blob()) + .fetch_optional(pool) + .await?; + Ok(row.is_some()) +} + +/// Txids currently stored under the mempool sentinel (`BlockHash::all_zeros`). +pub async fn get_unconfirmed_txids(pool: &SqlitePool) -> Result> { + let zero = BlockHash::all_zeros().to_blob(); + let rows = sqlx::query( + "SELECT txid FROM moria_v11_action WHERE blockhash = ? + UNION + SELECT txid FROM moria_v11_loan_utxo WHERE blockhash = ? + UNION + SELECT txid FROM moria_v11_allowance_utxo WHERE blockhash = ? + UNION + SELECT txid FROM moria_v11_bp_threshold WHERE blockhash = ?", + ) + .bind(&zero) + .bind(&zero) + .bind(&zero) + .bind(&zero) + .fetch_all(pool) + .await?; + let mut out = Vec::new(); + for row in rows { + let blob: Vec = row.get("txid"); + if blob.len() == 32 { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&blob); + out.push(Txid::from_byte_array(arr)); + } + } + Ok(out) +} + +/// Drop a mempool-only row set (evicted / replaced). Confirmed rows stay. +pub async fn delete_unconfirmed_tx(pool: &SqlitePool, txid: &Txid) -> Result { + let zero = BlockHash::all_zeros().to_blob(); + let id = txid.to_blob(); + let mut n = 0usize; + for table in [ + "moria_v11_loan_utxo", + "moria_v11_allowance_utxo", + "moria_v11_bp_threshold", + "moria_v11_action", + ] { + let sql = format!("DELETE FROM {table} WHERE txid = ? AND blockhash = ?"); + let r = sqlx::query(&sql) + .bind(&id) + .bind(&zero) + .execute(pool) + .await + .map_err(|e| anyhow::anyhow!("failed to delete unconfirmed {table}: {e}"))?; + n += r.rows_affected() as usize; + } + Ok(n) +} + +pub async fn clear_mempool(pool: &SqlitePool) -> Result { + let zero = BlockHash::all_zeros().to_blob(); + sqlx::query("DELETE FROM moria_v11_loan_utxo WHERE blockhash = ?") + .bind(&zero) + .execute(pool) + .await?; + sqlx::query("DELETE FROM moria_v11_allowance_utxo WHERE blockhash = ?") + .bind(&zero) + .execute(pool) + .await?; + sqlx::query("DELETE FROM moria_v11_bp_threshold WHERE blockhash = ?") + .bind(&zero) + .execute(pool) + .await?; + let r = sqlx::query("DELETE FROM moria_v11_action WHERE blockhash = ?") + .bind(&zero) + .execute(pool) + .await + .map_err(|e| anyhow::anyhow!("failed to clear v11 mempool entries: {}", e))?; + Ok(r.rows_affected() as usize) +} diff --git a/src/db/moria_v11/mod.rs b/src/db/moria_v11/mod.rs new file mode 100644 index 0000000..9b2df37 --- /dev/null +++ b/src/db/moria_v11/mod.rs @@ -0,0 +1,454 @@ +// 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 + +//! Moria v1-1 indexer: actions, live loan/allowance UTXO sets, and BP threshold history. +//! +//! Clean cutover from v1 — these are new tables (`moria_v11_*`). Old `moria.db` +//! instances are rebuilt on resync. + +use anyhow::Result; +use bitcoincash::{BlockHash, Txid}; +use riftenlabs_defi::moria_v1_1::MoriaV11ActionType; +use sqlx::{Row, SqlitePool}; + +use crate::db::blob::blob_to_display_hex; + +pub mod deploy; +pub mod staking; + +mod ingest; +mod query; + +#[cfg(test)] +mod tests; + +pub use deploy::{mempool_filter, token_ids}; +pub use ingest::{ + clear_mempool, delete_entries_for_block, delete_unconfirmed_tx, get_unconfirmed_txids, + has_entry, index_moria_v11, +}; +pub use query::{ + get_active_loans, get_allowances_by_owner, get_crankable, get_global_history, get_loan_history, + get_stats, +}; + +#[cfg(test)] +use ingest::{encode_bp_commitment, insert_allowance_utxo, insert_loan_utxo, parse_bp_commitment}; +#[cfg(test)] +use query::{get_latest_bp_threshold, retarget_in_band}; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct MoriaV11Entry { + pub txid: String, + pub blockhash: String, + pub action_type: &'static str, + pub borrower_hash: Option, + pub delegate_hash: Option, + /// Encoded principal in whole MUSD (× [`MUSD_BASE_UNITS_PER_MUSD`](riftenlabs_defi::moria_v1_1::MUSD_BASE_UNITS_PER_MUSD) for base units). + pub principal: Option, + pub interest_rate: Option, + pub loan_timestamp: Option, + pub collateral_sats: Option, + pub tokens_amount: Option, + pub mtp_timestamp: i64, + pub new_principal: Option, + pub new_interest_rate: Option, + pub new_loan_timestamp: Option, + pub new_collateral_sats: Option, + pub allowance_token_amount: Option, + pub allowance_sats: Option, + pub fee_take_sats: Option, + pub recovered_token_amount: Option, + pub recovered_sats: Option, + pub loan_output_index: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct MoriaV11LoanUtxo { + pub txid: String, + pub vout: i64, + pub borrower_hash: String, + pub delegate_hash: String, + /// Encoded principal in whole MUSD (× [`MUSD_BASE_UNITS_PER_MUSD`](riftenlabs_defi::moria_v1_1::MUSD_BASE_UNITS_PER_MUSD) for base units). + pub principal: i64, + pub interest_rate: i64, + pub loan_timestamp: i64, + pub collateral_sats: i64, + pub blockhash: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct MoriaV11AllowanceUtxo { + pub txid: String, + pub vout: i64, + pub owner_hash: String, + pub delegate_hash: String, + pub locking_bytecode: String, + pub token_amount: i64, + pub sats: i64, + pub blockhash: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_delta: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_max_bp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_rescue_lead_bp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_up_leg_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_down_leg_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fee_sats: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct MoriaV11BpThreshold { + pub txid: String, + pub blockhash: String, + pub threshold_bp: i64, + pub oracle_timestamp: i64, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct MoriaV11Crankable { + pub txid: String, + pub vout: i64, + pub borrower_hash: String, + pub delegate_hash: String, + /// Encoded principal in whole MUSD (× [`MUSD_BASE_UNITS_PER_MUSD`](riftenlabs_defi::moria_v1_1::MUSD_BASE_UNITS_PER_MUSD) for base units). + pub principal: i64, + pub interest_rate: i64, + pub loan_timestamp: i64, + /// From the parked allowance's CrankPolicy (not the loan NFT). + pub tracking_delta: i64, + /// From the parked allowance's CrankPolicy (not the loan NFT). + pub tracking_max_bp: i64, + pub collateral_sats: i64, + pub threshold_bp: i64, + pub target_bp: i64, + pub leg: &'static str, + pub allowance_txid: String, + pub allowance_vout: i64, + pub allowance_token_amount: i64, + pub allowance_sats: i64, +} + +fn action_type_to_str(action: MoriaV11ActionType) -> &'static str { + match action { + MoriaV11ActionType::Borrow => "borrow", + MoriaV11ActionType::Repay => "repay", + MoriaV11ActionType::Redeem => "redeem", + MoriaV11ActionType::Refinance => "refinance", + MoriaV11ActionType::AddCollateral => "add_collateral", + MoriaV11ActionType::Retarget => "retarget", + MoriaV11ActionType::Crank => "crank", + MoriaV11ActionType::AllowanceClose => "allowance_close", + MoriaV11ActionType::Liquidate => "liquidate", + } +} + +fn action_type_to_int(action: MoriaV11ActionType) -> i64 { + action as i64 +} + +fn int_to_action_type(val: i64) -> &'static str { + match val { + 0 => "borrow", + 1 => "repay", + 2 => "redeem", + 3 => "refinance", + 4 => "add_collateral", + 5 => "retarget", + 6 => "crank", + 7 => "allowance_close", + 8 => "liquidate", + _ => "unknown", + } +} + +impl MoriaV11Entry { + fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result { + let txid_blob: Vec = row.get("txid"); + let blockhash_blob: Vec = row.get("blockhash"); + let action_type: i64 = row.get("action_type"); + let borrower_blob: Option> = row.get("borrower_hash"); + let delegate_blob: Option> = row.get("delegate_hash"); + + Ok(Self { + txid: blob_to_display_hex::(&txid_blob)?, + blockhash: blob_to_display_hex::(&blockhash_blob)?, + action_type: int_to_action_type(action_type), + borrower_hash: borrower_blob.map(hex::encode), + delegate_hash: delegate_blob.map(hex::encode), + principal: row.get("principal"), + interest_rate: row.get("interest_rate"), + loan_timestamp: row.get("loan_timestamp"), + collateral_sats: row.get("collateral_sats"), + tokens_amount: row.get("tokens_amount"), + mtp_timestamp: row.get("mtp_timestamp"), + new_principal: row.get("new_principal"), + new_interest_rate: row.get("new_interest_rate"), + new_loan_timestamp: row.get("new_loan_timestamp"), + new_collateral_sats: row.get("new_collateral_sats"), + allowance_token_amount: row.get("allowance_token_amount"), + allowance_sats: row.get("allowance_sats"), + fee_take_sats: row.get("fee_take_sats"), + recovered_token_amount: row.get("recovered_token_amount"), + recovered_sats: row.get("recovered_sats"), + loan_output_index: row.get("loan_output_index"), + }) + } +} + +impl MoriaV11LoanUtxo { + fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result { + let txid_blob: Vec = row.get("txid"); + let blockhash_blob: Vec = row.get("blockhash"); + let borrower_blob: Vec = row.get("borrower_hash"); + let delegate_blob: Vec = row.get("delegate_hash"); + Ok(Self { + txid: blob_to_display_hex::(&txid_blob)?, + vout: row.get("vout"), + borrower_hash: hex::encode(borrower_blob), + delegate_hash: hex::encode(delegate_blob), + principal: row.get("principal"), + interest_rate: row.get("interest_rate"), + loan_timestamp: row.get("loan_timestamp"), + collateral_sats: row.get("collateral_sats"), + blockhash: blob_to_display_hex::(&blockhash_blob)?, + }) + } +} + +impl MoriaV11AllowanceUtxo { + fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result { + let txid_blob: Vec = row.get("txid"); + let blockhash_blob: Vec = row.get("blockhash"); + let owner_blob: Vec = row.get("owner_hash"); + let delegate_blob: Vec = row.get("delegate_hash"); + let script: Vec = row.get("locking_bytecode"); + Ok(Self { + txid: blob_to_display_hex::(&txid_blob)?, + vout: row.get("vout"), + owner_hash: hex::encode(owner_blob), + delegate_hash: hex::encode(delegate_blob), + locking_bytecode: hex::encode(script), + token_amount: row.get("token_amount"), + sats: row.get("sats"), + blockhash: blob_to_display_hex::(&blockhash_blob)?, + policy_delta: row.get("policy_delta"), + policy_max_bp: row.get("policy_max_bp"), + policy_rescue_lead_bp: row.get("policy_rescue_lead_bp"), + policy_up_leg_seconds: row.get("policy_up_leg_seconds"), + policy_down_leg_seconds: row.get("policy_down_leg_seconds"), + fee_sats: row.get("fee_sats"), + }) + } +} + +pub async fn prepare_tables(pool: &SqlitePool) { + sqlx::query( + "CREATE TABLE IF NOT EXISTS moria_v11_action ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + txid BLOB NOT NULL, + blockhash BLOB NOT NULL, + action_type INTEGER NOT NULL, + borrower_hash BLOB, + delegate_hash BLOB, + principal INTEGER, + interest_rate INTEGER, + loan_timestamp INTEGER, + collateral_sats BIGINT, + tokens_amount BIGINT, + mtp_timestamp BIGINT NOT NULL, + first_seen_timestamp BIGINT, + new_principal INTEGER, + new_interest_rate INTEGER, + new_loan_timestamp INTEGER, + new_collateral_sats BIGINT, + allowance_token_amount BIGINT, + allowance_sats BIGINT, + fee_take_sats BIGINT, + recovered_token_amount BIGINT, + recovered_sats BIGINT, + loan_output_index INTEGER + )", + ) + .execute(pool) + .await + .expect("failed to create moria_v11_action table"); + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_moria_v11_borrower_hash ON moria_v11_action(borrower_hash)", + ) + .execute(pool) + .await + .expect("failed to create v11 borrower_hash index"); + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_moria_v11_blockhash ON moria_v11_action(blockhash)", + ) + .execute(pool) + .await + .expect("failed to create v11 blockhash index"); + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_moria_v11_timestamp ON moria_v11_action(mtp_timestamp)", + ) + .execute(pool) + .await + .expect("failed to create v11 timestamp index"); + + sqlx::query( + "CREATE TABLE IF NOT EXISTS moria_v11_loan_utxo ( + txid BLOB NOT NULL, + vout INTEGER NOT NULL, + borrower_hash BLOB NOT NULL, + delegate_hash BLOB NOT NULL, + principal INTEGER NOT NULL, + interest_rate INTEGER NOT NULL, + loan_timestamp INTEGER NOT NULL, + collateral_sats BIGINT NOT NULL, + blockhash BLOB NOT NULL, + PRIMARY KEY (txid, vout) + )", + ) + .execute(pool) + .await + .expect("failed to create moria_v11_loan_utxo table"); + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_moria_v11_loan_borrower ON moria_v11_loan_utxo(borrower_hash)", + ) + .execute(pool) + .await + .expect("failed to create v11 loan borrower index"); + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_moria_v11_loan_blockhash ON moria_v11_loan_utxo(blockhash)", + ) + .execute(pool) + .await + .expect("failed to create v11 loan blockhash index"); + + sqlx::query( + "CREATE TABLE IF NOT EXISTS moria_v11_allowance_utxo ( + txid BLOB NOT NULL, + vout INTEGER NOT NULL, + owner_hash BLOB NOT NULL, + delegate_hash BLOB NOT NULL, + locking_bytecode BLOB NOT NULL, + token_amount BIGINT NOT NULL, + sats BIGINT NOT NULL, + blockhash BLOB NOT NULL, + policy_delta INTEGER, + policy_max_bp INTEGER, + policy_rescue_lead_bp INTEGER, + policy_up_leg_seconds INTEGER, + policy_down_leg_seconds INTEGER, + fee_sats INTEGER, + PRIMARY KEY (txid, vout) + )", + ) + .execute(pool) + .await + .expect("failed to create moria_v11_allowance_utxo table"); + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_moria_v11_allowance_owner ON moria_v11_allowance_utxo(owner_hash)", + ) + .execute(pool) + .await + .expect("failed to create v11 allowance owner index"); + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_moria_v11_allowance_script ON moria_v11_allowance_utxo(locking_bytecode)", + ) + .execute(pool) + .await + .expect("failed to create v11 allowance script index"); + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_moria_v11_allowance_blockhash ON moria_v11_allowance_utxo(blockhash)", + ) + .execute(pool) + .await + .expect("failed to create v11 allowance blockhash index"); + + // Existing DBs created before constructor fee lived on the live UTXO row. + let has_fee: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM pragma_table_info('moria_v11_allowance_utxo') WHERE name = 'fee_sats'", + ) + .fetch_one(pool) + .await + .expect("failed to inspect moria_v11_allowance_utxo columns"); + if has_fee.0 == 0 { + sqlx::query("ALTER TABLE moria_v11_allowance_utxo ADD COLUMN fee_sats INTEGER") + .execute(pool) + .await + .expect("failed to add fee_sats to moria_v11_allowance_utxo"); + } + + // ALTER leaves existing rows fee_sats=NULL. Copy from a sibling at the + // same vault script when one already has the constructor fields. + sqlx::query( + "UPDATE moria_v11_allowance_utxo + SET fee_sats = COALESCE(fee_sats, ( + SELECT s.fee_sats FROM moria_v11_allowance_utxo s + WHERE s.locking_bytecode = moria_v11_allowance_utxo.locking_bytecode + AND s.fee_sats IS NOT NULL + LIMIT 1)), + policy_delta = COALESCE(policy_delta, ( + SELECT s.policy_delta FROM moria_v11_allowance_utxo s + WHERE s.locking_bytecode = moria_v11_allowance_utxo.locking_bytecode + AND s.policy_delta IS NOT NULL + LIMIT 1)), + policy_max_bp = COALESCE(policy_max_bp, ( + SELECT s.policy_max_bp FROM moria_v11_allowance_utxo s + WHERE s.locking_bytecode = moria_v11_allowance_utxo.locking_bytecode + AND s.policy_max_bp IS NOT NULL + LIMIT 1)), + policy_rescue_lead_bp = COALESCE(policy_rescue_lead_bp, ( + SELECT s.policy_rescue_lead_bp FROM moria_v11_allowance_utxo s + WHERE s.locking_bytecode = moria_v11_allowance_utxo.locking_bytecode + AND s.policy_rescue_lead_bp IS NOT NULL + LIMIT 1)), + policy_up_leg_seconds = COALESCE(policy_up_leg_seconds, ( + SELECT s.policy_up_leg_seconds FROM moria_v11_allowance_utxo s + WHERE s.locking_bytecode = moria_v11_allowance_utxo.locking_bytecode + AND s.policy_up_leg_seconds IS NOT NULL + LIMIT 1)), + policy_down_leg_seconds = COALESCE(policy_down_leg_seconds, ( + SELECT s.policy_down_leg_seconds FROM moria_v11_allowance_utxo s + WHERE s.locking_bytecode = moria_v11_allowance_utxo.locking_bytecode + AND s.policy_down_leg_seconds IS NOT NULL + LIMIT 1)) + WHERE fee_sats IS NULL OR policy_delta IS NULL", + ) + .execute(pool) + .await + .expect("failed to backfill allowance policy/fee from sibling scripts"); + sqlx::query( + "CREATE TABLE IF NOT EXISTS moria_v11_bp_threshold ( + txid BLOB NOT NULL, + blockhash BLOB NOT NULL, + threshold_bp INTEGER NOT NULL, + oracle_timestamp INTEGER NOT NULL, + PRIMARY KEY (txid) + )", + ) + .execute(pool) + .await + .expect("failed to create moria_v11_bp_threshold table"); + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_moria_v11_bp_blockhash ON moria_v11_bp_threshold(blockhash)", + ) + .execute(pool) + .await + .expect("failed to create v11 bp blockhash index"); + + staking::prepare_tables(pool).await; +} diff --git a/src/db/moria_v11/query.rs b/src/db/moria_v11/query.rs new file mode 100644 index 0000000..f88d10d --- /dev/null +++ b/src/db/moria_v11/query.rs @@ -0,0 +1,308 @@ +// 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 + +//! Read path: history, live UTXOs, crankable feed, stats. + +use anyhow::Result; +use bitcoincash::{BlockHash, Txid}; +use riftenlabs_defi::moria_v1_1::{DEFAULT_DOWN_LEG_SECONDS, DEFAULT_UP_LEG_SECONDS}; +use sqlx::{Row, SqlitePool}; + +use crate::db::blob::blob_to_display_hex; + +use super::{ + MoriaV11AllowanceUtxo, MoriaV11BpThreshold, MoriaV11Crankable, MoriaV11Entry, MoriaV11LoanUtxo, +}; + +const ACTION_SELECT: &str = "SELECT txid, blockhash, action_type, borrower_hash, delegate_hash, + principal, interest_rate, loan_timestamp, + collateral_sats, tokens_amount, mtp_timestamp, + new_principal, new_interest_rate, new_loan_timestamp, new_collateral_sats, + allowance_token_amount, allowance_sats, fee_take_sats, + recovered_token_amount, recovered_sats, loan_output_index + FROM moria_v11_action"; + +/// Get full loan history for a borrower_hash. +pub async fn get_loan_history( + pool: &SqlitePool, + borrower_hash: &[u8], +) -> Result> { + let sql = format!( + "{ACTION_SELECT} + WHERE borrower_hash = ? + ORDER BY mtp_timestamp ASC, id ASC" + ); + let rows = sqlx::query(&sql) + .bind(borrower_hash) + .fetch_all(pool) + .await?; + let mut entries = Vec::new(); + for row in rows { + entries.push(MoriaV11Entry::from_row(&row)?); + } + Ok(entries) +} + +/// Active loans = live rows in the loan UTXO set. +pub async fn get_active_loans(pool: &SqlitePool) -> Result> { + let rows = sqlx::query( + "SELECT txid, vout, borrower_hash, delegate_hash, principal, interest_rate, + loan_timestamp, collateral_sats, blockhash + FROM moria_v11_loan_utxo + ORDER BY loan_timestamp DESC", + ) + .fetch_all(pool) + .await?; + let mut entries = Vec::new(); + for row in rows { + entries.push(MoriaV11LoanUtxo::from_row(&row)?); + } + Ok(entries) +} + +/// Global history with pagination and optional nfth filter (borrower hashes). +pub async fn get_global_history( + pool: &SqlitePool, + nfth_filter: &[Vec], + offset: i64, + limit: i64, +) -> Result> { + let rows = if nfth_filter.is_empty() { + let sql = format!( + "{ACTION_SELECT} + ORDER BY mtp_timestamp DESC, id DESC + LIMIT ? OFFSET ?" + ); + sqlx::query(&sql) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await? + } else { + let placeholders: Vec<&str> = nfth_filter.iter().map(|_| "?").collect(); + let sql = format!( + "{ACTION_SELECT} + WHERE borrower_hash IN ({}) + ORDER BY mtp_timestamp DESC, id DESC + LIMIT ? OFFSET ?", + placeholders.join(",") + ); + let mut query = sqlx::query(&sql); + for nfth in nfth_filter { + query = query.bind(nfth); + } + query = query.bind(limit).bind(offset); + query.fetch_all(pool).await? + }; + + let mut entries = Vec::new(); + for row in rows { + entries.push(MoriaV11Entry::from_row(&row)?); + } + Ok(entries) +} + +/// Live allowance UTXOs for an owner NFT hash (includes dead-loan salvage). +pub async fn get_allowances_by_owner( + pool: &SqlitePool, + owner_hash: &[u8], +) -> Result> { + let rows = sqlx::query( + "SELECT txid, vout, owner_hash, delegate_hash, locking_bytecode, + token_amount, sats, blockhash, + policy_delta, policy_max_bp, policy_rescue_lead_bp, + policy_up_leg_seconds, policy_down_leg_seconds, fee_sats + FROM moria_v11_allowance_utxo + WHERE owner_hash = ? + ORDER BY token_amount DESC", + ) + .bind(owner_hash) + .fetch_all(pool) + .await?; + let mut entries = Vec::new(); + for row in rows { + entries.push(MoriaV11AllowanceUtxo::from_row(&row)?); + } + Ok(entries) +} + +/// Latest BP oracle threshold row, if any. +pub async fn get_latest_bp_threshold(pool: &SqlitePool) -> Result> { + let row = sqlx::query( + "SELECT txid, blockhash, threshold_bp, oracle_timestamp + FROM moria_v11_bp_threshold + ORDER BY oracle_timestamp DESC, rowid DESC + LIMIT 1", + ) + .fetch_optional(pool) + .await?; + match row { + Some(row) => { + let txid_blob: Vec = row.get("txid"); + let blockhash_blob: Vec = row.get("blockhash"); + Ok(Some(MoriaV11BpThreshold { + txid: blob_to_display_hex::(&txid_blob)?, + blockhash: blob_to_display_hex::(&blockhash_blob)?, + threshold_bp: row.get("threshold_bp"), + oracle_timestamp: row.get("oracle_timestamp"), + })) + } + None => Ok(None), + } +} + +/// Whether a loan's current rate sits inside the retarget refusal band +/// `(threshold + rescue_lead, threshold + 2·delta]`. Loans at or below +/// `threshold + rescue_lead` are up-leg crankable. +pub fn retarget_in_band(rate: i64, threshold: i64, delta: i64, rescue_lead: i64) -> bool { + rate > threshold + rescue_lead && rate <= threshold + 2 * delta +} + +fn crank_leg(rate: i64, threshold: i64, rescue_lead: i64) -> &'static str { + if rate <= threshold + rescue_lead { + "up" + } else { + "down" + } +} + +/// Loans that are currently legal + funded to crank, evaluated at `now_ts` +/// (delphi-clock seconds; callers typically pass tip MTP or oracle time). +/// +/// Cadence and tracking knobs live on the parked allowance's CrankPolicy, +/// not on the loan NFT. +/// +/// - funded allowance for (owner, delegate) with `token_amount > 1` +/// - policy: `policy_delta > 0` and `policy_max_bp > 0` (revealed on first spend +/// or stored when known; otherwise default cadence 3600/86400 and skip if +/// delta/max unknown) +/// - band: crankable iff NOT in `(t + rescue_lead, t+2δ]` +/// - target `t+δ` must be `<= policy_max_bp` +/// - cadence: up-leg / down-leg seconds from the allowance policy +pub async fn get_crankable(pool: &SqlitePool, now_ts: i64) -> Result> { + let Some(bp) = get_latest_bp_threshold(pool).await? else { + return Ok(Vec::new()); + }; + let threshold = bp.threshold_bp; + + let loans = sqlx::query( + "SELECT txid, vout, borrower_hash, delegate_hash, principal, interest_rate, + loan_timestamp, collateral_sats + FROM moria_v11_loan_utxo", + ) + .fetch_all(pool) + .await?; + + let mut out = Vec::new(); + for row in loans { + let rate: i64 = row.get("interest_rate"); + let loan_ts: i64 = row.get("loan_timestamp"); + let owner: Vec = row.get("borrower_hash"); + let delegate: Vec = row.get("delegate_hash"); + + let allowance = sqlx::query( + "SELECT txid, vout, token_amount, sats, + policy_delta, policy_max_bp, policy_rescue_lead_bp, + policy_up_leg_seconds, policy_down_leg_seconds + FROM moria_v11_allowance_utxo + WHERE owner_hash = ? AND delegate_hash = ? AND token_amount > 1 + ORDER BY token_amount DESC + LIMIT 1", + ) + .bind(&owner) + .bind(&delegate) + .fetch_optional(pool) + .await?; + + let Some(allowance) = allowance else { + continue; + }; + + let delta: Option = allowance.get("policy_delta"); + let max_bp: Option = allowance.get("policy_max_bp"); + let (Some(delta), Some(max_bp)) = (delta, max_bp) else { + continue; + }; + if delta <= 0 || max_bp <= 0 { + continue; + } + let rescue_lead: i64 = allowance + .get::, _>("policy_rescue_lead_bp") + .unwrap_or(0); + let up_leg: i64 = allowance + .get::, _>("policy_up_leg_seconds") + .unwrap_or(i64::from(DEFAULT_UP_LEG_SECONDS)); + let down_leg: i64 = allowance + .get::, _>("policy_down_leg_seconds") + .unwrap_or(i64::from(DEFAULT_DOWN_LEG_SECONDS)); + + let target = threshold + delta; + if target > max_bp { + continue; + } + if retarget_in_band(rate, threshold, delta, rescue_lead) { + continue; + } + let leg = crank_leg(rate, threshold, rescue_lead); + let needed = if leg == "up" { up_leg } else { down_leg }; + if now_ts.saturating_sub(loan_ts) < needed { + continue; + } + + let txid_blob: Vec = row.get("txid"); + let a_txid_blob: Vec = allowance.get("txid"); + + out.push(MoriaV11Crankable { + txid: blob_to_display_hex::(&txid_blob)?, + vout: row.get("vout"), + borrower_hash: hex::encode(&owner), + delegate_hash: hex::encode(&delegate), + principal: row.get("principal"), + interest_rate: rate, + loan_timestamp: loan_ts, + tracking_delta: delta, + tracking_max_bp: max_bp, + collateral_sats: row.get("collateral_sats"), + threshold_bp: threshold, + target_bp: target, + leg, + allowance_txid: blob_to_display_hex::(&a_txid_blob)?, + allowance_vout: allowance.get("vout"), + allowance_token_amount: allowance.get("token_amount"), + allowance_sats: allowance.get("sats"), + }); + } + Ok(out) +} + +pub async fn get_stats(pool: &SqlitePool) -> Result { + let total_borrows: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM moria_v11_action WHERE action_type = 0") + .fetch_one(pool) + .await?; + + let active_loans: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM moria_v11_loan_utxo") + .fetch_one(pool) + .await?; + + let total_actions: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM moria_v11_action") + .fetch_one(pool) + .await?; + + let active_allowances: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM moria_v11_allowance_utxo") + .fetch_one(pool) + .await?; + + let latest_bp = get_latest_bp_threshold(pool).await?; + + Ok(serde_json::json!({ + "total_borrows": total_borrows.0, + "active_loans": active_loans.0, + "total_actions": total_actions.0, + "active_allowances": active_allowances.0, + "latest_threshold_bp": latest_bp.as_ref().map(|b| b.threshold_bp), + "latest_oracle_timestamp": latest_bp.as_ref().map(|b| b.oracle_timestamp), + })) +} diff --git a/src/db/moria_v11/staking.rs b/src/db/moria_v11/staking.rs new file mode 100644 index 0000000..b53f8c7 --- /dev/null +++ b/src/db/moria_v11/staking.rs @@ -0,0 +1,999 @@ +// 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 + +//! Bar (single-asset MUSD staking) and liquidation-pool state history, and +//! the realized NAV-growth APR derived from it. +//! +//! One row is appended per confirmed tx that **recreates** the respective +//! state NFT (category match + `Capability::Mutable`, mirroring the covenant +//! invariant that the state NFT is minted once at genesis and never changes +//! capability). Decoding itself lives in `riftenlabs_defi::moria_v1_1`; +//! this module only times, stores, and re-derives a rate from those samples. +//! +//! ## APR semantics +//! +//! `apr_nav_bp` is realized **NAV growth for a continuing holder**, GROSS of +//! any exit fee a leaver would pay — never call it a redemption/exit yield. +//! For the bar: `rate = musd_balance / xmusd_outstanding`. For the pool: +//! `rate = xmusd_balance / shares_outstanding`, in xMUSD terms **only** — the +//! BCH pot is deliberately excluded (pricing it needs a point-in-time oracle +//! print the history table doesn't carry), so the pool figure understates +//! the pool's true total return. Every consumer must say so. +//! +//! The time axis is indexed block MTP, not wall-clock time: the APR window's +//! "now" is the most recent history row's `mtp`, so the whole computation is +//! a pure function of stored samples (see [`nav_apr_window`]). + +use anyhow::Result; +use bitcoincash::blockdata::token::Capability; +use bitcoincash::{BlockHash, TokenID, Transaction, Txid}; +use riftenlabs_defi::moria_v1_1::{ + parse_bar_state_v11, parse_pool_state_v11, BarStateV11, MoriaV11TokenIds, PoolStateV11, +}; +use sqlx::{Row, SqlitePool}; + +use crate::db::blob::{blob_to_display_hex, ToBlob}; + +/// Default `/moria/v11/staking/apr` window: 7 days. +pub const DEFAULT_APR_WINDOW_SECONDS: i64 = 7 * 86_400; + +/// Annualization base (simple 365-day convention, no leap-year adjustment — +/// this is a reporting statistic, not a covenant amount). +const SECONDS_PER_YEAR: i64 = 365 * 86_400; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct BarHistoryEntry { + pub txid: String, + pub blockhash: String, + pub height: i64, + pub mtp: i64, + pub musd_balance: i64, + pub exit_fee_liability: i64, + pub last_decay_time: i64, + pub xmusd_outstanding: i64, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct PoolHistoryEntry { + pub txid: String, + pub blockhash: String, + pub height: i64, + pub mtp: i64, + pub xmusd_balance: i64, + pub exit_fee_liability: i64, + pub kick_time: i64, + pub shares_outstanding: i64, + pub bch_pot_sats: i64, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct RateFraction { + pub numerator: String, + pub denominator: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct BarAprWindow { + pub apr_nav_bp: i64, + pub from_mtp: i64, + pub to_mtp: i64, + pub elapsed_seconds: i64, + pub from_rate: RateFraction, + pub to_rate: RateFraction, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct PoolAprWindow { + pub apr_nav_bp: i64, + pub from_mtp: i64, + pub to_mtp: i64, + pub elapsed_seconds: i64, + pub from_rate: RateFraction, + pub to_rate: RateFraction, + /// Always `true`: this figure is xMUSD-terms NAV growth only and + /// excludes the pool's BCH pot (see module docs) — never present it as + /// the pool's total return. + pub excludes_bch_pot: bool, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct StakingAprResponse { + pub bar: Option, + pub pool: Option, +} + +impl BarHistoryEntry { + fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result { + let txid_blob: Vec = row.get("txid"); + let blockhash_blob: Vec = row.get("blockhash"); + Ok(Self { + txid: blob_to_display_hex::(&txid_blob)?, + blockhash: blob_to_display_hex::(&blockhash_blob)?, + height: row.get("height"), + mtp: row.get("mtp"), + musd_balance: row.get("musd_balance"), + exit_fee_liability: row.get("exit_fee_liability"), + last_decay_time: row.get("last_decay_time"), + xmusd_outstanding: row.get("xmusd_outstanding"), + }) + } +} + +impl PoolHistoryEntry { + fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result { + let txid_blob: Vec = row.get("txid"); + let blockhash_blob: Vec = row.get("blockhash"); + Ok(Self { + txid: blob_to_display_hex::(&txid_blob)?, + blockhash: blob_to_display_hex::(&blockhash_blob)?, + height: row.get("height"), + mtp: row.get("mtp"), + xmusd_balance: row.get("xmusd_balance"), + exit_fee_liability: row.get("exit_fee_liability"), + kick_time: row.get("kick_time"), + shares_outstanding: row.get("shares_outstanding"), + bch_pot_sats: row.get("bch_pot_sats"), + }) + } +} + +pub async fn prepare_tables(pool: &SqlitePool) { + sqlx::query( + "CREATE TABLE IF NOT EXISTS moria_v11_bar_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + txid BLOB NOT NULL, + blockhash BLOB NOT NULL, + height BIGINT NOT NULL, + mtp BIGINT NOT NULL, + musd_balance BIGINT NOT NULL, + exit_fee_liability BIGINT NOT NULL, + last_decay_time BIGINT NOT NULL, + xmusd_outstanding BIGINT NOT NULL + )", + ) + .execute(pool) + .await + .expect("failed to create moria_v11_bar_history table"); + + sqlx::query( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_moria_v11_bar_history_txid + ON moria_v11_bar_history(txid)", + ) + .execute(pool) + .await + .expect("failed to create v11 bar history txid index"); + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_moria_v11_bar_history_mtp ON moria_v11_bar_history(mtp)", + ) + .execute(pool) + .await + .expect("failed to create v11 bar history mtp index"); + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_moria_v11_bar_history_blockhash + ON moria_v11_bar_history(blockhash)", + ) + .execute(pool) + .await + .expect("failed to create v11 bar history blockhash index"); + + sqlx::query( + "CREATE TABLE IF NOT EXISTS moria_v11_pool_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + txid BLOB NOT NULL, + blockhash BLOB NOT NULL, + height BIGINT NOT NULL, + mtp BIGINT NOT NULL, + xmusd_balance BIGINT NOT NULL, + exit_fee_liability BIGINT NOT NULL, + kick_time BIGINT NOT NULL, + shares_outstanding BIGINT NOT NULL, + bch_pot_sats BIGINT NOT NULL + )", + ) + .execute(pool) + .await + .expect("failed to create moria_v11_pool_history table"); + + sqlx::query( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_moria_v11_pool_history_txid + ON moria_v11_pool_history(txid)", + ) + .execute(pool) + .await + .expect("failed to create v11 pool history txid index"); + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_moria_v11_pool_history_mtp ON moria_v11_pool_history(mtp)", + ) + .execute(pool) + .await + .expect("failed to create v11 pool history mtp index"); + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_moria_v11_pool_history_blockhash + ON moria_v11_pool_history(blockhash)", + ) + .execute(pool) + .await + .expect("failed to create v11 pool history blockhash index"); +} + +/// Find the (at most one) output recreating `category`'s mutable state NFT +/// in `tx`, returning its raw commitment, token amount, and sats. Matches +/// `Capability::Mutable` specifically: the state NFT is minted once at +/// genesis with that capability and never changes it (see module docs). +fn find_state_output<'a>(tx: &'a Transaction, category: &TokenID) -> Option<(&'a [u8], u64, u64)> { + tx.output.iter().find_map(|out| { + let tok = out.token.as_ref()?; + if &tok.id != category || !tok.has_nft() || tok.capability() != Capability::Mutable as u8 { + return None; + } + // TokenAmount's invariant is 0..=i64::MAX -- always non-negative. + Some(( + tok.commitment.as_slice(), + tok.amount.to_int() as u64, + out.value.to_sat(), + )) + }) +} + +async fn insert_bar_history( + pool: &SqlitePool, + txid: &Txid, + blockhash: &BlockHash, + height: i64, + mtp: i64, + state: &BarStateV11, +) -> Result<()> { + sqlx::query( + "INSERT OR REPLACE INTO moria_v11_bar_history + (txid, blockhash, height, mtp, musd_balance, exit_fee_liability, + last_decay_time, xmusd_outstanding) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(txid.to_blob()) + .bind(blockhash.to_blob()) + .bind(height) + .bind(mtp) + .bind(state.musd_balance as i64) + .bind(state.exit_fee_liability as i64) + .bind(state.last_decay_time as i64) + .bind(state.xmusd_outstanding as i64) + .execute(pool) + .await + .map_err(|e| anyhow::anyhow!("failed to insert bar history row: {e}"))?; + Ok(()) +} + +async fn insert_pool_history( + pool: &SqlitePool, + txid: &Txid, + blockhash: &BlockHash, + height: i64, + mtp: i64, + state: &PoolStateV11, +) -> Result<()> { + sqlx::query( + "INSERT OR REPLACE INTO moria_v11_pool_history + (txid, blockhash, height, mtp, xmusd_balance, exit_fee_liability, + kick_time, shares_outstanding, bch_pot_sats) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(txid.to_blob()) + .bind(blockhash.to_blob()) + .bind(height) + .bind(mtp) + .bind(state.xmusd_balance as i64) + .bind(state.exit_fee_liability as i64) + .bind(state.kick_time as i64) + .bind(state.shares_outstanding as i64) + .bind(state.bch_pot_sats as i64) + .execute(pool) + .await + .map_err(|e| anyhow::anyhow!("failed to insert pool history row: {e}"))?; + Ok(()) +} + +/// Index bar/pool state-NFT recreations from a confirmed block's txs. +/// +/// Returns the number of history rows inserted (bar + pool combined). Not +/// wired into mempool indexing: history is deliberately confirmed-block-only +/// (`mtp` only has meaning for a mined block). +pub async fn index_bar_pool_history( + pool: &SqlitePool, + txs: &[Transaction], + blockhash: &BlockHash, + height: i64, + mtp: i64, + token_ids: &MoriaV11TokenIds, +) -> Result { + let mut count = 0; + for tx in txs { + if let Some((commitment, amount, _sats)) = find_state_output(tx, &token_ids.bar) { + if let Some(state) = parse_bar_state_v11(commitment, amount) { + let txid = tx.compute_txid(); + insert_bar_history(pool, &txid, blockhash, height, mtp, &state).await?; + count += 1; + } + } + if let Some((commitment, amount, sats)) = find_state_output(tx, &token_ids.pool) { + if let Some(state) = parse_pool_state_v11(commitment, amount, sats) { + let txid = tx.compute_txid(); + insert_pool_history(pool, &txid, blockhash, height, mtp, &state).await?; + count += 1; + } + } + } + Ok(count) +} + +/// Reorg delete: removes every bar/pool history row recorded in `blockhash`. +/// Returns the total number of rows removed (bar + pool). +pub async fn delete_entries_for_block(pool: &SqlitePool, blockhash: &BlockHash) -> Result { + let r1 = sqlx::query("DELETE FROM moria_v11_bar_history WHERE blockhash = ?") + .bind(blockhash.to_blob()) + .execute(pool) + .await + .map_err(|e| anyhow::anyhow!("failed to delete bar history for block: {e}"))?; + let r2 = sqlx::query("DELETE FROM moria_v11_pool_history WHERE blockhash = ?") + .bind(blockhash.to_blob()) + .execute(pool) + .await + .map_err(|e| anyhow::anyhow!("failed to delete pool history for block: {e}"))?; + Ok(r1.rows_affected() as usize + r2.rows_affected() as usize) +} + +const BAR_HISTORY_SELECT: &str = "SELECT txid, blockhash, height, mtp, musd_balance, + exit_fee_liability, last_decay_time, xmusd_outstanding FROM moria_v11_bar_history"; + +/// Bar state history, newest first. `since` (when given) restricts to rows +/// with `mtp > since` (indexed block MTP seconds). +pub async fn get_bar_history( + pool: &SqlitePool, + since: Option, + limit: i64, +) -> Result> { + let rows = if let Some(since) = since { + sqlx::query(&format!( + "{BAR_HISTORY_SELECT} WHERE mtp > ? ORDER BY mtp DESC, id DESC LIMIT ?" + )) + .bind(since) + .bind(limit) + .fetch_all(pool) + .await? + } else { + sqlx::query(&format!( + "{BAR_HISTORY_SELECT} ORDER BY mtp DESC, id DESC LIMIT ?" + )) + .bind(limit) + .fetch_all(pool) + .await? + }; + rows.iter().map(BarHistoryEntry::from_row).collect() +} + +const POOL_HISTORY_SELECT: &str = "SELECT txid, blockhash, height, mtp, xmusd_balance, + exit_fee_liability, kick_time, shares_outstanding, bch_pot_sats FROM moria_v11_pool_history"; + +/// Pool state history, newest first. `since` (when given) restricts to rows +/// with `mtp > since` (indexed block MTP seconds). +pub async fn get_pool_history( + pool: &SqlitePool, + since: Option, + limit: i64, +) -> Result> { + let rows = if let Some(since) = since { + sqlx::query(&format!( + "{POOL_HISTORY_SELECT} WHERE mtp > ? ORDER BY mtp DESC, id DESC LIMIT ?" + )) + .bind(since) + .bind(limit) + .fetch_all(pool) + .await? + } else { + sqlx::query(&format!( + "{POOL_HISTORY_SELECT} ORDER BY mtp DESC, id DESC LIMIT ?" + )) + .bind(limit) + .fetch_all(pool) + .await? + }; + rows.iter().map(PoolHistoryEntry::from_row).collect() +} + +async fn latest_bar_rate(pool: &SqlitePool) -> Result> { + let row: Option<(i64, i64, i64)> = sqlx::query_as( + "SELECT mtp, musd_balance, xmusd_outstanding FROM moria_v11_bar_history + ORDER BY mtp DESC, id DESC LIMIT 1", + ) + .fetch_optional(pool) + .await?; + Ok(row.map(|(mtp, balance, shares)| (mtp, balance as u64, shares as u64))) +} + +async fn earliest_bar_rate_since( + pool: &SqlitePool, + threshold_mtp: i64, +) -> Result> { + let row: Option<(i64, i64, i64)> = sqlx::query_as( + "SELECT mtp, musd_balance, xmusd_outstanding FROM moria_v11_bar_history + WHERE mtp >= ? ORDER BY mtp ASC, id ASC LIMIT 1", + ) + .bind(threshold_mtp) + .fetch_optional(pool) + .await?; + Ok(row.map(|(mtp, balance, shares)| (mtp, balance as u64, shares as u64))) +} + +async fn latest_pool_rate(pool: &SqlitePool) -> Result> { + let row: Option<(i64, i64, i64)> = sqlx::query_as( + "SELECT mtp, xmusd_balance, shares_outstanding FROM moria_v11_pool_history + ORDER BY mtp DESC, id DESC LIMIT 1", + ) + .fetch_optional(pool) + .await?; + Ok(row.map(|(mtp, balance, shares)| (mtp, balance as u64, shares as u64))) +} + +async fn earliest_pool_rate_since( + pool: &SqlitePool, + threshold_mtp: i64, +) -> Result> { + let row: Option<(i64, i64, i64)> = sqlx::query_as( + "SELECT mtp, xmusd_balance, shares_outstanding FROM moria_v11_pool_history + WHERE mtp >= ? ORDER BY mtp ASC, id ASC LIMIT 1", + ) + .bind(threshold_mtp) + .fetch_optional(pool) + .await?; + Ok(row.map(|(mtp, balance, shares)| (mtp, balance as u64, shares as u64))) +} + +/// One endpoint's realized NAV-growth APR window, generic over bar +/// (MUSD/xMUSD) vs pool (xMUSD/share) rates — same math, only the meaning of +/// "balance"/"shares" differs. Converted into [`BarAprWindow`] / +/// [`PoolAprWindow`] for serialization. +#[derive(Debug, Clone, Copy, PartialEq)] +struct NavAprWindow { + apr_nav_bp: i64, + from_mtp: i64, + to_mtp: i64, + elapsed_seconds: i64, + from_numerator: u64, + from_denominator: u64, + to_numerator: u64, + to_denominator: u64, +} + +/// Realized NAV-growth APR (GROSS of any exit fee) between two +/// `(mtp, balance, shares)` samples, per the pinned formula: +/// +/// ```text +/// rate = balance / shares +/// apr_nav_bp = (rate_end/rate_start - 1) * (SECONDS_PER_YEAR / elapsed) * 10000 +/// ``` +/// +/// Returns `None` when: +/// - either endpoint has zero shares (rate undefined), or the start balance +/// is zero (growth from a zero base is undefined, not "infinite"); +/// - the samples don't span a positive duration, or span less than half of +/// `window_seconds` (too little history to trust the annualization). +fn nav_apr_window( + from_mtp: i64, + from_balance: u64, + from_shares: u64, + to_mtp: i64, + to_balance: u64, + to_shares: u64, + window_seconds: i64, +) -> Option { + if from_shares == 0 || to_shares == 0 || from_balance == 0 { + return None; + } + let elapsed = to_mtp - from_mtp; + if elapsed <= 0 || elapsed * 2 < window_seconds { + return None; + } + + // Exact integer cross-multiplication for the growth ratio: balances are + // < 2^63 and share counts <= 2^52 (GENESIS_RESERVE_2POW52), so these + // i128 products (up to ~2^115) stay comfortably in range. The final + // annualize-and-scale step uses f64 -- this is a reporting statistic (an + // annualized bp figure), and f64's 53-bit mantissa carries far more + // relative precision (~2^-53) than a rounded basis-point result needs, + // even for near-u64-max balances. + let to_balance_i = to_balance as i128; + let to_shares_i = to_shares as i128; + let from_balance_i = from_balance as i128; + let from_shares_i = from_shares as i128; + + let growth_num = to_balance_i * from_shares_i - from_balance_i * to_shares_i; + let growth_den = from_balance_i * to_shares_i; + + let apr = (growth_num as f64 / growth_den as f64) + * (SECONDS_PER_YEAR as f64 / elapsed as f64) + * 10_000.0; + + Some(NavAprWindow { + apr_nav_bp: apr.round() as i64, + from_mtp, + to_mtp, + elapsed_seconds: elapsed, + from_numerator: from_balance, + from_denominator: from_shares, + to_numerator: to_balance, + to_denominator: to_shares, + }) +} + +impl From for BarAprWindow { + fn from(w: NavAprWindow) -> Self { + Self { + apr_nav_bp: w.apr_nav_bp, + from_mtp: w.from_mtp, + to_mtp: w.to_mtp, + elapsed_seconds: w.elapsed_seconds, + from_rate: RateFraction { + numerator: w.from_numerator.to_string(), + denominator: w.from_denominator.to_string(), + }, + to_rate: RateFraction { + numerator: w.to_numerator.to_string(), + denominator: w.to_denominator.to_string(), + }, + } + } +} + +impl From for PoolAprWindow { + fn from(w: NavAprWindow) -> Self { + Self { + apr_nav_bp: w.apr_nav_bp, + from_mtp: w.from_mtp, + to_mtp: w.to_mtp, + elapsed_seconds: w.elapsed_seconds, + from_rate: RateFraction { + numerator: w.from_numerator.to_string(), + denominator: w.from_denominator.to_string(), + }, + to_rate: RateFraction { + numerator: w.to_numerator.to_string(), + denominator: w.to_denominator.to_string(), + }, + excludes_bch_pot: true, + } + } +} + +/// Realized NAV-growth APR for the bar over the trailing `window_seconds`, +/// anchored at the newest indexed row's `mtp` (not wall-clock time). `None` +/// when there's no history yet, or [`nav_apr_window`] rejects the window. +pub async fn get_bar_apr(pool: &SqlitePool, window_seconds: i64) -> Result> { + let Some((to_mtp, to_balance, to_shares)) = latest_bar_rate(pool).await? else { + return Ok(None); + }; + let Some((from_mtp, from_balance, from_shares)) = + earliest_bar_rate_since(pool, to_mtp - window_seconds).await? + else { + return Ok(None); + }; + Ok(nav_apr_window( + from_mtp, + from_balance, + from_shares, + to_mtp, + to_balance, + to_shares, + window_seconds, + ) + .map(BarAprWindow::from)) +} + +/// Realized NAV-growth APR for the pool (xMUSD terms only, excludes the BCH +/// pot) over the trailing `window_seconds`. Same anchoring/null rules as +/// [`get_bar_apr`]. +pub async fn get_pool_apr(pool: &SqlitePool, window_seconds: i64) -> Result> { + let Some((to_mtp, to_balance, to_shares)) = latest_pool_rate(pool).await? else { + return Ok(None); + }; + let Some((from_mtp, from_balance, from_shares)) = + earliest_pool_rate_since(pool, to_mtp - window_seconds).await? + else { + return Ok(None); + }; + Ok(nav_apr_window( + from_mtp, + from_balance, + from_shares, + to_mtp, + to_balance, + to_shares, + window_seconds, + ) + .map(PoolAprWindow::from)) +} + +/// Combined bar + pool realized NAV-growth APR for `/moria/v11/staking/apr`. +pub async fn get_staking_apr(pool: &SqlitePool, window_seconds: i64) -> Result { + Ok(StakingAprResponse { + bar: get_bar_apr(pool, window_seconds).await?, + pool: get_pool_apr(pool, window_seconds).await?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin_hashes::Hash; + use bitcoincash::absolute::LockTime; + use bitcoincash::blockdata::token::{OutputData, Structure, TokenAmount}; + use bitcoincash::transaction::Version; + use bitcoincash::{Amount, ScriptBuf, Sequence, TxOut}; + + fn tid(b: u8) -> TokenID { + TokenID::from_byte_array([b; 32]) + } + + fn blockhash(n: u8) -> BlockHash { + BlockHash::from_raw_hash(bitcoin_hashes::sha256d::Hash::from_slice(&[n; 32]).unwrap()) + } + + fn token_ids() -> MoriaV11TokenIds { + MoriaV11TokenIds { + moria: tid(0xA1), + bp_oracle: tid(0xB2), + pool: tid(0xC3), + bar: tid(0xD4), + loan_locking_bytecode: Vec::new(), + } + } + + /// A mutable state-NFT output: `HasNFT | Capability::Mutable`, plus + /// `HasAmount` when `amount > 0` (a fully-subscribed state UTXO can + /// legitimately carry zero of its own token amount). + fn state_nft(id: TokenID, commitment: Vec, amount: i64) -> OutputData { + let mut bitfield = Structure::HasNFT as u8 | Capability::Mutable as u8; + if !commitment.is_empty() { + bitfield |= Structure::HasCommitmentLength as u8; + } + if amount > 0 { + bitfield |= Structure::HasAmount as u8; + } + OutputData { + id, + bitfield, + amount: TokenAmount::from_int(amount).expect("non-negative"), + commitment, + } + } + + fn out(sats: u64, token: Option) -> TxOut { + TxOut { + value: Amount::from_sat(sats), + script_pubkey: ScriptBuf::from_bytes(vec![0xaa, 0x20]), + token, + } + } + + fn make_tx(outputs: Vec) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoincash::TxIn { + previous_output: bitcoincash::OutPoint { + txid: Txid::from_byte_array([0u8; 32]), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: bitcoincash::Witness::new(), + }], + output: outputs, + } + } + + fn bar_commitment(balance: u64, liability: u64, decay_time: u64) -> Vec { + let mut buf = vec![0u8; 22]; + buf[0..8].copy_from_slice(&balance.to_le_bytes()); + buf[8..16].copy_from_slice(&liability.to_le_bytes()); + buf[16..22].copy_from_slice(&decay_time.to_le_bytes()[..6]); + buf + } + + fn pool_commitment(balance: u64, liability: u64, kick_time: u64) -> Vec { + let mut buf = vec![0u8; 28]; + buf[0..8].copy_from_slice(&balance.to_le_bytes()); + buf[8..16].copy_from_slice(&liability.to_le_bytes()); + buf[16..22].copy_from_slice(&kick_time.to_le_bytes()[..6]); + buf + } + + async fn test_pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + prepare_tables(&pool).await; + pool + } + + #[rocket::async_test] + async fn indexes_bar_and_pool_recreations_from_a_block() { + let pool = test_pool().await; + let ids = token_ids(); + let bh = blockhash(1); + + let bar_tx = make_tx(vec![out( + 1_000, + Some(state_nft( + ids.bar, + bar_commitment(5_000_000, 0, 1_700_000_000), + 0, + )), + )]); + let pool_tx = make_tx(vec![out( + 1_500, + Some(state_nft( + ids.pool, + pool_commitment(2_000_000, 0, 1_700_000_000), + 0, + )), + )]); + + let inserted = index_bar_pool_history( + &pool, + &[bar_tx.clone(), pool_tx.clone()], + &bh, + 10, + 1_700_000_000, + &ids, + ) + .await + .unwrap(); + assert_eq!(inserted, 2); + + let bar_rows = get_bar_history(&pool, None, 10).await.unwrap(); + assert_eq!(bar_rows.len(), 1); + assert_eq!(bar_rows[0].musd_balance, 5_000_000); + assert_eq!(bar_rows[0].txid, bar_tx.compute_txid().to_string()); + assert_eq!(bar_rows[0].height, 10); + + let pool_rows = get_pool_history(&pool, None, 10).await.unwrap(); + assert_eq!(pool_rows.len(), 1); + assert_eq!(pool_rows[0].xmusd_balance, 2_000_000); + // BCH pot = 1_500 - POOL_BASE_SATS(1000) = 500. + assert_eq!(pool_rows[0].bch_pot_sats, 500); + } + + #[rocket::async_test] + async fn ignores_outputs_of_the_wrong_category_or_capability() { + let pool = test_pool().await; + let ids = token_ids(); + let bh = blockhash(2); + + // Wrong category, and an immutable (non-state) NFT of the real + // category: neither should produce a history row. + let noise_tx = make_tx(vec![ + out( + 1_000, + Some(state_nft(tid(0xFF), bar_commitment(1, 0, 0), 0)), + ), + out( + 1_000, + Some(OutputData { + id: ids.bar, + bitfield: Structure::HasNFT as u8, // Capability::None + amount: TokenAmount::ZERO, + commitment: bar_commitment(1, 0, 0), + }), + ), + ]); + + let inserted = index_bar_pool_history(&pool, &[noise_tx], &bh, 1, 1_000, &ids) + .await + .unwrap(); + assert_eq!(inserted, 0); + assert!(get_bar_history(&pool, None, 10).await.unwrap().is_empty()); + } + + #[rocket::async_test] + async fn reorg_rollback_removes_history_rows() { + let pool = test_pool().await; + let ids = token_ids(); + let bh = blockhash(3); + + let bar_tx = make_tx(vec![out( + 1_000, + Some(state_nft(ids.bar, bar_commitment(1_000_000, 0, 1), 0)), + )]); + index_bar_pool_history(&pool, &[bar_tx], &bh, 1, 1_000, &ids) + .await + .unwrap(); + assert_eq!(get_bar_history(&pool, None, 10).await.unwrap().len(), 1); + + let deleted = delete_entries_for_block(&pool, &bh).await.unwrap(); + assert_eq!(deleted, 1); + assert!(get_bar_history(&pool, None, 10).await.unwrap().is_empty()); + } + + /// Two bar states a day (86400s) apart: balance 1,000,000 -> 1,000,100 at + /// constant 1,000,000 shares. rate 1.0 -> 1.0001; with a 1-day window the + /// annualization factor is exactly SECONDS_PER_YEAR/86400 = 365, so + /// apr_nav_bp = 0.0001 * 365 * 10000 = 365 exactly (bp) -- hand-computed, + /// no rounding ambiguity. + #[rocket::async_test] + async fn bar_apr_matches_hand_computed_value_a_day_apart() { + let pool = test_pool().await; + let ids = token_ids(); + let bh = blockhash(4); + + // state_token_amount = RESERVE - 1_000_000 -> xmusd_outstanding = 1_000_000 + // (constant across both rows: fee accrual moves the balance, not shares). + let shares_1m = riftenlabs_defi::moria_v1_1::BAR_SHARE_RESERVE - 1_000_000; + let day0 = make_tx(vec![out( + 1_000, + Some(state_nft( + ids.bar, + bar_commitment(1_000_000, 0, 0), + shares_1m as i64, + )), + )]); + let day1 = make_tx(vec![out( + 1_000, + Some(state_nft( + ids.bar, + bar_commitment(1_000_100, 0, 86_400), + shares_1m as i64, + )), + )]); + index_bar_pool_history(&pool, &[day0], &bh, 1, 0, &ids) + .await + .unwrap(); + index_bar_pool_history(&pool, &[day1], &bh, 2, 86_400, &ids) + .await + .unwrap(); + + let apr = get_bar_apr(&pool, 86_400).await.unwrap().expect("computed"); + assert_eq!(apr.apr_nav_bp, 365); + assert_eq!(apr.from_mtp, 0); + assert_eq!(apr.to_mtp, 86_400); + assert_eq!(apr.elapsed_seconds, 86_400); + assert_eq!(apr.from_rate.numerator, "1000000"); + assert_eq!(apr.from_rate.denominator, "1000000"); + assert_eq!(apr.to_rate.numerator, "1000100"); + assert_eq!(apr.to_rate.denominator, "1000000"); + } + + #[rocket::async_test] + async fn bar_apr_is_null_when_the_window_start_has_zero_shares() { + let pool = test_pool().await; + let ids = token_ids(); + let bh = blockhash(5); + + // xmusd_outstanding = 0 at the start of the window (full reserve + // still sitting on the state UTXO -- nobody has deposited yet). + let empty = make_tx(vec![out( + 1_000, + Some(state_nft( + ids.bar, + bar_commitment(0, 0, 0), + riftenlabs_defi::moria_v1_1::BAR_SHARE_RESERVE as i64, + )), + )]); + let seeded = make_tx(vec![out( + 1_000, + Some(state_nft(ids.bar, bar_commitment(1_000_000, 0, 86_400), 0)), + )]); + index_bar_pool_history(&pool, &[empty], &bh, 1, 0, &ids) + .await + .unwrap(); + index_bar_pool_history(&pool, &[seeded], &bh, 2, 86_400, &ids) + .await + .unwrap(); + + assert!(get_bar_apr(&pool, 86_400).await.unwrap().is_none()); + } + + #[rocket::async_test] + async fn bar_apr_is_null_when_history_covers_less_than_half_the_window() { + let pool = test_pool().await; + let ids = token_ids(); + let bh = blockhash(6); + + // Only 1 day of history but a 7-day window requested (7d default) -- + // 1 day is less than half of 7 days, so the annualization is + // rejected as too little coverage rather than extrapolated wildly. + let day0 = make_tx(vec![out( + 1_000, + Some(state_nft(ids.bar, bar_commitment(1_000_000, 0, 0), 0)), + )]); + let day1 = make_tx(vec![out( + 1_000, + Some(state_nft(ids.bar, bar_commitment(1_000_100, 0, 86_400), 0)), + )]); + index_bar_pool_history(&pool, &[day0], &bh, 1, 0, &ids) + .await + .unwrap(); + index_bar_pool_history(&pool, &[day1], &bh, 2, 86_400, &ids) + .await + .unwrap(); + + assert!(get_bar_apr(&pool, DEFAULT_APR_WINDOW_SECONDS) + .await + .unwrap() + .is_none()); + } + + #[rocket::async_test] + async fn bar_apr_is_null_with_no_history() { + let pool = test_pool().await; + assert!(get_bar_apr(&pool, DEFAULT_APR_WINDOW_SECONDS) + .await + .unwrap() + .is_none()); + } + + #[rocket::async_test] + async fn pool_apr_excludes_bch_pot_flag_is_always_true() { + let pool = test_pool().await; + let ids = token_ids(); + let bh = blockhash(7); + + let day0 = make_tx(vec![out( + 2_000, + Some(state_nft(ids.pool, pool_commitment(2_000_000, 0, 0), 0)), + )]); + let day1 = make_tx(vec![out( + 2_000, + Some(state_nft( + ids.pool, + pool_commitment(2_000_200, 0, 86_400), + 0, + )), + )]); + index_bar_pool_history(&pool, &[day0], &bh, 1, 0, &ids) + .await + .unwrap(); + index_bar_pool_history(&pool, &[day1], &bh, 2, 86_400, &ids) + .await + .unwrap(); + + let apr = get_pool_apr(&pool, 86_400) + .await + .unwrap() + .expect("computed"); + assert!(apr.excludes_bch_pot); + assert_eq!(apr.apr_nav_bp, 365); + } + + #[rocket::async_test] + async fn history_pagination_orders_newest_first_and_respects_since() { + let pool = test_pool().await; + let ids = token_ids(); + let bh = blockhash(8); + + for (i, mtp) in [0i64, 100, 200].into_iter().enumerate() { + let tx = make_tx(vec![out( + 1_000, + Some(state_nft( + ids.bar, + bar_commitment(1_000_000 + i as u64, 0, mtp as u64), + 0, + )), + )]); + index_bar_pool_history(&pool, &[tx], &bh, i as i64, mtp, &ids) + .await + .unwrap(); + } + + let all = get_bar_history(&pool, None, 10).await.unwrap(); + assert_eq!(all.len(), 3); + assert_eq!(all[0].mtp, 200); // newest first + assert_eq!(all[2].mtp, 0); + + let since_100 = get_bar_history(&pool, Some(100), 10).await.unwrap(); + assert_eq!(since_100.len(), 1); + assert_eq!(since_100[0].mtp, 200); + } +} diff --git a/src/db/moria_v11/tests.rs b/src/db/moria_v11/tests.rs new file mode 100644 index 0000000..49268af --- /dev/null +++ b/src/db/moria_v11/tests.rs @@ -0,0 +1,1088 @@ +// 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 + +use super::*; +use bitcoin_hashes::Hash; +use bitcoincash::absolute::LockTime; +use bitcoincash::blockdata::token::{OutputData, Structure, TokenAmount}; +use bitcoincash::transaction::Version; +use bitcoincash::{ + Amount, BlockHash, OutPoint, ScriptBuf, Sequence, TokenID, Transaction, TxIn, TxOut, Witness, +}; +use riftenlabs_defi::moria_v1_1::{ + crank_allowance_locking_bytecode, crank_hint_to_policy, decode_crank_hint, + encode_loan_commitment_v11, principal_to_base_units, CrankPolicy, LoanCommitmentV11, + MoriaV11TokenIds, ALLOWANCE_METHOD_CLOSE, ALLOWANCE_METHOD_CRANK, DEFAULT_DOWN_LEG_SECONDS, + LOAN_METHOD_RETARGET, +}; +use sqlx::SqlitePool; + +/// Whole-MUSD → base units as i64 (DB/API). Scale lives only in defi `MUSD_BASE_UNITS_PER_MUSD`. +fn principal_base_units(principal_musd: u16) -> i64 { + principal_to_base_units(principal_musd) as i64 +} + +const MORIA_METHOD_BORROW: u8 = 2; +const MORIA_METHOD_RETARGET: u8 = 4; + +async fn setup(pool: SqlitePool) { + prepare_tables(&pool).await; +} + +fn tid(b: u8) -> TokenID { + TokenID::from_byte_array([b; 32]) +} + +fn token_ids() -> MoriaV11TokenIds { + MoriaV11TokenIds { + moria: tid(0xA1), + bp_oracle: tid(0xB2), + pool: tid(0xC3), + bar: tid(0xD4), + loan_locking_bytecode: Vec::new(), + } +} + +fn test_loan_lock() -> Vec { + hex::decode("aa20415812e068086c90686635a813d77b6c412427e574e89b16e79feeaa48cf0f0787").unwrap() +} + +fn token_ids_with_loan_lock() -> MoriaV11TokenIds { + let mut ids = token_ids(); + ids.loan_locking_bytecode = test_loan_lock(); + ids +} + +fn test_policy(delta: u32, max_bp: u32) -> CrankPolicy { + CrankPolicy { + delta, + max_bp, + rescue_lead_bp: 0, + up_leg_seconds: 3_600, + down_leg_seconds: 86_400, + } +} + +fn ta(n: i64) -> TokenAmount { + TokenAmount::from_int(n).expect("non-negative") +} + +fn ft(id: TokenID, amount: i64) -> OutputData { + OutputData { + id, + bitfield: Structure::HasAmount as u8, + amount: ta(amount), + commitment: vec![], + } +} + +fn nft(id: TokenID, commitment: Vec) -> OutputData { + let mut bitfield = Structure::HasNFT as u8; + if !commitment.is_empty() { + bitfield |= Structure::HasCommitmentLength as u8; + } + OutputData { + id, + bitfield, + amount: TokenAmount::ZERO, + commitment, + } +} + +fn minting(id: TokenID, commitment: Vec, amount: i64) -> OutputData { + let mut bitfield = Structure::HasNFT as u8 | 0x02; + if !commitment.is_empty() { + bitfield |= Structure::HasCommitmentLength as u8; + } + if amount > 0 { + bitfield |= Structure::HasAmount as u8; + } + OutputData { + id, + bitfield, + amount: ta(amount), + commitment, + } +} + +fn push_data(buf: &mut Vec, data: &[u8]) { + if data.is_empty() { + buf.push(0x00); + return; + } + if data.len() <= 75 { + buf.push(data.len() as u8); + buf.extend_from_slice(data); + return; + } + if data.len() <= 255 { + buf.push(0x4c); + buf.push(data.len() as u8); + buf.extend_from_slice(data); + return; + } + buf.push(0x4d); + buf.extend_from_slice(&(data.len() as u16).to_le_bytes()); + buf.extend_from_slice(data); +} + +fn push_method(buf: &mut Vec, method: u8) { + match method { + 0 => buf.push(0x00), + 1..=16 => buf.push(0x50 + method), + n => { + buf.push(1); + buf.push(n); + } + } +} + +fn unlock(method: u8, args: &[&[u8]]) -> ScriptBuf { + let mut bytes = Vec::new(); + for a in args { + push_data(&mut bytes, a); + } + push_method(&mut bytes, method); + push_data(&mut bytes, &[0x51, 0x51, 0x51, 0x51]); + ScriptBuf::from_bytes(bytes) +} + +fn txin(prev: u8, vout: u32, script_sig: ScriptBuf) -> TxIn { + TxIn { + previous_output: OutPoint { + txid: Txid::from_byte_array([prev; 32]), + vout, + }, + script_sig, + sequence: Sequence::ZERO, + witness: Witness::new(), + } +} + +fn bare(sats: u64) -> TxOut { + TxOut { + value: Amount::from_sat(sats), + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + token: None, + } +} + +fn out(sats: u64, token: Option) -> TxOut { + TxOut { + value: Amount::from_sat(sats), + script_pubkey: ScriptBuf::from_bytes(vec![0xaa, 0x20]), + token, + } +} + +fn out_script(sats: u64, script: Vec, token: Option) -> TxOut { + TxOut { + value: Amount::from_sat(sats), + script_pubkey: ScriptBuf::from_bytes(script), + token, + } +} + +fn make_tx(inputs: Vec, outputs: Vec) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: inputs, + output: outputs, + } +} + +fn commitment(borrower: [u8; 32], delegate: [u8; 32], principal: u16, bp: u16, ts: u64) -> Vec { + encode_loan_commitment_v11(&LoanCommitmentV11 { + borrower_nft_hash: borrower, + delegate_nft_hash: delegate, + principal, + annual_interest_rate_bp: bp, + timestamp: ts, + }) + .to_vec() +} + +fn blockhash(n: u8) -> BlockHash { + BlockHash::from_raw_hash(bitcoin_hashes::sha256d::Hash::from_slice(&[n; 32]).unwrap()) +} + +fn borrower() -> [u8; 32] { + [0x11; 32] +} +fn delegate() -> [u8; 32] { + [0x22; 32] +} + +fn borrow_tx_with_allowance(ids: &MoriaV11TokenIds, ts: u64, rate: u16) -> Transaction { + let c = commitment(borrower(), delegate(), 1_000, rate, ts); + let allowance_script = vec![0xaa, 0x20, 0xAB, 0xCD]; + make_tx( + vec![ + txin(0x01, 0, unlock(MORIA_METHOD_BORROW, &[])), + txin(0x02, 0, unlock(0, &[])), + txin(0x03, 0, ScriptBuf::new()), + txin(0x04, 0, unlock(0, &[])), + txin(0x05, 0, unlock(0, &[])), + ], + vec![ + out(1_000, Some(minting(ids.moria, vec![0x01], 900_000))), + out(1_000, Some(nft(tid(0xD1), vec![0x11; 16]))), + out(9_000_000, Some(nft(ids.moria, c))), + out(1_000, Some(ft(ids.moria, principal_base_units(800)))), + bare(1_000), + bare(1_000), + bare(1_000), + bare(1_000), + bare(1_000), + out_script( + 2_000, + allowance_script, + Some(ft(ids.moria, principal_base_units(200))), + ), + ], + ) +} +/// SDK `encodeCrankHint({200, 2000, 3d, 7500})`. +fn stock_crank_hint_seq() -> u32 { + 0x8000_1031 +} + +fn borrow_tx_with_verified_hint(ids: &MoriaV11TokenIds, ts: u64, rate: u16) -> Transaction { + let seq = stock_crank_hint_seq(); + let hint = decode_crank_hint(seq).expect("stock hint"); + let policy = crank_hint_to_policy(&hint); + let bp = ids.bp_oracle.to_byte_array(); + let script = crank_allowance_locking_bytecode( + &ids.loan_locking_bytecode, + &borrower(), + &delegate(), + hint.fee_sats, + &bp, + &policy, + ); + let c = commitment(borrower(), delegate(), 1_000, rate, ts); + let mut delegate_in = txin(0x05, 0, unlock(0, &[])); + delegate_in.sequence = Sequence::from_consensus(seq); + make_tx( + vec![ + txin(0x01, 0, unlock(MORIA_METHOD_BORROW, &[])), + txin(0x02, 0, unlock(0, &[])), + txin(0x03, 0, ScriptBuf::new()), + txin(0x04, 0, unlock(0, &[])), + delegate_in, + ], + vec![ + out(1_000, Some(minting(ids.moria, vec![0x01], 900_000))), + out(1_000, Some(nft(tid(0xD1), vec![0x11; 16]))), + out(9_000_000, Some(nft(ids.moria, c))), + out(1_000, Some(ft(ids.moria, principal_base_units(800)))), + bare(1_000), + bare(1_000), + bare(1_000), + bare(1_000), + out_script(1_000, script.to_vec(), None), + out_script( + 7_500, + script.to_vec(), + Some(ft(ids.moria, principal_base_units(200))), + ), + ], + ) +} + +#[rocket::async_test] +async fn index_borrow_with_allowance() { + let db = crate::utiltest::mock_db_pool(setup).await; + let pool = &db.moria_w; + let ids = token_ids(); + + let bh = blockhash(0xAA); + let tx = borrow_tx_with_allowance(&ids, 1_800_000_000, 400); + + let n = index_moria_v11(pool, &[tx], &bh, 1_800_000_000, &ids) + .await + .unwrap(); + assert_eq!(n, 1); + + let history = get_loan_history(pool, &borrower()).await.unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].action_type, "borrow"); + assert_eq!(history[0].principal, Some(1_000)); + assert_eq!( + history[0].delegate_hash.as_deref(), + Some(hex::encode(delegate()).as_str()) + ); + assert_eq!( + history[0].allowance_token_amount, + Some(principal_base_units(200)) + ); + assert_eq!(history[0].allowance_sats, Some(2_000)); + + let loans = get_active_loans(pool).await.unwrap(); + assert_eq!(loans.len(), 1); + assert_eq!(loans[0].interest_rate, 400); + + let allowances = get_allowances_by_owner(pool, &borrower()).await.unwrap(); + assert_eq!(allowances.len(), 1); + assert_eq!(allowances[0].token_amount, principal_base_units(200)); + assert_eq!(allowances[0].vout, 9); + assert_eq!(allowances[0].policy_delta, None); + assert_eq!(allowances[0].policy_max_bp, None); + assert_eq!(allowances[0].policy_rescue_lead_bp, None); + assert_eq!(allowances[0].policy_up_leg_seconds, None); + assert_eq!(allowances[0].policy_down_leg_seconds, None); + assert_eq!(allowances[0].fee_sats, None); +} + +#[rocket::async_test] +async fn known_script_topup_copies_policy_and_fee() { + let db = crate::utiltest::mock_db_pool(setup).await; + let pool = &db.moria_w; + let ids = token_ids(); + let bh1 = blockhash(0xB1); + let bh2 = blockhash(0xB2); + + let borrow = borrow_tx_with_allowance(&ids, 1_800_000_000, 400); + index_moria_v11( + pool, + std::slice::from_ref(&borrow), + &bh1, + 1_800_000_000, + &ids, + ) + .await + .unwrap(); + let borrow_txid = borrow.compute_txid(); + let seeded = get_allowances_by_owner(pool, &borrower()).await.unwrap(); + let script = hex::decode(&seeded[0].locking_bytecode).unwrap(); + let policy = test_policy(200, 2_000); + insert_allowance_utxo( + pool, + &borrow_txid, + seeded[0].vout as u32, + &borrower(), + &delegate(), + &script, + seeded[0].token_amount, + seeded[0].sats as u64, + &bh1, + Some(&policy), + Some(7_500), + ) + .await + .unwrap(); + + let topup = make_tx( + vec![txin(0x99, 0, unlock(0, &[]))], + vec![out_script( + 8_000, + script.clone(), + Some(ft(ids.moria, 50_000)), + )], + ); + index_moria_v11( + pool, + std::slice::from_ref(&topup), + &bh2, + 1_800_010_000, + &ids, + ) + .await + .unwrap(); + + let rows = get_allowances_by_owner(pool, &borrower()).await.unwrap(); + let fresh = rows + .iter() + .find(|r| r.txid == topup.compute_txid().to_string()) + .expect("top-up indexed"); + assert_eq!(fresh.token_amount, 50_000); + assert_eq!(fresh.policy_delta, Some(200)); + assert_eq!(fresh.policy_max_bp, Some(2_000)); + assert_eq!(fresh.fee_sats, Some(7_500)); +} + +#[rocket::async_test] +async fn borrow_hint_persists_policy_and_fee() { + let db = crate::utiltest::mock_db_pool(setup).await; + let pool = &db.moria_w; + let ids = token_ids_with_loan_lock(); + let bh = blockhash(0xAB); + let tx = borrow_tx_with_verified_hint(&ids, 1_800_000_000, 400); + + let n = index_moria_v11(pool, std::slice::from_ref(&tx), &bh, 1_800_000_000, &ids) + .await + .unwrap(); + assert_eq!(n, 1); + + let allowances = get_allowances_by_owner(pool, &borrower()).await.unwrap(); + assert_eq!(allowances.len(), 1); + assert_eq!(allowances[0].vout, 9); + assert_eq!(allowances[0].policy_delta, Some(200)); + assert_eq!(allowances[0].policy_max_bp, Some(2_000)); + assert_eq!(allowances[0].policy_rescue_lead_bp, Some(50)); + assert_eq!(allowances[0].policy_up_leg_seconds, Some(1)); + assert_eq!(allowances[0].policy_down_leg_seconds, Some(3 * 86_400)); + assert_eq!(allowances[0].fee_sats, Some(7_500)); +} + +#[test] +fn allowance_api_json_shape() { + let unknown = MoriaV11AllowanceUtxo { + txid: "aa".repeat(32), + vout: 9, + owner_hash: "11".repeat(32), + delegate_hash: "22".repeat(32), + locking_bytecode: "aa20abcd".into(), + token_amount: 200, + sats: 2_000, + blockhash: "bb".repeat(32), + policy_delta: None, + policy_max_bp: None, + policy_rescue_lead_bp: None, + policy_up_leg_seconds: None, + policy_down_leg_seconds: None, + fee_sats: None, + }; + let omitted = serde_json::to_value(&unknown).unwrap(); + assert!(omitted.get("fee_sats").is_none()); + assert!(omitted.get("policy_delta").is_none()); + + let known = MoriaV11AllowanceUtxo { + policy_delta: Some(200), + policy_max_bp: Some(2_000), + policy_rescue_lead_bp: Some(50), + policy_up_leg_seconds: Some(1), + policy_down_leg_seconds: Some(259_200), + fee_sats: Some(7_500), + ..unknown + }; + let present = serde_json::to_value(&known).unwrap(); + assert_eq!(present["policy_delta"], 200); + assert_eq!(present["policy_max_bp"], 2_000); + assert_eq!(present["policy_rescue_lead_bp"], 50); + assert_eq!(present["policy_up_leg_seconds"], 1); + assert_eq!(present["policy_down_leg_seconds"], 259_200); + assert_eq!(present["fee_sats"], 7_500); +} + +#[rocket::async_test] +async fn index_crank_updates_allowance_in_place() { + let db = crate::utiltest::mock_db_pool(setup).await; + let pool = &db.moria_w; + let ids = token_ids(); + let bh1 = blockhash(0xA1); + let bh2 = blockhash(0xA2); + + let borrow = borrow_tx_with_allowance(&ids, 1_700_000_000, 500); + index_moria_v11( + pool, + std::slice::from_ref(&borrow), + &bh1, + 1_700_000_000, + &ids, + ) + .await + .unwrap(); + let borrow_txid = borrow.compute_txid(); + + // Seed constructor policy+fee so the crank continuation must copy them. + let seeded_policy = test_policy(200, 2_000); + let allowances = get_allowances_by_owner(pool, &borrower()).await.unwrap(); + insert_allowance_utxo( + pool, + &borrow_txid, + allowances[0].vout as u32, + &borrower(), + &delegate(), + &hex::decode(&allowances[0].locking_bytecode).unwrap(), + allowances[0].token_amount, + allowances[0].sats as u64, + &bh1, + Some(&seeded_policy), + Some(7_500), + ) + .await + .unwrap(); + + let old = commitment(borrower(), delegate(), 1_000, 500, 1_700_000_000); + let mut new = old.clone(); + new[66] = 0x20; + new[67] = 0x03; + new[68] = 0x20; + + let interest = 15i64; + let allowance_remaining = 500i64; + let allowance_script = vec![0xaa, 0x20, 0xAB, 0xCD]; + + // Header 2 + stride 2. Delegate slot (loan+1) unlocks METHOD_CRANK + // and names a distinct vault input. Purse continuation is contIdx+1. + let crank = make_tx( + vec![ + txin(0x01, 0, unlock(MORIA_METHOD_RETARGET, &[&[1]])), + txin(0x02, 0, unlock(0, &[])), + TxIn { + previous_output: OutPoint { + txid: borrow_txid, + vout: 2, + }, + script_sig: unlock(LOAN_METHOD_RETARGET, &[&old]), + sequence: Sequence::ZERO, + witness: Witness::new(), + }, + TxIn { + previous_output: OutPoint { + txid: borrow_txid, + vout: 9, + }, + script_sig: unlock( + ALLOWANCE_METHOD_CRANK, + &[&[1u8], &[4u8], &[3u8]], // bpIdx, vaultIn, contIdx + ), + sequence: Sequence::ZERO, + witness: Witness::new(), + }, + txin(0xFE, 0, unlock(0, &[])), // vault.spend + ], + vec![ + out(1_000, Some(minting(ids.moria, vec![0x05], 5_000_000))), + out(1_000, Some(nft(tid(0xD1), vec![0x11; 16]))), + out(5_000_000, Some(nft(ids.moria, new))), + out(1_000, Some(ft(ids.moria, interest))), + out_script( + 1_500, + allowance_script, + Some(ft(ids.moria, allowance_remaining)), + ), + ], + ); + + let n = index_moria_v11( + pool, + std::slice::from_ref(&crank), + &bh2, + 1_700_003_600, + &ids, + ) + .await + .unwrap(); + assert_eq!(n, 1); + + let history = get_loan_history(pool, &borrower()).await.unwrap(); + assert_eq!(history.len(), 2); + assert_eq!(history[1].action_type, "crank"); + assert_eq!(history[1].fee_take_sats, Some(0)); + assert_eq!(history[1].allowance_token_amount, Some(allowance_remaining)); + + let allowances = get_allowances_by_owner(pool, &borrower()).await.unwrap(); + assert_eq!(allowances.len(), 1); + assert_eq!(allowances[0].token_amount, allowance_remaining); + assert_eq!(allowances[0].vout, 4); + assert_eq!(allowances[0].txid, crank.compute_txid().to_string()); + assert_eq!(allowances[0].policy_delta, Some(200)); + assert_eq!(allowances[0].policy_max_bp, Some(2_000)); + assert_eq!(allowances[0].policy_rescue_lead_bp, Some(0)); + assert_eq!(allowances[0].policy_up_leg_seconds, Some(3_600)); + assert_eq!(allowances[0].policy_down_leg_seconds, Some(86_400)); + assert_eq!(allowances[0].fee_sats, Some(7_500)); + + let loans = get_active_loans(pool).await.unwrap(); + assert_eq!(loans.len(), 1); + assert_eq!(loans[0].interest_rate, 0x0320); + assert_eq!(loans[0].vout, 2); +} + +#[rocket::async_test] +async fn index_allowance_close_removes_utxo() { + let db = crate::utiltest::mock_db_pool(setup).await; + let pool = &db.moria_w; + let ids = token_ids(); + let bh1 = blockhash(0xB1); + let bh2 = blockhash(0xB2); + + let borrow = borrow_tx_with_allowance(&ids, 1_800_000_000, 400); + index_moria_v11( + pool, + std::slice::from_ref(&borrow), + &bh1, + 1_800_000_000, + &ids, + ) + .await + .unwrap(); + let borrow_txid = borrow.compute_txid(); + + let dest = vec![0xaa, 0x20, 0x99]; + let close = make_tx( + vec![ + TxIn { + previous_output: OutPoint { + txid: borrow_txid, + vout: 9, + }, + script_sig: unlock(ALLOWANCE_METHOD_CLOSE, &[&[1u8]]), + sequence: Sequence::ZERO, + witness: Witness::new(), + }, + txin(0x0B, 0, ScriptBuf::new()), + ], + vec![out_script( + 2_500, + dest, + Some(ft(ids.moria, principal_base_units(200))), + )], + ); + + let n = index_moria_v11(pool, &[close], &bh2, 1_800_001_000, &ids) + .await + .unwrap(); + assert_eq!(n, 1); + + let allowances = get_allowances_by_owner(pool, &borrower()).await.unwrap(); + assert!(allowances.is_empty()); + + let global = get_global_history(pool, &[], 0, 100).await.unwrap(); + let close_entry = global + .iter() + .find(|e| e.action_type == "allowance_close") + .expect("close action"); + assert_eq!( + close_entry.recovered_token_amount, + Some(principal_base_units(200)) + ); + assert_eq!(close_entry.recovered_sats, Some(2_500)); + assert_eq!( + close_entry.borrower_hash.as_deref(), + Some(hex::encode(borrower()).as_str()) + ); +} + +#[rocket::async_test] +async fn index_two_loan_retarget_batch() { + let db = crate::utiltest::mock_db_pool(setup).await; + let pool = &db.moria_w; + let ids = token_ids(); + let bh = blockhash(0xCC); + + let old0 = commitment(borrower(), delegate(), 1_000, 500, 1_700_000_000); + let mut new0 = old0.clone(); + new0[66] = 0xF4; + new0[67] = 0x01; + new0[68] = 0x10; + + let borrower2 = [0x33; 32]; + let delegate2 = [0x44; 32]; + let old1 = commitment(borrower2, delegate2, 2_000, 600, 1_700_000_000); + let mut new1 = old1.clone(); + new1[66] = 0x58; + new1[67] = 0x02; + new1[68] = 0x10; + + let seed_txid0 = Txid::from_byte_array([0xA0; 32]); + let seed_txid1 = Txid::from_byte_array([0xA1; 32]); + insert_loan_utxo( + pool, + &seed_txid0, + 2, + &LoanCommitmentV11 { + borrower_nft_hash: borrower(), + delegate_nft_hash: delegate(), + principal: 1_000, + annual_interest_rate_bp: 500, + timestamp: 1_700_000_000, + }, + 5_000_000, + &bh, + ) + .await + .unwrap(); + insert_loan_utxo( + pool, + &seed_txid1, + 2, + &LoanCommitmentV11 { + borrower_nft_hash: borrower2, + delegate_nft_hash: delegate2, + principal: 2_000, + annual_interest_rate_bp: 600, + timestamp: 1_700_000_000, + }, + 7_000_000, + &bh, + ) + .await + .unwrap(); + + // Header 2 + two strides: loan0, del0, loan1, del1. + let tx = make_tx( + vec![ + txin(0x01, 0, unlock(MORIA_METHOD_RETARGET, &[&[2]])), + txin(0x02, 0, unlock(0, &[])), + txin(0xA0, 2, unlock(LOAN_METHOD_RETARGET, &[&old0])), + txin(0xD0, 0, ScriptBuf::new()), + txin(0xA1, 2, unlock(LOAN_METHOD_RETARGET, &[&old1])), + txin(0xD1, 0, ScriptBuf::new()), + ], + vec![ + out(1_000, Some(minting(ids.moria, vec![0x05], 5_000_000))), + out(1_000, Some(nft(tid(0xD1), vec![0x11; 16]))), + out(5_000_000, Some(nft(ids.moria, new0))), + out(1_000, Some(ft(ids.moria, 10))), + out(7_000_000, Some(nft(ids.moria, new1))), + out(1_000, Some(ft(ids.moria, 20))), + ], + ); + + let n = index_moria_v11(pool, &[tx], &bh, 1_700_100_000, &ids) + .await + .unwrap(); + assert_eq!(n, 2, "one action row per loan in the batch"); + + let h0 = get_loan_history(pool, &borrower()).await.unwrap(); + let h1 = get_loan_history(pool, &borrower2).await.unwrap(); + assert_eq!(h0.len(), 1); + assert_eq!(h1.len(), 1); + assert_eq!(h0[0].action_type, "retarget"); + assert_eq!(h1[0].action_type, "retarget"); + assert_eq!(h0[0].tokens_amount, Some(10)); + assert_eq!(h1[0].tokens_amount, Some(20)); + + let active = get_active_loans(pool).await.unwrap(); + assert_eq!(active.len(), 2); +} + +#[rocket::async_test] +async fn index_bp_threshold_update() { + let db = crate::utiltest::mock_db_pool(setup).await; + let pool = &db.moria_w; + let ids = token_ids(); + let bh = blockhash(0xDD); + + let tx = make_tx( + vec![txin(0xB2, 0, unlock(0, &[]))], + vec![out( + 1_000, + Some(nft(ids.bp_oracle, encode_bp_commitment(750, 1_900_000_000))), + )], + ); + + index_moria_v11(pool, &[tx], &bh, 1_900_000_000, &ids) + .await + .unwrap(); + + let latest = get_latest_bp_threshold(pool).await.unwrap().unwrap(); + assert_eq!(latest.threshold_bp, 750); + assert_eq!(latest.oracle_timestamp, 1_900_000_000); +} + +#[rocket::async_test] +async fn reorg_deletes_all_v11_tables() { + let db = crate::utiltest::mock_db_pool(setup).await; + let pool = &db.moria_w; + let ids = token_ids(); + let bh = blockhash(0xEE); + + let borrow = borrow_tx_with_allowance(&ids, 1_800_000_000, 400); + let bp_tx = make_tx( + vec![txin(0xB2, 0, unlock(0, &[]))], + vec![out( + 1_000, + Some(nft(ids.bp_oracle, encode_bp_commitment(500, 1_800_000_000))), + )], + ); + index_moria_v11(pool, &[borrow, bp_tx], &bh, 1_800_000_000, &ids) + .await + .unwrap(); + + assert_eq!(get_active_loans(pool).await.unwrap().len(), 1); + assert_eq!( + get_allowances_by_owner(pool, &borrower()) + .await + .unwrap() + .len(), + 1 + ); + assert!(get_latest_bp_threshold(pool).await.unwrap().is_some()); + assert_eq!( + get_global_history(pool, &[], 0, 100).await.unwrap().len(), + 1 + ); + + let deleted = delete_entries_for_block(pool, &bh).await.unwrap(); + assert_eq!(deleted, 1); + + assert!(get_active_loans(pool).await.unwrap().is_empty()); + assert!(get_allowances_by_owner(pool, &borrower()) + .await + .unwrap() + .is_empty()); + assert!(get_latest_bp_threshold(pool).await.unwrap().is_none()); + assert!(get_global_history(pool, &[], 0, 100) + .await + .unwrap() + .is_empty()); +} + +#[rocket::async_test] +async fn crankable_uses_allowance_policy_not_loan_tail() { + let db = crate::utiltest::mock_db_pool(setup).await; + let pool = &db.moria_w; + let ids = token_ids(); + let bh = blockhash(0xF1); + + // rate=600, policy delta=100 → in-band for threshold=500 (band (500, 700]) + // out-of-band (up-leg) for threshold=600 (rate <= t) + let borrow = borrow_tx_with_allowance(&ids, 1_000_000, 600); + let bp_in_band = make_tx( + vec![txin(0xB2, 0, unlock(0, &[]))], + vec![out( + 1_000, + Some(nft(ids.bp_oracle, encode_bp_commitment(500, 2_000_000))), + )], + ); + index_moria_v11(pool, &[borrow, bp_in_band], &bh, 2_000_000, &ids) + .await + .unwrap(); + + // Policy is not on the loan; attach it to the live allowance. + let allowances = get_allowances_by_owner(pool, &borrower()).await.unwrap(); + assert_eq!(allowances.len(), 1); + let a_txid: Txid = allowances[0].txid.parse().expect("allowance txid hex"); + insert_allowance_utxo( + pool, + &a_txid, + allowances[0].vout as u32, + &borrower(), + &delegate(), + &hex::decode(&allowances[0].locking_bytecode).unwrap(), + allowances[0].token_amount, + allowances[0].sats as u64, + &bh, + Some(&test_policy(100, 3_000)), + Some(7_500), + ) + .await + .unwrap(); + + let now = 1_000_000 + i64::from(DEFAULT_DOWN_LEG_SECONDS) + 10; + let crankable = get_crankable(pool, now).await.unwrap(); + assert!( + crankable.is_empty(), + "rate 600 is in-band for threshold 500 delta 100" + ); + + let bh2 = blockhash(0xF2); + let bp_out = make_tx( + vec![txin(0xB3, 0, unlock(0, &[]))], + vec![out( + 1_000, + Some(nft(ids.bp_oracle, encode_bp_commitment(600, 2_100_000))), + )], + ); + index_moria_v11(pool, &[bp_out], &bh2, 2_100_000, &ids) + .await + .unwrap(); + + let crankable = get_crankable(pool, now).await.unwrap(); + assert_eq!(crankable.len(), 1); + assert_eq!(crankable[0].threshold_bp, 600); + assert_eq!(crankable[0].target_bp, 700); + assert_eq!(crankable[0].leg, "up"); + assert_eq!( + crankable[0].allowance_token_amount, + principal_base_units(200) + ); + assert_eq!(crankable[0].interest_rate, 600); + assert_eq!(crankable[0].tracking_delta, 100); + assert_eq!(crankable[0].tracking_max_bp, 3_000); +} + +#[rocket::async_test] +async fn stats_and_history_filters() { + let db = crate::utiltest::mock_db_pool(setup).await; + let pool = &db.moria_w; + let ids = token_ids(); + let bh = blockhash(0x99); + + let borrow = borrow_tx_with_allowance(&ids, 1_800_000_000, 400); + index_moria_v11(pool, &[borrow], &bh, 1_800_000_000, &ids) + .await + .unwrap(); + + let stats = get_stats(pool).await.unwrap(); + assert_eq!(stats["total_borrows"], 1); + assert_eq!(stats["active_loans"], 1); + assert_eq!(stats["active_allowances"], 1); + + let filtered = get_global_history(pool, &[borrower().to_vec()], 0, 10) + .await + .unwrap(); + assert_eq!(filtered.len(), 1); + let other = get_global_history(pool, &[[0xFFu8; 32].to_vec()], 0, 10) + .await + .unwrap(); + assert!(other.is_empty()); +} + +#[test] +fn bp_commitment_round_trip() { + let bytes = encode_bp_commitment(1234, 1_700_000_000); + let (bp, ts) = parse_bp_commitment(&bytes).unwrap(); + assert_eq!(bp, 1234); + assert_eq!(ts, 1_700_000_000); + assert!(parse_bp_commitment(&[0u8; 25]).is_none()); + assert!(parse_bp_commitment(&[0u8; 27]).is_none()); +} + +#[test] +fn retarget_in_band_edges() { + assert!(!retarget_in_band(500, 500, 100, 0)); + assert!(retarget_in_band(501, 500, 100, 0)); + assert!(retarget_in_band(700, 500, 100, 0)); + assert!(!retarget_in_band(701, 500, 100, 0)); + // rescue lead moves the up-leg edge + assert!(!retarget_in_band(525, 500, 100, 25)); + assert!(retarget_in_band(526, 500, 100, 25)); +} + +#[rocket::async_test] +async fn first_spend_classifies_unregistered_allowance() { + // Off-ladder / unknown P2SH32 stays invisible until METHOD_CRANK/close. + let db = crate::utiltest::mock_db_pool(setup).await; + let pool = &db.moria_w; + let ids = token_ids(); + let bh1 = blockhash(0xD1); + let bh2 = blockhash(0xD2); + + let borrow = borrow_tx_with_allowance(&ids, 1_800_000_000, 400); + index_moria_v11( + pool, + std::slice::from_ref(&borrow), + &bh1, + 1_800_000_000, + &ids, + ) + .await + .unwrap(); + + // Close the borrow-time allowance so we start from a clean owner set. + let borrow_txid = borrow.compute_txid(); + let close = make_tx( + vec![TxIn { + previous_output: OutPoint { + txid: borrow_txid, + vout: 9, + }, + script_sig: unlock(ALLOWANCE_METHOD_CLOSE, &[&[1u8]]), + sequence: Sequence::ZERO, + witness: Witness::new(), + }], + vec![out_script( + 2_000, + vec![0xaa, 0x20, 0x99], + Some(ft(ids.moria, principal_base_units(200))), + )], + ); + index_moria_v11( + pool, + std::slice::from_ref(&close), + &bh1, + 1_800_000_500, + &ids, + ) + .await + .unwrap(); + assert!(get_allowances_by_owner(pool, &borrower()) + .await + .unwrap() + .is_empty()); + + // A later close of a never-registered allowance still classifies via + // METHOD_CLOSE (parser) even though we never pre-derived the script. + let unknown = make_tx( + vec![TxIn { + previous_output: OutPoint { + txid: Txid::from_byte_array([0xEE; 32]), + vout: 0, + }, + script_sig: unlock(ALLOWANCE_METHOD_CLOSE, &[&[1u8]]), + sequence: Sequence::ZERO, + witness: Witness::new(), + }], + vec![out_script( + 1_000, + vec![0xaa, 0x20, 0x42], + Some(ft(ids.moria, 7_000)), + )], + ); + let n = index_moria_v11(pool, &[unknown], &bh2, 1_800_001_000, &ids) + .await + .unwrap(); + assert_eq!(n, 1); + let global = get_global_history(pool, &[], 0, 100).await.unwrap(); + assert!(global.iter().any(|e| e.action_type == "allowance_close")); +} + +#[rocket::async_test] +async fn mempool_borrow_is_active_then_promoted_on_confirm() { + let db = crate::utiltest::mock_db_pool(setup).await; + let pool = &db.moria_w; + let ids = token_ids(); + let tx = borrow_tx_with_allowance(&ids, 1_800_000_000, 400); + let zero = BlockHash::all_zeros(); + let confirmed = blockhash(0xCC); + + let n = index_moria_v11(pool, std::slice::from_ref(&tx), &zero, 1_800_000_000, &ids) + .await + .unwrap(); + assert_eq!(n, 1); + let loans = get_active_loans(pool).await.unwrap(); + assert_eq!(loans.len(), 1); + assert_eq!(loans[0].blockhash, zero.to_string()); + assert_eq!(get_unconfirmed_txids(pool).await.unwrap().len(), 1); + + let n = index_moria_v11( + pool, + std::slice::from_ref(&tx), + &confirmed, + 1_800_000_100, + &ids, + ) + .await + .unwrap(); + assert_eq!(n, 1); + let loans = get_active_loans(pool).await.unwrap(); + assert_eq!(loans.len(), 1); + assert_eq!(loans[0].blockhash, confirmed.to_string()); + assert!(get_unconfirmed_txids(pool).await.unwrap().is_empty()); + let history = get_loan_history(pool, &borrower()).await.unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].blockhash, confirmed.to_string()); +} + +#[rocket::async_test] +async fn mempool_eviction_drops_unconfirmed_loan() { + let db = crate::utiltest::mock_db_pool(setup).await; + let pool = &db.moria_w; + let ids = token_ids(); + let tx = borrow_tx_with_allowance(&ids, 1_800_000_000, 400); + index_moria_v11( + pool, + std::slice::from_ref(&tx), + &BlockHash::all_zeros(), + 1_800_000_000, + &ids, + ) + .await + .unwrap(); + assert_eq!(get_active_loans(pool).await.unwrap().len(), 1); + + let n = delete_unconfirmed_tx(pool, &tx.compute_txid()) + .await + .unwrap(); + assert!(n > 0); + assert!(get_active_loans(pool).await.unwrap().is_empty()); + assert!(get_unconfirmed_txids(pool).await.unwrap().is_empty()); +} diff --git a/src/db/oracle/v2.rs b/src/db/oracle/v2.rs index fe0284b..cce999d 100644 --- a/src/db/oracle/v2.rs +++ b/src/db/oracle/v2.rs @@ -10,9 +10,6 @@ //! deployment-agnostic. This module pins the **mainnet BCH/USD** deployment //! constants — token category ids for the v1 (legacy) and v2 (current) //! contracts — that the indexer needs to recognise live oracle output. -//! -//! Sourced from `~/libriften/packages/delphi-contract/src/v2/mainnet.ts` -//! (the `delphi-contract/v2: typed mainnet deployment constants` commit). use std::sync::OnceLock; @@ -78,11 +75,9 @@ mod tests { } #[test] - fn v2_token_matches_libriften_constant() { - // The hex must equal `DELPHI_CONTRACT_TOKEN` in - // `~/libriften/packages/delphi-contract/src/v2/mainnet.ts`. If the - // values drift, downstream packages and the indexer will disagree - // about which on-chain UTXOs count as "the v2 oracle". + fn v2_bchusd_token_hex_is_stable() { + // Pin the published mainnet v2 BCH/USD category so a typo in the + // constant cannot silently retarget "the" oracle. assert_eq!( V2_BCHUSD_TOKEN_HEX, "be0d0d8324e8cda41d34b85bd203ce2482256eb337a0ad0fea82c2ddd7306c88" diff --git a/src/db/orbconstants.rs b/src/db/orbconstants.rs index 68df547..9a486a9 100644 --- a/src/db/orbconstants.rs +++ b/src/db/orbconstants.rs @@ -5,11 +5,9 @@ //! Per-network ORB v0 deployment constants. //! -//! Mirrors libriften's `packages/cauldron/src/orb/v0/{chipnet,mainnet}.ts` -//! deployment records. Values here are MONEY-SAFETY-critical: while a -//! network's value is the all-zero placeholder, the dependent indexer is -//! disabled outright (fail-closed) — nothing is indexed with assumed -//! parameters. +//! Values here are MONEY-SAFETY-critical: while a network's value is the +//! all-zero placeholder, the dependent indexer is disabled outright +//! (fail-closed) — nothing is indexed with assumed parameters. use bitcoincash::Network; use riftenlabs_defi::{tokenbch_delegation, tokentoken_delegation}; @@ -17,30 +15,24 @@ use riftenlabs_defi::{tokenbch_delegation, tokentoken_delegation}; /// The delegation-pool platform NFTH (the platform-fee settlement destination /// baked into every tokentoken/tokenbch pool's withdraw blob) on chipnet. /// One value serves BOTH delegation contracts. -/// -/// From libriften `orb/v0/chipnet.ts` (`ORB_V0_CHIPNET.poolPlatformNfth`). const CHIPNET_POOL_PLATFORM_NFTH: &[u8; 32] = &[ 0x07, 0x2d, 0x5f, 0xbe, 0xcf, 0xa1, 0xbc, 0x37, 0xa1, 0x3f, 0x58, 0xb1, 0x88, 0xf5, 0x0a, 0x5b, 0xc1, 0xdf, 0xb9, 0xab, 0x8a, 0xe0, 0xbf, 0x5d, 0x3d, 0x9a, 0x63, 0xf6, 0xf8, 0x1e, 0xfc, 0x0b, ]; -// TODO:: set the pool platform nfth for mainnet once ORB v0 deploys there -// (libriften orb/v0/mainnet.ts is still all-zero too). +// TODO:: set the pool platform nfth for mainnet once ORB v0 deploys there. const MAINNET_POOL_PLATFORM_NFTH: &[u8; 32] = &[0u8; 32]; /// The ORB IdoParams NFT category (display byte order, like a token id) on /// chipnet. Every IDO preinit must include this NFT (input #1, preserved at /// output #10); its commitment carries the economic parameters the announced /// IDO parameters are validated against. -/// -/// From libriften `orb/v0/chipnet.ts` (`ORB_V0_CHIPNET.idoParams.token`). 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 once ORB v0 deploys there -// (libriften orb/v0/mainnet.ts is still all-zero too). +// TODO:: set the ido params nft category for mainnet once ORB v0 deploys there. const MAINNET_IDO_PARAMS_NFT_CATEGORY: &[u8; 32] = &[0u8; 32]; /// The ORB IdoParams NFT category for a network (all-zero = unconfigured; the @@ -63,8 +55,7 @@ pub fn pool_platform_nfth(network: Option) -> [u8; 32] { /// True once a real (non-placeholder) value is configured. The build-time /// `0xab…ab` contract placeholder counts as unconfigured too: pools built on -/// it must never be surfaced (libriften DESIGN.md "never fund a pool on the -/// placeholder"). +/// it must never be surfaced. pub fn is_configured(value: &[u8; 32]) -> bool { *value != [0u8; 32] && value != tokentoken_delegation::PLATFORM_NFTH_PLACEHOLDER } diff --git a/src/electrum.rs b/src/electrum.rs index b670572..be70abc 100644 --- a/src/electrum.rs +++ b/src/electrum.rs @@ -41,12 +41,21 @@ pub fn electrum_get_tip(client: &Client) -> Result<(BlockHeader, u64)> { Ok((deserialize(&hex::decode(header)?)?, height as u64)) } -/// The (defi, oracle, ido, bcmr) mempool txid sets. -pub type MempoolTxSets = (HashSet, HashSet, HashSet, HashSet); +/// The (defi, oracle, ido, bcmr, moria_v11) mempool txid sets. +pub type MempoolTxSets = ( + HashSet, + HashSet, + HashSet, + HashSet, + HashSet, +); -/// Fetch defi (cauldron + delegation pools), oracle, ido and bcmr mempool -/// transactions -pub fn electrum_fetch_mempool(client: &Client, network: Option) -> Result { +/// Fetch defi, oracle, ido, bcmr and optional moria v1-1 mempool transactions. +pub fn electrum_fetch_mempool( + client: &Client, + network: Option, + moria_filter: Option, +) -> Result { let cauldron_filter = json!({ "scriptsig": hex::encode(&V2_CONTRACT_TEMPLATE[(V2_CONTRACT_TEMPLATE.len() - 43)..]), // cauldron spends "scriptpubkey": hex::encode([0x6a /* op_return */, 0x06 /* push */, b'S', b'U', b'M', b'M', b'O', b'N']), // new pools (potentially) @@ -141,8 +150,13 @@ pub fn electrum_fetch_mempool(client: &Client, network: Option) -> Resu oracle_txs.extend(fetch_txs(oracle_v2_filter)?); let ido_txs = fetch_txs(ido_filter)?; let bcmr_txs = fetch_txs(bcmr_filter)?; + let moria_txs = if let Some(filter) = moria_filter { + fetch_txs(filter)? + } else { + HashSet::new() + }; - Ok((defi_txs, oracle_txs, ido_txs, bcmr_txs)) + Ok((defi_txs, oracle_txs, ido_txs, bcmr_txs, moria_txs)) } /// Fetch blockchain tip from electrum server diff --git a/src/index.rs b/src/index.rs index 0d7885b..3d47c9c 100644 --- a/src/index.rs +++ b/src/index.rs @@ -12,13 +12,12 @@ use std::{ use bitcoin_hashes::Hash; use bitcoincash::{ blockdata::block::Header as BlockHeader, consensus::deserialize, Block, BlockHash, Network, - TokenID, Transaction, Txid, + Transaction, Txid, }; use electrum_client_netagnostic::{Client, ElectrumApi, Param}; use log::{debug, info, warn}; use riftenlabs_defi::cauldron::{parse_cauldrons_from_tx, ParsedContract}; use riftenlabs_defi::chainutil::{compute_outpoint_hash, OutPointHash}; -use riftenlabs_defi::moria::MoriaTokenIds; use crate::{ bcmr::index_bcmr, @@ -47,28 +46,6 @@ use crate::{ }; use anyhow::{bail, Context, Result}; -/// Get the Moria token IDs for a given network -fn moria_token_ids(network: Option) -> MoriaTokenIds { - match network { - Some(Network::Chipnet) => MoriaTokenIds { - moria: "29566f4884539dbcedfcb55cc7f5e66b5ae14975b70c0b6eb12de7e9707775ea" - .parse::() - .expect("valid chipnet moria token_id"), - bp_oracle: "f3d6b85bfb0eaaf417ccabc8c8032464c5ec410e40b3b13f0c369a541bfb2a6a" - .parse::() - .expect("valid chipnet bp_oracle token_id"), - }, - _ => MoriaTokenIds { - moria: "b38a33f750f84c5c169a6f23cb873e6e79605021585d4f3408789689ed87f366" - .parse::() - .expect("valid mainnet moria token_id"), - bp_oracle: "01711e39e7bf3b8ca0d9a6fc6ea32e340caa1d64dc7d1dc51fae20fd66755558" - .parse::() - .expect("valid mainnet bp_oracle token_id"), - }, - } -} - pub async fn update_mempool( db: &DB, electrum: Arc>, @@ -78,10 +55,12 @@ pub async fn update_mempool( db::cauldron::mempool::load_mempool(&db.cauldron_w).await?; let electrum_clone = electrum.clone(); - let (cauldron_txs, oracle_txs, ido_txs, bcmr_txs) = tokio::task::spawn_blocking(move || { - electrum_fetch_mempool(&electrum_clone.lock().unwrap(), network) - }) - .await??; + let moria_filter = db::moria_v11::mempool_filter(network); + let (cauldron_txs, oracle_txs, ido_txs, bcmr_txs, moria_txs) = + tokio::task::spawn_blocking(move || { + electrum_fetch_mempool(&electrum_clone.lock().unwrap(), network, moria_filter) + }) + .await??; let txs_to_delete: Vec = our_mempool_txs.difference(&cauldron_txs).cloned().collect(); let txs_to_add: Vec<&Txid> = cauldron_txs.difference(&our_mempool_txs).collect(); @@ -318,6 +297,53 @@ pub async fn update_mempool( let txs_to_add = ttor_sorted_kahn(txs_to_add); index_bcmr(&db.bcmr_w, &BlockHash::all_zeros(), txs_to_add).await?; + if let Some(token_ids) = db::moria_v11::token_ids(network) { + for txid in db::moria_v11::get_unconfirmed_txids(&db.moria_w).await? { + if !moria_txs.contains(&txid) { + db::moria_v11::delete_unconfirmed_tx(&db.moria_w, &txid).await?; + } + } + + let mut moria_to_add = Vec::new(); + for txid in moria_txs { + if !db::moria_v11::has_entry(&db.moria_w, &txid).await? { + moria_to_add.push(txid); + } + } + + let moria_electrum = electrum.clone(); + let txs_to_add: Vec = tokio::task::spawn_blocking(move || { + moria_to_add + .into_iter() + .filter_map( + |txid| match electrum_get_tx(&moria_electrum.lock().unwrap(), &txid) { + Ok(tx) => Some(tx), + Err(e) => { + info!("Failed to get mempool moria tx {txid}: {e}"); + None + } + }, + ) + .collect() + }) + .await?; + + let txs_to_add = ttor_sorted_kahn(txs_to_add); + if !txs_to_add.is_empty() { + let n = db::moria_v11::index_moria_v11( + &db.moria_w, + &txs_to_add, + &BlockHash::all_zeros(), + time_now(), + &token_ids, + ) + .await?; + if n > 0 { + info!("mempool moria_v11: indexed {n} actions"); + } + } + } + Ok(()) } @@ -389,10 +415,18 @@ pub async fn index_blocks( // uses Handle::current().block_on() which panics on Tokio worker threads. let chain_for_update = chain.clone(); let undoer_db = db.clone(); + let tip_hash = tip_header.block_hash(); tokio::task::spawn_blocking(move || { let chain_guard = chain_for_update.lock().unwrap(); let undoer = StoreBlockUndoer::new(undoer_db)?; - chain_guard.update(undoer, new_headers, None) + // Empty headers means electrum's tip is already in the chain + // (shrink / invalidate). Height comes from our map, then electrum. + let shrink_to = if new_headers.is_empty() { + chain_guard.get_block_height(&tip_hash).or(Some(tip_height)) + } else { + None + }; + chain_guard.update(undoer, new_headers, shrink_to) }) .await??; debug!("Header update done"); @@ -631,15 +665,28 @@ pub async fn index_blocks( // oracle updates index_oracle(&db.oracle_w, &sorted_txs, &blockhash).await?; - // moria lending - let moria_actions = db::moria::index_moria( - &db.moria_w, - &sorted_txs, - &blockhash, - mtp as i64, - &moria_token_ids(network), - ) - .await?; + // moria v1-1 lending (chipnet only) + let mut moria_actions = 0usize; + if let Some(token_ids) = db::moria_v11::token_ids(network) { + moria_actions = db::moria_v11::index_moria_v11( + &db.moria_w, + &sorted_txs, + &blockhash, + mtp as i64, + &token_ids, + ) + .await?; + + db::moria_v11::staking::index_bar_pool_history( + &db.moria_w, + &sorted_txs, + &blockhash, + block_height as i64, + mtp as i64, + &token_ids, + ) + .await?; + } db::ido::index_txs( network, diff --git a/src/main.rs b/src/main.rs index c932466..b29c0a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -232,7 +232,7 @@ async fn start_program( )?; db::oracle::clear_mempool(&db.oracle_w).await.unwrap(); - db::moria::clear_mempool(&db.moria_w).await.unwrap(); + db::moria_v11::clear_mempool(&db.moria_w).await.unwrap(); db::bcmr::clear_mempool(&db.bcmr_w).await.unwrap(); let indexing_in_progress_clone = indexing_in_progress.clone(); @@ -449,71 +449,84 @@ async fn launch() -> _ { materialized_end: AtomicI64::new(initial_ohlcv_end), }); - // Synchronous post-IBD backfill: run the full ohlcv_1h materialisation before - // allowing metrics_cache and other background writers to start. We reuse the + // Post-IBD backfill: run the full ohlcv_1h materialisation before allowing + // metrics_cache and other background writers to start. We reuse the // indexing_in_progress flag so metrics_cache backs off during this window. // - // Skipped after a version wipe: the backfill runs before `rocket::build()` returns, - // so re-materialising all of history here would refuse connections for the whole - // rebuild rather than degrading to the (correct, slower) raw path. + // Runs in a task: it must WAIT for IBD, and main() sits before + // rocket::build(), so awaiting it inline kept the API from binding for the + // entire initial sync — on a fresh database the server refused connections + // for hours. The gate it holds preserves the old writer ordering. + // + // Skipped after a version wipe: re-materialising all of history would + // contend with the rebuild rather than degrading to the (correct, slower) + // raw path. if !ohlcv_wiped { - const BACKFILL_BATCH_SECS: i64 = 24 * 3600; - const BACKFILL_SAFETY_SECS: i64 = 3 * 3600; + let dbpool = dbpool.clone(); + let ibd_state = ibd_state.clone(); + let indexing_in_progress = indexing_in_progress.clone(); + let ohlcv_state = ohlcv_state.clone(); + tokio::spawn(async move { + const BACKFILL_BATCH_SECS: i64 = 24 * 3600; + const BACKFILL_SAFETY_SECS: i64 = 3 * 3600; - // Wait for IBD to finish — ohlcv_1h data is only useful for confirmed blocks. - while !ibd_state.initial_sync_complete.load(Ordering::Relaxed) { - tokio::time::sleep(Duration::from_secs(1)).await; - } - - // Gate metrics_cache so it doesn't compete for cauldron_w during backfill. - indexing_in_progress.store(true, Ordering::Relaxed); - info!("ohlcv: starting post-IBD full backfill"); - - let now = crate::timeutil::time_now(); - let cutoff = (now - BACKFILL_SAFETY_SECS) / 3600 * 3600; - let since_opt = match max_bucket_ts { - Some(ts) => Some(ts + 3600), - None => match db::cauldron::ohlcv::get_min_trade_bucket_ts(&dbpool.cauldron_r).await { - Ok(v) => v, - Err(e) => { - warn!("ohlcv backfill: could not read min trade ts: {e}"); - None - } - }, - }; - if since_opt.is_none() { - info!("ohlcv backfill: no confirmed trades found, skipping"); - } - if let Some(mut batch_start) = since_opt { - while batch_start < cutoff { - let batch_end = (batch_start + BACKFILL_BATCH_SECS).min(cutoff); - match db::cauldron::ohlcv::rebuild_range( - &dbpool.cauldron_r, - &dbpool.cauldron_w, - batch_start, - batch_end, - ) - .await - { - Ok(n) => { - info!("ohlcv backfill: {n} buckets [{batch_start}, {batch_end})"); - ohlcv_state - .materialized_end - .store(batch_end, Ordering::Relaxed); - } - Err(e) => { - warn!("ohlcv backfill failed at [{batch_start}, {batch_end}): {e}"); - break; - } - } - batch_start = batch_end; - // Brief yield so new block writes are not starved. - tokio::time::sleep(Duration::from_millis(200)).await; + // Wait for IBD to finish — ohlcv_1h data is only useful for confirmed blocks. + while !ibd_state.initial_sync_complete.load(Ordering::Relaxed) { + tokio::time::sleep(Duration::from_secs(1)).await; } - } - info!("ohlcv: post-IBD backfill complete"); - indexing_in_progress.store(false, Ordering::Relaxed); + // Gate metrics_cache so it doesn't compete for cauldron_w during backfill. + indexing_in_progress.store(true, Ordering::Relaxed); + info!("ohlcv: starting post-IBD full backfill"); + + let now = crate::timeutil::time_now(); + let cutoff = (now - BACKFILL_SAFETY_SECS) / 3600 * 3600; + let since_opt = match max_bucket_ts { + Some(ts) => Some(ts + 3600), + None => { + match db::cauldron::ohlcv::get_min_trade_bucket_ts(&dbpool.cauldron_r).await { + Ok(v) => v, + Err(e) => { + warn!("ohlcv backfill: could not read min trade ts: {e}"); + None + } + } + } + }; + if since_opt.is_none() { + info!("ohlcv backfill: no confirmed trades found, skipping"); + } + if let Some(mut batch_start) = since_opt { + while batch_start < cutoff { + let batch_end = (batch_start + BACKFILL_BATCH_SECS).min(cutoff); + match db::cauldron::ohlcv::rebuild_range( + &dbpool.cauldron_r, + &dbpool.cauldron_w, + batch_start, + batch_end, + ) + .await + { + Ok(n) => { + info!("ohlcv backfill: {n} buckets [{batch_start}, {batch_end})"); + ohlcv_state + .materialized_end + .store(batch_end, Ordering::Relaxed); + } + Err(e) => { + warn!("ohlcv backfill failed at [{batch_start}, {batch_end}): {e}"); + break; + } + } + batch_start = batch_end; + // Brief yield so new block writes are not starved. + tokio::time::sleep(Duration::from_millis(200)).await; + } + } + + info!("ohlcv: post-IBD backfill complete"); + indexing_in_progress.store(false, Ordering::Relaxed); + }); } // Background task: incrementally materialise new 1-hour OHLCV buckets as blocks arrive. @@ -664,12 +677,17 @@ async fn launch() -> _ { ], ) .mount( - "/moria", + "/moria/v11", routes![ - rpc::moria::loan_history, - rpc::moria::global_history, - rpc::moria::active_loans, - rpc::moria::moria_stats, + rpc::moria_v11::loan_history, + rpc::moria_v11::global_history, + rpc::moria_v11::active_loans, + rpc::moria_v11::allowances, + rpc::moria_v11::crankable, + rpc::moria_v11::moria_stats, + rpc::moria_v11::bar_history, + rpc::moria_v11::pool_history, + rpc::moria_v11::staking_apr, ], ) .mount( diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 29be036..5f0bc52 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -18,7 +18,7 @@ pub mod contract; pub mod err; pub mod health; pub mod ido; -pub mod moria; +pub mod moria_v11; pub mod oracle; pub mod pool; pub mod price; diff --git a/src/rpc/moria.rs b/src/rpc/moria.rs deleted file mode 100644 index 572bf8e..0000000 --- a/src/rpc/moria.rs +++ /dev/null @@ -1,118 +0,0 @@ -// 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 - -use crate::db::moria::{get_active_loans, get_global_history, get_loan_history, get_stats}; -use crate::db::DB; -use crate::rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult}; -use crate::rpc::response::{cached_ok, CACHE_AGGREGATE}; -use rocket::{get, State}; -use serde_json::Value; - -fn parse_nfth(hex_str: &str) -> Result, (ApiErrorCode, String)> { - let bytes = hex::decode(hex_str).map_err(|e| { - ( - ApiErrorCode::InvalidParameters, - format!("Invalid nfth hex: {e}"), - ) - })?; - if bytes.len() != 32 { - return Err(( - ApiErrorCode::InvalidParameters, - format!("nfth must be 32 bytes (64 hex chars), got {}", bytes.len()), - )); - } - Ok(bytes) -} - -/// Get full loan history for a given borrower NFT hash. -/// -/// - borrower_hash: 64-character hex string (32-byte P2NFTH hash identifying the loan) -/// -/// Returns an array of all actions (borrow, repay, redeem, refinance, add_collateral) -/// for this loan, sorted by timestamp. -#[get("/loan//history")] -pub async fn loan_history(borrower_hash: &str, db: &State) -> CachedApiResult { - let hash_bytes = hex::decode(borrower_hash).map_err(|e| { - bad_request( - ApiErrorCode::InvalidParameters, - &format!("Invalid borrower hash: {e}"), - ) - })?; - - if hash_bytes.len() != 32 { - return Err(bad_request( - ApiErrorCode::InvalidParameters, - "Borrower hash must be 32 bytes (64 hex characters)", - )); - } - - let entries = get_loan_history(&db.moria_r, &hash_bytes) - .await - .map_err(db_error)?; - - Ok(cached_ok( - serde_json::to_value(entries).unwrap(), - CACHE_AGGREGATE, - )) -} - -/// Get global moria action history with pagination and optional nfth filter. -/// -/// - offset: Number of entries to skip (default: 0) -/// - limit: Maximum entries to return (default: 50, max: 200) -/// - nfth: Comma-separated list of borrower NFT hashes (64-char hex each) to filter by -#[get("/history?&&")] -pub async fn global_history( - offset: Option, - limit: Option, - nfth: Option<&str>, - db: &State, -) -> CachedApiResult { - let offset = offset.unwrap_or(0).max(0); - let limit = limit.unwrap_or(50).clamp(1, 200); - - let nfth_filter: Vec> = match nfth { - Some(s) if !s.is_empty() => { - let mut filters = Vec::new(); - for hash_hex in s.split(',') { - let hash_hex = hash_hex.trim(); - if hash_hex.is_empty() { - continue; - } - filters.push(parse_nfth(hash_hex).map_err(|(code, msg)| bad_request(code, &msg))?); - } - filters - } - _ => Vec::new(), - }; - - let entries = get_global_history(&db.moria_r, &nfth_filter, offset, limit) - .await - .map_err(db_error)?; - - Ok(cached_ok( - serde_json::to_value(entries).unwrap(), - CACHE_AGGREGATE, - )) -} - -/// List all active (not yet repaid/redeemed) loans. -#[get("/loans/active")] -pub async fn active_loans(db: &State) -> CachedApiResult { - let entries = get_active_loans(&db.moria_r).await.map_err(db_error)?; - - Ok(cached_ok( - serde_json::to_value(entries).unwrap(), - CACHE_AGGREGATE, - )) -} - -/// Get Moria protocol statistics. -#[get("/stats")] -pub async fn moria_stats(db: &State) -> CachedApiResult { - let stats = get_stats(&db.moria_r).await.map_err(db_error)?; - - Ok(cached_ok(stats, CACHE_AGGREGATE)) -} diff --git a/src/rpc/moria_v11.rs b/src/rpc/moria_v11.rs new file mode 100644 index 0000000..b7b47b3 --- /dev/null +++ b/src/rpc/moria_v11.rs @@ -0,0 +1,224 @@ +// 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 + +use crate::db::moria_v11::{ + get_active_loans, get_allowances_by_owner, get_crankable, get_global_history, get_loan_history, + get_stats, + staking::{get_bar_history, get_pool_history, get_staking_apr, DEFAULT_APR_WINDOW_SECONDS}, +}; +use crate::db::DB; +use crate::rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult}; +use crate::rpc::response::{cached_ok, CACHE_AGGREGATE}; +use crate::timeutil::time_now; +use rocket::{get, State}; +use serde_json::Value; + +fn parse_nfth(hex_str: &str) -> Result, (ApiErrorCode, String)> { + let bytes = hex::decode(hex_str).map_err(|e| { + ( + ApiErrorCode::InvalidParameters, + format!("Invalid nfth hex: {e}"), + ) + })?; + if bytes.len() != 32 { + return Err(( + ApiErrorCode::InvalidParameters, + format!("nfth must be 32 bytes (64 hex chars), got {}", bytes.len()), + )); + } + Ok(bytes) +} + +/// Get full loan history for a given borrower NFT hash (v1-1). +/// +/// - borrower_hash: 64-character hex string (32-byte P2NFTH hash) +/// +/// Returns an array of all actions for this loan, sorted by timestamp ascending. +#[get("/loan//history")] +pub async fn loan_history(borrower_hash: &str, db: &State) -> CachedApiResult { + let hash_bytes = hex::decode(borrower_hash).map_err(|e| { + bad_request( + ApiErrorCode::InvalidParameters, + &format!("Invalid borrower hash: {e}"), + ) + })?; + + if hash_bytes.len() != 32 { + return Err(bad_request( + ApiErrorCode::InvalidParameters, + "Borrower hash must be 32 bytes (64 hex characters)", + )); + } + + let entries = get_loan_history(&db.moria_r, &hash_bytes) + .await + .map_err(db_error)?; + + Ok(cached_ok( + serde_json::to_value(entries).unwrap(), + CACHE_AGGREGATE, + )) +} + +/// Get global moria v1-1 action history with pagination and optional nfth filter. +/// +/// - offset: Number of entries to skip (default: 0) +/// - limit: Maximum entries to return (default: 50, max: 200) +/// - nfth: Comma-separated list of borrower NFT hashes (64-char hex each) +#[get("/history?&&")] +pub async fn global_history( + offset: Option, + limit: Option, + nfth: Option<&str>, + db: &State, +) -> CachedApiResult { + let offset = offset.unwrap_or(0).max(0); + let limit = limit.unwrap_or(50).clamp(1, 200); + + let nfth_filter: Vec> = match nfth { + Some(s) if !s.is_empty() => { + let mut filters = Vec::new(); + for hash_hex in s.split(',') { + let hash_hex = hash_hex.trim(); + if hash_hex.is_empty() { + continue; + } + filters.push(parse_nfth(hash_hex).map_err(|(code, msg)| bad_request(code, &msg))?); + } + filters + } + _ => Vec::new(), + }; + + let entries = get_global_history(&db.moria_r, &nfth_filter, offset, limit) + .await + .map_err(db_error)?; + + Ok(cached_ok( + serde_json::to_value(entries).unwrap(), + CACHE_AGGREGATE, + )) +} + +/// List all active (live loan UTXO) v1-1 loans, including delegate hash. +#[get("/loans/active")] +pub async fn active_loans(db: &State) -> CachedApiResult { + let entries = get_active_loans(&db.moria_r).await.map_err(db_error)?; + + Ok(cached_ok( + serde_json::to_value(entries).unwrap(), + CACHE_AGGREGATE, + )) +} + +/// Live allowance UTXOs for an owner NFT hash (salvage feed — dead-loan deposits included). +/// +/// - owner_hash: 64-character hex string (borrower / owner P2NFTH) +#[get("/allowances/")] +pub async fn allowances(owner_hash: &str, db: &State) -> CachedApiResult { + let hash_bytes = parse_nfth(owner_hash).map_err(|(code, msg)| bad_request(code, &msg))?; + + let entries = get_allowances_by_owner(&db.moria_r, &hash_bytes) + .await + .map_err(db_error)?; + + Ok(cached_ok( + serde_json::to_value(entries).unwrap(), + CACHE_AGGREGATE, + )) +} + +/// Loans that are currently legal and funded to crank. +/// +/// Optional `now` query overrides the clock used for cadence checks (unix seconds). +/// Defaults to wall-clock time. +#[get("/crankable?")] +pub async fn crankable(now: Option, db: &State) -> CachedApiResult { + let now_ts = now.unwrap_or_else(time_now); + let entries = get_crankable(&db.moria_r, now_ts).await.map_err(db_error)?; + + Ok(cached_ok( + serde_json::to_value(entries).unwrap(), + CACHE_AGGREGATE, + )) +} + +/// Get Moria v1-1 protocol statistics. +#[get("/stats")] +pub async fn moria_stats(db: &State) -> CachedApiResult { + let stats = get_stats(&db.moria_r).await.map_err(db_error)?; + + Ok(cached_ok(stats, CACHE_AGGREGATE)) +} + +/// Bar (single-asset MUSD staking) state history, newest first. +/// +/// - since: only rows with mtp > since (indexed block MTP seconds) +/// - limit: max rows to return (default 100, max 1000) +#[get("/bar/history?&")] +pub async fn bar_history( + since: Option, + limit: Option, + db: &State, +) -> CachedApiResult { + let limit = limit.unwrap_or(100).clamp(1, 1000); + let entries = get_bar_history(&db.moria_r, since, limit) + .await + .map_err(db_error)?; + + Ok(cached_ok( + serde_json::to_value(entries).unwrap(), + CACHE_AGGREGATE, + )) +} + +/// Liquidation-pool state history, newest first. +/// +/// - since: only rows with mtp > since (indexed block MTP seconds) +/// - limit: max rows to return (default 100, max 1000) +#[get("/pool/history?&")] +pub async fn pool_history( + since: Option, + limit: Option, + db: &State, +) -> CachedApiResult { + let limit = limit.unwrap_or(100).clamp(1, 1000); + let entries = get_pool_history(&db.moria_r, since, limit) + .await + .map_err(db_error)?; + + Ok(cached_ok( + serde_json::to_value(entries).unwrap(), + CACHE_AGGREGATE, + )) +} + +/// Realized bar + pool NAV-growth APR over a trailing window, GROSS of any +/// exit fee (a continuing-holder figure, never a redemption/exit rate). The +/// pool figure is xMUSD-terms only (excludes the BCH pot — see +/// `excludes_bch_pot` on the response). `bar`/`pool` are independently +/// `null` when there's no history yet or the window has insufficient +/// coverage (see `db::moria_v11::staking` module docs for the exact rule). +/// +/// - window: window length in seconds (default 604800 = 7 days) +#[get("/staking/apr?")] +pub async fn staking_apr(window: Option, db: &State) -> CachedApiResult { + let window = window.unwrap_or(DEFAULT_APR_WINDOW_SECONDS); + if window <= 0 { + return Err(bad_request( + ApiErrorCode::InvalidParameters, + "window must be a positive number of seconds", + )); + } + + let resp = get_staking_apr(&db.moria_r, window) + .await + .map_err(db_error)?; + + Ok(cached_ok( + serde_json::to_value(resp).unwrap(), + CACHE_AGGREGATE, + )) +}