diff --git a/crates/session/src/error.rs b/crates/session/src/error.rs index 8db5d7e..44c134d 100644 --- a/crates/session/src/error.rs +++ b/crates/session/src/error.rs @@ -34,6 +34,9 @@ pub enum SessionError { #[error("Offer is not active")] OfferNotActive, + #[error("Offer is not repaid")] + OfferNotRepaid, + #[error("Pending offer UTXO not found")] PendingOfferUtxoNotFound, @@ -55,6 +58,9 @@ pub enum SessionError { #[error("Borrower principal UTXO not found")] BorrowerPrincipalUtxoNotFound, + #[error("Repayment UTXO not found")] + RepaymentUtxoNotFound, + #[error("Indexer returned invalid `{field}` value for offer: {value}")] InvalidOfferData { field: &'static str, value: String }, diff --git a/crates/session/src/offer.rs b/crates/session/src/offer.rs index ca9a00b..36fd1d1 100644 --- a/crates/session/src/offer.rs +++ b/crates/session/src/offer.rs @@ -7,8 +7,8 @@ use lending_contracts::programs::program::SimplexProgram; use lending_contracts::programs::script_auth::ScriptAuth; use lending_contracts::utils::get_random_seed; use simplex::provider::{ProviderTrait, SimplicityNetwork}; -use simplex::simplicityhl::elements::AssetId; use simplex::simplicityhl::elements::hex::ToHex; +use simplex::simplicityhl::elements::{AssetId, Script}; use simplex::transaction::partial_input::IssuanceInput; use simplex::transaction::{FinalTransaction, PartialInput, PartialOutput, RequiredSignature}; @@ -623,4 +623,80 @@ impl Session { Ok(transaction) } + + pub async fn claim_lender_vault( + &self, + offer_id: &str, + ) -> Result { + let offer_details = self.indexer().get_offer(offer_id).await?; + if offer_details.info.base.status != OfferStatus::Repaid { + return Err(SessionError::OfferNotRepaid); + } + + let lender_vault_outpoint = offer_details + .utxos + .iter() + .find(|utxo| utxo.utxo_type == UtxoType::Repayment) + .ok_or(SessionError::RepaymentUtxoNotFound)?; + let lender_nft_outpoint = offer_details + .participants + .iter() + .find(|participant| { + participant.participant_type == ParticipantType::Lender + && participant.spent_txid.is_none() + }) + .ok_or(SessionError::LenderNftUtxoNotFound)?; + + let parameters = + lending_offer_parameters_from_indexer(&offer_details.info, self.network())?; + let finalized_lender_vault = parameters.get_finalized_lender_vault(); + + let lender_vault_utxo = self + .provider() + .fetch_scripthash_utxos(&finalized_lender_vault.get_script_pubkey())? + .into_iter() + .find(|utxo| { + utxo.outpoint.vout == lender_vault_outpoint.vout + && utxo.outpoint.txid.to_string() == lender_vault_outpoint.txid + && utxo.txout.asset.explicit() == Some(parameters.principal_asset_id) + }) + .ok_or(SessionError::RepaymentUtxoNotFound)?; + + let lender_nft_utxo = self + .signer() + .get_utxos_asset(parameters.lender_nft_asset_id)? + .into_iter() + .find(|utxo| { + utxo.outpoint.vout == lender_nft_outpoint.vout + && utxo.outpoint.txid.to_string() == lender_nft_outpoint.txid + && utxo.txout.asset.explicit() == Some(parameters.lender_nft_asset_id) + && utxo.txout.value.explicit() == Some(1) + }) + .ok_or(SessionError::LenderNftUtxoNotFound)?; + + let principal_amount = lender_vault_utxo.explicit_amount(); + + let mut transaction = FinalTransaction::new(); + + finalized_lender_vault.attach_withdrawing_all(&mut transaction, lender_vault_utxo, 1, 0); + transaction.add_input( + PartialInput::new(lender_nft_utxo), + RequiredSignature::NativeEcdsa, + ); + transaction.add_output(PartialOutput::new( + Script::new_op_return(b"burn"), + 1, + parameters.lender_nft_asset_id, + )); + transaction.add_output( + PartialOutput::new( + self.signer().get_confidential_address().script_pubkey(), + principal_amount, + parameters.principal_asset_id, + ) + .with_blinding_key(self.signer().get_blinding_public_key()), + ); + + Ok(transaction) + } } diff --git a/crates/session/tests/offer_lender_vault_claim_session.rs b/crates/session/tests/offer_lender_vault_claim_session.rs new file mode 100644 index 0000000..82b57ec --- /dev/null +++ b/crates/session/tests/offer_lender_vault_claim_session.rs @@ -0,0 +1,181 @@ +mod utils; + +use lending_contracts::programs::program::SimplexProgram; +use lending_indexer::indexer::update_offer_status; +use lending_indexer::models::OfferStatus; +use lending_session::SessionError; +use serial_test::serial; + +use utils::{ + DEFAULT_LOAN_EXPIRATION_OFFSET, TEST_PRINCIPAL_AMOUNT, accept_pending_offer, build_session, + build_session_with_signer, dummy_principal_asset_id, fund_asset_outputs, issue_asset, + offer_params, repay_active_offer, setup_it_context_pool, setup_pending_offer, + start_indexer_api, +}; + +const BORROWER_PRINCIPAL_ASSET_SUPPLY: u64 = 30_000; + +#[tokio::test] +#[serial] +async fn claim_lender_vault_burns_nft_and_unlocks_principal() -> anyhow::Result<()> { + let (context, pool) = setup_it_context_pool().await?; + let (indexer_url, server_handle) = start_indexer_api(pool.clone()).await?; + let borrower = build_session(&context, &indexer_url); + let lender = build_session_with_signer(&context, context.random_signer(), &indexer_url); + + let principal_asset_id = issue_asset(&borrower, BORROWER_PRINCIPAL_ASSET_SUPPLY)?; + let offer = setup_pending_offer( + &borrower, + &pool, + offer_params( + &borrower, + principal_asset_id, + DEFAULT_LOAN_EXPIRATION_OFFSET, + )?, + ) + .await?; + + fund_asset_outputs( + &borrower, + lender.signer(), + principal_asset_id, + &[TEST_PRINCIPAL_AMOUNT], + )?; + let (_, accept_txid) = accept_pending_offer(&lender, &pool, 1, &offer).await?; + repay_active_offer(&borrower, &pool, 1, accept_txid).await?; + + let expected_principal = offer + .parameters + .offer_parameters + .get_total_amount_to_repay() + - offer.parameters.offer_parameters.get_total_protocol_fee(); + let lender_vault = offer.parameters.get_finalized_lender_vault(); + + assert!( + !lender + .signer() + .get_provider() + .fetch_scripthash_utxos(&lender_vault.get_script_pubkey())? + .is_empty(), + "finalized lender vault must exist after repayment" + ); + + let claim_tx = lender.claim_lender_vault("1").await?; + + assert_eq!( + claim_tx.n_inputs(), + 2, + "finalized lender vault + lender NFT" + ); + assert_eq!( + claim_tx.n_outputs(), + 2, + "burned lender NFT + unlocked principal" + ); + + let outputs = claim_tx.outputs(); + assert_eq!(outputs[0].asset, offer.parameters.lender_nft_asset_id); + assert_eq!(outputs[0].amount, 1); + assert!(outputs[0].script_pubkey.is_op_return()); + assert_eq!(outputs[1].asset, offer.parameters.principal_asset_id); + assert_eq!(outputs[1].amount, expected_principal); + assert_eq!( + outputs[1].script_pubkey, + lender.signer().get_confidential_address().script_pubkey() + ); + assert_eq!( + outputs[1].blinding_key, + Some(lender.signer().get_blinding_public_key()) + ); + + let receipt = lender.signer().broadcast(&claim_tx)?; + let claim_txid = receipt.txid(); + receipt.wait()?; + + assert!( + lender + .signer() + .get_provider() + .fetch_scripthash_utxos(&lender_vault.get_script_pubkey())? + .is_empty(), + "finalized lender vault UTXO must be spent by the claim" + ); + assert!( + lender + .signer() + .get_utxos_asset(offer.parameters.lender_nft_asset_id)? + .is_empty(), + "lender NFT must be burned by the claim" + ); + assert!( + lender + .signer() + .get_utxos_asset(offer.parameters.principal_asset_id)? + .iter() + .any(|utxo| { + utxo.outpoint.txid == claim_txid + && utxo.outpoint.vout == 1 + && utxo.amount() == expected_principal + }), + "unlocked principal must return to the lender's wallet" + ); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn claim_lender_vault_returns_offer_not_repaid_for_pending_offer() -> anyhow::Result<()> { + let (context, pool) = setup_it_context_pool().await?; + let (indexer_url, server_handle) = start_indexer_api(pool.clone()).await?; + let borrower = build_session(&context, &indexer_url); + + setup_pending_offer( + &borrower, + &pool, + offer_params( + &borrower, + dummy_principal_asset_id(), + DEFAULT_LOAN_EXPIRATION_OFFSET, + )?, + ) + .await?; + + let result = borrower.claim_lender_vault("1").await; + + assert!(matches!(result, Err(SessionError::OfferNotRepaid))); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn claim_lender_vault_returns_repayment_utxo_not_found_when_missing() -> anyhow::Result<()> { + let (context, pool) = setup_it_context_pool().await?; + let (indexer_url, server_handle) = start_indexer_api(pool.clone()).await?; + let borrower = build_session(&context, &indexer_url); + + setup_pending_offer( + &borrower, + &pool, + offer_params( + &borrower, + dummy_principal_asset_id(), + DEFAULT_LOAN_EXPIRATION_OFFSET, + )?, + ) + .await?; + + let mut sql_tx = pool.begin().await?; + update_offer_status(&mut sql_tx, 1, OfferStatus::Repaid, 100).await?; + sql_tx.commit().await?; + + let result = borrower.claim_lender_vault("1").await; + + assert!(matches!(result, Err(SessionError::RepaymentUtxoNotFound))); + + server_handle.abort(); + Ok(()) +} diff --git a/crates/session/tests/utils/mod.rs b/crates/session/tests/utils/mod.rs index f498655..c06f54f 100644 --- a/crates/session/tests/utils/mod.rs +++ b/crates/session/tests/utils/mod.rs @@ -16,8 +16,8 @@ pub use indexer::start_indexer_api; #[allow(unused_imports)] pub use offer::{ DEFAULT_LOAN_EXPIRATION_OFFSET, OfferCreation, TEST_PRINCIPAL_AMOUNT, accept_pending_offer, - create_and_broadcast_offer, dummy_principal_asset_id, offer_params, seed_pending_offer, - setup_pending_offer, + create_and_broadcast_offer, dummy_principal_asset_id, offer_params, repay_active_offer, + seed_pending_offer, setup_pending_offer, }; #[allow(unused_imports)] pub use session::{build_session, build_session_with_signer, setup_it_context_pool}; diff --git a/crates/session/tests/utils/offer.rs b/crates/session/tests/utils/offer.rs index 78350f1..86f3d86 100644 --- a/crates/session/tests/utils/offer.rs +++ b/crates/session/tests/utils/offer.rs @@ -213,7 +213,7 @@ pub async fn accept_pending_offer( pool: &PgPool, offer_id: i64, offer: &OfferCreation, -) -> anyhow::Result { +) -> anyhow::Result<(AcceptOfferTx, Txid)> { let accept = lender.accept_offer(&offer_id.to_string()).await?; let receipt = lender.signer().broadcast(&accept.transaction)?; @@ -284,5 +284,50 @@ pub async fn accept_pending_offer( sql_tx.commit().await?; - Ok(accept) + Ok((accept, accept_txid)) +} + +pub async fn repay_active_offer( + borrower: &Session, + pool: &PgPool, + offer_id: i64, + accept_txid: Txid, +) -> anyhow::Result { + let repay = borrower.repay_offer(&offer_id.to_string()).await?; + + let receipt = borrower.signer().broadcast(&repay)?; + let repay_txid = receipt.txid(); + receipt.wait()?; + + let block_height = 300_u64; + let active_offer_outpoint = OutPoint::new(accept_txid, 0); + + let mut sql_tx = pool.begin().await?; + + spend_offer_utxo( + &mut sql_tx, + &active_offer_outpoint, + block_height, + repay_txid, + ) + .await?; + update_offer_status(&mut sql_tx, offer_id, OfferStatus::Repaid, block_height).await?; + + insert_offer_utxo( + &mut sql_tx, + &OfferUtxoModel { + offer_id, + txid: repay_txid.as_byte_array().to_vec(), + vout: 1, + utxo_type: UtxoType::Repayment, + created_at_height: block_height as i64, + spent_txid: None, + spent_at_height: None, + }, + ) + .await?; + + sql_tx.commit().await?; + + Ok(repay_txid) }