Merge branch 'moria' into 'master'
Added support for indexing Moria contracts See merge request riftenlabs/riftenlabs-indexer!77
This commit is contained in:
commit
8ed611ada2
12 changed files with 1056 additions and 5 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
821
src/db/moria/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
66
src/index.rs
66
src/index.rs
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
14
src/main.rs
14
src/main.rs
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
118
src/rpc/moria.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue