2026-08-18 08:28:54 +02:00
|
|
|
// 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,
|
2026-08-21 13:36:30 +02:00
|
|
|
LOAN_METHOD_REDEEM, LOAN_METHOD_REPAY, LOAN_METHOD_RETARGET,
|
2026-08-18 08:28:54 +02:00
|
|
|
};
|
|
|
|
|
use sqlx::SqlitePool;
|
|
|
|
|
|
|
|
|
|
/// Whole-MUSD → base units as i64 (DB/API). Scale lives only in defi `MUSD_BASE_UNITS_PER_MUSD`.
|
2026-08-21 13:36:30 +02:00
|
|
|
fn principal_base_units(principal_musd: u32) -> i64 {
|
2026-08-18 08:28:54 +02:00
|
|
|
principal_to_base_units(principal_musd) as i64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MORIA_METHOD_BORROW: u8 = 2;
|
2026-08-21 13:36:30 +02:00
|
|
|
const MORIA_METHOD_REPAY: u8 = 0;
|
2026-08-18 08:28:54 +02:00
|
|
|
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> {
|
2026-08-21 13:36:30 +02:00
|
|
|
hex::decode("aa208bbc3b3f65b354e69a93175c70f3cbb619154ae2902878521c17edcbd83cc12f87").unwrap()
|
2026-08-18 08:28:54 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-21 13:36:30 +02:00
|
|
|
fn commitment(borrower: [u8; 32], delegate: [u8; 32], principal: u32, bp: u16, ts: u64) -> Vec<u8> {
|
2026-08-18 08:28:54 +02:00
|
|
|
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);
|
2026-08-21 13:36:30 +02:00
|
|
|
// 75-byte: rate @67, timestamp @69 (frozen head [0,67) includes principal).
|
|
|
|
|
let new = commitment(borrower(), delegate(), 1_000, 0x0320, 1_700_000_016);
|
2026-08-18 08:28:54 +02:00
|
|
|
|
|
|
|
|
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);
|
2026-08-21 13:36:30 +02:00
|
|
|
// 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);
|
2026-08-18 08:28:54 +02:00
|
|
|
|
|
|
|
|
let borrower2 = [0x33; 32];
|
|
|
|
|
let delegate2 = [0x44; 32];
|
|
|
|
|
let old1 = commitment(borrower2, delegate2, 2_000, 600, 1_700_000_000);
|
2026-08-21 13:36:30 +02:00
|
|
|
let new1 = commitment(borrower2, delegate2, 2_000, 600, 1_700_000_016);
|
2026-08-18 08:28:54 +02:00
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
}
|
2026-08-21 13:36:30 +02:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|