Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/session/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand All @@ -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 },

Expand Down
78 changes: 77 additions & 1 deletion crates/session/src/offer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -623,4 +623,80 @@ impl Session {

Ok(transaction)
}

pub async fn claim_lender_vault(
&self,
offer_id: &str,
) -> Result<FinalTransaction, SessionError> {
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)
}
}
181 changes: 181 additions & 0 deletions crates/session/tests/offer_lender_vault_claim_session.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
4 changes: 2 additions & 2 deletions crates/session/tests/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
49 changes: 47 additions & 2 deletions crates/session/tests/utils/offer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ pub async fn accept_pending_offer(
pool: &PgPool,
offer_id: i64,
offer: &OfferCreation,
) -> anyhow::Result<AcceptOfferTx> {
) -> anyhow::Result<(AcceptOfferTx, Txid)> {
let accept = lender.accept_offer(&offer_id.to_string()).await?;

let receipt = lender.signer().broadcast(&accept.transaction)?;
Expand Down Expand Up @@ -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<Txid> {
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)
}
Loading