Merge branch 'moria' into 'master'

Added support for indexing Moria contracts

See merge request riftenlabs/riftenlabs-indexer!77
This commit is contained in:
Dagur Valberg Johannsson 2026-04-08 08:25:07 +00:00
commit 8ed611ada2
12 changed files with 1056 additions and 5 deletions

4
Cargo.lock generated
View file

@ -2154,9 +2154,9 @@ dependencies = [
[[package]]
name = "riftenlabs-defi"
version = "0.1.4"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93f0d8096ed55bebd47d84776b565fae9dd7b195c73c4520753aa07c1dfb45b7"
checksum = "adefa28c0d92eabb6d22de714c63bd966178a509f3d100d9d9ca82765c92688d"
dependencies = [
"anyhow",
"bitcoin_hashes",

View file

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

View file

@ -39,3 +39,15 @@ name = "oracle_read_slots"
type = "u32"
doc = "Number of read connection pool slots for the oracle database (default: 8)"
default = "8"
[[param]]
name = "start_height"
type = "u64"
doc = "Start indexing from this block height instead of the default (e.g. 252000 to skip ahead on chipnet). Only takes effect on fresh databases with no indexed blocks."
default = "0"
[[param]]
name = "moria_read_slots"
type = "u32"
doc = "Number of read connection pool slots for the Moria lending database (default: 8)"
default = "8"

View file

@ -74,6 +74,12 @@ impl BlockUndoer for StoreBlockUndoer {
{
was_indexed = true;
}
if db::moria::delete_entries_for_block(&self.db.moria_w, &blockheader.block_hash())
.await?
> 0
{
was_indexed = true;
}
if was_indexed {
config_set(

View file

@ -15,6 +15,7 @@ use crate::db::bcmr::prepare_tables as bcmr_prepare_tables;
use crate::db::cauldron::config::check_db_version;
use crate::db::cauldron::prepare_tables as cauldron_prepare_tables;
use crate::db::crc20::prepare_tables as crc20_prepare_tables;
use crate::db::moria::prepare_tables as moria_prepare_tables;
use crate::db::oracle::prepare_tables as oracle_prepare_tables;
/// Create read and write database pools for a given database path
@ -86,6 +87,7 @@ pub struct ReadSlots {
pub bcmr: u32,
pub crc20: u32,
pub oracle: u32,
pub moria: u32,
}
/// Initialize all databases and return a DB struct
@ -123,6 +125,13 @@ pub async fn initialize_databases(network: &str, read_slots: ReadSlots) -> Resul
oracle_prepare_tables(&oracle_db_write).await;
}
// Initialize moria lending database
let (db_exists, moria_db_write, moria_db_read) =
create_db_pool(&db_path(db_dir, "moria.db"), read_slots.moria).await;
if !db_exists {
moria_prepare_tables(&moria_db_write).await;
}
Ok(DB {
cauldron_w: cauldron_db_write,
cauldron_r: cauldron_db_read,
@ -132,5 +141,7 @@ pub async fn initialize_databases(network: &str, read_slots: ReadSlots) -> Resul
crc20_r: crc20_db_read,
oracle_w: oracle_db_write,
oracle_r: oracle_db_read,
moria_w: moria_db_write,
moria_r: moria_db_read,
})
}

View file

@ -10,6 +10,7 @@ pub mod blob;
pub mod cauldron;
pub mod crc20;
pub mod init;
pub mod moria;
pub mod oracle;
pub mod search;
@ -31,4 +32,7 @@ pub struct DB {
// on-chain oracles
pub oracle_w: SqlitePool,
pub oracle_r: SqlitePool,
// moria lending protocol
pub moria_w: SqlitePool,
pub moria_r: SqlitePool,
}

821
src/db/moria/mod.rs Normal file
View file

@ -0,0 +1,821 @@
// 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::hex::ToHex;
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.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.to_hex(),
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_hash(bitcoin_hashes::sha256d::Hash::from_slice(&[0xAA; 32]).unwrap())
}
fn test_txid(n: u8) -> Txid {
Txid::from_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

@ -13,10 +13,13 @@ use bitcoin_hashes::{
hex::{FromHex, ToHex},
Hash,
};
use bitcoincash::{consensus::deserialize, Block, BlockHash, Network, Transaction, Txid};
use bitcoincash::{
consensus::deserialize, Block, BlockHash, BlockHeader, Network, TokenID, Transaction, Txid,
};
use electrum_client_netagnostic::{Client, ElectrumApi, Param};
use log::{debug, info, warn};
use riftenlabs_defi::cauldron::{parse_cauldrons_from_tx, ParsedContract};
use riftenlabs_defi::moria::MoriaTokenIds;
use crate::{
bcmr::index_bcmr,
@ -43,6 +46,32 @@ use crate::{
};
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: TokenID::from_hex(
"29566f4884539dbcedfcb55cc7f5e66b5ae14975b70c0b6eb12de7e9707775ea",
)
.expect("valid chipnet moria token_id"),
bp_oracle: TokenID::from_hex(
"f3d6b85bfb0eaaf417ccabc8c8032464c5ec410e40b3b13f0c369a541bfb2a6a",
)
.expect("valid chipnet bp_oracle token_id"),
},
_ => MoriaTokenIds {
moria: TokenID::from_hex(
"b38a33f750f84c5c169a6f23cb873e6e79605021585d4f3408789689ed87f366",
)
.expect("valid mainnet moria token_id"),
bp_oracle: TokenID::from_hex(
"01711e39e7bf3b8ca0d9a6fc6ea32e340caa1d64dc7d1dc51fae20fd66755558",
)
.expect("valid mainnet bp_oracle token_id"),
},
}
}
pub async fn update_mempool(db: &DB, electrum: Arc<Mutex<Client>>) -> Result<()> {
let our_mempool_txs: HashSet<Txid> =
db::cauldron::mempool::load_mempool(&db.cauldron_w).await?;
@ -155,6 +184,7 @@ pub async fn index_blocks(
bcmr_enabled: bool,
network: Option<Network>,
ibd_state: Option<Arc<IbdState>>,
start_height: u64,
) -> Result<BlockHash> {
let electrum_clone = client.clone();
let (tip_header, tip_height) =
@ -234,7 +264,28 @@ pub async fn index_blocks(
.block_on(config_get(&db.cauldron_r, KEY_LAST_INDEXED))
.unwrap();
let mut last_indexed = if let Some(last) = last_indexed {
if start_height > 0 {
warn!("--start-height is ignored because database already has indexed blocks. Delete the database to re-index from a different height.");
}
BlockHash::from_hex(&last).unwrap()
} else if start_height > 0 {
// Resolve configured start height to a block hash via electrum
let resp = client_cpy
.lock()
.unwrap()
.raw_call(
"blockchain.block.header",
vec![Param::U32(start_height as u32)],
)
.unwrap_or_else(|e| panic!("Failed to fetch header at height {start_height}: {e}"));
let header_hex: String = serde_json::from_str(&resp.to_string()).unwrap();
let header: BlockHeader = deserialize(&hex::decode(&header_hex).unwrap()).unwrap();
info!(
"Starting indexing from configured height {} ({})",
start_height,
header.block_hash().to_hex()
);
header.block_hash()
} else {
start_block_hash
};
@ -362,6 +413,16 @@ pub async fn index_blocks(
// oracle updates
index_oracle(&db.oracle_w, &sorted_txs, &blockhash).await?;
// moria lending
let moria_actions = db::moria::index_moria(
&db.moria_w,
&sorted_txs,
&blockhash,
mtp as i64,
&moria_token_ids(network),
)
.await?;
let autheader_updates = if bcmr_enabled {
let updates = index_bcmr(&db.bcmr_w, &blockhash, sorted_txs).await?;
updates as i64
@ -377,11 +438,12 @@ pub async fn index_blocks(
}
info!(
"Indexed {}; mtp: {}, height {}, {} trades, {} autheader updates.",
"Indexed {}; mtp: {}, height {}, {} trades, {} moria, {} autheader updates.",
blockhash.to_hex(),
mtp,
block_height,
total_cauldrons,
moria_actions,
autheader_updates,
);
}

View file

@ -168,6 +168,7 @@ async fn start_program(
bcmr: config.bcmr_read_slots,
crc20: config.crc20_read_slots,
oracle: config.oracle_read_slots,
moria: config.moria_read_slots,
},
)
.await?;
@ -228,9 +229,11 @@ async fn start_program(
)?;
db::oracle::clear_mempool(&db.oracle_w).await.unwrap();
db::moria::clear_mempool(&db.moria_w).await.unwrap();
let indexing_in_progress_clone = indexing_in_progress.clone();
let ibd_state_clone = ibd_state.clone();
let start_height = config.start_height;
tokio::spawn(async move {
let db = db_cpy;
@ -259,6 +262,7 @@ async fn start_program(
true,
Some(network),
Some(ibd_state_clone.clone()),
start_height,
)
.await
{
@ -317,6 +321,7 @@ async fn start_program(
true,
Some(network),
None,
0, // start_height only matters for initial sync
)
.await
{
@ -609,6 +614,15 @@ async fn launch() -> _ {
rpc::oracle::oracle_get_history
],
)
.mount(
"/moria",
routes![
rpc::moria::loan_history,
rpc::moria::global_history,
rpc::moria::active_loans,
rpc::moria::moria_stats,
],
)
.mount("/", routes![rpc::health::health])
.attach(cors)
}

View file

@ -17,6 +17,7 @@ pub mod candlesticks;
pub mod contract;
pub mod err;
pub mod health;
pub mod moria;
pub mod oracle;
pub mod pool;
pub mod price;

118
src/rpc/moria.rs Normal file
View file

@ -0,0 +1,118 @@
// 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))
}

View file

@ -41,6 +41,8 @@ mod test_utils {
crc20_r: pool.clone(),
oracle_w: pool.clone(),
oracle_r: pool.clone(),
moria_w: pool.clone(),
moria_r: pool.clone(),
}
}
}