Merge branch 'v11' into 'master'

Cut over to Moria v1-1; drop v1 indexing

See merge request riftenlabs/riftenlabs-indexer!102
This commit is contained in:
Dagur Valberg Johannsson 2026-08-18 07:18:56 +00:00
commit 9dc1a4dfb1
22 changed files with 4260 additions and 1095 deletions

4
Cargo.lock generated
View file

@ -2283,7 +2283,9 @@ dependencies = [
[[package]] [[package]]
name = "riftenlabs-defi" name = "riftenlabs-defi"
version = "0.4.5" version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcf89dd770556a74ecd88b6971e7fa151f58bcee5b1a400b836c485c668973b"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitcoin_hashes", "bitcoin_hashes",

View file

@ -22,7 +22,7 @@ serde_json = "1.0.133"
rocket = { version = "0.5.1", features = ["json"] } rocket = { version = "0.5.1", features = ["json"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
rayon = "1.10.0" rayon = "1.10.0"
riftenlabs-defi = "0.4.5" riftenlabs-defi = "0.4.6"
rocket_cors = "0.6.0" rocket_cors = "0.6.0"
log = "0.4" log = "0.4"
stderrlog = "0.6.0" stderrlog = "0.6.0"

View file

@ -422,7 +422,7 @@ mod tests {
#[test] #[test]
fn ipfs_path_validation_accepts_real_cids_and_rejects_injection() { 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. // base64url CID with a filename extension appended directly.
assert!(is_safe_ipfs_path("QmAbc123")); assert!(is_safe_ipfs_path("QmAbc123"));
assert!(is_safe_ipfs_path("bafybeig4n5ut/icon.png")); assert!(is_safe_ipfs_path("bafybeig4n5ut/icon.png"));

View file

@ -90,7 +90,7 @@ impl BlockUndoer for StoreBlockUndoer {
{ {
was_indexed = true; 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? .await?
> 0 > 0
{ {
@ -277,7 +277,12 @@ impl Chain {
let rewind = match new_headers.first() { let rewind = match new_headers.first() {
Some(first) => first.height, 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..) { for (hash, header) in headers.drain(rewind as usize..) {
@ -575,4 +580,46 @@ mod tests {
"0e16637fe0700a7c52e9a6eaa58bd6ac7202652103be8f778680c66f51ad2e9b" "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()));
}
} }

View file

@ -131,8 +131,7 @@ static REBUILD_IPFS_PLACEHOLDER_BCMR_FOR_GENESIS_WITH_AUTHGUARD_CONTRACT: LazyLo
hex::decode("0902a9147ca97e01877e57897c6b00c08851d100887c635ab2756d51cd016a88674d0301404142434445464748494a4b4c4d4e4f505152535455565758595a6162636465666768696a6b6c6d6e6f707172737475767778797a303132333435363738392d5f6b007c8253a26365537f7c76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7b7c7e7c82539f666882760087636d677d537c94007c807e76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7c51937f757e686c7554893a10303132333435363738396162636465667c006b65517f7c5279785f84817f77517f756c7e6b52797c5499817f77517f756c7e6b82009c666d6c5689097f7b827b7c7f777e7e538978a8886b518ac0ce568a65766c537a538a6b74519c66756c766ba804015512207c7e548a01757c7e6b528a6c7c6b65766c537a538a6b74519c66756c6ca87c7e076a0442434d52207c7e51cd886801206c0f51ce8851d0009d6300cdc0c78868517e7e578a00cd8800ce00d18800cf00d28800d000d38800c600cca26952d10088c45387").unwrap() hex::decode("0902a9147ca97e01877e57897c6b00c08851d100887c635ab2756d51cd016a88674d0301404142434445464748494a4b4c4d4e4f505152535455565758595a6162636465666768696a6b6c6d6e6f707172737475767778797a303132333435363738392d5f6b007c8253a26365537f7c76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7b7c7e7c82539f666882760087636d677d537c94007c807e76011299527f77816c766b7c7f77517f75785c99527f77013f84816c766b7c7f77517f757e785699527f77013f84816c766b7c7f77517f757e7c527f77013f84816c766b7c7f77517f757e7c51937f757e686c7554893a10303132333435363738396162636465667c006b65517f7c5279785f84817f77517f756c7e6b52797c5499817f77517f756c7e6b82009c666d6c5689097f7b827b7c7f777e7e538978a8886b518ac0ce568a65766c537a538a6b74519c66756c766ba804015512207c7e548a01757c7e6b528a6c7c6b65766c537a538a6b74519c66756c6ca87c7e076a0442434d52207c7e51cd886801206c0f51ce8851d0009d6300cdc0c78868517e7e578a00cd8800ce00d18800cf00d28800d000d38800c600cca26952d10088c45387").unwrap()
}); });
/// The 127-byte commitment of the ORB IdoParams NFT (libriften /// The 127-byte commitment of the ORB IdoParams NFT. Byte layout:
/// packages/cauldron/src/orb/v0). Byte layout:
/// \[0\] version (must equal IDO_PARAMS_VERSION) /// \[0\] version (must equal IDO_PARAMS_VERSION)
/// [1..33] permanentPoolPlatformNfth /// [1..33] permanentPoolPlatformNfth
/// [33..65] delphiCategory /// [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. /// nfths are hashes and are kept in their on-chain order.
const IDO_PARAMS_COMMITMENT_SIZE: usize = 127; const IDO_PARAMS_COMMITMENT_SIZE: usize = 127;
/// The only ORB IdoParams NFT commitment version this indexer understands /// The only ORB IdoParams NFT commitment version this indexer understands.
/// (libriften `ORB_IDO_PARAMS_VERSION`). Any other value means the params NFT /// Any other value means the params NFT was minted for a newer/incompatible
/// was minted for a newer/incompatible layout and the IDO is not indexed. /// layout and the IDO is not indexed.
const IDO_PARAMS_VERSION: u8 = 0x00; const IDO_PARAMS_VERSION: u8 = 0x00;
pub struct IdoParamsNftCommitment { pub struct IdoParamsNftCommitment {
@ -597,7 +596,7 @@ fn build_offering_bytecode(
minOffer: &Integer, minOffer: &Integer,
// display byte order; baked into the bytecode in VM (reversed) order. None // 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 // 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]>, xTokenCategory: Option<&[u8]>,
) -> Vec<u8> { ) -> Vec<u8> {
let rev_xtoken_cat: Vec<u8> = xTokenCategory let rev_xtoken_cat: Vec<u8> = xTokenCategory
@ -688,8 +687,7 @@ fn build_partial_offering_initiator_bytecode(
} }
// The partial postlaunch bytecode stored in preinit output #7 (via the partial // The partial postlaunch bytecode stored in preinit output #7 (via the partial
// ido initiator bytecode). Composition per libriften templates/ido.json // ido initiator bytecode). Composition:
// `postLaunchPartialBytecode`:
// <xTokenCategory rev> <permanentLiquidityShare> <price> <platformFeeNFTH> // <xTokenCategory rev> <permanentLiquidityShare> <price> <platformFeeNFTH>
// <platformFee> <permanentLiquidityMinFee 2B> <permanentPoolPlatformNfth 32B> // <platformFee> <permanentLiquidityMinFee 2B> <permanentPoolPlatformNfth 32B>
// <executionFee 4B> contracts.IDOPostLaunch // <executionFee 4B> contracts.IDOPostLaunch
@ -1521,8 +1519,7 @@ fn parse_ido_preinit_tx_params(
minOffer: vm_number_to_bigint(&data[122..130]), minOffer: vm_number_to_bigint(&data[122..130]),
// On-chain in VM order; kept in display/UI order. An // On-chain in VM order; kept in display/UI order. An
// all-zero category is the native-BCH sentinel and // all-zero category is the native-BCH sentinel and
// normalizes to None (libriften // normalizes to None.
// isNativeXTokenCategory).
xTokenCategory: if data[130..162].iter().all(|&b| b == 0) { xTokenCategory: if data[130..162].iter().all(|&b| b == 0) {
None None
} else { } else {
@ -1870,7 +1867,7 @@ fn parse_ido_preinit_tx(
// In an IDO the offering layer never charges a platform fee; // In an IDO the offering layer never charges a platform fee;
// the fee is collected by the postlaunch covenant instead, so // the fee is collected by the postlaunch covenant instead, so
// every offering-layer contract is instantiated with a zero // every offering-layer contract is instantiated with a zero
// platformFee (libriften idoOfferingLayerParameters). // platformFee.
&Integer::from(0i64), &Integer::from(0i64),
&params.offering.delphiCategory, &params.offering.delphiCategory,
&params.offering.launchConditions.expiresAt, &params.offering.launchConditions.expiresAt,
@ -3526,7 +3523,7 @@ mod tests {
} }
// A native-BCH xToken bakes an EMPTY push (the contract branches on // A native-BCH xToken bakes an EMPTY push (the contract branches on
// xTokenCategory == 0x), matching libriften encodeIdoXTokenCategory. // xTokenCategory == 0x).
#[test] #[test]
fn partial_postlaunch_bakes_empty_push_for_native_x_token() { fn partial_postlaunch_bakes_empty_push_for_native_x_token() {
let partial = build_partial_postlaunch_bytecode(&PartialPostlaunchParameters { let partial = build_partial_postlaunch_bytecode(&PartialPostlaunchParameters {

View file

@ -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::cauldron::tokentoken::create_table as tokentoken_prepare_tables;
use crate::db::crc20::prepare_tables as crc20_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::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; use crate::db::oracle::prepare_tables as oracle_prepare_tables;
/// Create read and write database pools for a given database path /// 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; 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) = let (db_exists, moria_db_write, moria_db_read) =
create_db_pool(&db_path(db_dir, "moria.db"), read_slots.moria).await; create_db_pool(&db_path(db_dir, "moria.db"), read_slots.moria).await;
if !db_exists { let _ = db_exists; // schema is additive via IF NOT EXISTS
moria_prepare_tables(&moria_db_write).await; moria_prepare_tables(&moria_db_write).await;
}
// Initialize ido database // Initialize ido database
let (db_exists, ido_db_write, ido_db_read) = let (db_exists, ido_db_write, ido_db_read) =

View file

@ -11,7 +11,7 @@ pub mod cauldron;
pub mod crc20; pub mod crc20;
pub mod ido; pub mod ido;
pub mod init; pub mod init;
pub mod moria; pub mod moria_v11;
pub mod oracle; pub mod oracle;
pub mod orbconstants; pub mod orbconstants;
pub mod search; pub mod search;

View file

@ -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<String>,
pub principal: Option<i64>,
pub interest_rate: Option<i64>,
pub loan_timestamp: Option<i64>,
pub collateral_sats: Option<i64>,
pub tokens_amount: Option<i64>,
pub mtp_timestamp: i64,
// Refinance new terms
pub new_principal: Option<i64>,
pub new_interest_rate: Option<i64>,
pub new_loan_timestamp: Option<i64>,
pub new_collateral_sats: Option<i64>,
}
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<Self> {
let txid_blob: Vec<u8> = row.get("txid");
let blockhash_blob: Vec<u8> = row.get("blockhash");
let action_type: i64 = row.get("action_type");
let borrower_blob: Option<Vec<u8>> = row.get("borrower_hash");
Ok(Self {
txid: blob_to_display_hex::<Txid>(&txid_blob)?,
blockhash: blob_to_display_hex::<BlockHash>(&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<Option<(Vec<u8>, i64, i64, i64, i64)>> {
let row: Option<(Vec<u8>, 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<i64>,
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<usize> {
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<Vec<u8>> = 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<usize> {
// 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<bool> {
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<usize> {
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<Vec<MoriaEntry>> {
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<Vec<MoriaEntry>> {
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<u8>],
offset: i64,
limit: i64,
) -> Result<Vec<MoriaEntry>> {
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<serde_json::Value> {
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);
}
}

View file

@ -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<Network>) -> Option<MoriaV11TokenIds> {
match network {
Some(Network::Chipnet) => Some(MoriaV11TokenIds {
moria: "56d909e16cd3b4cecae685a7ca45baa292bf7550205c2ad59320a342c73754de"
.parse::<TokenID>()
.expect("valid chipnet moria v1-1 token_id"),
bp_oracle: "a0628bc21d10211a23f986fd12770e836bae43878db93db8b164ab8a5ab33c96"
.parse::<TokenID>()
.expect("valid chipnet bp_oracle v1-1 token_id"),
pool: "a533b7e96435af18136b534b43e465dd759db3ac33e8490b9bfd254b8c27ae48"
.parse::<TokenID>()
.expect("valid chipnet pool v1-1 token_id"),
bar: "bc98b2733ca1c2070586cce1a4216418830ccd9b19ff77b5d43d47542bb3b50d"
.parse::<TokenID>()
.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<Network>) -> Option<serde_json::Value> {
let ids = token_ids(network)?;
Some(serde_json::json!({
"scriptsig": hex::encode(REDEEM_TAIL),
"scriptpubkey": hex::encode(&ids.loan_locking_bytecode),
"operation": "union",
}))
}

867
src/db/moria_v11/ingest.rs Normal file
View file

@ -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<u8> {
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<u8>, Vec<u8>, i64, i64, i64, i64);
async fn lookup_loan_utxo(
pool: &SqlitePool,
txid: &Txid,
vout: u32,
) -> Result<Option<LoanUtxoRow>> {
let row: Option<LoanUtxoRow> = 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<i64>,
) -> 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<u8>,
delegate_hash: Vec<u8>,
policy: Option<CrankPolicy>,
fee_sats: Option<i64>,
}
fn crank_policy_from_cols(
delta: Option<i64>,
max_bp: Option<i64>,
rescue_lead_bp: Option<i64>,
up_leg_seconds: Option<i64>,
down_leg_seconds: Option<i64>,
) -> Option<CrankPolicy> {
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<Option<AllowanceUtxoRow>> {
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<u8>,
owner_hash: Vec<u8>,
delegate_hash: Vec<u8>,
policy: Option<CrankPolicy>,
fee_sats: Option<i64>,
}
async fn known_allowance_scripts(pool: &SqlitePool) -> Result<Vec<KnownAllowanceScript>> {
let mut out: Vec<KnownAllowanceScript> = Vec::new();
let mut seen: std::collections::HashMap<Vec<u8>, 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<u8> = 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<i64> = 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<i64>,
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<usize> {
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<Vec<u8>> = None;
let mut looked_up_delegate: Option<Vec<u8>> = 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<usize> {
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<bool> {
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<Vec<Txid>> {
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<u8> = 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<usize> {
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<usize> {
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)
}

454
src/db/moria_v11/mod.rs Normal file
View file

@ -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<String>,
pub delegate_hash: Option<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: Option<i64>,
pub interest_rate: Option<i64>,
pub loan_timestamp: Option<i64>,
pub collateral_sats: Option<i64>,
pub tokens_amount: Option<i64>,
pub mtp_timestamp: i64,
pub new_principal: Option<i64>,
pub new_interest_rate: Option<i64>,
pub new_loan_timestamp: Option<i64>,
pub new_collateral_sats: Option<i64>,
pub allowance_token_amount: Option<i64>,
pub allowance_sats: Option<i64>,
pub fee_take_sats: Option<i64>,
pub recovered_token_amount: Option<i64>,
pub recovered_sats: Option<i64>,
pub loan_output_index: Option<i64>,
}
#[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<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_max_bp: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_rescue_lead_bp: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_up_leg_seconds: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_down_leg_seconds: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fee_sats: Option<i64>,
}
#[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<Self> {
let txid_blob: Vec<u8> = row.get("txid");
let blockhash_blob: Vec<u8> = row.get("blockhash");
let action_type: i64 = row.get("action_type");
let borrower_blob: Option<Vec<u8>> = row.get("borrower_hash");
let delegate_blob: Option<Vec<u8>> = row.get("delegate_hash");
Ok(Self {
txid: blob_to_display_hex::<Txid>(&txid_blob)?,
blockhash: blob_to_display_hex::<BlockHash>(&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<Self> {
let txid_blob: Vec<u8> = row.get("txid");
let blockhash_blob: Vec<u8> = row.get("blockhash");
let borrower_blob: Vec<u8> = row.get("borrower_hash");
let delegate_blob: Vec<u8> = row.get("delegate_hash");
Ok(Self {
txid: blob_to_display_hex::<Txid>(&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>(&blockhash_blob)?,
})
}
}
impl MoriaV11AllowanceUtxo {
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Self> {
let txid_blob: Vec<u8> = row.get("txid");
let blockhash_blob: Vec<u8> = row.get("blockhash");
let owner_blob: Vec<u8> = row.get("owner_hash");
let delegate_blob: Vec<u8> = row.get("delegate_hash");
let script: Vec<u8> = row.get("locking_bytecode");
Ok(Self {
txid: blob_to_display_hex::<Txid>(&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>(&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;
}

308
src/db/moria_v11/query.rs Normal file
View file

@ -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<Vec<MoriaV11Entry>> {
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<Vec<MoriaV11LoanUtxo>> {
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<u8>],
offset: i64,
limit: i64,
) -> Result<Vec<MoriaV11Entry>> {
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<Vec<MoriaV11AllowanceUtxo>> {
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<Option<MoriaV11BpThreshold>> {
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<u8> = row.get("txid");
let blockhash_blob: Vec<u8> = row.get("blockhash");
Ok(Some(MoriaV11BpThreshold {
txid: blob_to_display_hex::<Txid>(&txid_blob)?,
blockhash: blob_to_display_hex::<BlockHash>(&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<Vec<MoriaV11Crankable>> {
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<u8> = row.get("borrower_hash");
let delegate: Vec<u8> = 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<i64> = allowance.get("policy_delta");
let max_bp: Option<i64> = 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::<Option<i64>, _>("policy_rescue_lead_bp")
.unwrap_or(0);
let up_leg: i64 = allowance
.get::<Option<i64>, _>("policy_up_leg_seconds")
.unwrap_or(i64::from(DEFAULT_UP_LEG_SECONDS));
let down_leg: i64 = allowance
.get::<Option<i64>, _>("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<u8> = row.get("txid");
let a_txid_blob: Vec<u8> = allowance.get("txid");
out.push(MoriaV11Crankable {
txid: blob_to_display_hex::<Txid>(&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::<Txid>(&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<serde_json::Value> {
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),
}))
}

999
src/db/moria_v11/staking.rs Normal file
View file

@ -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<BarAprWindow>,
pub pool: Option<PoolAprWindow>,
}
impl BarHistoryEntry {
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Self> {
let txid_blob: Vec<u8> = row.get("txid");
let blockhash_blob: Vec<u8> = row.get("blockhash");
Ok(Self {
txid: blob_to_display_hex::<Txid>(&txid_blob)?,
blockhash: blob_to_display_hex::<BlockHash>(&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<Self> {
let txid_blob: Vec<u8> = row.get("txid");
let blockhash_blob: Vec<u8> = row.get("blockhash");
Ok(Self {
txid: blob_to_display_hex::<Txid>(&txid_blob)?,
blockhash: blob_to_display_hex::<BlockHash>(&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<usize> {
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<usize> {
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<i64>,
limit: i64,
) -> Result<Vec<BarHistoryEntry>> {
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<i64>,
limit: i64,
) -> Result<Vec<PoolHistoryEntry>> {
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<Option<(i64, u64, u64)>> {
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<Option<(i64, u64, u64)>> {
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<Option<(i64, u64, u64)>> {
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<Option<(i64, u64, u64)>> {
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<NavAprWindow> {
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<NavAprWindow> 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<NavAprWindow> 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<Option<BarAprWindow>> {
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<Option<PoolAprWindow>> {
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<StakingAprResponse> {
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<u8>, 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<OutputData>) -> TxOut {
TxOut {
value: Amount::from_sat(sats),
script_pubkey: ScriptBuf::from_bytes(vec![0xaa, 0x20]),
token,
}
}
fn make_tx(outputs: Vec<TxOut>) -> 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<u8> {
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<u8> {
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);
}
}

1088
src/db/moria_v11/tests.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -10,9 +10,6 @@
//! deployment-agnostic. This module pins the **mainnet BCH/USD** deployment //! deployment-agnostic. This module pins the **mainnet BCH/USD** deployment
//! constants — token category ids for the v1 (legacy) and v2 (current) //! constants — token category ids for the v1 (legacy) and v2 (current)
//! contracts — that the indexer needs to recognise live oracle output. //! 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; use std::sync::OnceLock;
@ -78,11 +75,9 @@ mod tests {
} }
#[test] #[test]
fn v2_token_matches_libriften_constant() { fn v2_bchusd_token_hex_is_stable() {
// The hex must equal `DELPHI_CONTRACT_TOKEN` in // Pin the published mainnet v2 BCH/USD category so a typo in the
// `~/libriften/packages/delphi-contract/src/v2/mainnet.ts`. If the // constant cannot silently retarget "the" oracle.
// values drift, downstream packages and the indexer will disagree
// about which on-chain UTXOs count as "the v2 oracle".
assert_eq!( assert_eq!(
V2_BCHUSD_TOKEN_HEX, V2_BCHUSD_TOKEN_HEX,
"be0d0d8324e8cda41d34b85bd203ce2482256eb337a0ad0fea82c2ddd7306c88" "be0d0d8324e8cda41d34b85bd203ce2482256eb337a0ad0fea82c2ddd7306c88"

View file

@ -5,11 +5,9 @@
//! Per-network ORB v0 deployment constants. //! Per-network ORB v0 deployment constants.
//! //!
//! Mirrors libriften's `packages/cauldron/src/orb/v0/{chipnet,mainnet}.ts` //! Values here are MONEY-SAFETY-critical: while a network's value is the
//! deployment records. Values here are MONEY-SAFETY-critical: while a //! all-zero placeholder, the dependent indexer is disabled outright
//! network's value is the all-zero placeholder, the dependent indexer is //! (fail-closed) — nothing is indexed with assumed parameters.
//! disabled outright (fail-closed) — nothing is indexed with assumed
//! parameters.
use bitcoincash::Network; use bitcoincash::Network;
use riftenlabs_defi::{tokenbch_delegation, tokentoken_delegation}; 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 /// The delegation-pool platform NFTH (the platform-fee settlement destination
/// baked into every tokentoken/tokenbch pool's withdraw blob) on chipnet. /// baked into every tokentoken/tokenbch pool's withdraw blob) on chipnet.
/// One value serves BOTH delegation contracts. /// One value serves BOTH delegation contracts.
///
/// From libriften `orb/v0/chipnet.ts` (`ORB_V0_CHIPNET.poolPlatformNfth`).
const CHIPNET_POOL_PLATFORM_NFTH: &[u8; 32] = &[ const CHIPNET_POOL_PLATFORM_NFTH: &[u8; 32] = &[
0x07, 0x2d, 0x5f, 0xbe, 0xcf, 0xa1, 0xbc, 0x37, 0xa1, 0x3f, 0x58, 0xb1, 0x88, 0xf5, 0x0a, 0x5b, 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, 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 // TODO:: set the pool platform nfth for mainnet once ORB v0 deploys there.
// (libriften orb/v0/mainnet.ts is still all-zero too).
const MAINNET_POOL_PLATFORM_NFTH: &[u8; 32] = &[0u8; 32]; const MAINNET_POOL_PLATFORM_NFTH: &[u8; 32] = &[0u8; 32];
/// The ORB IdoParams NFT category (display byte order, like a token id) on /// 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 /// chipnet. Every IDO preinit must include this NFT (input #1, preserved at
/// output #10); its commitment carries the economic parameters the announced /// output #10); its commitment carries the economic parameters the announced
/// IDO parameters are validated against. /// IDO parameters are validated against.
///
/// From libriften `orb/v0/chipnet.ts` (`ORB_V0_CHIPNET.idoParams.token`).
const CHIPNET_IDO_PARAMS_NFT_CATEGORY: &[u8; 32] = &[ const CHIPNET_IDO_PARAMS_NFT_CATEGORY: &[u8; 32] = &[
0xc9, 0x16, 0x7d, 0x12, 0x39, 0x46, 0x27, 0xba, 0x28, 0x30, 0x70, 0x5f, 0xb2, 0xb0, 0xf0, 0x0b, 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, 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 // TODO:: set the ido params nft category for mainnet once ORB v0 deploys there.
// (libriften orb/v0/mainnet.ts is still all-zero too).
const MAINNET_IDO_PARAMS_NFT_CATEGORY: &[u8; 32] = &[0u8; 32]; const MAINNET_IDO_PARAMS_NFT_CATEGORY: &[u8; 32] = &[0u8; 32];
/// The ORB IdoParams NFT category for a network (all-zero = unconfigured; the /// The ORB IdoParams NFT category for a network (all-zero = unconfigured; the
@ -63,8 +55,7 @@ pub fn pool_platform_nfth(network: Option<Network>) -> [u8; 32] {
/// True once a real (non-placeholder) value is configured. The build-time /// True once a real (non-placeholder) value is configured. The build-time
/// `0xab…ab` contract placeholder counts as unconfigured too: pools built on /// `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 /// it must never be surfaced.
/// placeholder").
pub fn is_configured(value: &[u8; 32]) -> bool { pub fn is_configured(value: &[u8; 32]) -> bool {
*value != [0u8; 32] && value != tokentoken_delegation::PLATFORM_NFTH_PLACEHOLDER *value != [0u8; 32] && value != tokentoken_delegation::PLATFORM_NFTH_PLACEHOLDER
} }

View file

@ -41,12 +41,21 @@ pub fn electrum_get_tip(client: &Client) -> Result<(BlockHeader, u64)> {
Ok((deserialize(&hex::decode(header)?)?, height as u64)) Ok((deserialize(&hex::decode(header)?)?, height as u64))
} }
/// The (defi, oracle, ido, bcmr) mempool txid sets. /// The (defi, oracle, ido, bcmr, moria_v11) mempool txid sets.
pub type MempoolTxSets = (HashSet<Txid>, HashSet<Txid>, HashSet<Txid>, HashSet<Txid>); pub type MempoolTxSets = (
HashSet<Txid>,
HashSet<Txid>,
HashSet<Txid>,
HashSet<Txid>,
HashSet<Txid>,
);
/// Fetch defi (cauldron + delegation pools), oracle, ido and bcmr mempool /// Fetch defi, oracle, ido, bcmr and optional moria v1-1 mempool transactions.
/// transactions pub fn electrum_fetch_mempool(
pub fn electrum_fetch_mempool(client: &Client, network: Option<Network>) -> Result<MempoolTxSets> { client: &Client,
network: Option<Network>,
moria_filter: Option<Value>,
) -> Result<MempoolTxSets> {
let cauldron_filter = json!({ let cauldron_filter = json!({
"scriptsig": hex::encode(&V2_CONTRACT_TEMPLATE[(V2_CONTRACT_TEMPLATE.len() - 43)..]), // cauldron spends "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) "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<Network>) -> Resu
oracle_txs.extend(fetch_txs(oracle_v2_filter)?); oracle_txs.extend(fetch_txs(oracle_v2_filter)?);
let ido_txs = fetch_txs(ido_filter)?; let ido_txs = fetch_txs(ido_filter)?;
let bcmr_txs = fetch_txs(bcmr_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 /// Fetch blockchain tip from electrum server

View file

@ -12,13 +12,12 @@ use std::{
use bitcoin_hashes::Hash; use bitcoin_hashes::Hash;
use bitcoincash::{ use bitcoincash::{
blockdata::block::Header as BlockHeader, consensus::deserialize, Block, BlockHash, Network, blockdata::block::Header as BlockHeader, consensus::deserialize, Block, BlockHash, Network,
TokenID, Transaction, Txid, Transaction, Txid,
}; };
use electrum_client_netagnostic::{Client, ElectrumApi, Param}; use electrum_client_netagnostic::{Client, ElectrumApi, Param};
use log::{debug, info, warn}; use log::{debug, info, warn};
use riftenlabs_defi::cauldron::{parse_cauldrons_from_tx, ParsedContract}; use riftenlabs_defi::cauldron::{parse_cauldrons_from_tx, ParsedContract};
use riftenlabs_defi::chainutil::{compute_outpoint_hash, OutPointHash}; use riftenlabs_defi::chainutil::{compute_outpoint_hash, OutPointHash};
use riftenlabs_defi::moria::MoriaTokenIds;
use crate::{ use crate::{
bcmr::index_bcmr, bcmr::index_bcmr,
@ -47,28 +46,6 @@ use crate::{
}; };
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
/// Get the Moria token IDs for a given network
fn moria_token_ids(network: Option<Network>) -> MoriaTokenIds {
match network {
Some(Network::Chipnet) => MoriaTokenIds {
moria: "29566f4884539dbcedfcb55cc7f5e66b5ae14975b70c0b6eb12de7e9707775ea"
.parse::<TokenID>()
.expect("valid chipnet moria token_id"),
bp_oracle: "f3d6b85bfb0eaaf417ccabc8c8032464c5ec410e40b3b13f0c369a541bfb2a6a"
.parse::<TokenID>()
.expect("valid chipnet bp_oracle token_id"),
},
_ => MoriaTokenIds {
moria: "b38a33f750f84c5c169a6f23cb873e6e79605021585d4f3408789689ed87f366"
.parse::<TokenID>()
.expect("valid mainnet moria token_id"),
bp_oracle: "01711e39e7bf3b8ca0d9a6fc6ea32e340caa1d64dc7d1dc51fae20fd66755558"
.parse::<TokenID>()
.expect("valid mainnet bp_oracle token_id"),
},
}
}
pub async fn update_mempool( pub async fn update_mempool(
db: &DB, db: &DB,
electrum: Arc<Mutex<Client>>, electrum: Arc<Mutex<Client>>,
@ -78,8 +55,10 @@ pub async fn update_mempool(
db::cauldron::mempool::load_mempool(&db.cauldron_w).await?; db::cauldron::mempool::load_mempool(&db.cauldron_w).await?;
let electrum_clone = electrum.clone(); let electrum_clone = electrum.clone();
let (cauldron_txs, oracle_txs, ido_txs, bcmr_txs) = tokio::task::spawn_blocking(move || { let moria_filter = db::moria_v11::mempool_filter(network);
electrum_fetch_mempool(&electrum_clone.lock().unwrap(), 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??; .await??;
@ -318,6 +297,53 @@ pub async fn update_mempool(
let txs_to_add = ttor_sorted_kahn(txs_to_add); let txs_to_add = ttor_sorted_kahn(txs_to_add);
index_bcmr(&db.bcmr_w, &BlockHash::all_zeros(), txs_to_add).await?; 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<Transaction> = 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(()) Ok(())
} }
@ -389,10 +415,18 @@ pub async fn index_blocks(
// uses Handle::current().block_on() which panics on Tokio worker threads. // uses Handle::current().block_on() which panics on Tokio worker threads.
let chain_for_update = chain.clone(); let chain_for_update = chain.clone();
let undoer_db = db.clone(); let undoer_db = db.clone();
let tip_hash = tip_header.block_hash();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let chain_guard = chain_for_update.lock().unwrap(); let chain_guard = chain_for_update.lock().unwrap();
let undoer = StoreBlockUndoer::new(undoer_db)?; 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??; .await??;
debug!("Header update done"); debug!("Header update done");
@ -631,16 +665,29 @@ pub async fn index_blocks(
// oracle updates // oracle updates
index_oracle(&db.oracle_w, &sorted_txs, &blockhash).await?; index_oracle(&db.oracle_w, &sorted_txs, &blockhash).await?;
// moria lending // moria v1-1 lending (chipnet only)
let moria_actions = db::moria::index_moria( 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, &db.moria_w,
&sorted_txs, &sorted_txs,
&blockhash, &blockhash,
mtp as i64, mtp as i64,
&moria_token_ids(network), &token_ids,
) )
.await?; .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( db::ido::index_txs(
network, network,
&db.ido_w, &db.ido_w,

View file

@ -232,7 +232,7 @@ async fn start_program(
)?; )?;
db::oracle::clear_mempool(&db.oracle_w).await.unwrap(); 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(); db::bcmr::clear_mempool(&db.bcmr_w).await.unwrap();
let indexing_in_progress_clone = indexing_in_progress.clone(); let indexing_in_progress_clone = indexing_in_progress.clone();
@ -449,14 +449,24 @@ async fn launch() -> _ {
materialized_end: AtomicI64::new(initial_ohlcv_end), materialized_end: AtomicI64::new(initial_ohlcv_end),
}); });
// Synchronous post-IBD backfill: run the full ohlcv_1h materialisation before // Post-IBD backfill: run the full ohlcv_1h materialisation before allowing
// allowing metrics_cache and other background writers to start. We reuse the // metrics_cache and other background writers to start. We reuse the
// indexing_in_progress flag so metrics_cache backs off during this window. // indexing_in_progress flag so metrics_cache backs off during this window.
// //
// Skipped after a version wipe: the backfill runs before `rocket::build()` returns, // Runs in a task: it must WAIT for IBD, and main() sits before
// so re-materialising all of history here would refuse connections for the whole // rocket::build(), so awaiting it inline kept the API from binding for the
// rebuild rather than degrading to the (correct, slower) raw path. // 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 { if !ohlcv_wiped {
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_BATCH_SECS: i64 = 24 * 3600;
const BACKFILL_SAFETY_SECS: i64 = 3 * 3600; const BACKFILL_SAFETY_SECS: i64 = 3 * 3600;
@ -473,13 +483,15 @@ async fn launch() -> _ {
let cutoff = (now - BACKFILL_SAFETY_SECS) / 3600 * 3600; let cutoff = (now - BACKFILL_SAFETY_SECS) / 3600 * 3600;
let since_opt = match max_bucket_ts { let since_opt = match max_bucket_ts {
Some(ts) => Some(ts + 3600), Some(ts) => Some(ts + 3600),
None => match db::cauldron::ohlcv::get_min_trade_bucket_ts(&dbpool.cauldron_r).await { None => {
match db::cauldron::ohlcv::get_min_trade_bucket_ts(&dbpool.cauldron_r).await {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
warn!("ohlcv backfill: could not read min trade ts: {e}"); warn!("ohlcv backfill: could not read min trade ts: {e}");
None None
} }
}, }
}
}; };
if since_opt.is_none() { if since_opt.is_none() {
info!("ohlcv backfill: no confirmed trades found, skipping"); info!("ohlcv backfill: no confirmed trades found, skipping");
@ -514,6 +526,7 @@ async fn launch() -> _ {
info!("ohlcv: post-IBD backfill complete"); info!("ohlcv: post-IBD backfill complete");
indexing_in_progress.store(false, Ordering::Relaxed); indexing_in_progress.store(false, Ordering::Relaxed);
});
} }
// Background task: incrementally materialise new 1-hour OHLCV buckets as blocks arrive. // Background task: incrementally materialise new 1-hour OHLCV buckets as blocks arrive.
@ -664,12 +677,17 @@ async fn launch() -> _ {
], ],
) )
.mount( .mount(
"/moria", "/moria/v11",
routes![ routes![
rpc::moria::loan_history, rpc::moria_v11::loan_history,
rpc::moria::global_history, rpc::moria_v11::global_history,
rpc::moria::active_loans, rpc::moria_v11::active_loans,
rpc::moria::moria_stats, 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( .mount(

View file

@ -18,7 +18,7 @@ pub mod contract;
pub mod err; pub mod err;
pub mod health; pub mod health;
pub mod ido; pub mod ido;
pub mod moria; pub mod moria_v11;
pub mod oracle; pub mod oracle;
pub mod pool; pub mod pool;
pub mod price; pub mod price;

View file

@ -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<Vec<u8>, (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/<borrower_hash>/history")]
pub async fn loan_history(borrower_hash: &str, db: &State<DB>) -> CachedApiResult<Value> {
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?<offset>&<limit>&<nfth>")]
pub async fn global_history(
offset: Option<i64>,
limit: Option<i64>,
nfth: Option<&str>,
db: &State<DB>,
) -> CachedApiResult<Value> {
let offset = offset.unwrap_or(0).max(0);
let limit = limit.unwrap_or(50).clamp(1, 200);
let nfth_filter: Vec<Vec<u8>> = 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<DB>) -> CachedApiResult<Value> {
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<DB>) -> CachedApiResult<Value> {
let stats = get_stats(&db.moria_r).await.map_err(db_error)?;
Ok(cached_ok(stats, CACHE_AGGREGATE))
}

224
src/rpc/moria_v11.rs Normal file
View file

@ -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<Vec<u8>, (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/<borrower_hash>/history")]
pub async fn loan_history(borrower_hash: &str, db: &State<DB>) -> CachedApiResult<Value> {
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?<offset>&<limit>&<nfth>")]
pub async fn global_history(
offset: Option<i64>,
limit: Option<i64>,
nfth: Option<&str>,
db: &State<DB>,
) -> CachedApiResult<Value> {
let offset = offset.unwrap_or(0).max(0);
let limit = limit.unwrap_or(50).clamp(1, 200);
let nfth_filter: Vec<Vec<u8>> = 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<DB>) -> CachedApiResult<Value> {
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/<owner_hash>")]
pub async fn allowances(owner_hash: &str, db: &State<DB>) -> CachedApiResult<Value> {
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?<now>")]
pub async fn crankable(now: Option<i64>, db: &State<DB>) -> CachedApiResult<Value> {
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<DB>) -> CachedApiResult<Value> {
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?<since>&<limit>")]
pub async fn bar_history(
since: Option<i64>,
limit: Option<i64>,
db: &State<DB>,
) -> CachedApiResult<Value> {
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?<since>&<limit>")]
pub async fn pool_history(
since: Option<i64>,
limit: Option<i64>,
db: &State<DB>,
) -> CachedApiResult<Value> {
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?<window>")]
pub async fn staking_apr(window: Option<i64>, db: &State<DB>) -> CachedApiResult<Value> {
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,
))
}