Skip to content
Open
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
77 changes: 52 additions & 25 deletions quicklendx-contracts/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

use crate::errors::QuickLendXError;
use crate::observability::OBSERVABILITY_SCHEMA_VERSION;
use crate::storage::{bump_persistent, extend_persistent_ttl};
use crate::types::{Invoice, InvoiceStatus};
use soroban_sdk::{
contracttype, symbol_short, xdr::ToXdr, Address, Bytes, BytesN, Env, String, Symbol, Vec,
Expand Down Expand Up @@ -691,7 +692,8 @@ impl AuditStorage {
}

// Store individual entry
env.storage().instance().set(&entry.audit_id, entry);
env.storage().persistent().set(&entry.audit_id, entry);
extend_persistent_ttl(env, &entry.audit_id);

// Add to invoice audit trail
Self::add_to_invoice_audit_trail(env, &entry.invoice_id, &entry.audit_id);
Expand All @@ -711,16 +713,25 @@ impl AuditStorage {

/// Get audit entry by ID
pub fn get_audit_entry(env: &Env, audit_id: &BytesN<32>) -> Option<AuditLogEntry> {
env.storage().instance().get(audit_id)
let entry = env.storage().persistent().get(audit_id);
if entry.is_some() {
extend_persistent_ttl(env, audit_id);
}
entry
}

/// Get audit trail for an invoice
pub fn get_invoice_audit_trail(env: &Env, invoice_id: &BytesN<32>) -> Vec<BytesN<32>> {
let key = (symbol_short!("inv_aud"), invoice_id.clone());
env.storage()
.instance()
let result: Vec<BytesN<32>> = env
.storage()
.persistent()
.get(&key)
.unwrap_or_else(|| Vec::new(env))
.unwrap_or_else(|| Vec::new(env));
if !result.is_empty() {
extend_persistent_ttl(env, &key);
}
result
}

/// Get audit entries by operation type
Expand All @@ -729,19 +740,29 @@ impl AuditStorage {
operation: &AuditOperation,
) -> Vec<BytesN<32>> {
let key = (symbol_short!("op_aud"), operation.clone());
env.storage()
.instance()
let result: Vec<BytesN<32>> = env
.storage()
.persistent()
.get(&key)
.unwrap_or_else(|| Vec::new(env))
.unwrap_or_else(|| Vec::new(env));
if !result.is_empty() {
extend_persistent_ttl(env, &key);
}
result
}

/// Get audit entries by actor
pub fn get_audit_entries_by_actor(env: &Env, actor: &Address) -> Vec<BytesN<32>> {
let key = (symbol_short!("act_aud"), actor.clone());
env.storage()
.instance()
let result: Vec<BytesN<32>> = env
.storage()
.persistent()
.get(&key)
.unwrap_or_else(|| Vec::new(env))
.unwrap_or_else(|| Vec::new(env));
if !result.is_empty() {
extend_persistent_ttl(env, &key);
}
result
}

/// Query audit logs with filters
Expand Down Expand Up @@ -873,52 +894,58 @@ impl AuditStorage {
let key = (symbol_short!("inv_aud"), invoice_id.clone());
let mut trail = Self::get_invoice_audit_trail(env, invoice_id);
trail.push_back(audit_id.clone());
env.storage().instance().set(&key, &trail);
env.storage().persistent().set(&key, &trail);
extend_persistent_ttl(env, &key);
}

fn add_to_operation_index(env: &Env, operation: &AuditOperation, audit_id: &BytesN<32>) {
let key = (symbol_short!("op_aud"), operation.clone());
let mut entries = Self::get_audit_entries_by_operation(env, operation);
entries.push_back(audit_id.clone());
env.storage().instance().set(&key, &entries);
env.storage().persistent().set(&key, &entries);
extend_persistent_ttl(env, &key);
}

fn add_to_actor_index(env: &Env, actor: &Address, audit_id: &BytesN<32>) {
let key = (symbol_short!("act_aud"), actor.clone());
let mut entries = Self::get_audit_entries_by_actor(env, actor);
entries.push_back(audit_id.clone());
env.storage().instance().set(&key, &entries);
env.storage().persistent().set(&key, &entries);
extend_persistent_ttl(env, &key);
}

fn add_to_timestamp_index(env: &Env, timestamp: u64, audit_id: &BytesN<32>) {
let day_key = timestamp / 86400; // Group by day
let key = (symbol_short!("ts_aud"), day_key);
let mut entries: Vec<BytesN<32>> = env
.storage()
.instance()
.persistent()
.get(&key)
.unwrap_or_else(|| Vec::new(env));
entries.push_back(audit_id.clone());
env.storage().instance().set(&key, &entries);
env.storage().persistent().set(&key, &entries);
extend_persistent_ttl(env, &key);
}

fn add_to_all_audit_entries(env: &Env, audit_id: &BytesN<32>) {
let key = symbol_short!("all_aud");
let mut all: Vec<BytesN<32>> = env
.storage()
.instance()
.get(&key)
.unwrap_or_else(|| Vec::new(env));
let mut all = Self::get_all_audit_entries(env);
all.push_back(audit_id.clone());
env.storage().instance().set(&key, &all);
env.storage().persistent().set(&key, &all);
extend_persistent_ttl(env, &key);
}

fn get_all_audit_entries(env: &Env) -> Vec<BytesN<32>> {
let key = symbol_short!("all_aud");
env.storage()
.instance()
let result: Vec<BytesN<32>> = env
.storage()
.persistent()
.get(&key)
.unwrap_or_else(|| Vec::new(env))
.unwrap_or_else(|| Vec::new(env));
if !result.is_empty() {
extend_persistent_ttl(env, &key);
}
result
}

fn matches_filter(entry: &AuditLogEntry, filter: &AuditQueryFilter) -> bool {
Expand Down
23 changes: 8 additions & 15 deletions quicklendx-contracts/src/bid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,21 +356,14 @@ impl BidStorage {
bids
}

pub fn get_bids_for_invoice_count(env: &Env, invoice_id: &BytesN<32>) -> u32 {
let count_key = Self::invoice_bid_count_key(invoice_id);
env.storage().persistent().get(&count_key).unwrap_or(0)
}

pub fn get_active_bid_count(env: &Env, invoice_id: &BytesN<32>) -> u32 {
let _ = Self::refresh_expired_bids(env, invoice_id);
let bid_ids = Self::get_bids_for_invoice(env, invoice_id);
let mut active_count = 0u32;
let mut idx: u32 = 0;
while idx < bid_ids.len() {
let bid_id = bid_ids.get(idx).unwrap();
if let Some(bid) = Self::get_bid(env, &bid_id) {
if bid.status == BidStatus::Placed {
active_count += 1;
}
}
idx += 1;
}
active_count
Self::get_bids_for_invoice_count(env, invoice_id)
}

/// Return the currently active bid TTL in days.
Expand Down Expand Up @@ -1087,8 +1080,8 @@ impl BidStorage {
/// This guarantees reproducible ranking across validators even when all economic
/// values match.
pub fn compare_bids(bid1: &Bid, bid2: &Bid) -> Ordering {
let profit1 = bid1.expected_return.saturating_sub(bid1.bid_amount);
let profit2 = bid2.expected_return.saturating_sub(bid2.bid_amount);
let profit1 = bid1.expected_return.saturating_sub(bid1.bid_amount).max(0);
let profit2 = bid2.expected_return.saturating_sub(bid2.bid_amount).max(0);
if profit1 != profit2 {
return profit1.cmp(&profit2);
}
Expand Down
2 changes: 1 addition & 1 deletion quicklendx-contracts/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ impl QuickLendXContract {
}

pub fn get_total_invoice_count(env: Env) -> u32 {
InvoiceStorage::get_total_count(&env)
u32::try_from(InvoiceStorage::get_total_count(&env)).unwrap_or(u32::MAX)
}

pub fn get_invoice_count_by_status(env: Env, status: InvoiceStatus) -> u32 {
Expand Down
2 changes: 1 addition & 1 deletion quicklendx-contracts/src/idempotency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ pub fn store_idempotency_result<T: soroban_sdk::IntoVal<Env, soroban_sdk::Val>>(
extend_persistent_ttl(env, &composite_key);
}

#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod tests {
use super::*;
use soroban_sdk::Env;
Expand Down
4 changes: 2 additions & 2 deletions quicklendx-contracts/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#![no_std]

use soroban_sdk::{contract, contractimpl, Env, Symbol, symbol_short};
use soroban_sdk::{contract, contractimpl, symbol_short, Env, Symbol};

pub mod errors;
/// Invoice amount precision and overflow validation (Issue #2432).
Expand All @@ -19,7 +19,7 @@ pub struct QuickLendXContract;

#[contractimpl]
impl QuickLendXContract {
pub fn hello(env: Env) -> Symbol {
pub fn hello(_env: Env) -> Symbol {
symbol_short!("A1")
}
}
63 changes: 46 additions & 17 deletions quicklendx-contracts/src/test_bid_concurrency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ fn place_bid(
#[test]
fn concurrent_placement_same_invoice() {
let (env, client, admin) = setup();
let contract_id = env.current_contract_address();
let contract_id = client.address.clone();

let investor_a = verified_investor(&env, &client, &admin, 500_000);
let investor_b = verified_investor(&env, &client, &admin, 500_000);
Expand Down Expand Up @@ -199,7 +199,7 @@ fn concurrent_placement_same_invoice() {
#[test]
fn acceptance_rejects_stale_expired_bid() {
let (env, client, admin) = setup();
let contract_id = env.current_contract_address();
let contract_id = client.address.clone();

let investor = verified_investor(&env, &client, &admin, 500_000);
let business = verified_business(&env, &client, &admin);
Expand Down Expand Up @@ -245,7 +245,7 @@ fn acceptance_rejects_stale_expired_bid() {
#[test]
fn acceptance_rejects_cancelled_bid() {
let (env, client, admin) = setup();
let contract_id = env.current_contract_address();
let contract_id = client.address.clone();

let investor = verified_investor(&env, &client, &admin, 500_000);
let business = verified_business(&env, &client, &admin);
Expand Down Expand Up @@ -283,7 +283,7 @@ fn acceptance_rejects_cancelled_bid() {
#[test]
fn cancel_then_accept_returns_bid_stale() {
let (env, client, admin) = setup();
let contract_id = env.current_contract_address();
let contract_id = client.address.clone();

let investor = verified_investor(&env, &client, &admin, 500_000);
let business = verified_business(&env, &client, &admin);
Expand Down Expand Up @@ -325,7 +325,7 @@ fn cancel_then_accept_returns_bid_stale() {
#[test]
fn accept_then_cancel_returns_bid_stale() {
let (env, client, admin) = setup();
let contract_id = env.current_contract_address();
let contract_id = client.address.clone();

let investor = verified_investor(&env, &client, &admin, 500_000);
let business = verified_business(&env, &client, &admin);
Expand Down Expand Up @@ -392,7 +392,7 @@ fn cancel_bid_not_found() {
#[test]
fn retry_after_conflict_succeeds_with_fresh_bid() {
let (env, client, admin) = setup();
let contract_id = env.current_contract_address();
let contract_id = client.address.clone();

let investor_a = verified_investor(&env, &client, &admin, 500_000);
let investor_b = verified_investor(&env, &client, &admin, 500_000);
Expand Down Expand Up @@ -445,7 +445,7 @@ fn retry_after_conflict_succeeds_with_fresh_bid() {
#[test]
fn no_partial_state_after_failed_accept() {
let (env, client, admin) = setup();
let contract_id = env.current_contract_address();
let contract_id = client.address.clone();

let investor_a = verified_investor(&env, &client, &admin, 500_000);
let investor_b = verified_investor(&env, &client, &admin, 500_000);
Expand Down Expand Up @@ -511,7 +511,7 @@ fn ranking_deterministic_under_contention() {
let investor_c = verified_investor(&env, &client, &admin, 500_000);
let business = verified_business(&env, &client, &admin);

let contract_id = env.current_contract_address();
let contract_id = client.address.clone();
let currency = setup_token(
&env,
&contract_id,
Expand All @@ -521,11 +521,24 @@ fn ranking_deterministic_under_contention() {

let invoice_id = upload_and_verify_invoice(&env, &client, &business, &currency, 100_000);

// Place three bids with different economics
let bid_a = place_bid(&env, &client, &investor_a, &invoice_id, 90_000); // lowest amount → highest profit
let bid_b = place_bid(&env, &client, &investor_b, &invoice_id, 95_000);
let bid_c = place_bid(&env, &client, &investor_c, &invoice_id, 98_000); // highest amount → lowest profit

// Place three bids with different economics:
// A: 90k bid, 100k return -> 10k profit (highest)
// B: 95k bid, 100k return -> 5k profit (middle)
// C: 98k bid, 100k return -> 2k profit (lowest)
let bid_a = client.place_bid(
&investor_a,
&invoice_id,
&90_000i128,
&100_000i128,
&BytesN::from_array(&env, &[0u8; 32]),
);
let bid_b = client.place_bid(
&investor_b,
&invoice_id,
&95_000i128,
&100_000i128,
&BytesN::from_array(&env, &[0u8; 32]),
);
// Initial ranking: A should be best (highest profit)
let best = client.get_best_bid(&invoice_id);
assert!(best.is_some(), "must have a best bid");
Expand All @@ -547,7 +560,21 @@ fn ranking_deterministic_under_contention() {
"best must be bid B after A cancelled"
);

// Advance time past bid B's expiration
// Advance time slightly and place bid C (which will expire later than B)
env.ledger().with_mut(|li| li.timestamp = 2_000);
let bid_c = client.place_bid(
&investor_c,
&invoice_id,
&98_000i128,
&100_000i128,
&BytesN::from_array(&env, &[0u8; 32]),
);

// B is still better than C (5k profit vs 2k profit)
let best = client.get_best_bid(&invoice_id);
assert_eq!(best.unwrap().bid_id, bid_b);

// Advance time past bid B's expiration (t = 1000 + 7d + 1, C expires at 2000 + 7d)
let bid_b_record = client.get_bid(&bid_b).expect("bid B must exist");
env.ledger()
.with_mut(|li| li.timestamp = bid_b_record.expiration_timestamp + 1);
Expand Down Expand Up @@ -585,7 +612,7 @@ fn expiry_during_contention() {
let investor_b = verified_investor(&env, &client, &admin, 500_000);
let business = verified_business(&env, &client, &admin);

let contract_id = env.current_contract_address();
let contract_id = client.address.clone();
let currency = setup_token(
&env,
&contract_id,
Expand Down Expand Up @@ -640,7 +667,9 @@ fn expiry_during_contention() {
#[test]
fn max_bids_per_invoice_enforced_under_contention() {
let (env, client, admin) = setup();
let contract_id = env.current_contract_address();
env.budget().reset_unlimited();
let _ = env.host().set_invocation_resource_limits(None);
let contract_id = client.address.clone();

let business = verified_business(&env, &client, &admin);
let currency = setup_token(&env, &contract_id, &[&business], 200_000);
Expand Down Expand Up @@ -710,7 +739,7 @@ fn max_bids_per_invoice_enforced_under_contention() {
#[test]
fn stale_read_rejected_with_bid_stale() {
let (env, client, admin) = setup();
let contract_id = env.current_contract_address();
let contract_id = client.address.clone();

let investor_a = verified_investor(&env, &client, &admin, 500_000);
let investor_b = verified_investor(&env, &client, &admin, 500_000);
Expand Down
6 changes: 3 additions & 3 deletions quicklendx-contracts/src/test_bid_ranking_determinism.rs
Original file line number Diff line number Diff line change
Expand Up @@ -625,10 +625,10 @@ fn test_contract_get_bid_history_paged_filters_expired_on_placed() {
assert_eq!(placed_paged.total_count, 1);
assert_eq!(placed_paged.items.get(0).unwrap().bid_id, active.bid_id);

// Filter None: returns all historical bids
// Filter None: returns all remaining bids in index (2: active + cancelled)
let all_paged = client.get_bid_history_paged(&inv, &None, &0, &10);
assert_eq!(all_paged.items.len(), 3);
assert_eq!(all_paged.total_count, 3);
assert_eq!(all_paged.items.len(), 2);
assert_eq!(all_paged.total_count, 2);
}

#[test]
Expand Down
Loading
Loading