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
1 change: 1 addition & 0 deletions contracts/invoice_liquidity/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name = "invoice_liquidity"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
publish = false

[lib]
crate-type = ["lib", "cdylib"]
Expand Down
9 changes: 9 additions & 0 deletions contracts/invoice_liquidity/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,12 @@ pub const UPGRADE_COOLDOWN_LEDGERS: u64 = 1440;

/// Rate limit cooldown for economic parameters — 30 minutes (360 ledgers).
pub const ECONOMIC_PARAM_COOLDOWN_LEDGERS: u64 = 360;

/// Minimum number of ledgers that must elapse between the first LP joining the
/// fund queue and `resolve_fund_queue` being callable. At ~5 s per ledger,
/// 120 ledgers ≈ 10 minutes, giving other LPs a fair window to join.
///
/// Prevents MEV / front-running: an attacker who observes a high-reputation LP
/// joining the queue can no longer immediately resolve the queue in the same
/// block to lock-out competing LPs (Issue #MEV-1).
pub const QUEUE_DELAY_LEDGERS: u32 = 120;
5 changes: 5 additions & 0 deletions contracts/invoice_liquidity/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,9 @@ pub enum ContractError {
Reentrancy = 37,
/// Rate-limited function called before the cooldown period elapsed (Issue #541).
RateLimited = 38,
/// resolve_fund_queue called before the minimum queue maturity delay has
/// elapsed since the first LP joined the queue. Prevents MEV/front-running
/// attacks where an attacker races to resolve the queue immediately after a
/// high-reputation LP joins (Issue #MEV-1).
QueueNotMature = 39,
}
18 changes: 18 additions & 0 deletions contracts/invoice_liquidity/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,24 @@ pub struct FundQueueResolved {
pub score: u32,
}

/// Emitted whenever `resolve_fund_queue` is called, regardless of outcome.
/// `success=true` means a winner was selected; `success=false` means the
/// call was rejected (e.g. maturity delay not yet elapsed).
///
/// Useful for off-chain monitoring to detect MEV attempts and track queue
/// activity (Issue #MEV-1).
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct FundQueueResolutionAttempted {
pub invoice_id: u64,
/// Caller that triggered the resolution attempt.
pub caller_ledger: u32,
/// Ledger sequence when the attempt was made.
pub attempted_at_ledger: u32,
/// Whether the resolution succeeded.
pub success: bool,
}

#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct InvoiceExpired {
Expand Down
21 changes: 21 additions & 0 deletions contracts/invoice_liquidity/src/invoice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,27 @@ pub fn save_queue_resolution(env: &Env, invoice_id: u64, approved_lp: &Address)
.persistent()
.set(&StorageKey::QueueResolution(invoice_id), approved_lp);
}

/// Record the ledger sequence when the first LP joined the fund queue.
/// Called once when the queue transitions from empty to non-empty.
/// Subsequent joins do not overwrite this value.
pub fn try_set_fund_queue_opened_at(env: &Env, invoice_id: u64) {
let key = StorageKey::FundQueueOpenedAt(invoice_id);
if !env.storage().persistent().has(&key) {
env.storage()
.persistent()
.set(&key, &env.ledger().sequence());
}
}

/// Return the ledger sequence when the fund queue for `invoice_id` was first
/// opened (i.e. when the first LP joined), or `None` if the queue is still
/// empty.
pub fn get_fund_queue_opened_at(env: &Env, invoice_id: u64) -> Option<u32> {
env.storage()
.persistent()
.get(&StorageKey::FundQueueOpenedAt(invoice_id))
}
// Contract stats helpers
// ----------------------------------------------------------------

