riftenlabs-indexer/src/db/moria_v11/ingest.rs
Dagur Valberg Johannsson a5e91afea8 Cut over to Moria v1-1; drop v1 indexing
Replace the v1 moria indexer with v1-1 tables and RPC. Chipnet tracks the
live deployment only; mainnet stays idle until a v1-1 deployment exists.

v1-1 rows carry delegate hash, borrow-time allowance deposits, crank fee
takes, and allowance-close recoveries. Policy lives on the parked
allowance (verified nSequence hint or first spend), not on the loan NFT.
Bar/pool history and realized NAV APR are confirmed-blocks only.

Bind the API before IBD so Rocket is up during sync. Empty electrum
header batches shrink the chain instead of panicking.

Depend on published riftenlabs-defi 0.4.6.
2026-08-18 09:15:19 +02:00

867 lines
30 KiB
Rust

// Copyright (C) 2024-2026 Whiterun LLC
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
//! 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)
}