riftenlabs-indexer/src/db/moria_v11/tests.rs

1296 lines
39 KiB
Rust
Raw Normal View History

// 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 super::*;
use bitcoin_hashes::Hash;
use bitcoincash::absolute::LockTime;
use bitcoincash::blockdata::token::{OutputData, Structure, TokenAmount};
use bitcoincash::transaction::Version;
use bitcoincash::{
Amount, BlockHash, OutPoint, ScriptBuf, Sequence, TokenID, Transaction, TxIn, TxOut, Witness,
};
use riftenlabs_defi::moria_v1_1::{
crank_allowance_locking_bytecode, crank_hint_to_policy, decode_crank_hint,
encode_loan_commitment_v11, principal_to_base_units, CrankPolicy, LoanCommitmentV11,
MoriaV11TokenIds, ALLOWANCE_METHOD_CLOSE, ALLOWANCE_METHOD_CRANK, DEFAULT_DOWN_LEG_SECONDS,
LOAN_METHOD_REDEEM, LOAN_METHOD_REPAY, LOAN_METHOD_RETARGET,
};
use sqlx::SqlitePool;
/// Whole-MUSD → base units as i64 (DB/API). Scale lives only in defi `MUSD_BASE_UNITS_PER_MUSD`.
fn principal_base_units(principal_musd: u32) -> i64 {
principal_to_base_units(principal_musd) as i64
}
const MORIA_METHOD_BORROW: u8 = 2;
const MORIA_METHOD_REPAY: u8 = 0;
const MORIA_METHOD_RETARGET: u8 = 4;
async fn setup(pool: SqlitePool) {
prepare_tables(&pool).await;
}
fn tid(b: u8) -> TokenID {
TokenID::from_byte_array([b; 32])
}
fn token_ids() -> MoriaV11TokenIds {
MoriaV11TokenIds {
moria: tid(0xA1),
bp_oracle: tid(0xB2),
pool: tid(0xC3),
bar: tid(0xD4),
loan_locking_bytecode: Vec::new(),
}
}
fn test_loan_lock() -> Vec<u8> {
hex::decode("aa208bbc3b3f65b354e69a93175c70f3cbb619154ae2902878521c17edcbd83cc12f87").unwrap()
}
fn token_ids_with_loan_lock() -> MoriaV11TokenIds {
let mut ids = token_ids();
ids.loan_locking_bytecode = test_loan_lock();
ids
}
fn test_policy(delta: u32, max_bp: u32) -> CrankPolicy {
CrankPolicy {
delta,
max_bp,
rescue_lead_bp: 0,
up_leg_seconds: 3_600,
down_leg_seconds: 86_400,
}
}
fn ta(n: i64) -> TokenAmount {
TokenAmount::from_int(n).expect("non-negative")
}
fn ft(id: TokenID, amount: i64) -> OutputData {
OutputData {
id,
bitfield: Structure::HasAmount as u8,
amount: ta(amount),
commitment: vec![],
}
}
fn nft(id: TokenID, commitment: Vec<u8>) -> OutputData {
let mut bitfield = Structure::HasNFT as u8;
if !commitment.is_empty() {
bitfield |= Structure::HasCommitmentLength as u8;
}
OutputData {
id,
bitfield,
amount: TokenAmount::ZERO,
commitment,
}
}
fn minting(id: TokenID, commitment: Vec<u8>, amount: i64) -> OutputData {
let mut bitfield = Structure::HasNFT as u8 | 0x02;
if !commitment.is_empty() {
bitfield |= Structure::HasCommitmentLength as u8;
}
if amount > 0 {
bitfield |= Structure::HasAmount as u8;
}
OutputData {
id,
bitfield,
amount: ta(amount),
commitment,
}
}
fn push_data(buf: &mut Vec<u8>, data: &[u8]) {
if data.is_empty() {
buf.push(0x00);
return;
}
if data.len() <= 75 {
buf.push(data.len() as u8);
buf.extend_from_slice(data);
return;
}
if data.len() <= 255 {
buf.push(0x4c);
buf.push(data.len() as u8);
buf.extend_from_slice(data);
return;
}
buf.push(0x4d);
buf.extend_from_slice(&(data.len() as u16).to_le_bytes());
buf.extend_from_slice(data);
}
fn push_method(buf: &mut Vec<u8>, method: u8) {
match method {
0 => buf.push(0x00),
1..=16 => buf.push(0x50 + method),
n => {
buf.push(1);
buf.push(n);
}
}
}
fn unlock(method: u8, args: &[&[u8]]) -> ScriptBuf {
let mut bytes = Vec::new();
for a in args {
push_data(&mut bytes, a);
}
push_method(&mut bytes, method);
push_data(&mut bytes, &[0x51, 0x51, 0x51, 0x51]);
ScriptBuf::from_bytes(bytes)
}
fn txin(prev: u8, vout: u32, script_sig: ScriptBuf) -> TxIn {
TxIn {
previous_output: OutPoint {
txid: Txid::from_byte_array([prev; 32]),
vout,
},
script_sig,
sequence: Sequence::ZERO,
witness: Witness::new(),
}
}
fn bare(sats: u64) -> TxOut {
TxOut {
value: Amount::from_sat(sats),
script_pubkey: ScriptBuf::from_bytes(vec![0x51]),
token: None,
}
}
fn out(sats: u64, token: Option<OutputData>) -> TxOut {
TxOut {
value: Amount::from_sat(sats),
script_pubkey: ScriptBuf::from_bytes(vec![0xaa, 0x20]),
token,
}
}
fn out_script(sats: u64, script: Vec<u8>, token: Option<OutputData>) -> TxOut {
TxOut {
value: Amount::from_sat(sats),
script_pubkey: ScriptBuf::from_bytes(script),
token,
}
}
fn make_tx(inputs: Vec<TxIn>, outputs: Vec<TxOut>) -> Transaction {
Transaction {
version: Version::TWO,
lock_time: LockTime::ZERO,
input: inputs,
output: outputs,
}
}
fn commitment(borrower: [u8; 32], delegate: [u8; 32], principal: u32, bp: u16, ts: u64) -> Vec<u8> {
encode_loan_commitment_v11(&LoanCommitmentV11 {
borrower_nft_hash: borrower,
delegate_nft_hash: delegate,
principal,
annual_interest_rate_bp: bp,
timestamp: ts,
})
.to_vec()
}
fn blockhash(n: u8) -> BlockHash {
BlockHash::from_raw_hash(bitcoin_hashes::sha256d::Hash::from_slice(&[n; 32]).unwrap())
}
fn borrower() -> [u8; 32] {
[0x11; 32]
}
fn delegate() -> [u8; 32] {
[0x22; 32]
}
fn borrow_tx_with_allowance(ids: &MoriaV11TokenIds, ts: u64, rate: u16) -> Transaction {
let c = commitment(borrower(), delegate(), 1_000, rate, ts);
let allowance_script = vec![0xaa, 0x20, 0xAB, 0xCD];
make_tx(
vec![
txin(0x01, 0, unlock(MORIA_METHOD_BORROW, &[])),
txin(0x02, 0, unlock(0, &[])),
txin(0x03, 0, ScriptBuf::new()),
txin(0x04, 0, unlock(0, &[])),
txin(0x05, 0, unlock(0, &[])),
],
vec![
out(1_000, Some(minting(ids.moria, vec![0x01], 900_000))),
out(1_000, Some(nft(tid(0xD1), vec![0x11; 16]))),
out(9_000_000, Some(nft(ids.moria, c))),
out(1_000, Some(ft(ids.moria, principal_base_units(800)))),
bare(1_000),
bare(1_000),
bare(1_000),
bare(1_000),
bare(1_000),
out_script(
2_000,
allowance_script,
Some(ft(ids.moria, principal_base_units(200))),
),
],
)
}
/// SDK `encodeCrankHint({200, 2000, 3d, 7500})`.
fn stock_crank_hint_seq() -> u32 {
0x8000_1031
}
fn borrow_tx_with_verified_hint(ids: &MoriaV11TokenIds, ts: u64, rate: u16) -> Transaction {
let seq = stock_crank_hint_seq();
let hint = decode_crank_hint(seq).expect("stock hint");
let policy = crank_hint_to_policy(&hint);
let bp = ids.bp_oracle.to_byte_array();
let script = crank_allowance_locking_bytecode(
&ids.loan_locking_bytecode,
&borrower(),
&delegate(),
hint.fee_sats,
&bp,
&policy,
);
let c = commitment(borrower(), delegate(), 1_000, rate, ts);
let mut delegate_in = txin(0x05, 0, unlock(0, &[]));
delegate_in.sequence = Sequence::from_consensus(seq);
make_tx(
vec![
txin(0x01, 0, unlock(MORIA_METHOD_BORROW, &[])),
txin(0x02, 0, unlock(0, &[])),
txin(0x03, 0, ScriptBuf::new()),
txin(0x04, 0, unlock(0, &[])),
delegate_in,
],
vec![
out(1_000, Some(minting(ids.moria, vec![0x01], 900_000))),
out(1_000, Some(nft(tid(0xD1), vec![0x11; 16]))),
out(9_000_000, Some(nft(ids.moria, c))),
out(1_000, Some(ft(ids.moria, principal_base_units(800)))),
bare(1_000),
bare(1_000),
bare(1_000),
bare(1_000),
out_script(1_000, script.to_vec(), None),
out_script(
7_500,
script.to_vec(),
Some(ft(ids.moria, principal_base_units(200))),
),
],
)
}
#[rocket::async_test]
async fn index_borrow_with_allowance() {
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let ids = token_ids();
let bh = blockhash(0xAA);
let tx = borrow_tx_with_allowance(&ids, 1_800_000_000, 400);
let n = index_moria_v11(pool, &[tx], &bh, 1_800_000_000, &ids)
.await
.unwrap();
assert_eq!(n, 1);
let history = get_loan_history(pool, &borrower()).await.unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].action_type, "borrow");
assert_eq!(history[0].principal, Some(1_000));
assert_eq!(
history[0].delegate_hash.as_deref(),
Some(hex::encode(delegate()).as_str())
);
assert_eq!(
history[0].allowance_token_amount,
Some(principal_base_units(200))
);
assert_eq!(history[0].allowance_sats, Some(2_000));
let loans = get_active_loans(pool).await.unwrap();
assert_eq!(loans.len(), 1);
assert_eq!(loans[0].interest_rate, 400);
let allowances = get_allowances_by_owner(pool, &borrower()).await.unwrap();
assert_eq!(allowances.len(), 1);
assert_eq!(allowances[0].token_amount, principal_base_units(200));
assert_eq!(allowances[0].vout, 9);
assert_eq!(allowances[0].policy_delta, None);
assert_eq!(allowances[0].policy_max_bp, None);
assert_eq!(allowances[0].policy_rescue_lead_bp, None);
assert_eq!(allowances[0].policy_up_leg_seconds, None);
assert_eq!(allowances[0].policy_down_leg_seconds, None);
assert_eq!(allowances[0].fee_sats, None);
}
#[rocket::async_test]
async fn known_script_topup_copies_policy_and_fee() {
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let ids = token_ids();
let bh1 = blockhash(0xB1);
let bh2 = blockhash(0xB2);
let borrow = borrow_tx_with_allowance(&ids, 1_800_000_000, 400);
index_moria_v11(
pool,
std::slice::from_ref(&borrow),
&bh1,
1_800_000_000,
&ids,
)
.await
.unwrap();
let borrow_txid = borrow.compute_txid();
let seeded = get_allowances_by_owner(pool, &borrower()).await.unwrap();
let script = hex::decode(&seeded[0].locking_bytecode).unwrap();
let policy = test_policy(200, 2_000);
insert_allowance_utxo(
pool,
&borrow_txid,
seeded[0].vout as u32,
&borrower(),
&delegate(),
&script,
seeded[0].token_amount,
seeded[0].sats as u64,
&bh1,
Some(&policy),
Some(7_500),
)
.await
.unwrap();
let topup = make_tx(
vec![txin(0x99, 0, unlock(0, &[]))],
vec![out_script(
8_000,
script.clone(),
Some(ft(ids.moria, 50_000)),
)],
);
index_moria_v11(
pool,
std::slice::from_ref(&topup),
&bh2,
1_800_010_000,
&ids,
)
.await
.unwrap();
let rows = get_allowances_by_owner(pool, &borrower()).await.unwrap();
let fresh = rows
.iter()
.find(|r| r.txid == topup.compute_txid().to_string())
.expect("top-up indexed");
assert_eq!(fresh.token_amount, 50_000);
assert_eq!(fresh.policy_delta, Some(200));
assert_eq!(fresh.policy_max_bp, Some(2_000));
assert_eq!(fresh.fee_sats, Some(7_500));
}
#[rocket::async_test]
async fn borrow_hint_persists_policy_and_fee() {
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let ids = token_ids_with_loan_lock();
let bh = blockhash(0xAB);
let tx = borrow_tx_with_verified_hint(&ids, 1_800_000_000, 400);
let n = index_moria_v11(pool, std::slice::from_ref(&tx), &bh, 1_800_000_000, &ids)
.await
.unwrap();
assert_eq!(n, 1);
let allowances = get_allowances_by_owner(pool, &borrower()).await.unwrap();
assert_eq!(allowances.len(), 1);
assert_eq!(allowances[0].vout, 9);
assert_eq!(allowances[0].policy_delta, Some(200));
assert_eq!(allowances[0].policy_max_bp, Some(2_000));
assert_eq!(allowances[0].policy_rescue_lead_bp, Some(50));
assert_eq!(allowances[0].policy_up_leg_seconds, Some(1));
assert_eq!(allowances[0].policy_down_leg_seconds, Some(3 * 86_400));
assert_eq!(allowances[0].fee_sats, Some(7_500));
}
#[test]
fn allowance_api_json_shape() {
let unknown = MoriaV11AllowanceUtxo {
txid: "aa".repeat(32),
vout: 9,
owner_hash: "11".repeat(32),
delegate_hash: "22".repeat(32),
locking_bytecode: "aa20abcd".into(),
token_amount: 200,
sats: 2_000,
blockhash: "bb".repeat(32),
policy_delta: None,
policy_max_bp: None,
policy_rescue_lead_bp: None,
policy_up_leg_seconds: None,
policy_down_leg_seconds: None,
fee_sats: None,
};
let omitted = serde_json::to_value(&unknown).unwrap();
assert!(omitted.get("fee_sats").is_none());
assert!(omitted.get("policy_delta").is_none());
let known = MoriaV11AllowanceUtxo {
policy_delta: Some(200),
policy_max_bp: Some(2_000),
policy_rescue_lead_bp: Some(50),
policy_up_leg_seconds: Some(1),
policy_down_leg_seconds: Some(259_200),
fee_sats: Some(7_500),
..unknown
};
let present = serde_json::to_value(&known).unwrap();
assert_eq!(present["policy_delta"], 200);
assert_eq!(present["policy_max_bp"], 2_000);
assert_eq!(present["policy_rescue_lead_bp"], 50);
assert_eq!(present["policy_up_leg_seconds"], 1);
assert_eq!(present["policy_down_leg_seconds"], 259_200);
assert_eq!(present["fee_sats"], 7_500);
}
#[rocket::async_test]
async fn index_crank_updates_allowance_in_place() {
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let ids = token_ids();
let bh1 = blockhash(0xA1);
let bh2 = blockhash(0xA2);
let borrow = borrow_tx_with_allowance(&ids, 1_700_000_000, 500);
index_moria_v11(
pool,
std::slice::from_ref(&borrow),
&bh1,
1_700_000_000,
&ids,
)
.await
.unwrap();
let borrow_txid = borrow.compute_txid();
// Seed constructor policy+fee so the crank continuation must copy them.
let seeded_policy = test_policy(200, 2_000);
let allowances = get_allowances_by_owner(pool, &borrower()).await.unwrap();
insert_allowance_utxo(
pool,
&borrow_txid,
allowances[0].vout as u32,
&borrower(),
&delegate(),
&hex::decode(&allowances[0].locking_bytecode).unwrap(),
allowances[0].token_amount,
allowances[0].sats as u64,
&bh1,
Some(&seeded_policy),
Some(7_500),
)
.await
.unwrap();
let old = commitment(borrower(), delegate(), 1_000, 500, 1_700_000_000);
// 75-byte: rate @67, timestamp @69 (frozen head [0,67) includes principal).
let new = commitment(borrower(), delegate(), 1_000, 0x0320, 1_700_000_016);
let interest = 15i64;
let allowance_remaining = 500i64;
let allowance_script = vec![0xaa, 0x20, 0xAB, 0xCD];
// Header 2 + stride 2. Delegate slot (loan+1) unlocks METHOD_CRANK
// and names a distinct vault input. Purse continuation is contIdx+1.
let crank = make_tx(
vec![
txin(0x01, 0, unlock(MORIA_METHOD_RETARGET, &[&[1]])),
txin(0x02, 0, unlock(0, &[])),
TxIn {
previous_output: OutPoint {
txid: borrow_txid,
vout: 2,
},
script_sig: unlock(LOAN_METHOD_RETARGET, &[&old]),
sequence: Sequence::ZERO,
witness: Witness::new(),
},
TxIn {
previous_output: OutPoint {
txid: borrow_txid,
vout: 9,
},
script_sig: unlock(
ALLOWANCE_METHOD_CRANK,
&[&[1u8], &[4u8], &[3u8]], // bpIdx, vaultIn, contIdx
),
sequence: Sequence::ZERO,
witness: Witness::new(),
},
txin(0xFE, 0, unlock(0, &[])), // vault.spend
],
vec![
out(1_000, Some(minting(ids.moria, vec![0x05], 5_000_000))),
out(1_000, Some(nft(tid(0xD1), vec![0x11; 16]))),
out(5_000_000, Some(nft(ids.moria, new))),
out(1_000, Some(ft(ids.moria, interest))),
out_script(
1_500,
allowance_script,
Some(ft(ids.moria, allowance_remaining)),
),
],
);
let n = index_moria_v11(
pool,
std::slice::from_ref(&crank),
&bh2,
1_700_003_600,
&ids,
)
.await
.unwrap();
assert_eq!(n, 1);
let history = get_loan_history(pool, &borrower()).await.unwrap();
assert_eq!(history.len(), 2);
assert_eq!(history[1].action_type, "crank");
assert_eq!(history[1].fee_take_sats, Some(0));
assert_eq!(history[1].allowance_token_amount, Some(allowance_remaining));
let allowances = get_allowances_by_owner(pool, &borrower()).await.unwrap();
assert_eq!(allowances.len(), 1);
assert_eq!(allowances[0].token_amount, allowance_remaining);
assert_eq!(allowances[0].vout, 4);
assert_eq!(allowances[0].txid, crank.compute_txid().to_string());
assert_eq!(allowances[0].policy_delta, Some(200));
assert_eq!(allowances[0].policy_max_bp, Some(2_000));
assert_eq!(allowances[0].policy_rescue_lead_bp, Some(0));
assert_eq!(allowances[0].policy_up_leg_seconds, Some(3_600));
assert_eq!(allowances[0].policy_down_leg_seconds, Some(86_400));
assert_eq!(allowances[0].fee_sats, Some(7_500));
let loans = get_active_loans(pool).await.unwrap();
assert_eq!(loans.len(), 1);
assert_eq!(loans[0].interest_rate, 0x0320);
assert_eq!(loans[0].vout, 2);
}
#[rocket::async_test]
async fn index_allowance_close_removes_utxo() {
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let ids = token_ids();
let bh1 = blockhash(0xB1);
let bh2 = blockhash(0xB2);
let borrow = borrow_tx_with_allowance(&ids, 1_800_000_000, 400);
index_moria_v11(
pool,
std::slice::from_ref(&borrow),
&bh1,
1_800_000_000,
&ids,
)
.await
.unwrap();
let borrow_txid = borrow.compute_txid();
let dest = vec![0xaa, 0x20, 0x99];
let close = make_tx(
vec![
TxIn {
previous_output: OutPoint {
txid: borrow_txid,
vout: 9,
},
script_sig: unlock(ALLOWANCE_METHOD_CLOSE, &[&[1u8]]),
sequence: Sequence::ZERO,
witness: Witness::new(),
},
txin(0x0B, 0, ScriptBuf::new()),
],
vec![out_script(
2_500,
dest,
Some(ft(ids.moria, principal_base_units(200))),
)],
);
let n = index_moria_v11(pool, &[close], &bh2, 1_800_001_000, &ids)
.await
.unwrap();
assert_eq!(n, 1);
let allowances = get_allowances_by_owner(pool, &borrower()).await.unwrap();
assert!(allowances.is_empty());
let global = get_global_history(pool, &[], 0, 100).await.unwrap();
let close_entry = global
.iter()
.find(|e| e.action_type == "allowance_close")
.expect("close action");
assert_eq!(
close_entry.recovered_token_amount,
Some(principal_base_units(200))
);
assert_eq!(close_entry.recovered_sats, Some(2_500));
assert_eq!(
close_entry.borrower_hash.as_deref(),
Some(hex::encode(borrower()).as_str())
);
}
#[rocket::async_test]
async fn index_two_loan_retarget_batch() {
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let ids = token_ids();
let bh = blockhash(0xCC);
let old0 = commitment(borrower(), delegate(), 1_000, 500, 1_700_000_000);
// 75-byte layout: rate @67 and timestamp @69 may change; frozen head [0,67).
let new0 = commitment(borrower(), delegate(), 1_000, 500, 1_700_000_016);
let borrower2 = [0x33; 32];
let delegate2 = [0x44; 32];
let old1 = commitment(borrower2, delegate2, 2_000, 600, 1_700_000_000);
let new1 = commitment(borrower2, delegate2, 2_000, 600, 1_700_000_016);
let seed_txid0 = Txid::from_byte_array([0xA0; 32]);
let seed_txid1 = Txid::from_byte_array([0xA1; 32]);
insert_loan_utxo(
pool,
&seed_txid0,
2,
&LoanCommitmentV11 {
borrower_nft_hash: borrower(),
delegate_nft_hash: delegate(),
principal: 1_000,
annual_interest_rate_bp: 500,
timestamp: 1_700_000_000,
},
5_000_000,
&bh,
)
.await
.unwrap();
insert_loan_utxo(
pool,
&seed_txid1,
2,
&LoanCommitmentV11 {
borrower_nft_hash: borrower2,
delegate_nft_hash: delegate2,
principal: 2_000,
annual_interest_rate_bp: 600,
timestamp: 1_700_000_000,
},
7_000_000,
&bh,
)
.await
.unwrap();
// Header 2 + two strides: loan0, del0, loan1, del1.
let tx = make_tx(
vec![
txin(0x01, 0, unlock(MORIA_METHOD_RETARGET, &[&[2]])),
txin(0x02, 0, unlock(0, &[])),
txin(0xA0, 2, unlock(LOAN_METHOD_RETARGET, &[&old0])),
txin(0xD0, 0, ScriptBuf::new()),
txin(0xA1, 2, unlock(LOAN_METHOD_RETARGET, &[&old1])),
txin(0xD1, 0, ScriptBuf::new()),
],
vec![
out(1_000, Some(minting(ids.moria, vec![0x05], 5_000_000))),
out(1_000, Some(nft(tid(0xD1), vec![0x11; 16]))),
out(5_000_000, Some(nft(ids.moria, new0))),
out(1_000, Some(ft(ids.moria, 10))),
out(7_000_000, Some(nft(ids.moria, new1))),
out(1_000, Some(ft(ids.moria, 20))),
],
);
let n = index_moria_v11(pool, &[tx], &bh, 1_700_100_000, &ids)
.await
.unwrap();
assert_eq!(n, 2, "one action row per loan in the batch");
let h0 = get_loan_history(pool, &borrower()).await.unwrap();
let h1 = get_loan_history(pool, &borrower2).await.unwrap();
assert_eq!(h0.len(), 1);
assert_eq!(h1.len(), 1);
assert_eq!(h0[0].action_type, "retarget");
assert_eq!(h1[0].action_type, "retarget");
assert_eq!(h0[0].tokens_amount, Some(10));
assert_eq!(h1[0].tokens_amount, Some(20));
let active = get_active_loans(pool).await.unwrap();
assert_eq!(active.len(), 2);
}
#[rocket::async_test]
async fn index_bp_threshold_update() {
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let ids = token_ids();
let bh = blockhash(0xDD);
let tx = make_tx(
vec![txin(0xB2, 0, unlock(0, &[]))],
vec![out(
1_000,
Some(nft(ids.bp_oracle, encode_bp_commitment(750, 1_900_000_000))),
)],
);
index_moria_v11(pool, &[tx], &bh, 1_900_000_000, &ids)
.await
.unwrap();
let latest = get_latest_bp_threshold(pool).await.unwrap().unwrap();
assert_eq!(latest.threshold_bp, 750);
assert_eq!(latest.oracle_timestamp, 1_900_000_000);
}
#[rocket::async_test]
async fn reorg_deletes_all_v11_tables() {
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let ids = token_ids();
let bh = blockhash(0xEE);
let borrow = borrow_tx_with_allowance(&ids, 1_800_000_000, 400);
let bp_tx = make_tx(
vec![txin(0xB2, 0, unlock(0, &[]))],
vec![out(
1_000,
Some(nft(ids.bp_oracle, encode_bp_commitment(500, 1_800_000_000))),
)],
);
index_moria_v11(pool, &[borrow, bp_tx], &bh, 1_800_000_000, &ids)
.await
.unwrap();
assert_eq!(get_active_loans(pool).await.unwrap().len(), 1);
assert_eq!(
get_allowances_by_owner(pool, &borrower())
.await
.unwrap()
.len(),
1
);
assert!(get_latest_bp_threshold(pool).await.unwrap().is_some());
assert_eq!(
get_global_history(pool, &[], 0, 100).await.unwrap().len(),
1
);
let deleted = delete_entries_for_block(pool, &bh).await.unwrap();
assert_eq!(deleted, 1);
assert!(get_active_loans(pool).await.unwrap().is_empty());
assert!(get_allowances_by_owner(pool, &borrower())
.await
.unwrap()
.is_empty());
assert!(get_latest_bp_threshold(pool).await.unwrap().is_none());
assert!(get_global_history(pool, &[], 0, 100)
.await
.unwrap()
.is_empty());
}
#[rocket::async_test]
async fn crankable_uses_allowance_policy_not_loan_tail() {
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let ids = token_ids();
let bh = blockhash(0xF1);
// rate=600, policy delta=100 → in-band for threshold=500 (band (500, 700])
// out-of-band (up-leg) for threshold=600 (rate <= t)
let borrow = borrow_tx_with_allowance(&ids, 1_000_000, 600);
let bp_in_band = make_tx(
vec![txin(0xB2, 0, unlock(0, &[]))],
vec![out(
1_000,
Some(nft(ids.bp_oracle, encode_bp_commitment(500, 2_000_000))),
)],
);
index_moria_v11(pool, &[borrow, bp_in_band], &bh, 2_000_000, &ids)
.await
.unwrap();
// Policy is not on the loan; attach it to the live allowance.
let allowances = get_allowances_by_owner(pool, &borrower()).await.unwrap();
assert_eq!(allowances.len(), 1);
let a_txid: Txid = allowances[0].txid.parse().expect("allowance txid hex");
insert_allowance_utxo(
pool,
&a_txid,
allowances[0].vout as u32,
&borrower(),
&delegate(),
&hex::decode(&allowances[0].locking_bytecode).unwrap(),
allowances[0].token_amount,
allowances[0].sats as u64,
&bh,
Some(&test_policy(100, 3_000)),
Some(7_500),
)
.await
.unwrap();
let now = 1_000_000 + i64::from(DEFAULT_DOWN_LEG_SECONDS) + 10;
let crankable = get_crankable(pool, now).await.unwrap();
assert!(
crankable.is_empty(),
"rate 600 is in-band for threshold 500 delta 100"
);
let bh2 = blockhash(0xF2);
let bp_out = make_tx(
vec![txin(0xB3, 0, unlock(0, &[]))],
vec![out(
1_000,
Some(nft(ids.bp_oracle, encode_bp_commitment(600, 2_100_000))),
)],
);
index_moria_v11(pool, &[bp_out], &bh2, 2_100_000, &ids)
.await
.unwrap();
let crankable = get_crankable(pool, now).await.unwrap();
assert_eq!(crankable.len(), 1);
assert_eq!(crankable[0].threshold_bp, 600);
assert_eq!(crankable[0].target_bp, 700);
assert_eq!(crankable[0].leg, "up");
assert_eq!(
crankable[0].allowance_token_amount,
principal_base_units(200)
);
assert_eq!(crankable[0].interest_rate, 600);
assert_eq!(crankable[0].tracking_delta, 100);
assert_eq!(crankable[0].tracking_max_bp, 3_000);
}
#[rocket::async_test]
async fn stats_and_history_filters() {
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let ids = token_ids();
let bh = blockhash(0x99);
let borrow = borrow_tx_with_allowance(&ids, 1_800_000_000, 400);
index_moria_v11(pool, &[borrow], &bh, 1_800_000_000, &ids)
.await
.unwrap();
let stats = get_stats(pool).await.unwrap();
assert_eq!(stats["total_borrows"], 1);
assert_eq!(stats["active_loans"], 1);
assert_eq!(stats["active_allowances"], 1);
let filtered = get_global_history(pool, &[borrower().to_vec()], 0, 10)
.await
.unwrap();
assert_eq!(filtered.len(), 1);
let other = get_global_history(pool, &[[0xFFu8; 32].to_vec()], 0, 10)
.await
.unwrap();
assert!(other.is_empty());
}
#[test]
fn bp_commitment_round_trip() {
let bytes = encode_bp_commitment(1234, 1_700_000_000);
let (bp, ts) = parse_bp_commitment(&bytes).unwrap();
assert_eq!(bp, 1234);
assert_eq!(ts, 1_700_000_000);
assert!(parse_bp_commitment(&[0u8; 25]).is_none());
assert!(parse_bp_commitment(&[0u8; 27]).is_none());
}
#[test]
fn retarget_in_band_edges() {
assert!(!retarget_in_band(500, 500, 100, 0));
assert!(retarget_in_band(501, 500, 100, 0));
assert!(retarget_in_band(700, 500, 100, 0));
assert!(!retarget_in_band(701, 500, 100, 0));
// rescue lead moves the up-leg edge
assert!(!retarget_in_band(525, 500, 100, 25));
assert!(retarget_in_band(526, 500, 100, 25));
}
#[rocket::async_test]
async fn first_spend_classifies_unregistered_allowance() {
// Off-ladder / unknown P2SH32 stays invisible until METHOD_CRANK/close.
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let ids = token_ids();
let bh1 = blockhash(0xD1);
let bh2 = blockhash(0xD2);
let borrow = borrow_tx_with_allowance(&ids, 1_800_000_000, 400);
index_moria_v11(
pool,
std::slice::from_ref(&borrow),
&bh1,
1_800_000_000,
&ids,
)
.await
.unwrap();
// Close the borrow-time allowance so we start from a clean owner set.
let borrow_txid = borrow.compute_txid();
let close = make_tx(
vec![TxIn {
previous_output: OutPoint {
txid: borrow_txid,
vout: 9,
},
script_sig: unlock(ALLOWANCE_METHOD_CLOSE, &[&[1u8]]),
sequence: Sequence::ZERO,
witness: Witness::new(),
}],
vec![out_script(
2_000,
vec![0xaa, 0x20, 0x99],
Some(ft(ids.moria, principal_base_units(200))),
)],
);
index_moria_v11(
pool,
std::slice::from_ref(&close),
&bh1,
1_800_000_500,
&ids,
)
.await
.unwrap();
assert!(get_allowances_by_owner(pool, &borrower())
.await
.unwrap()
.is_empty());
// A later close of a never-registered allowance still classifies via
// METHOD_CLOSE (parser) even though we never pre-derived the script.
let unknown = make_tx(
vec![TxIn {
previous_output: OutPoint {
txid: Txid::from_byte_array([0xEE; 32]),
vout: 0,
},
script_sig: unlock(ALLOWANCE_METHOD_CLOSE, &[&[1u8]]),
sequence: Sequence::ZERO,
witness: Witness::new(),
}],
vec![out_script(
1_000,
vec![0xaa, 0x20, 0x42],
Some(ft(ids.moria, 7_000)),
)],
);
let n = index_moria_v11(pool, &[unknown], &bh2, 1_800_001_000, &ids)
.await
.unwrap();
assert_eq!(n, 1);
let global = get_global_history(pool, &[], 0, 100).await.unwrap();
assert!(global.iter().any(|e| e.action_type == "allowance_close"));
}
#[rocket::async_test]
async fn mempool_borrow_is_active_then_promoted_on_confirm() {
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let ids = token_ids();
let tx = borrow_tx_with_allowance(&ids, 1_800_000_000, 400);
let zero = BlockHash::all_zeros();
let confirmed = blockhash(0xCC);
let n = index_moria_v11(pool, std::slice::from_ref(&tx), &zero, 1_800_000_000, &ids)
.await
.unwrap();
assert_eq!(n, 1);
let loans = get_active_loans(pool).await.unwrap();
assert_eq!(loans.len(), 1);
assert_eq!(loans[0].blockhash, zero.to_string());
assert_eq!(get_unconfirmed_txids(pool).await.unwrap().len(), 1);
let n = index_moria_v11(
pool,
std::slice::from_ref(&tx),
&confirmed,
1_800_000_100,
&ids,
)
.await
.unwrap();
assert_eq!(n, 1);
let loans = get_active_loans(pool).await.unwrap();
assert_eq!(loans.len(), 1);
assert_eq!(loans[0].blockhash, confirmed.to_string());
assert!(get_unconfirmed_txids(pool).await.unwrap().is_empty());
let history = get_loan_history(pool, &borrower()).await.unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].blockhash, confirmed.to_string());
}
#[rocket::async_test]
async fn mempool_eviction_drops_unconfirmed_loan() {
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let ids = token_ids();
let tx = borrow_tx_with_allowance(&ids, 1_800_000_000, 400);
index_moria_v11(
pool,
std::slice::from_ref(&tx),
&BlockHash::all_zeros(),
1_800_000_000,
&ids,
)
.await
.unwrap();
assert_eq!(get_active_loans(pool).await.unwrap().len(), 1);
let n = delete_unconfirmed_tx(pool, &tx.compute_txid())
.await
.unwrap();
assert!(n > 0);
assert!(get_active_loans(pool).await.unwrap().is_empty());
assert!(get_unconfirmed_txids(pool).await.unwrap().is_empty());
}
fn partial_repay_tx(
ids: &MoriaV11TokenIds,
remaining: Vec<u8>,
remaining_sats: u64,
) -> Transaction {
make_tx(
vec![
txin(0x01, 0, unlock(MORIA_METHOD_REPAY, &[])),
txin(0x02, 0, unlock(0, &[])),
txin(0xA0, 2, unlock(LOAN_METHOD_REPAY, &[])),
txin(0xB0, 0, ScriptBuf::new()),
],
vec![
out(1_000, Some(minting(ids.moria, vec![0x02], 1_100_000))),
out(1_000, Some(nft(tid(0xD1), vec![0x11; 16]))),
out(1_000, Some(ft(ids.moria, 42))),
bare(1_000),
out(remaining_sats, Some(nft(ids.moria, remaining))),
bare(1_000),
],
)
}
fn partial_redeem_tx(
ids: &MoriaV11TokenIds,
remaining: Vec<u8>,
remaining_sats: u64,
) -> Transaction {
make_tx(
vec![
txin(0x01, 0, unlock(MORIA_METHOD_REPAY, &[])),
txin(0x02, 0, unlock(0, &[])),
txin(0xA0, 2, unlock(LOAN_METHOD_REDEEM, &[])),
txin(0xB2, 0, ScriptBuf::new()),
],
vec![
out(1_000, Some(minting(ids.moria, vec![0x02], 1_050_000))),
out(1_000, Some(nft(tid(0xD1), vec![0x11; 16]))),
out(1_000, Some(ft(ids.moria, 77))),
out(1_000, Some(nft(ids.bp_oracle, vec![0x22; 26]))),
out(remaining_sats, Some(nft(ids.moria, remaining))),
bare(1_000),
],
)
}
#[rocket::async_test]
async fn partial_repay_keeps_continuation_loan_utxo() {
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let ids = token_ids();
let bh = blockhash(0xE1);
let seed = Txid::from_byte_array([0xA0; 32]);
insert_loan_utxo(
pool,
&seed,
2,
&LoanCommitmentV11 {
borrower_nft_hash: borrower(),
delegate_nft_hash: delegate(),
principal: 20_000,
annual_interest_rate_bp: 500,
timestamp: 1_700_000_000,
},
9_000_000,
&bh,
)
.await
.unwrap();
let remaining = commitment(borrower(), delegate(), 15_000, 500, 1_700_000_000);
let tx = partial_repay_tx(&ids, remaining, 6_000_000);
let n = index_moria_v11(pool, &[tx.clone()], &bh, 1_700_100_000, &ids)
.await
.unwrap();
assert_eq!(n, 1);
let history = get_loan_history(pool, &borrower()).await.unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].action_type, "repay");
assert_eq!(history[0].new_principal, Some(15_000));
let active = get_active_loans(pool).await.unwrap();
assert_eq!(
active.len(),
1,
"partial repay must keep the continuation UTXO"
);
assert_eq!(active[0].principal, 15_000);
assert_eq!(active[0].txid, tx.compute_txid().to_string());
}
#[rocket::async_test]
async fn partial_redeem_keeps_continuation_loan_utxo() {
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let ids = token_ids();
let bh = blockhash(0xE2);
let seed = Txid::from_byte_array([0xA0; 32]);
insert_loan_utxo(
pool,
&seed,
2,
&LoanCommitmentV11 {
borrower_nft_hash: borrower(),
delegate_nft_hash: delegate(),
principal: 20_000,
annual_interest_rate_bp: 500,
timestamp: 1_700_000_000,
},
9_000_000,
&bh,
)
.await
.unwrap();
let remaining = commitment(borrower(), delegate(), 10_000, 500, 1_700_000_000);
let tx = partial_redeem_tx(&ids, remaining, 4_000_000);
index_moria_v11(pool, &[tx.clone()], &bh, 1_700_100_000, &ids)
.await
.unwrap();
let history = get_loan_history(pool, &borrower()).await.unwrap();
assert_eq!(history[0].action_type, "redeem");
assert_eq!(history[0].new_principal, Some(10_000));
let active = get_active_loans(pool).await.unwrap();
assert_eq!(
active.len(),
1,
"partial redeem must keep the continuation UTXO"
);
assert_eq!(active[0].principal, 10_000);
assert_eq!(active[0].txid, tx.compute_txid().to_string());
}
#[rocket::async_test]
async fn full_repay_drops_loan_utxo() {
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let ids = token_ids();
let bh = blockhash(0xE3);
let seed = Txid::from_byte_array([0xA0; 32]);
insert_loan_utxo(
pool,
&seed,
2,
&LoanCommitmentV11 {
borrower_nft_hash: borrower(),
delegate_nft_hash: delegate(),
principal: 20_000,
annual_interest_rate_bp: 500,
timestamp: 1_700_000_000,
},
9_000_000,
&bh,
)
.await
.unwrap();
let tx = make_tx(
vec![
txin(0x01, 0, unlock(MORIA_METHOD_REPAY, &[])),
txin(0x02, 0, unlock(0, &[])),
txin(0xA0, 2, unlock(LOAN_METHOD_REPAY, &[])),
txin(0xB0, 0, ScriptBuf::new()),
],
vec![
out(1_000, Some(minting(ids.moria, vec![0x02], 1_100_000))),
out(1_000, Some(nft(tid(0xD1), vec![0x11; 16]))),
out(1_000, Some(ft(ids.moria, 42))),
bare(1_000),
bare(5_000_000),
bare(1_000),
],
);
index_moria_v11(pool, &[tx], &bh, 1_700_100_000, &ids)
.await
.unwrap();
assert!(get_active_loans(pool).await.unwrap().is_empty());
}
#[rocket::async_test]
async fn principal_128000_fits_loan_utxo() {
let db = crate::utiltest::mock_db_pool(setup).await;
let pool = &db.moria_w;
let bh = blockhash(0xE4);
insert_loan_utxo(
pool,
&Txid::from_byte_array([0xC8; 32]),
2,
&LoanCommitmentV11 {
borrower_nft_hash: borrower(),
delegate_nft_hash: delegate(),
principal: 128_000,
annual_interest_rate_bp: 500,
timestamp: 1_700_000_000,
},
50_000_000,
&bh,
)
.await
.unwrap();
let active = get_active_loans(pool).await.unwrap();
assert_eq!(active.len(), 1);
assert_eq!(active[0].principal, 128_000);
}