diff --git a/Cargo.lock b/Cargo.lock index d136a9a..7ee2f64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2283,9 +2283,9 @@ dependencies = [ [[package]] name = "riftenlabs-defi" -version = "0.4.6" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcf89dd770556a74ecd88b6971e7fa151f58bcee5b1a400b836c485c668973b" +checksum = "e7541d5d2101a03925b9de89846c1e9a66bcdcdf6a4435a7bcf30bf294bb236f" dependencies = [ "anyhow", "bitcoin_hashes", diff --git a/Cargo.toml b/Cargo.toml index 62fc4dc..fe251ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ serde_json = "1.0.133" rocket = { version = "0.5.1", features = ["json"] } sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } rayon = "1.10.0" -riftenlabs-defi = "0.4.6" +riftenlabs-defi = "0.5.1" rocket_cors = "0.6.0" log = "0.4" stderrlog = "0.6.0" diff --git a/src/db/moria_v11/deploy.rs b/src/db/moria_v11/deploy.rs index 6568098..559cb11 100644 --- a/src/db/moria_v11/deploy.rs +++ b/src/db/moria_v11/deploy.rs @@ -3,40 +3,41 @@ // 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 -//! Per-network Moria v1-1 deployment constants. +//! Per-network Moria v1-1 TEST18 deployment constants. //! -//! Mainnet is unconfigured until a v1-1 deployment exists (fail-closed). +//! Chipnet is TEST18. Mainnet is unconfigured until a v1-1 deployment exists +//! (fail-closed). use bitcoincash::{Network, TokenID}; use riftenlabs_defi::moria_v1_1::MoriaV11TokenIds; -/// Unique tail of the moria v1-1 redeem script. Partial scriptsig match — -/// every moria method spends this body. +/// Unique tail of the moria v1-1 TEST18 redeem script. Partial scriptsig match — +/// every moria method spends this body. Last 20 bytes of REDEEMSCRIPT_PROD. const REDEEM_TAIL: [u8; 20] = [ - 0x51, 0xd2, 0x51, 0xcf, 0x88, 0x52, 0xd1, 0x00, 0x88, 0xc4, 0x53, 0x9c, 0x77, 0x77, 0x77, 0x68, + 0xd2, 0x51, 0xcf, 0x88, 0x52, 0xd1, 0x00, 0x88, 0xc4, 0x53, 0x9d, 0x6d, 0x6d, 0x51, 0x68, 0x68, 0x68, 0x68, 0x68, 0x68, ]; -/// Chipnet v1-1 token categories and loan P2SH32, or `None` on other networks. +/// Chipnet TEST18 token categories and loan P2SH32, or `None` on other networks. pub fn token_ids(network: Option) -> Option { match network { Some(Network::Chipnet) => Some(MoriaV11TokenIds { - moria: "56d909e16cd3b4cecae685a7ca45baa292bf7550205c2ad59320a342c73754de" + moria: "1c6dc7c7ad3e7e37fa1c84e715cd22a28177f52a2c49853e6e0b638505c29637" .parse::() - .expect("valid chipnet moria v1-1 token_id"), - bp_oracle: "a0628bc21d10211a23f986fd12770e836bae43878db93db8b164ab8a5ab33c96" + .expect("valid chipnet TEST18 moria token_id"), + bp_oracle: "640d2c75cb1d5d15dbaf26022b37661d3578485a6788736893c1331ed2384905" .parse::() - .expect("valid chipnet bp_oracle v1-1 token_id"), - pool: "a533b7e96435af18136b534b43e465dd759db3ac33e8490b9bfd254b8c27ae48" + .expect("valid chipnet TEST18 bp_oracle token_id"), + pool: "8aaa8012b206f45b33472a55b5c3c328bae0eb33e4ee466f7f9cbade4750225c" .parse::() - .expect("valid chipnet pool v1-1 token_id"), - bar: "bc98b2733ca1c2070586cce1a4216418830ccd9b19ff77b5d43d47542bb3b50d" + .expect("valid chipnet TEST18 pool token_id"), + bar: "779df3e91141f0ea414c477f61d08a3c55b69cd91eb58e42953d8c7eaec84bb9" .parse::() - .expect("valid chipnet bar v1-1 token_id"), + .expect("valid chipnet TEST18 bar token_id"), loan_locking_bytecode: hex::decode( - "aa20415812e068086c90686635a813d77b6c412427e574e89b16e79feeaa48cf0f0787", + "aa208bbc3b3f65b354e69a93175c70f3cbb619154ae2902878521c17edcbd83cc12f87", ) - .expect("valid chipnet loan locking bytecode"), + .expect("valid chipnet TEST18 loan locking bytecode"), }), _ => None, } diff --git a/src/db/moria_v11/ingest.rs b/src/db/moria_v11/ingest.rs index 2c8d864..666a58b 100644 --- a/src/db/moria_v11/ingest.rs +++ b/src/db/moria_v11/ingest.rs @@ -544,7 +544,7 @@ pub async fn index_moria_v11( action.loan_commitment = Some(LoanCommitmentV11 { borrower_nft_hash: borrower, delegate_nft_hash: delegate, - principal: principal as u16, + principal: principal as u32, annual_interest_rate_bp: interest_rate as u16, timestamp: loan_ts as u64, }); @@ -565,7 +565,10 @@ pub async fn index_moria_v11( } } - // Create / replace continuing loan UTXO. + // Create / replace continuing loan UTXO. Refinance, borrow, retarget, + // add-collateral, and partial redeem/repay (continuation NFT via + // `new_loan_commitment` + `loan_output_index`) all land here. Full + // close has no continuation and the spent UTXO stays deleted. if let Some(output_idx) = action.loan_output_index { if let Some(commitment) = action .new_loan_commitment @@ -581,7 +584,6 @@ pub async fn index_moria_v11( blockhash, ) .await?; - // Policy lives on the allowance, revealed at first spend. } } diff --git a/src/db/moria_v11/mod.rs b/src/db/moria_v11/mod.rs index 9b2df37..12115c0 100644 --- a/src/db/moria_v11/mod.rs +++ b/src/db/moria_v11/mod.rs @@ -46,7 +46,8 @@ pub struct MoriaV11Entry { pub action_type: &'static str, pub borrower_hash: Option, pub delegate_hash: Option, - /// Encoded principal in whole MUSD (× [`MUSD_BASE_UNITS_PER_MUSD`](riftenlabs_defi::moria_v1_1::MUSD_BASE_UNITS_PER_MUSD) for base units). + /// Whole-MUSD principal (`i64`, not `u16`; 128_000 fits). Multiply by + /// [`MUSD_BASE_UNITS_PER_MUSD`](riftenlabs_defi::moria_v1_1::MUSD_BASE_UNITS_PER_MUSD) for base units. pub principal: Option, pub interest_rate: Option, pub loan_timestamp: Option, @@ -71,7 +72,8 @@ pub struct MoriaV11LoanUtxo { pub vout: i64, pub borrower_hash: String, pub delegate_hash: String, - /// Encoded principal in whole MUSD (× [`MUSD_BASE_UNITS_PER_MUSD`](riftenlabs_defi::moria_v1_1::MUSD_BASE_UNITS_PER_MUSD) for base units). + /// Whole-MUSD principal (`i64`, not `u16`; 128_000 fits). Multiply by + /// [`MUSD_BASE_UNITS_PER_MUSD`](riftenlabs_defi::moria_v1_1::MUSD_BASE_UNITS_PER_MUSD) for base units. pub principal: i64, pub interest_rate: i64, pub loan_timestamp: i64, @@ -117,7 +119,8 @@ pub struct MoriaV11Crankable { pub vout: i64, pub borrower_hash: String, pub delegate_hash: String, - /// Encoded principal in whole MUSD (× [`MUSD_BASE_UNITS_PER_MUSD`](riftenlabs_defi::moria_v1_1::MUSD_BASE_UNITS_PER_MUSD) for base units). + /// Whole-MUSD principal (`i64`, not `u16`; 128_000 fits). Multiply by + /// [`MUSD_BASE_UNITS_PER_MUSD`](riftenlabs_defi::moria_v1_1::MUSD_BASE_UNITS_PER_MUSD) for base units. pub principal: i64, pub interest_rate: i64, pub loan_timestamp: i64, diff --git a/src/db/moria_v11/tests.rs b/src/db/moria_v11/tests.rs index 49268af..871a1ae 100644 --- a/src/db/moria_v11/tests.rs +++ b/src/db/moria_v11/tests.rs @@ -15,16 +15,17 @@ 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, + 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: u16) -> i64 { +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) { @@ -46,7 +47,7 @@ fn token_ids() -> MoriaV11TokenIds { } fn test_loan_lock() -> Vec { - hex::decode("aa20415812e068086c90686635a813d77b6c412427e574e89b16e79feeaa48cf0f0787").unwrap() + hex::decode("aa208bbc3b3f65b354e69a93175c70f3cbb619154ae2902878521c17edcbd83cc12f87").unwrap() } fn token_ids_with_loan_lock() -> MoriaV11TokenIds { @@ -194,7 +195,7 @@ fn make_tx(inputs: Vec, outputs: Vec) -> Transaction { } } -fn commitment(borrower: [u8; 32], delegate: [u8; 32], principal: u16, bp: u16, ts: u64) -> Vec { +fn commitment(borrower: [u8; 32], delegate: [u8; 32], principal: u32, bp: u16, ts: u64) -> Vec { encode_loan_commitment_v11(&LoanCommitmentV11 { borrower_nft_hash: borrower, delegate_nft_hash: delegate, @@ -508,10 +509,8 @@ async fn index_crank_updates_allowance_in_place() { .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; + // 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; @@ -667,18 +666,13 @@ async fn index_two_loan_retarget_batch() { 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; + // 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 mut new1 = old1.clone(); - new1[66] = 0x58; - new1[67] = 0x02; - new1[68] = 0x10; + 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]); @@ -1086,3 +1080,216 @@ async fn mempool_eviction_drops_unconfirmed_loan() { 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, + 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, + 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); +}