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
12 changes: 10 additions & 2 deletions quicklendx-contracts/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,15 @@ impl QuickLendXContract {
invoice_id: BytesN<32>,
bid_amount: i128,
expected_return: i128,
) -> BytesN<32> {
salt: BytesN<32>,
) -> Result<BytesN<32>, QuickLendXError> {
// Idempotency check
let idem_key = idempotency_key(&invoice_id, &investor, &salt, &env);
if idempotency_exists(&env, &idem_key) {
return Err(QuickLendXError::DuplicateBid);
}
// Store idempotency marker
store_idempotency(&env, &idem_key);
let bid_id = BidStorage::generate_unique_bid_id(&env);
let bid = Bid {
bid_id: bid_id.clone(),
Expand All @@ -207,7 +215,7 @@ impl QuickLendXContract {
expiration_timestamp: env.ledger().timestamp() + 86400,
};
BidStorage::store_bid(&env, &bid);
bid_id
Ok(bid_id)
}

pub fn accept_bid(env: Env, invoice_id: BytesN<32>, bid_id: BytesN<32>) {
Expand Down
1 change: 1 addition & 0 deletions quicklendx-contracts/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ pub enum QuickLendXError {
/// BREAKING: Do not renumber this variant. public ABI consumption.
DuplicateDefaultTransition = 2202,
BackupVersionUnsupported = 2203,
DuplicateBid = 2204,
}

impl From<QuickLendXError> for Symbol {
Expand Down
24 changes: 24 additions & 0 deletions quicklendx-contracts/src/idempotency.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
use soroban_sdk::{Env, BytesN, Address, Symbol};
use crate::storage::{bump_persistent, extend_persistent_ttl};

pub const IDEMPOTENCY_MAP_KEY: Symbol = symbol_short!("idem_map");

pub fn idempotency_key(invoice_id: &BytesN<32>, investor: &Address, salt: &BytesN<32>, env: &Env) -> BytesN<32> {
// Hash the concatenation of invoice_id, investor, and salt to produce a unique key
let mut data = Vec::new(env);
data.append(&invoice_id.to_array());
data.append(&investor.to_array());
data.append(&salt.to_array());
env.crypto().sha256(&data)
}

pub fn idempotency_exists(env: &Env, key: &BytesN<32>) -> bool {
env.storage().persistent().has(&IDEMPOTENCY_MAP_KEY, key)
}

pub fn store_idempotency(env: &Env, key: &BytesN<32>) {
// Store a placeholder value (empty Bytes) to mark existence
let placeholder: BytesN<32> = BytesN::from_array(env, &[0u8; 32]);
env.storage().persistent().set(&IDEMPOTENCY_MAP_KEY, key, &placeholder);
extend_persistent_ttl(env, &IDEMPOTENCY_MAP_KEY);
}
9 changes: 9 additions & 0 deletions quicklendx-contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ mod test_maintenance_write_matrix;
#[cfg(test)]
mod test_settlement_history_reconstruction;
use soroban_sdk::{contract, contractimpl, symbol_short, Address, BytesN, Env, Map, String, Vec};
use crate::idempotency::{idempotency_key, idempotency_exists, store_idempotency};

#[cfg(any(test, feature = "testutils"))]
pub mod bench;
Expand Down Expand Up @@ -1515,9 +1516,15 @@ impl QuickLendXContract {
invoice_id: BytesN<32>,
bid_amount: i128,
expected_return: i128,
salt: BytesN<32>,
) -> Result<BytesN<32>, QuickLendXError> {
pause::PauseControl::require_not_paused(&env)?;
require_not_self(&env, &investor)?;
// Idempotency check
let idem_key = idempotency_key(&invoice_id, &investor, &salt, &env);
if idempotency_exists(&env, &idem_key) {
return Err(QuickLendXError::DuplicateBid);
}
// Authorization check: Only the investor can place their own bid
investor.require_auth();

Expand Down Expand Up @@ -1577,6 +1584,8 @@ impl QuickLendXContract {
BidStorage::store_bid(&env, &bid);
// Track bid for this invoice
BidStorage::add_bid_to_invoice(&env, &invoice_id, &bid_id);
// Store idempotency marker
store_idempotency(&env, &idem_key);

crate::qlx_log!(
&env,
Expand Down
3 changes: 2 additions & 1 deletion quicklendx-contracts/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ pub fn create_funded_invoice(
&Vec::new(env),
);
client.verify_invoice(&invoice_id);
let bid_id = client.place_bid(&investor, &invoice_id, &amount, &(amount + 100));
let salt = BytesN::from_array(&env, &[0u8; 32]);
let bid_id = client.place_bid(&investor, &invoice_id, &amount, &(amount + 100), &salt);
client.accept_bid(&invoice_id, &bid_id);
(invoice_id, business, investor, currency, contract_id)
}
Expand Down
32 changes: 32 additions & 0 deletions quicklendx-contracts/src/test/duplicate_bid_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// src/test/duplicate_bid_test.rs
//! Test that submitting the same bid with identical (invoice_id, investor, salt) fails with `DuplicateBid`.

use super::*; // bring the test utilities into scope

#[test]
fn test_duplicate_bid_idempotency() {
// Setup environment
let env = Env::default();
let contract = QuickLendXContractClient::new(&env, &env.register_contract(None, QuickLendXContract {}));

// Create a mock investor and invoice
let investor = env.accounts().generate();
let issuer = env.accounts().generate();
let invoice_id = BytesN::from_array(&env, &[0u8; 32]);
let amount: i128 = 1_000;
let expected_return: i128 = 1_100;

// Assume invoice creation helper exists
contract.create_invoice(&issuer, &invoice_id, &amount, &expected_return);

// Prepare a deterministic salt
let salt = BytesN::from_array(&env, &[1u8; 32]);

// First bid should succeed
let first_bid = contract.place_bid(&investor, &invoice_id, &amount, &(amount + 100), &salt);
assert_eq!(first_bid, Ok(()));

// Second bid with same parameters should return DuplicateBid error
let duplicate_bid = contract.place_bid(&investor, &invoice_id, &amount, &(amount + 100), &salt);
assert_eq!(duplicate_bid, Err(ContractError::DuplicateBid));
}
31 changes: 31 additions & 0 deletions quicklendx-contracts/src/test_max_invoices_per_business.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
mod test_max_invoices_per_business {
use crate::errors::QuickLendXError;
use crate::protocol_limits::is_active_status;
use crate::QuickLendXContractClient;
use soroban_sdk::{Address, Env, String, Vec};
use crate::types::InvoiceStatus;
use crate::types::InvoiceCategory;

// Core logic test extracted from check_invoice_limit architecture
fn enforce_limit_logic(active_count: u32, limit: u32) -> Result<(), QuickLendXError> {
Expand Down Expand Up @@ -53,4 +56,32 @@ mod test_max_invoices_per_business {
assert_eq!(is_active_status(&InvoiceStatus::Cancelled), false);
assert_eq!(is_active_status(&InvoiceStatus::Refunded), false);
}
#[test]
fn test_store_invoice_respects_cap() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(QuickLendXContract, ());
let client = QuickLendXContractClient::new(&env, &contract_id);

let admin = Address::generate(&env);
client.set_admin(&admin);
// set low invoice cap = 2
client.set_protocol_limits(&admin, 1_000i128, 1_000i128, 0u32, 365u64, 86400u64, 2).unwrap();

let business = Address::generate(&env);
let currency = Address::generate(&env);
let due_date = env.ledger().timestamp() + 86_400;
// verify business
client.submit_kyc_application(&business, &String::from_str(&env, "biz"));
client.verify_business(&admin, &business);

// store two invoices successfully
for _ in 0..2 {
client.store_invoice(&business, 1_000i128, &currency, &due_date, &String::from_str(&env, "inv"), &InvoiceCategory::Services, &Vec::new(&env)).unwrap();
}

// third invoice should fail
let result = client.try_store_invoice(&business, 1_000i128, &currency, &due_date, &String::from_str(&env, "inv3"), &InvoiceCategory::Services, &Vec::new(&env));
assert_eq!(result, Err(Ok(QuickLendXError::MaxInvoicesPerBusinessExceeded)));
}
}
Loading