868 lines
30 KiB
Rust
868 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)
|
||
|
|
}
|