// 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_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: u16) -> i64 { principal_to_base_units(principal_musd) as i64 } const MORIA_METHOD_BORROW: u8 = 2; 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 { hex::decode("aa20415812e068086c90686635a813d77b6c412427e574e89b16e79feeaa48cf0f0787").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) -> 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, 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, 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, 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) -> TxOut { TxOut { value: Amount::from_sat(sats), script_pubkey: ScriptBuf::from_bytes(vec![0xaa, 0x20]), token, } } fn out_script(sats: u64, script: Vec, token: Option) -> TxOut { TxOut { value: Amount::from_sat(sats), script_pubkey: ScriptBuf::from_bytes(script), token, } } fn make_tx(inputs: Vec, outputs: Vec) -> Transaction { Transaction { version: Version::TWO, lock_time: LockTime::ZERO, input: inputs, output: outputs, } } fn commitment(borrower: [u8; 32], delegate: [u8; 32], principal: u16, bp: u16, ts: u64) -> Vec { 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); let mut new = old.clone(); new[66] = 0x20; new[67] = 0x03; new[68] = 0x20; 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); let mut new0 = old0.clone(); new0[66] = 0xF4; new0[67] = 0x01; new0[68] = 0x10; let borrower2 = [0x33; 32]; let delegate2 = [0x44; 32]; let old1 = commitment(borrower2, delegate2, 2_000, 600, 1_700_000_000); let mut new1 = old1.clone(); new1[66] = 0x58; new1[67] = 0x02; new1[68] = 0x10; 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()); }