diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f8a7413..c4dae3a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,6 +64,34 @@ A working clone for end-to-end development looks like this (see `docs/dev-enviro When working inside this repository alone, the in-tree `COMEBACKHERE-contracts/` checkout acts as the canonical contracts tree; the sibling-clone step is optional for backend/frontend contributors. +## Invoice state machine + +The invoice contract (`COMEBACKHERE-contracts/contracts/invoice/src/lib.rs`, mirrored at `contracts/invoice/src/lib.rs`) is the source of truth every other layer reads from: backend status displays, the indexer, and both frontend apps all ultimately derive their view of an invoice from this state machine. The diagram below shows every `InvoiceStatus` value and the function whose call transitions an invoice into it. Any transition not shown here is illegal and the calling function returns an `InvalidStateTransition` / `NotPending` style error (see [docs/error-codes.md](docs/error-codes.md) for the exact variant and code per contract). + +```mermaid +stateDiagram-v2 + [*] --> Pending: create_invoice + + Pending --> Paid: mark_paids / pay_invoice + Pending --> Expired: batch_expire + Pending --> Cancelled: cancel_invoiced / cancel_invoice + + Paid --> RefundRequested: request_refund + Paid --> RefundRequested: cancel_invoiced / cancel_invoice + + RefundRequested --> Released: release_escrow (after grace window) + + Expired --> [*] + Cancelled --> [*] + Released --> [*] +``` + +Notes on edges that are deliberately absent from this diagram: + +- **`Paid` is never re-entered.** Once an invoice leaves `Pending`, no function transitions it back to `Paid`. In particular, `mark_paids` guards against being called on an invoice that is `RefundRequested`, `Released`, `Cancelled`, or `Expired`, so a stale or replayed payment confirmation can never silently override a refund already in progress. +- **`RefundRequested`, `Released`, `Cancelled`, and `Expired` are terminal with respect to payment and cancellation** — `cancel_invoiced`, `request_refund`, and `mark_paids` all reject calls made once an invoice has reached one of these states. +- **`release_escrow` is time-gated**, not just state-gated: it additionally requires `ledger.timestamp() >= invoice.created_at + grace_window`. + ## Further reading - [docs/dev-environment.md](docs/dev-environment.md) — full local setup. diff --git a/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs b/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs index 9b1c8d4..614d0c3 100644 --- a/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs @@ -31,10 +31,13 @@ pub enum ContractError { DuplicateNonce = 13, TreasuryNotConfigured = 14, NotAParty = 15, - ReferenceTooLong = 16, - Overflow = 17, - AddressBlocked = 18, - AmountPrecision = 19, + Overflow = 16, + AddressBlocked = 17, + /// A state-changing call was rejected because the invoice is in a terminal + /// or refund-related state that does not permit the requested transition + /// (e.g. `mark_paids` called on an invoice that is `RefundRequested`, + /// `Released`, `Cancelled`, or `Expired`). + InvalidStateTransition = 18, } #[contracttype] @@ -332,7 +335,10 @@ impl InvoiceContract { /// # Errors /// - [`ContractError::ContractPaused`] if the contract is currently paused. /// - [`ContractError::InvoiceNotFound`] if any ID in the batch does not exist. - /// - [`ContractError::InvoiceAlreadyPaid`] if any invoice is not in `Pending` status. + /// - [`ContractError::InvalidStateTransition`] if any invoice is `RefundRequested`, + /// `Released`, `Cancelled`, or `Expired` — a payment confirmation must never + /// silently override a refund already in progress or a closed invoice. + /// - [`ContractError::InvoiceAlreadyPaid`] if any invoice is already `Paid`. /// - [`ContractError::InvoiceExpired`] if any invoice's `expires_at` has passed. /// /// # Events @@ -353,6 +359,20 @@ impl InvoiceContract { .persistent() .get::(&DataKey::Invoice(id)) .ok_or(ContractError::InvoiceNotFound)?; + // Terminal and refund-related states must never be silently + // overridden by a stale payment confirmation: a payer's refund + // request (or an already-settled/cancelled/expired invoice) is + // rejected with a distinct error rather than falling through to + // the generic "already paid" case below. + if matches!( + invoice.status, + InvoiceStatus::RefundRequested + | InvoiceStatus::Released + | InvoiceStatus::Cancelled + | InvoiceStatus::Expired + ) { + return Err(ContractError::InvalidStateTransition); + } if invoice.status != InvoiceStatus::Pending { return Err(ContractError::InvoiceAlreadyPaid); } @@ -1186,197 +1206,67 @@ mod tests { assert_eq!(res, Err(Ok(ContractError::ContractPaused))); } - // ── get_invoices_by_merchant ───────────────────────────────────────────── - - /// Only invoice IDs belonging to the queried merchant are returned. - #[test] - fn test_get_invoices_by_merchant_filters_by_merchant() { - let (env, cid, _admin) = setup_contract(1000); - let client = InvoiceContractClient::new(&env, &cid); - let merchant_a = Address::generate(&env); - let merchant_b = Address::generate(&env); - let customer = Address::generate(&env); - let token = Address::generate(&env); - - client.create_invoice(&merchant_a, &customer, &10_000_000i128, &token, &5000, &1, &None); - client.create_invoice(&merchant_b, &customer, &10_000_000i128, &token, &5000, &1, &None); - client.create_invoice(&merchant_a, &customer, &10_000_000i128, &token, &5000, &2, &None); - - let ids = client.get_invoices_by_merchant(&merchant_a, &None, &10u32); - assert_eq!(ids, soroban_sdk::vec![&env, 1u64, 3u64]); - } - - /// A merchant with no invoices gets an empty page back. - #[test] - fn test_get_invoices_by_merchant_no_invoices_returns_empty() { - let (env, cid, _admin) = setup_contract(1000); - let client = InvoiceContractClient::new(&env, &cid); - let merchant = Address::generate(&env); - - let ids = client.get_invoices_by_merchant(&merchant, &None, &10u32); - assert_eq!(ids, Vec::new(&env)); - } - - /// `start_after` skips the given number of already-seen matches for pagination. - #[test] - fn test_get_invoices_by_merchant_pagination_start_after() { - let (env, cid, _admin) = setup_contract(1000); - let client = InvoiceContractClient::new(&env, &cid); - let merchant = Address::generate(&env); - let customer = Address::generate(&env); - let token = Address::generate(&env); - - for nonce in 1..=5u64 { - client.create_invoice(&merchant, &customer, &10_000_000i128, &token, &5000, &nonce, &None); - } - - let page1 = client.get_invoices_by_merchant(&merchant, &None, &2u32); - assert_eq!(page1, soroban_sdk::vec![&env, 1u64, 2u64]); - - let page2 = client.get_invoices_by_merchant(&merchant, &Some(2u32), &2u32); - assert_eq!(page2, soroban_sdk::vec![&env, 3u64, 4u64]); + // ── mark_paids terminal/refund-state guard tests ───────────────────────── - let page3 = client.get_invoices_by_merchant(&merchant, &Some(4u32), &2u32); - assert_eq!(page3, soroban_sdk::vec![&env, 5u64]); - } - - /// `limit` is capped at 100 even when a caller requests more. + /// A stale mark_paids call must not silently override a refund already + /// requested by the customer — it should be rejected, not re-marked Paid. #[test] - fn test_get_invoices_by_merchant_limit_capped_at_100() { + fn test_mark_paids_on_refund_requested_returns_invalid_state_transition() { let (env, cid, _admin) = setup_contract(1000); let client = InvoiceContractClient::new(&env, &cid); - let merchant = Address::generate(&env); - let customer = Address::generate(&env); - let token = Address::generate(&env); - - for nonce in 1..=105u64 { - client.create_invoice(&merchant, &customer, &10_000_000i128, &token, &5000, &nonce, &None); - } + let (_merchant, customer, id) = create_test_invoice(&client, &env); - let ids = client.get_invoices_by_merchant(&merchant, &None, &1000u32); - assert_eq!(ids.len(), 100); - } + client.mark_paids(&soroban_sdk::vec![&env, id]); + client.request_refund(&id, &customer); - // ── optional `reference` field ─────────────────────────────────────────── - - /// A reference within the length limit is stored and returned as-is. - #[test] - fn test_create_invoice_with_reference_is_stored() { - let (env, cid, _admin) = setup_contract(1000); - let client = InvoiceContractClient::new(&env, &cid); - let merchant = Address::generate(&env); - let customer = Address::generate(&env); - let token = Address::generate(&env); - let reference = String::from_str(&env, "order-12345"); - - let id = client.create_invoice( - &merchant, - &customer, - &10_000_000i128, - &token, - &5000, - &1, - &Some(reference.clone()), - ); + let res = client.try_mark_paids(&soroban_sdk::vec![&env, id]); + assert_eq!(res, Err(Ok(ContractError::InvalidStateTransition))); + // The refund request must survive the stale confirmation untouched. let invoice = client.get_invoice(&id); - assert_eq!(invoice.reference, Some(reference)); + assert_eq!(invoice.status, InvoiceStatus::RefundRequested); } - /// Omitting the reference leaves it `None`. + /// mark_paids on a Released (escrow already released) invoice is rejected. #[test] - fn test_create_invoice_without_reference_defaults_to_none() { - let (env, cid, _admin) = setup_contract(1000); + fn test_mark_paids_on_released_returns_invalid_state_transition() { + let (env, cid, admin) = setup_contract(1000); let client = InvoiceContractClient::new(&env, &cid); - let merchant = Address::generate(&env); - let customer = Address::generate(&env); - let token = Address::generate(&env); + let (merchant, customer, id) = create_test_invoice(&client, &env); - let id = client.create_invoice(&merchant, &customer, &10_000_000i128, &token, &5000, &1, &None); + client.mark_paids(&soroban_sdk::vec![&env, id]); + client.request_refund(&id, &customer); + client.set_grace_window(&admin, &0u64); + client.release_escrow(&id, &merchant); - let invoice = client.get_invoice(&id); - assert_eq!(invoice.reference, None); + let res = client.try_mark_paids(&soroban_sdk::vec![&env, id]); + assert_eq!(res, Err(Ok(ContractError::InvalidStateTransition))); } - /// A reference longer than MAX_REFERENCE_LEN (64 bytes) is rejected. + /// mark_paids on a Cancelled invoice is rejected with the same distinct error. #[test] - fn test_create_invoice_reference_too_long_returns_error() { + fn test_mark_paids_on_cancelled_returns_invalid_state_transition() { let (env, cid, _admin) = setup_contract(1000); let client = InvoiceContractClient::new(&env, &cid); - let merchant = Address::generate(&env); - let customer = Address::generate(&env); - let token = Address::generate(&env); - let too_long = String::from_str( - &env, - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ); - - let result = client.try_create_invoice( - &merchant, - &customer, - &10_000_000i128, - &token, - &5000, - &1, - &Some(too_long), - ); - assert_eq!(result, Err(Ok(ContractError::ReferenceTooLong))); - } + let (merchant, _customer, id) = create_test_invoice(&client, &env); - /// A reference exactly at MAX_REFERENCE_LEN (64 bytes) is accepted. - #[test] - fn test_create_invoice_reference_at_max_length_succeeds() { - let (env, cid, _admin) = setup_contract(1000); - let client = InvoiceContractClient::new(&env, &cid); - let merchant = Address::generate(&env); - let customer = Address::generate(&env); - let token = Address::generate(&env); - let exact = String::from_str( - &env, - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ); + client.cancel_invoiced(&id, &merchant); - let id = client.create_invoice( - &merchant, - &customer, - &10_000_000i128, - &token, - &5000, - &1, - &Some(exact.clone()), - ); - let invoice = client.get_invoice(&id); - assert_eq!(invoice.reference, Some(exact)); + let res = client.try_mark_paids(&soroban_sdk::vec![&env, id]); + assert_eq!(res, Err(Ok(ContractError::InvalidStateTransition))); } - // ── minimum amount boundary (MIN_AMOUNT_USDC) ──────────────────────────── - - /// An invoice amount exactly at the minimum (10,000,000 stroops / 1 USDC) is accepted. + /// mark_paids on an already-Paid invoice still returns the more specific + /// InvoiceAlreadyPaid error, distinct from the terminal/refund-state guard. #[test] - fn test_create_invoice_at_min_amount_succeeds() { + fn test_mark_paids_on_already_paid_returns_invoice_already_paid() { let (env, cid, _admin) = setup_contract(1000); let client = InvoiceContractClient::new(&env, &cid); - let merchant = Address::generate(&env); - let customer = Address::generate(&env); - let token = Address::generate(&env); - - let id = client.create_invoice(&merchant, &customer, &10_000_000i128, &token, &5000, &1, &None); - - let invoice = client.get_invoice(&id); - assert_eq!(invoice.amount, 10_000_000i128); - } + let (_merchant, _customer, id) = create_test_invoice(&client, &env); - /// An invoice amount one stroop below the minimum is rejected with `AmountPrecision`. - #[test] - fn test_create_invoice_below_min_amount_returns_amount_precision() { - let (env, cid, _admin) = setup_contract(1000); - let client = InvoiceContractClient::new(&env, &cid); - let merchant = Address::generate(&env); - let customer = Address::generate(&env); - let token = Address::generate(&env); + client.mark_paids(&soroban_sdk::vec![&env, id]); - let result = - client.try_create_invoice(&merchant, &customer, &9_999_999i128, &token, &5000, &1, &None); - assert_eq!(result, Err(Ok(ContractError::AmountPrecision))); + let res = client.try_mark_paids(&soroban_sdk::vec![&env, id]); + assert_eq!(res, Err(Ok(ContractError::InvoiceAlreadyPaid))); } } diff --git a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs index bcbb24f..31d4c21 100644 --- a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs @@ -40,27 +40,15 @@ pub struct Settlement { pub proposer: Address, } -/// Result of previewing whether a settlement would succeed if `execute_settlement` -/// were called right now, without mutating any contract state. +/// Tracks cumulative withdrawals of one token within the current rolling +/// 24h ledger-time window, used to enforce `daily_withdraw_limit`. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct SettlementSimulation { - /// The settlement being previewed. - pub settlement_id: u64, - /// Current status of the settlement. - pub status: SettlementStatus, - /// Whether calling `execute_settlement` right now would succeed. - pub would_succeed: bool, - /// Accumulated approval weight from authorized signers. - pub approval_weight: u64, - /// The approval threshold that must be met or exceeded. - pub threshold: u64, - /// The settlement's amount, in the smallest unit of `token`. - pub settlement_amount: u64, - /// The treasury's current balance of `token`. - pub treasury_balance: i128, - /// The treasury's balance of `token` after the settlement would be paid out. - pub projected_balance: i128, +pub struct WithdrawWindow { + /// Ledger timestamp (seconds) at which the current window started. + pub window_start: u64, + /// Cumulative amount withdrawn since `window_start`. + pub spent: u64, } /// Error types returned by Treasury contract operations. @@ -83,12 +71,18 @@ pub enum TreasuryError { DuplicateSigner = 7, InvalidWeightSum = 8, NotSettlementParty = 9, - /// No settlement exists with the given ID. - SettlementNotFound = 10, - /// A new threshold would exceed the total signer voting weight. - ThresholdExceedsWeight = 11, - /// Requested pagination `limit` exceeds the maximum page size. - InvalidPagination = 12, + /// `update_threshold` was called with a threshold above the sum of all + /// registered signer weights. + ThresholdExceedsWeight = 10, + /// `get_pending_settlements` was called with `limit` above `MAX_PAGE_SIZE`. + InvalidPagination = 11, + /// `rotate_signer` was called with an `old_signer` that has no registered + /// weight (i.e. is not a current signer). + SignerNotFound = 12, + /// `withdraw` was called for a token with a configured + /// `daily_withdraw_limit` and the withdrawal would push cumulative + /// withdrawals for the current 24h window above that limit. + DailyLimitExceeded = 13, } /// Storage keys for Treasury contract instance state. @@ -100,6 +94,10 @@ pub enum DataKey { Paused, /// Mapping of signer address to voting weight key. Signer(Address), + /// List of all addresses ever registered as a signer, so total signer + /// weight can be recomputed on demand instead of relying on a cached + /// counter that could drift out of sync. + SignerList, /// Settlement proposal storage key by settlement ID. Settlement(u64), /// Auto-incrementing settlement ID counter key. @@ -108,8 +106,10 @@ pub enum DataKey { Threshold, /// Token allowlist key. TokenAllowlist, - /// List of all known signer addresses key. - SignerList, + /// Per-token admin-configured daily withdrawal cap. + DailyWithdrawLimit(Address), + /// Per-token rolling-window withdrawal ledger; value is a [`WithdrawWindow`]. + WithdrawWindow(Address), } fn is_paused(e: &Env) -> bool { @@ -202,6 +202,94 @@ impl TreasuryContract { Ok(()) } + /// Rotates a signer's key: deregisters `old_signer` entirely and + /// registers `new_signer` with `new_weight` in its place. Unlike calling + /// `set_signer` twice, this keeps `SignerList` (and therefore + /// `total_signer_weight`) exactly in sync with the active signer set + /// regardless of whether `new_weight` is higher or lower than the weight + /// `old_signer` held — `total_signer_weight` is always recomputed from + /// live storage rather than tracked as a separate running total, so it + /// cannot drift out of sync with the individual signer weights. + /// + /// # Arguments + /// * `e` - Soroban environment handle. + /// * `admin` - Admin address (must authenticate). + /// * `old_signer` - The signer address being replaced; must currently hold non-zero weight. + /// * `new_signer` - The replacement signer address. + /// * `new_weight` - Voting weight assigned to `new_signer`. + /// + /// # Errors + /// * Returns [`TreasuryError::ContractPaused`] if contract operations are paused. + /// * Returns [`TreasuryError::Unauthorized`] if `admin` is not the stored contract admin. + /// * Returns [`TreasuryError::SignerNotFound`] if `old_signer` is not a current signer. + /// * Returns [`TreasuryError::DuplicateSigner`] if `new_signer` is already a distinct + /// active signer. + /// + /// # Note + /// Settlements that already accumulated approval weight from `old_signer` keep that + /// weight as a snapshot on the settlement record — rotating (or reweighting) a signer + /// does not retroactively change `approval_weight` on in-flight settlements, so a + /// settlement that already reached quorum remains executable. + pub fn rotate_signer( + e: Env, + admin: Address, + old_signer: Address, + new_signer: Address, + new_weight: u64, + ) -> Result<(), TreasuryError> { + check_not_paused(&e)?; + Self::check_admin(&e, &admin)?; + + let old_weight: u64 = e + .storage() + .instance() + .get(&DataKey::Signer(old_signer.clone())) + .unwrap_or(0u64); + if old_weight == 0 { + return Err(TreasuryError::SignerNotFound); + } + + if new_signer != old_signer { + let existing_new_weight: u64 = e + .storage() + .instance() + .get(&DataKey::Signer(new_signer.clone())) + .unwrap_or(0u64); + if existing_new_weight > 0 { + return Err(TreasuryError::DuplicateSigner); + } + } + + e.storage() + .instance() + .remove(&DataKey::Signer(old_signer.clone())); + e.storage() + .instance() + .set(&DataKey::Signer(new_signer.clone()), &new_weight); + + let signer_list: Vec
= e + .storage() + .instance() + .get(&DataKey::SignerList) + .unwrap_or_else(|| Vec::new(&e)); + let mut updated_list: Vec
= Vec::new(&e); + for s in signer_list.iter() { + if s != old_signer { + updated_list.push_back(s); + } + } + if !updated_list.contains(&new_signer) { + updated_list.push_back(new_signer.clone()); + } + e.storage().instance().set(&DataKey::SignerList, &updated_list); + + e.events().publish( + (Symbol::new(&e, "signer_rotated"),), + (old_signer, new_signer, old_weight, new_weight), + ); + Ok(()) + } + /// Proposes a new settlement for approval and execution. /// /// # Arguments @@ -561,6 +649,31 @@ impl TreasuryContract { Ok(()) } + /// Returns the sum of all currently registered signer weights, recomputed + /// live from storage on every call (not a cached counter), so it can + /// never drift out of sync with the individual `Signer(address)` entries. + pub fn get_total_signer_weight(e: Env) -> u64 { + Self::total_signer_weight(&e) + } + + fn total_signer_weight(e: &Env) -> u64 { + let signer_list: Vec
= e + .storage() + .instance() + .get(&DataKey::SignerList) + .unwrap_or_else(|| Vec::new(e)); + let mut total: u64 = 0; + for signer in signer_list.iter() { + let weight: u64 = e + .storage() + .instance() + .get(&DataKey::Signer(signer)) + .unwrap_or(0u64); + total += weight; + } + total + } + /// Places a pending settlement on hold by raising a dispute. /// /// # Arguments @@ -672,25 +785,117 @@ impl TreasuryContract { Ok(()) } + /// Sets (or clears, with `limit = 0`) the admin-configured daily withdrawal + /// cap for a token. `withdraw` enforces this cap over a rolling 24h + /// ledger-time window per token; a token with no configured limit is + /// unrestricted. + /// + /// # Arguments + /// * `e` - Soroban environment handle. + /// * `admin` - Admin address (must authenticate). + /// * `token` - Token contract address the cap applies to. + /// * `limit` - Maximum cumulative withdrawal amount per 24h window. + /// + /// # Errors + /// * Returns [`TreasuryError::ContractPaused`] if contract is paused. + /// * Returns [`TreasuryError::Unauthorized`] if caller is not the contract admin. + pub fn set_daily_withdraw_limit( + e: Env, + admin: Address, + token: Address, + limit: u64, + ) -> Result<(), TreasuryError> { + check_not_paused(&e)?; + Self::check_admin(&e, &admin)?; + e.storage() + .instance() + .set(&DataKey::DailyWithdrawLimit(token.clone()), &limit); + e.events().publish( + (Symbol::new(&e, "daily_withdraw_limit_set"),), + (token, limit), + ); + Ok(()) + } + + /// Returns the configured daily withdrawal cap for a token, or `None` if + /// the admin has never set one (in which case withdrawals of that token + /// are unrestricted). + pub fn get_daily_withdraw_limit(e: Env, token: Address) -> Option { + e.storage() + .instance() + .get(&DataKey::DailyWithdrawLimit(token)) + } + /// Withdraws funds from the treasury to a recipient address. /// + /// If a `daily_withdraw_limit` has been configured for `token`, this + /// tracks cumulative withdrawals of that token in a 24h ledger-time + /// window (measured from the first withdrawal in the window; the window + /// resets once `ledger.timestamp()` has advanced 24h past its start) and + /// rejects any withdrawal that would push the window's cumulative total + /// above the limit. This bounds worst-case exposure from a compromised + /// signer set to one configured cap per settlement cycle, rather than + /// allowing the treasury to be drained in a single transaction. + /// /// # Arguments /// * `e` - Soroban environment handle. /// * `admin` - Admin address (must authenticate). + /// * `token` - Token being withdrawn; used to look up the daily cap. /// * `_to` - Target recipient address. - /// * `_amount` - Amount to withdraw. + /// * `amount` - Amount to withdraw. /// /// # Errors /// * Returns [`TreasuryError::ContractPaused`] if contract is paused. /// * Returns [`TreasuryError::Unauthorized`] if caller is not the contract admin. + /// * Returns [`TreasuryError::DailyLimitExceeded`] if `token` has a configured + /// daily limit and `amount` would push the current 24h window's cumulative + /// withdrawals above it. pub fn withdraw( e: Env, admin: Address, + token: Address, _to: Address, - _amount: u64, + amount: u64, ) -> Result<(), TreasuryError> { check_not_paused(&e)?; Self::check_admin(&e, &admin)?; + + const WINDOW_SECONDS: u64 = 86_400; + + let limit: Option = e + .storage() + .instance() + .get(&DataKey::DailyWithdrawLimit(token.clone())); + + if let Some(limit) = limit { + let now = e.ledger().timestamp(); + let window: Option = e + .storage() + .instance() + .get(&DataKey::WithdrawWindow(token.clone())); + let (window_start, spent) = match window { + Some(w) if now.saturating_sub(w.window_start) < WINDOW_SECONDS => { + (w.window_start, w.spent) + } + _ => (now, 0u64), + }; + + let new_spent = spent + .checked_add(amount) + .ok_or(TreasuryError::DailyLimitExceeded)?; + if new_spent > limit { + return Err(TreasuryError::DailyLimitExceeded); + } + + e.storage().instance().set( + &DataKey::WithdrawWindow(token.clone()), + &WithdrawWindow { + window_start, + spent: new_spent, + }, + ); + } + Ok(()) } @@ -765,41 +970,6 @@ impl TreasuryContract { .get(&DataKey::Settlement(settlement_id)) .unwrap() } - - fn get_dispute_internal(e: &Env, settlement_id: u64) -> Dispute { - e.storage() - .instance() - .get(&DataKey::Dispute(settlement_id)) - .unwrap_or_else(|| panic_with_error!(e, TreasuryError::DisputeNotFound)) - } - - fn finalize_dispute_internal(e: &Env, settlement_id: u64, resolve_in_favor: bool) { - let mut dispute: Dispute = e - .storage() - .instance() - .get(&DataKey::Dispute(settlement_id)) - .unwrap_or_else(|| panic_with_error!(e, TreasuryError::DisputeNotFound)); - - dispute.status = if resolve_in_favor { - DisputeStatus::ResolvedClaimant - } else { - DisputeStatus::ResolvedCounterparty - }; - e.storage().instance().set(&DataKey::Dispute(settlement_id), &dispute); - - // In favour of the claimant (the dispute raiser): the settlement is voided. - // In favour of the counterparty (the merchant): the settlement resumes as - // Pending and can proceed through the normal approval/execution flow. - let mut settlement = Self::get_settlement_internal(e, settlement_id); - settlement.status = if resolve_in_favor { - SettlementStatus::Cancelled - } else { - SettlementStatus::Pending - }; - e.storage().instance().set(&DataKey::Settlement(settlement_id), &settlement); - - events::dispute_resolved(e, &settlement_id, &resolve_in_favor, &dispute.resolution_weight); - } } #[cfg(test)] @@ -1228,10 +1398,11 @@ mod tests { let c = client(&e, &id); let admin = soroban_sdk::Address::generate(&e); let user = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); c.initialize(&soroban_sdk::vec![&e], &1, &admin); c.deposit(&user, &1000u64); - c.withdraw(&admin, &user, &500u64); + c.withdraw(&admin, &token, &user, &500u64); } #[test] @@ -1240,338 +1411,196 @@ mod tests { let c = client(&e, &id); let admin = soroban_sdk::Address::generate(&e); let non_admin = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); c.initialize(&soroban_sdk::vec![&e], &1, &admin); assert_eq!(c.try_pause(&non_admin), Err(Ok(TreasuryError::Unauthorized))); assert_eq!(c.try_unpause(&non_admin), Err(Ok(TreasuryError::Unauthorized))); assert_eq!(c.try_update_threshold(&non_admin, &2u32), Err(Ok(TreasuryError::Unauthorized))); - assert_eq!(c.try_withdraw(&non_admin, &non_admin, &100u64), Err(Ok(TreasuryError::Unauthorized))); - } - - // ── simulate_settlement ─────────────────────────────────────────────────── - - /// Minimal token stub exposing the standard `balance(id) -> i128` read used by - /// `simulate_settlement` to check treasury funds, with a test-only setter. - mod token_stub { - use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; - - #[contracttype] - pub enum StubKey { - Balance(Address), - } - - #[contract] - pub struct TokenStub; - - #[contractimpl] - impl TokenStub { - pub fn set_balance(e: Env, id: Address, amount: i128) { - e.storage().instance().set(&StubKey::Balance(id), &amount); - } - - pub fn balance(e: Env, id: Address) -> i128 { - e.storage() - .instance() - .get(&StubKey::Balance(id)) - .unwrap_or(0) - } - } + assert_eq!( + c.try_withdraw(&non_admin, &token, &non_admin, &100u64), + Err(Ok(TreasuryError::Unauthorized)) + ); } - use token_stub::{TokenStub, TokenStubClient}; + // ── rotate_signer tests ────────────────────────────────────────────────── + /// Rotating a signer to a HIGHER weight must increase total_signer_weight + /// by exactly the delta, not leave the old weight double-counted or lost. #[test] - fn test_simulate_settlement_would_succeed_when_quorum_and_balance_ok() { + fn test_rotate_signer_to_higher_weight_updates_total_signer_weight() { let (e, id) = setup(); let c = client(&e, &id); let admin = soroban_sdk::Address::generate(&e); - let merchant = soroban_sdk::Address::generate(&e); - let signer = soroban_sdk::Address::generate(&e); - let token_id = e.register(TokenStub, ()); - let token_client = TokenStubClient::new(&e, &token_id); - - c.initialize(&soroban_sdk::vec![&e, (signer.clone(), 1u64)], &1, &admin); - token_client.set_balance(&id, &1000i128); - - let sid = c.propose_settlement(&signer, &token_id, &500u64, &merchant); - c.approve_settlement(&signer, &sid); + let s1 = soroban_sdk::Address::generate(&e); + let s2 = soroban_sdk::Address::generate(&e); + let new_s1 = soroban_sdk::Address::generate(&e); + // total weight = 3 (s1=1, s2=2) + c.initialize( + &soroban_sdk::vec![&e, (s1.clone(), 1u64), (s2.clone(), 2u64)], + &1, + &admin, + ); + assert_eq!(c.get_total_signer_weight(), 3u64); - let sim = c.simulate_settlement(&sid); - assert!(sim.would_succeed); - assert_eq!(sim.approval_weight, 1u64); - assert_eq!(sim.threshold, 1u64); - assert_eq!(sim.treasury_balance, 1000i128); - assert_eq!(sim.settlement_amount, 500u64); - assert_eq!(sim.projected_balance, 500i128); + // Rotate s1 (weight 1) -> new_s1 (weight 5): total should become 2 + 5 = 7. + c.rotate_signer(&admin, &s1, &new_s1, &5u64); + assert_eq!(c.get_total_signer_weight(), 7u64); - // Simulation must not mutate state: the settlement is still Pending and a - // real execute_settlement still succeeds afterwards. - let unchanged = c.get_pending_settlements(&None, &None); - assert_eq!(unchanged.len(), 1); - c.execute_settlement(&signer, &sid, &token_id); + // The old signer address must no longer carry any weight. + let res = c.try_rotate_signer(&admin, &s1, &new_s1, &1u64); + assert_eq!(res, Err(Ok(TreasuryError::SignerNotFound))); } + /// Rotating a signer to a LOWER weight updates total_signer_weight, but + /// must not retroactively shrink approval_weight already accumulated on + /// an in-flight settlement — a settlement that already reached quorum + /// stays executable even after the approving signer's weight drops. #[test] - fn test_simulate_settlement_false_when_quorum_not_reached() { + fn test_rotate_signer_to_lower_weight_preserves_inflight_quorum() { let (e, id) = setup(); let c = client(&e, &id); let admin = soroban_sdk::Address::generate(&e); + let s1 = soroban_sdk::Address::generate(&e); + let s2 = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); let merchant = soroban_sdk::Address::generate(&e); - let signer = soroban_sdk::Address::generate(&e); - let signer2 = soroban_sdk::Address::generate(&e); - let token_id = e.register(TokenStub, ()); - let token_client = TokenStubClient::new(&e, &token_id); - - // Total signer weight (2) meets the threshold (2), but only one signer - // approves, so the settlement itself is short of quorum. + let new_s1 = soroban_sdk::Address::generate(&e); + // threshold=3, s1 weight=3 (alone meets threshold), s2 weight=1 c.initialize( - &soroban_sdk::vec![&e, (signer.clone(), 1u64), (signer2.clone(), 1u64)], - &2, + &soroban_sdk::vec![&e, (s1.clone(), 3u64), (s2.clone(), 1u64)], + &3, &admin, ); - token_client.set_balance(&id, &1000i128); + assert_eq!(c.get_total_signer_weight(), 4u64); - let sid = c.propose_settlement(&signer, &token_id, &500u64, &merchant); - c.approve_settlement(&signer, &sid); + let sid = c.propose_settlement(&s1, &token, &500u64, &merchant); + c.approve_settlement(&s1, &sid); + + // Rotate s1 down to weight 1 *after* it already approved. + c.rotate_signer(&admin, &s1, &new_s1, &1u64); + assert_eq!(c.get_total_signer_weight(), 2u64, "total weight must reflect the new, lower weight"); - let sim = c.simulate_settlement(&sid); - assert!(!sim.would_succeed); - assert_eq!(sim.approval_weight, 1u64); - assert_eq!(sim.threshold, 2u64); + // The settlement's already-captured approval_weight (3) is a snapshot + // and is unaffected by the later rotation, so it still clears the + // threshold (3) and executes successfully. + c.execute_settlement(&s1, &sid, &token); + let settlement = c.get_settlement(&sid).unwrap(); + assert!(matches!(settlement.status, SettlementStatus::Executed)); } #[test] - fn test_simulate_settlement_false_when_balance_insufficient() { + fn test_rotate_signer_requires_admin() { let (e, id) = setup(); let c = client(&e, &id); let admin = soroban_sdk::Address::generate(&e); - let merchant = soroban_sdk::Address::generate(&e); - let signer = soroban_sdk::Address::generate(&e); - let token_id = e.register(TokenStub, ()); - let token_client = TokenStubClient::new(&e, &token_id); - - c.initialize(&soroban_sdk::vec![&e, (signer.clone(), 1u64)], &1, &admin); - token_client.set_balance(&id, &100i128); - - let sid = c.propose_settlement(&signer, &token_id, &500u64, &merchant); - c.approve_settlement(&signer, &sid); + let non_admin = soroban_sdk::Address::generate(&e); + let s1 = soroban_sdk::Address::generate(&e); + let new_s1 = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e, (s1.clone(), 1u64)], &1, &admin); - let sim = c.simulate_settlement(&sid); - assert!(!sim.would_succeed); - assert_eq!(sim.treasury_balance, 100i128); - assert_eq!(sim.projected_balance, -400i128); + let res = c.try_rotate_signer(&non_admin, &s1, &new_s1, &1u64); + assert_eq!(res, Err(Ok(TreasuryError::Unauthorized))); } #[test] - fn test_simulate_settlement_nonexistent_returns_error() { + fn test_rotate_signer_rejects_duplicate_new_signer() { let (e, id) = setup(); let c = client(&e, &id); let admin = soroban_sdk::Address::generate(&e); - let signer = soroban_sdk::Address::generate(&e); - c.initialize(&soroban_sdk::vec![&e, (signer, 1u64)], &1, &admin); - - let result = c.try_simulate_settlement(&999u64); - assert_eq!(result, Err(Ok(TreasuryError::SettlementNotFound))); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use soroban_sdk::testutils::Address as _; - use soroban_sdk::{vec, Env}; - - struct TestContext { - env: Env, - contract_id: Address, - signer1: Address, - signer2: Address, - signer3: Address, - token: Address, - merchant: Address, - } - - fn setup() -> TestContext { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let signer1 = Address::generate(&env); - let signer2 = Address::generate(&env); - let signer3 = Address::generate(&env); - let token = Address::generate(&env); - let merchant = Address::generate(&env); - - let signers = vec![ - &env, - (signer1.clone(), 1u64), - (signer2.clone(), 1u64), - (signer3.clone(), 1u64), - ]; - - let contract_id = env.register_contract(None, TreasuryContract); - let client = TreasuryContractClient::new(&env, &contract_id); - client.initialize(&signers, &2u64, &admin); - - TestContext { - env, - contract_id, - signer1, - signer2, - signer3, - token, - merchant, - } - } - - fn propose_and_raise(ctx: &TestContext) -> u64 { - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - let settlement_id = client.propose_settlement(&ctx.signer1, &ctx.token, &1000u64, &ctx.merchant); - client.raise_dispute(&ctx.signer1, &settlement_id, &1u32); - settlement_id - } - - fn read_dispute(ctx: &TestContext, settlement_id: u64) -> Dispute { - ctx.env.as_contract(&ctx.contract_id, || { - ctx.env - .storage() - .instance() - .get(&DataKey::Dispute(settlement_id)) - .unwrap() - }) - } - - fn read_settlement(ctx: &TestContext, settlement_id: u64) -> Settlement { - ctx.env.as_contract(&ctx.contract_id, || { - ctx.env - .storage() - .instance() - .get(&DataKey::Settlement(settlement_id)) - .unwrap() - }) - } - - #[test] - fn test_raise_dispute_holds_settlement_and_records_dispute() { - let ctx = setup(); - let settlement_id = propose_and_raise(&ctx); - - let dispute = read_dispute(&ctx, settlement_id); - assert_eq!(dispute.status, DisputeStatus::Raised); - assert_eq!(dispute.resolution_weight, 0u64); - assert_eq!(dispute.raised_by, ctx.signer1); - assert_eq!(dispute.reason, 1u32); - assert!(dispute.voters.is_empty()); - - let settlement = read_settlement(&ctx, settlement_id); - assert!(matches!(settlement.status, SettlementStatus::OnHold)); - } - - #[test] - fn test_raise_dispute_twice_fails() { - let ctx = setup(); - let settlement_id = propose_and_raise(&ctx); + let s1 = soroban_sdk::Address::generate(&e); + let s2 = soroban_sdk::Address::generate(&e); + c.initialize( + &soroban_sdk::vec![&e, (s1.clone(), 1u64), (s2.clone(), 2u64)], + &1, + &admin, + ); - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - let result = client.try_raise_dispute(&ctx.signer2, &settlement_id, &2u32); - assert_eq!(result, Err(Ok(TreasuryError::DisputeAlreadyRaised))); + // s2 is already an active signer; rotating s1 onto it must be rejected. + let res = c.try_rotate_signer(&admin, &s1, &s2, &5u64); + assert_eq!(res, Err(Ok(TreasuryError::DuplicateSigner))); } - #[test] - fn test_votes_resolve_dispute_in_favour_of_claimant() { - let ctx = setup(); - let settlement_id = propose_and_raise(&ctx); - - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - - // First vote: weight 1 < threshold 2, dispute stays Raised. - client.vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); - let dispute = read_dispute(&ctx, settlement_id); - assert_eq!(dispute.status, DisputeStatus::Raised); - assert_eq!(dispute.resolution_weight, 1u64); - - // Second vote reaches the threshold and resolves in favour of the claimant. - client.vote_dispute_resolution(&ctx.signer2, &settlement_id, &true); - let dispute = read_dispute(&ctx, settlement_id); - assert_eq!(dispute.status, DisputeStatus::ResolvedClaimant); - assert_eq!(dispute.resolution_weight, 2u64); - - let settlement = read_settlement(&ctx, settlement_id); - assert!(matches!(settlement.status, SettlementStatus::Cancelled)); - } + // ── daily withdrawal limit tests ───────────────────────────────────────── #[test] - fn test_votes_resolve_dispute_in_favour_of_counterparty() { - let ctx = setup(); - let settlement_id = propose_and_raise(&ctx); - - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - client.vote_dispute_resolution(&ctx.signer1, &settlement_id, &false); - client.vote_dispute_resolution(&ctx.signer2, &settlement_id, &false); - - let dispute = read_dispute(&ctx, settlement_id); - assert_eq!(dispute.status, DisputeStatus::ResolvedCounterparty); + fn test_withdraw_within_daily_limit_succeeds() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let user = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e], &1, &admin); - let settlement = read_settlement(&ctx, settlement_id); - assert!(matches!(settlement.status, SettlementStatus::Pending)); + c.set_daily_withdraw_limit(&admin, &token, &1_000u64); + c.withdraw(&admin, &token, &user, &600u64); + c.withdraw(&admin, &token, &user, &400u64); } #[test] - fn test_signer_cannot_vote_twice() { - let ctx = setup(); - let settlement_id = propose_and_raise(&ctx); + fn test_withdraw_exceeding_daily_limit_rejected() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let user = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e], &1, &admin); - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - client.vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); + c.set_daily_withdraw_limit(&admin, &token, &1_000u64); + c.withdraw(&admin, &token, &user, &600u64); - let result = client.try_vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); - assert_eq!(result, Err(Ok(TreasuryError::AlreadyVoted))); + let res = c.try_withdraw(&admin, &token, &user, &500u64); + assert_eq!(res, Err(Ok(TreasuryError::DailyLimitExceeded))); } #[test] - fn test_non_signer_cannot_vote() { - let ctx = setup(); - let settlement_id = propose_and_raise(&ctx); - - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - let outsider = Address::generate(&ctx.env); - - let result = client.try_vote_dispute_resolution(&outsider, &settlement_id, &true); - assert_eq!(result, Err(Ok(TreasuryError::UnauthorizedSigner))); - } + fn test_withdraw_limit_is_per_token() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let user = soroban_sdk::Address::generate(&e); + let token_a = soroban_sdk::Address::generate(&e); + let token_b = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e], &1, &admin); - #[test] - fn test_vote_without_dispute_fails() { - let ctx = setup(); - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - let settlement_id = client.propose_settlement(&ctx.signer1, &ctx.token, &1000u64, &ctx.merchant); + c.set_daily_withdraw_limit(&admin, &token_a, &1_000u64); + c.withdraw(&admin, &token_a, &user, &1_000u64); - let result = client.try_vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); - assert_eq!(result, Err(Ok(TreasuryError::DisputeNotFound))); + // token_b has no configured limit, so it is unrestricted even though + // token_a's window is exhausted. + c.withdraw(&admin, &token_b, &user, &1_000_000u64); } #[test] - fn test_resolve_before_threshold_fails() { - let ctx = setup(); - let settlement_id = propose_and_raise(&ctx); + fn test_withdraw_window_resets_after_24h() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let user = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e], &1, &admin); - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - client.vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); + c.set_daily_withdraw_limit(&admin, &token, &1_000u64); + c.withdraw(&admin, &token, &user, &1_000u64); + assert_eq!( + c.try_withdraw(&admin, &token, &user, &1u64), + Err(Ok(TreasuryError::DailyLimitExceeded)) + ); - let result = client.try_resolve_dispute(&ctx.signer2, &settlement_id, &true); - assert_eq!(result, Err(Ok(TreasuryError::ThresholdNotMet))); + e.ledger().with_mut(|li| li.timestamp += 86_400); + // A full window has elapsed, so the cap applies fresh. + c.withdraw(&admin, &token, &user, &1_000u64); } #[test] - fn test_vote_after_resolution_fails() { - let ctx = setup(); - let settlement_id = propose_and_raise(&ctx); - - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - client.vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); - client.vote_dispute_resolution(&ctx.signer2, &settlement_id, &true); + fn test_withdraw_without_configured_limit_is_unrestricted() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let user = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e], &1, &admin); - let result = client.try_vote_dispute_resolution(&ctx.signer3, &settlement_id, &false); - assert_eq!(result, Err(Ok(TreasuryError::DisputeNotRaised))); + c.withdraw(&admin, &token, &user, &1_000_000_000u64); } } diff --git a/docs/error-codes.md b/docs/error-codes.md index f7ae9b1..6e1e4df 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -3,6 +3,8 @@ This document maps every `InvoiceError`, `ContractError`, `SettlementError`, and `TreasuryError` variant (and other contract error codes) to its numeric value, the condition that triggers it, and the recommended remediation steps for integrators. > Cross-reference: see [docs/api-reference.md](./api-reference.md) for HTTP-level error shapes returned by the backend. +> +> Cross-reference: see [ARCHITECTURE.md § Invoice state machine](../ARCHITECTURE.md#invoice-state-machine) for a diagram of every legal `InvoiceStatus` transition and the function that triggers it — useful context for knowing which `InvalidStateTransition` / `NotPending` cases below are expected versus a real bug. --- @@ -47,6 +49,10 @@ Defined in `COMEBACKHERE-contracts/contracts/invoice/src/lib.rs`. Shares some va | 12 | `GraceWindowNotExpired` | `release_escrow` was called before `created_at + grace_window`. | Wait until `ledger.timestamp() >= created_at + grace_window`. Admin may reduce `GraceWindow` via `set_grace_window` (default 86 400 seconds). | | 13 | `DuplicateNonce` | The (merchant, nonce) pair has already been used by a previous invoice. | Generate a fresh nonce for each invoice. Different merchants may reuse the same nonce value without collision. | | 14 | `TreasuryNotConfigured` | `raise_dispute` was called before the admin ran `set_treasury`. | Admin must call `set_treasury` once before disputes can be raised. | +| 15 | `NotAParty` | `raise_dispute` was called by an address that is neither the invoice's merchant nor its customer. | Sign with the merchant or customer key associated with the invoice. | +| 16 | `Overflow` | An internal counter (invoice ID, or `created_at + grace_window`) would overflow `u64`. | Practically unreachable outside of adversarial ledger state; not user-actionable. | +| 17 | `AddressBlocked` | `mark_paids` was called for a customer that the configured compliance contract reports as not allowed. | Confirm the customer's compliance status with `ComplianceContract.is_allowed` before retrying. | +| 18 | `InvalidStateTransition` | `mark_paids` was called on an invoice in `RefundRequested`, `Released`, `Cancelled`, or `Expired` status — see [ARCHITECTURE.md § Invoice state machine](../ARCHITECTURE.md#invoice-state-machine) for the full legal-transition diagram. | Fetch the current status with `get_invoice_status` first. A refund already in progress must not be overridden by a stale payment confirmation. | ---