Expand Down
83 changes: 73 additions & 10 deletions contracts/invoice_liquidity/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use access::{check_rate_limit, lock_reentrancy, unlock_reentrancy};
pub mod constants;
use constants::{
ADMIN_CHANGE_COOLDOWN_LEDGERS, DEFAULT_RATE_LIMIT_LEDGERS, ECONOMIC_PARAM_COOLDOWN_LEDGERS,
UPGRADE_COOLDOWN_LEDGERS,
QUEUE_DELAY_LEDGERS, UPGRADE_COOLDOWN_LEDGERS,
};
pub mod oracle_interface;
pub mod oracle_registry;
Expand All @@ -45,22 +45,24 @@ use crate::storage::get_admin;
use events::{
AdminChanged, AppealResolved, ContractInitialized, ContractPaused, ContractUnpaused,
ContractUpgraded, DefaultAppealed, DisputeResolved, DistributionContractUpdated,
FundQueueResolved, FundRequested, InsuranceClaimAttempted, InvoiceCancelled, InvoiceDefaulted,
FundQueueResolutionAttempted, FundQueueResolved, FundRequested, InsuranceClaimAttempted,
InvoiceCancelled, InvoiceDefaulted,
InvoiceDisputed, InvoiceExpired, InvoiceFunded, InvoicePaid, InvoicePartiallyPaid,
InvoiceSubmitted, InvoiceTokenChanged, InvoiceTransferred, InvoiceUpdated,
LPPositionTransferred, ParameterUpdated, PriceOracleUpdated, TokenAdded, TokenRemoved,
};
use invoice::{
add_invoice_to_lp, add_invoice_to_submitter, add_volume, get_appeal, get_contract_stats,
get_dispute, get_fund_queue, get_invoice_funders, get_lp_invoices, get_lp_score,
get_min_payer_reputation, get_payer_score, get_pre_default_payer_score, get_queue_resolution,
get_reputation, get_submitter_invoices, increment_invoices_defaulted, increment_invoices_paid,
increment_invoices_submitted, increment_total_funded, increment_total_invoices,
increment_total_paid, invoice_exists, is_paused, load_invoice, next_invoice_id,
remove_invoice_from_lp, remove_invoice_from_submitter, save_appeal, save_dispute,
save_fund_queue, save_invoice, save_invoice_funders, save_pre_default_payer_score,
get_dispute, get_fund_queue, get_fund_queue_opened_at, get_invoice_funders, get_lp_invoices,
get_lp_score, get_min_payer_reputation, get_payer_score, get_pre_default_payer_score,
get_queue_resolution, get_reputation, get_submitter_invoices, increment_invoices_defaulted,
increment_invoices_paid, increment_invoices_submitted, increment_total_funded,
increment_total_invoices, increment_total_paid, invoice_exists, is_paused, load_invoice,
next_invoice_id, remove_invoice_from_lp, remove_invoice_from_submitter, save_appeal,
save_dispute, save_fund_queue, save_invoice, save_invoice_funders, save_pre_default_payer_score,
save_queue_resolution, set_lp_score, set_min_payer_reputation, set_paused, set_payer_score,
set_reputation, try_load_invoice, ContractStats, DisputeRecord, StorageKey,
set_reputation, try_load_invoice, try_set_fund_queue_opened_at, ContractStats, DisputeRecord,
StorageKey,
};
// 30-day window in seconds for a payer to file an appeal after a default.
const APPEAL_WINDOW_SECONDS: u64 = 30 * 24 * 60 * 60;
Expand Down Expand Up @@ -1207,6 +1209,12 @@ impl InvoiceLiquidityContract {

// Increment total invoices counter
increment_total_invoices(&env);

// Increment detailed reputation invoices_submitted count
// (mirrors the same call in submit_invoice so batch submission
// does not unfairly penalise high-volume freelancers).
increment_invoices_submitted(&env, &params.freelancer);

env.events().publish(
(
Symbol::new(&env, "submitted"),
Expand Down Expand Up @@ -1319,6 +1327,11 @@ impl InvoiceLiquidityContract {
queue.insert(insert_pos, new_request);
save_fund_queue(&env, invoice_id, &queue);

// MEV mitigation (Issue #MEV-1): record the ledger when the first LP
// joins so that `resolve_fund_queue` can enforce a minimum maturity
// delay before locking in the winner.
try_set_fund_queue_opened_at(&env, invoice_id);

env.events().publish(
(Symbol::new(&env, "fund_requested"), invoice_id, lp.clone()),
FundRequested {
Expand Down Expand Up @@ -1353,13 +1366,47 @@ impl InvoiceLiquidityContract {
return Err(ContractError::NotFunded); // no one in queue
}

// MEV mitigation (Issue #MEV-1): enforce a minimum maturity delay so
// that all LPs have a fair window to join before the winner is locked.
// The delay is measured in ledger sequences (not timestamps) because
// ledger sequence is monotonically increasing and cannot be manipulated.
if let Some(opened_at) = get_fund_queue_opened_at(&env, invoice_id) {
let current = env.ledger().sequence();
if current < opened_at.saturating_add(QUEUE_DELAY_LEDGERS) {
// Emit an attempt event so off-chain monitors can detect MEV
// probing even on rejected calls.
env.events().publish(
(Symbol::new(&env, "queue_resolve_attempt"), invoice_id),
FundQueueResolutionAttempted {
invoice_id,
caller_ledger: opened_at,
attempted_at_ledger: current,
success: false,
},
);
return Err(ContractError::QueueNotMature);
}
}

// Queue is sorted by score (descending), so highest score is at index 0.
let best_entry = queue.get(0).unwrap();
let best_lp = best_entry.lp.clone();
let best_score = best_entry.score;

save_queue_resolution(&env, invoice_id, &best_lp);

// Emit resolution attempt event (successful).
let current_ledger = env.ledger().sequence();
env.events().publish(
(Symbol::new(&env, "queue_resolve_attempt"), invoice_id),
FundQueueResolutionAttempted {
invoice_id,
caller_ledger: get_fund_queue_opened_at(&env, invoice_id).unwrap_or(0),
attempted_at_ledger: current_ledger,
success: true,
},
);

env.events().publish(
(
Symbol::new(&env, "fund_queue_resolved"),
Expand Down Expand Up @@ -1820,6 +1867,10 @@ impl InvoiceLiquidityContract {
// ------------------------------------------------------------
/// Access: Anyone
pub fn expire_invoice(env: Env, invoice_id: u64) -> Result<(), ContractError> {
if is_paused(&env) {
return Err(ContractError::ContractPaused);
}

if !invoice_exists(&env, invoice_id) {
return Err(ContractError::InvoiceNotFound);
}
Expand Down Expand Up @@ -2231,6 +2282,10 @@ impl InvoiceLiquidityContract {
invoice_id: u64,
evidence_hash: BytesN<32>,
) -> Result<(), ContractError> {
if is_paused(&env) {
return Err(ContractError::ContractPaused);
}

if !invoice_exists(&env, invoice_id) {
return Err(ContractError::InvoiceNotFound);
}
Expand Down Expand Up @@ -2978,3 +3033,11 @@ mod tests_new_features;
mod tests_oracle_registry;
mod tests_storage;
mod tests_storage_layout;
// Issue #MEV-1: resolve_fund_queue maturity delay
mod tests_mev_mitigation;
// Issue #invoice-count: get_invoice_count underflow safety
mod tests_invoice_count;
// Issue #batch-reputation: batch_submit increments invoices_submitted
mod tests_batch_submit_reputation;
// Issue #pause-checks: expire_invoice and appeal_default pause guards
mod tests_pause_checks;
24 changes: 24 additions & 0 deletions contracts/invoice_liquidity/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ pub enum DataKey {
LpScore(Address),
FundQueue(u64),
QueueResolution(u64),
/// Ledger sequence when the first LP joined the fund queue for an invoice.
/// Used to enforce a minimum maturity delay before `resolve_fund_queue` may
/// be called, preventing MEV / front-running (Issue #MEV-1).
FundQueueOpenedAt(u64),

// Stats (Persistent)
TotalInvoices,
Expand Down Expand Up @@ -376,6 +380,26 @@ pub fn save_queue_resolution(env: &Env, invoice_id: u64, approved_lp: &Address)
.set(&DataKey::QueueResolution(invoice_id), approved_lp);
}

/// Record the ledger sequence when the first LP joined the fund queue.
/// Must only be called once per invoice (when the queue transitions from empty
/// to non-empty). Subsequent joins do not overwrite this timestamp.
pub fn try_set_fund_queue_opened_at(env: &Env, invoice_id: u64) {
let key = DataKey::FundQueueOpenedAt(invoice_id);
if !env.storage().persistent().has(&key) {
env.storage()
.persistent()
.set(&key, &env.ledger().sequence());
}
}

/// Return the ledger sequence when the fund queue for `invoice_id` was first
/// opened (i.e. the first LP join), or `None` if the queue is still empty.
pub fn get_fund_queue_opened_at(env: &Env, invoice_id: u64) -> Option<u32> {
env.storage()
.persistent()
.get(&DataKey::FundQueueOpenedAt(invoice_id))
}

// ----------------------------------------------------------------
// Appeal Helpers
// ----------------------------------------------------------------
Expand Down
Loading
Loading