From eed740b0f04782872c20f2562c58a15eb660cd31 Mon Sep 17 00:00:00 2001 From: OZILSOLAR Date: Sun, 18 Oct 2026 01:39:21 +0000 Subject: [PATCH 1/4] feat(invoice-escrow): add co-signer approval system (#914) Add optional N-of-M co-signer approval gating invoice release: - CosignerApproval record with signers, threshold, approval/rejection tallies and a derived Pending/Approved/Rejected status - set_cosigners (creator-only, locked once voting starts), approve_release, reject_release and revoke_vote with require_auth signature checks and duplicate-vote protection - release and deposit auto-release now go through check_release_gates - read-only hooks for UIs: get_approval, get_approval_status, has_approved, approvals_remaining - events for configuration, votes and status transitions, plus tests --- contracts/invoice-escrow/src/errors.rs | 17 ++ contracts/invoice-escrow/src/lib.rs | 323 ++++++++++++++++++++++-- contracts/invoice-escrow/src/test.rs | 327 +++++++++++++++++++++++++ contracts/invoice-escrow/src/types.rs | 42 +++- 4 files changed, 690 insertions(+), 19 deletions(-) diff --git a/contracts/invoice-escrow/src/errors.rs b/contracts/invoice-escrow/src/errors.rs index cd250c2..2964257 100644 --- a/contracts/invoice-escrow/src/errors.rs +++ b/contracts/invoice-escrow/src/errors.rs @@ -46,4 +46,21 @@ pub enum Error { AlreadyFinalised = 18, /// Caller is not the blacklisted payer. NotBlacklistedPayer = 19, + /// Co-signer list is empty, has duplicates, exceeds the maximum, or the + /// threshold is outside `1..=signers.len()`. + InvalidCosignerConfig = 20, + /// Caller is not one of the invoice's configured co-signers. + NotCosigner = 21, + /// Co-signer has already approved or rejected this invoice. + AlreadyVoted = 22, + /// Release requires co-signer approval that has not yet reached threshold. + ApprovalRequired = 23, + /// Co-signers rejected the release; threshold can no longer be reached. + ApprovalRejected = 24, + /// Co-signer has not voted, so there is nothing to revoke. + NotVoted = 25, + /// Co-signer configuration cannot change once voting has started. + CosignersLocked = 26, + /// No co-signer approval is configured for this invoice. + NoCosigners = 27, } diff --git a/contracts/invoice-escrow/src/lib.rs b/contracts/invoice-escrow/src/lib.rs index 4b212f7..6ce90e2 100644 --- a/contracts/invoice-escrow/src/lib.rs +++ b/contracts/invoice-escrow/src/lib.rs @@ -5,6 +5,7 @@ //! - Automatic fund release when fully funded //! - Deadline-based refund fallback //! - **Upgradable admin authority** via a two-step `transfer_admin` flow +//! - **Co-signer approval**: optional N-of-M sign-off gating release //! //! # Admin Transfer Flow //! @@ -27,7 +28,9 @@ use errors::Error; use soroban_sdk::{ contract, contractimpl, symbol_short, token, Address, BytesN, Env, Symbol, Vec, }; -use types::{BlacklistEntry, EscrowInvoice, EscrowReleased, EscrowStatus}; +use types::{ + ApprovalStatus, BlacklistEntry, CosignerApproval, EscrowInvoice, EscrowReleased, EscrowStatus, +}; // --------------------------------------------------------------------------- // Storage key helpers @@ -63,6 +66,11 @@ fn blacklist_key(payer: &Address) -> (Symbol, Address) { (symbol_short!("blacklist"), payer.clone()) } +/// Persistent storage: co-signer approval record for an invoice. +fn approval_key(id: u64) -> (Symbol, u64) { + (symbol_short!("approval"), id) +} + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -71,6 +79,10 @@ fn blacklist_key(payer: &Address) -> (Symbol, Address) { /// ~30 minutes at 5s ledger close time. const APPEAL_WINDOW_LEDGERS: u32 = 360; +/// Maximum number of co-signers on a single invoice. Bounds the linear +/// membership scans performed on every vote. +const MAX_COSIGNERS: u32 = 20; + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- @@ -96,6 +108,92 @@ fn save_invoice(env: &Env, id: u64, invoice: &EscrowInvoice) { env.storage().persistent().set(&invoice_key(id), invoice); } +/// Returns `true` while the invoice can still accept configuration changes +/// and votes (i.e. it has not been released, refunded or cancelled). +fn is_open(invoice: &EscrowInvoice) -> bool { + invoice.status == EscrowStatus::Pending || invoice.status == EscrowStatus::Active +} + +fn get_approval(env: &Env, id: u64) -> Option { + env.storage().persistent().get(&approval_key(id)) +} + +fn save_approval(env: &Env, id: u64, approval: &CosignerApproval) { + env.storage().persistent().set(&approval_key(id), approval); +} + +/// Recompute `approval.status` from the current tallies. +fn recompute_approval_status(approval: &mut CosignerApproval) { + let remaining = approval.signers.len() - approval.rejections.len(); + approval.status = if approval.approvals.len() >= approval.threshold { + ApprovalStatus::Approved + } else if remaining < approval.threshold { + ApprovalStatus::Rejected + } else { + ApprovalStatus::Pending + }; +} + +/// Check every release gate configured on the invoice. Returns `Ok(())` +/// when the invoice may be released now. +fn check_release_gates(env: &Env, id: u64) -> Result<(), Error> { + if let Some(approval) = get_approval(env, id) { + match approval.status { + ApprovalStatus::Approved => {} + ApprovalStatus::Pending => return Err(Error::ApprovalRequired), + ApprovalStatus::Rejected => return Err(Error::ApprovalRejected), + } + } + Ok(()) +} + +/// Shared implementation of `approve_release` / `reject_release`. +fn cast_vote(env: &Env, signer: Address, invoice_id: u64, approve: bool) -> Result { + signer.require_auth(); + let invoice = get_invoice(env, invoice_id)?; + if !is_open(&invoice) { + return Err(Error::InvalidStatus); + } + let mut approval = get_approval(env, invoice_id).ok_or(Error::NoCosigners)?; + if !approval.signers.contains(&signer) { + return Err(Error::NotCosigner); + } + if approval.approvals.contains(&signer) || approval.rejections.contains(&signer) { + return Err(Error::AlreadyVoted); + } + + let action = if approve { + approval.approvals.push_back(signer.clone()); + symbol_short!("approved") + } else { + approval.rejections.push_back(signer.clone()); + symbol_short!("rejected") + }; + + let old_status = approval.status.clone(); + recompute_approval_status(&mut approval); + approval.updated_ledger = env.ledger().sequence(); + save_approval(env, invoice_id, &approval); + + emit_cosigner_vote(env, action, invoice_id, &signer, &approval); + if old_status != approval.status { + emit_approval_status_changed(env, invoice_id, &old_status, &approval.status); + } + Ok(approval.status) +} + +/// Mark the invoice released and transfer `total_amount` to the creator. +fn execute_release(env: &Env, id: u64, invoice: &mut EscrowInvoice) -> (Address, i128) { + let total = invoice.total_amount; + let creator = invoice.creator.clone(); + invoice.status = EscrowStatus::Released; + save_invoice(env, id, invoice); + let token_client = token::Client::new(env, &invoice.token); + token_client.transfer(&env.current_contract_address(), &creator, &total); + emit_released(env, id, total); + (creator, total) +} + // --------------------------------------------------------------------------- // Event helpers // --------------------------------------------------------------------------- @@ -156,6 +254,37 @@ fn emit_refunded(env: &Env, id: u64, payer: &Address, amount: i128) { ); } +/// Topics: `(escrow, cosig_set, id)` — Data: `(signers, threshold)` +fn emit_cosigners_set(env: &Env, id: u64, signers: &Vec
, threshold: u32) { + env.events().publish( + (symbol_short!("escrow"), symbol_short!("cosig_set"), id), + (signers.clone(), threshold), + ); +} + +/// Topics: `(escrow, , id)` — Data: `(signer, approvals, rejections, status)` +/// +/// `action` is one of `approved`, `rejected` or `revoked`. +fn emit_cosigner_vote(env: &Env, action: Symbol, id: u64, signer: &Address, approval: &CosignerApproval) { + env.events().publish( + (symbol_short!("escrow"), action, id), + ( + signer.clone(), + approval.approvals.len(), + approval.rejections.len(), + approval.status.clone(), + ), + ); +} + +/// Topics: `(escrow, appr_stat, id)` — Data: `(old_status, new_status)` +fn emit_approval_status_changed(env: &Env, id: u64, old: &ApprovalStatus, new: &ApprovalStatus) { + env.events().publish( + (symbol_short!("escrow"), symbol_short!("appr_stat"), id), + (old.clone(), new.clone()), + ); +} + /// Topics: `(blacklist, bl_add)` — Data: `(payer, reason_hash, banned_at)` fn emit_payer_blacklisted(env: &Env, payer: &Address, reason_hash: &BytesN<32>, banned_at: u64) { env.events().publish( @@ -431,14 +560,11 @@ impl InvoiceEscrowContract { emit_deposited(&env, invoice_id, &payer, amount, new_funded); - // Auto-release on full funding. - if new_funded >= invoice.total_amount { - let total = invoice.total_amount; - let creator = invoice.creator.clone(); - invoice.status = EscrowStatus::Released; - save_invoice(&env, invoice_id, &invoice); - token_client.transfer(&env.current_contract_address(), &creator, &total); - emit_released(&env, invoice_id, total); + // Auto-release on full funding, unless a release gate (e.g. pending + // co-signer approval) still blocks it. A gated invoice stays `Active` + // and fully funded until `release` is called once the gate clears. + if new_funded >= invoice.total_amount && check_release_gates(&env, invoice_id).is_ok() { + execute_release(&env, invoice_id, &mut invoice); } else { save_invoice(&env, invoice_id, &invoice); } @@ -449,12 +575,15 @@ impl InvoiceEscrowContract { /// Release funds to the creator once the invoice is fully funded. /// /// Anyone may call this after the invoice reaches `total_amount`. Useful - /// if auto-release did not fire (e.g. due to separate partial deposits). + /// if auto-release did not fire (e.g. due to separate partial deposits, + /// or because a release gate was still closed when funding completed). /// /// # Errors /// * [`Error::InvoiceNotFound`] — Unknown invoice ID. /// * [`Error::InvalidStatus`] — Invoice was already released/refunded/cancelled. /// * [`Error::InsufficientFunding`] — Not fully funded yet. + /// * [`Error::ApprovalRequired`] — Co-signer threshold not yet reached. + /// * [`Error::ApprovalRejected`] — Co-signers rejected the release. pub fn release(env: Env, invoice_id: u64) -> Result<(), Error> { let mut invoice = get_invoice(&env, invoice_id)?; @@ -467,15 +596,9 @@ impl InvoiceEscrowContract { if invoice.funded_amount < invoice.total_amount { return Err(Error::InsufficientFunding); } + check_release_gates(&env, invoice_id)?; - let total = invoice.total_amount; - let creator = invoice.creator.clone(); - invoice.status = EscrowStatus::Released; - save_invoice(&env, invoice_id, &invoice); - - let token_client = token::Client::new(&env, &invoice.token); - token_client.transfer(&env.current_contract_address(), &creator, &total); - emit_released(&env, invoice_id, total); + let (creator, total) = execute_release(&env, invoice_id, &mut invoice); // Emit structured EscrowReleased event for off-chain indexers. env.events().publish( @@ -566,6 +689,148 @@ impl InvoiceEscrowContract { Ok(()) } + // ----------------------------------------------------------------------- + // Co-signer approval + // ----------------------------------------------------------------------- + + /// Require N-of-M co-signer approval before the invoice can be released. + /// + /// Only the invoice creator may configure co-signers, and only while the + /// invoice is open and no co-signer has voted yet. Calling again before + /// any vote replaces the previous configuration. + /// + /// # Arguments + /// * `invoice_id` — Target invoice. + /// * `signers` — Distinct co-signer addresses (1..=`MAX_COSIGNERS`). + /// * `threshold` — Approvals required, in `1..=signers.len()`. + /// + /// # Events + /// Emits `(escrow, cosig_set, id)` with `(signers, threshold)`. + /// + /// # Errors + /// * [`Error::InvoiceNotFound`] — Unknown invoice ID. + /// * [`Error::InvalidStatus`] — Invoice is no longer open. + /// * [`Error::InvalidCosignerConfig`] — Bad signer list or threshold. + /// * [`Error::CosignersLocked`] — A co-signer has already voted. + pub fn set_cosigners( + env: Env, + invoice_id: u64, + signers: Vec
, + threshold: u32, + ) -> Result<(), Error> { + let invoice = get_invoice(&env, invoice_id)?; + invoice.creator.require_auth(); + if !is_open(&invoice) { + return Err(Error::InvalidStatus); + } + + let count = signers.len(); + if count == 0 || count > MAX_COSIGNERS || threshold == 0 || threshold > count { + return Err(Error::InvalidCosignerConfig); + } + for i in 0..count { + let signer = signers.get_unchecked(i); + for j in (i + 1)..count { + if signers.get_unchecked(j) == signer { + return Err(Error::InvalidCosignerConfig); + } + } + } + + if let Some(existing) = get_approval(&env, invoice_id) { + if !existing.approvals.is_empty() || !existing.rejections.is_empty() { + return Err(Error::CosignersLocked); + } + } + + let approval = CosignerApproval { + signers: signers.clone(), + threshold, + approvals: Vec::new(&env), + rejections: Vec::new(&env), + status: ApprovalStatus::Pending, + updated_ledger: env.ledger().sequence(), + }; + save_approval(&env, invoice_id, &approval); + emit_cosigners_set(&env, invoice_id, &signers, threshold); + Ok(()) + } + + /// Approve the release of an invoice as one of its co-signers. + /// + /// The signer authenticates via `require_auth` and must be in the + /// configured signer list. Each co-signer may vote once; duplicate votes + /// are rejected, so concurrent or replayed approvals cannot inflate the + /// tally. When the threshold is reached the status becomes `Approved`. + /// + /// Approving does not release funds by itself — call [`Self::release`] + /// once the invoice is fully funded and approved. + /// + /// # Errors + /// * [`Error::InvoiceNotFound`] — Unknown invoice ID. + /// * [`Error::InvalidStatus`] — Invoice is no longer open. + /// * [`Error::NoCosigners`] — No co-signers configured. + /// * [`Error::NotCosigner`] — `signer` is not a configured co-signer. + /// * [`Error::AlreadyVoted`] — `signer` already approved or rejected. + pub fn approve_release(env: Env, signer: Address, invoice_id: u64) -> Result { + cast_vote(&env, signer, invoice_id, true) + } + + /// Reject the release of an invoice as one of its co-signers. + /// + /// Once enough co-signers reject that the threshold can no longer be + /// reached, the status becomes `Rejected` and release is blocked. + /// + /// # Errors + /// Same as [`Self::approve_release`]. + pub fn reject_release(env: Env, signer: Address, invoice_id: u64) -> Result { + cast_vote(&env, signer, invoice_id, false) + } + + /// Withdraw a previously cast approval or rejection. + /// + /// Allowed only while the invoice is open (not yet released). The status + /// is recomputed, so revoking an approval can move `Approved` back to + /// `Pending`, and revoking a rejection can move `Rejected` back to + /// `Pending`. + /// + /// # Errors + /// * [`Error::InvoiceNotFound`] — Unknown invoice ID. + /// * [`Error::InvalidStatus`] — Invoice is no longer open. + /// * [`Error::NoCosigners`] — No co-signers configured. + /// * [`Error::NotCosigner`] — `signer` is not a configured co-signer. + /// * [`Error::NotVoted`] — `signer` has no vote to revoke. + pub fn revoke_vote(env: Env, signer: Address, invoice_id: u64) -> Result { + signer.require_auth(); + let invoice = get_invoice(&env, invoice_id)?; + if !is_open(&invoice) { + return Err(Error::InvalidStatus); + } + let mut approval = get_approval(&env, invoice_id).ok_or(Error::NoCosigners)?; + if !approval.signers.contains(&signer) { + return Err(Error::NotCosigner); + } + + if let Some(idx) = approval.approvals.first_index_of(&signer) { + approval.approvals.remove(idx); + } else if let Some(idx) = approval.rejections.first_index_of(&signer) { + approval.rejections.remove(idx); + } else { + return Err(Error::NotVoted); + } + + let old_status = approval.status.clone(); + recompute_approval_status(&mut approval); + approval.updated_ledger = env.ledger().sequence(); + save_approval(&env, invoice_id, &approval); + + emit_cosigner_vote(&env, symbol_short!("revoked"), invoice_id, &signer, &approval); + if old_status != approval.status { + emit_approval_status_changed(&env, invoice_id, &old_status, &approval.status); + } + Ok(approval.status) + } + // ----------------------------------------------------------------------- // Payer blacklist // ----------------------------------------------------------------------- @@ -820,6 +1085,28 @@ impl InvoiceEscrowContract { .get(&blacklist_key(&payer)) } + /// Return the full co-signer approval record for an invoice, if any. + pub fn get_approval(env: Env, invoice_id: u64) -> Option { + get_approval(&env, invoice_id) + } + + /// Return the aggregate co-signer approval status, or `None` when the + /// invoice has no co-signer requirement. + pub fn get_approval_status(env: Env, invoice_id: u64) -> Option { + get_approval(&env, invoice_id).map(|a| a.status) + } + + /// Return `true` if `signer` has approved the invoice's release. + pub fn has_approved(env: Env, invoice_id: u64, signer: Address) -> bool { + get_approval(&env, invoice_id).map_or(false, |a| a.approvals.contains(&signer)) + } + + /// Return how many more approvals are needed to reach the threshold + /// (`0` when already approved or when no co-signers are configured). + pub fn approvals_remaining(env: Env, invoice_id: u64) -> u32 { + get_approval(&env, invoice_id).map_or(0, |a| a.threshold.saturating_sub(a.approvals.len())) + } + /// Return `true` if the payer is blacklisted with a finalised and upheld entry. pub fn is_payer_blacklisted(env: Env, payer: Address) -> bool { env.storage() diff --git a/contracts/invoice-escrow/src/test.rs b/contracts/invoice-escrow/src/test.rs index abc501f..a340f6f 100644 --- a/contracts/invoice-escrow/src/test.rs +++ b/contracts/invoice-escrow/src/test.rs @@ -843,3 +843,330 @@ fn test_release_emits_escrow_released_event() { } assert!(found, "EscrowReleased event was not emitted during release"); } + +// --------------------------------------------------------------------------- +// Co-signer approval (#914) +// --------------------------------------------------------------------------- + +mod cosigner_tests { + use super::*; + use crate::types::ApprovalStatus; + use soroban_sdk::{vec, Symbol, TryFromVal, Vec}; + + struct Ctx { + env: Env, + contract_id: Address, + creator: Address, + payer: Address, + token: Address, + id: u64, + } + + impl Ctx { + fn client(&self) -> InvoiceEscrowContractClient<'_> { + InvoiceEscrowContractClient::new(&self.env, &self.contract_id) + } + } + + fn setup_invoice(total: i128) -> Ctx { + let (env, contract_id) = setup(); + let client = InvoiceEscrowContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let token_admin = Address::generate(&env); + let token = create_token(&env, &token_admin); + mint(&env, &token, &token_admin, &payer, total * 2); + + client.initialize(&admin); + let deadline = env.ledger().timestamp() + 10_000; + let id = client.create_invoice(&creator, &token, &total, &deadline); + Ctx { env, contract_id, creator, payer, token, id } + } + + fn signers(env: &Env, n: u32) -> Vec
{ + let mut v = Vec::new(env); + for _ in 0..n { + v.push_back(Address::generate(env)); + } + v + } + + #[test] + fn test_set_cosigners_creates_pending_approval() { + let ctx = setup_invoice(1_000); + let client = ctx.client(); + let s = signers(&ctx.env, 3); + + client.set_cosigners(&ctx.id, &s, &2); + + let approval = client.get_approval(&ctx.id).unwrap(); + assert_eq!(approval.signers, s); + assert_eq!(approval.threshold, 2); + assert_eq!(approval.approvals.len(), 0); + assert_eq!(approval.status, ApprovalStatus::Pending); + assert_eq!(client.approvals_remaining(&ctx.id), 2); + } + + #[test] + fn test_set_cosigners_invalid_configs() { + let ctx = setup_invoice(1_000); + let client = ctx.client(); + let s = signers(&ctx.env, 2); + + assert_eq!( + client.try_set_cosigners(&ctx.id, &Vec::new(&ctx.env), &1), + Err(Ok(Error::InvalidCosignerConfig)) + ); + assert_eq!( + client.try_set_cosigners(&ctx.id, &s, &0), + Err(Ok(Error::InvalidCosignerConfig)) + ); + assert_eq!( + client.try_set_cosigners(&ctx.id, &s, &3), + Err(Ok(Error::InvalidCosignerConfig)) + ); + let a = s.get(0).unwrap(); + let dup = vec![&ctx.env, a.clone(), a]; + assert_eq!( + client.try_set_cosigners(&ctx.id, &dup, &1), + Err(Ok(Error::InvalidCosignerConfig)) + ); + assert_eq!( + client.try_set_cosigners(&ctx.id, &signers(&ctx.env, 21), &1), + Err(Ok(Error::InvalidCosignerConfig)) + ); + } + + #[test] + fn test_set_cosigners_unknown_invoice() { + let ctx = setup_invoice(1_000); + let s = signers(&ctx.env, 1); + assert_eq!( + ctx.client().try_set_cosigners(&99, &s, &1), + Err(Ok(Error::InvoiceNotFound)) + ); + } + + #[test] + fn test_set_cosigners_locked_after_vote() { + let ctx = setup_invoice(1_000); + let client = ctx.client(); + let s = signers(&ctx.env, 2); + client.set_cosigners(&ctx.id, &s, &2); + + // Replacing before any vote is allowed. + client.set_cosigners(&ctx.id, &s, &1); + + client.approve_release(&s.get(0).unwrap(), &ctx.id); + assert_eq!( + client.try_set_cosigners(&ctx.id, &s, &2), + Err(Ok(Error::CosignersLocked)) + ); + } + + #[test] + fn test_full_funding_does_not_auto_release_without_approval() { + let ctx = setup_invoice(1_000); + let client = ctx.client(); + let s = signers(&ctx.env, 2); + client.set_cosigners(&ctx.id, &s, &2); + + client.deposit(&ctx.payer, &ctx.id, &1_000); + let invoice = client.get_invoice(&ctx.id); + assert_eq!(invoice.status, EscrowStatus::Active); + assert_eq!(invoice.funded_amount, 1_000); + + assert_eq!(client.try_release(&ctx.id), Err(Ok(Error::ApprovalRequired))); + } + + #[test] + fn test_threshold_approval_unblocks_release() { + let ctx = setup_invoice(1_000); + let client = ctx.client(); + let s = signers(&ctx.env, 3); + client.set_cosigners(&ctx.id, &s, &2); + client.deposit(&ctx.payer, &ctx.id, &1_000); + + assert_eq!(client.approve_release(&s.get(0).unwrap(), &ctx.id), ApprovalStatus::Pending); + assert_eq!(client.approvals_remaining(&ctx.id), 1); + assert!(client.has_approved(&ctx.id, &s.get(0).unwrap())); + assert!(!client.has_approved(&ctx.id, &s.get(1).unwrap())); + + assert_eq!(client.approve_release(&s.get(1).unwrap(), &ctx.id), ApprovalStatus::Approved); + assert_eq!(client.get_approval_status(&ctx.id), Some(ApprovalStatus::Approved)); + assert_eq!(client.approvals_remaining(&ctx.id), 0); + + client.release(&ctx.id); + assert_eq!(client.get_invoice(&ctx.id).status, EscrowStatus::Released); + let tok = token::Client::new(&ctx.env, &ctx.token); + assert_eq!(tok.balance(&ctx.creator), 1_000); + } + + #[test] + fn test_approval_before_full_funding_allows_auto_release() { + let ctx = setup_invoice(1_000); + let client = ctx.client(); + let s = signers(&ctx.env, 1); + client.set_cosigners(&ctx.id, &s, &1); + client.approve_release(&s.get(0).unwrap(), &ctx.id); + + client.deposit(&ctx.payer, &ctx.id, &1_000); + assert_eq!(client.get_invoice(&ctx.id).status, EscrowStatus::Released); + } + + #[test] + fn test_non_cosigner_cannot_vote() { + let ctx = setup_invoice(1_000); + let client = ctx.client(); + client.set_cosigners(&ctx.id, &signers(&ctx.env, 2), &1); + let outsider = Address::generate(&ctx.env); + + assert_eq!( + client.try_approve_release(&outsider, &ctx.id), + Err(Ok(Error::NotCosigner)) + ); + assert_eq!( + client.try_reject_release(&outsider, &ctx.id), + Err(Ok(Error::NotCosigner)) + ); + } + + #[test] + fn test_vote_without_cosigners_fails() { + let ctx = setup_invoice(1_000); + let signer = Address::generate(&ctx.env); + assert_eq!( + ctx.client().try_approve_release(&signer, &ctx.id), + Err(Ok(Error::NoCosigners)) + ); + } + + #[test] + fn test_duplicate_votes_rejected() { + let ctx = setup_invoice(1_000); + let client = ctx.client(); + let s = signers(&ctx.env, 3); + client.set_cosigners(&ctx.id, &s, &2); + let a = s.get(0).unwrap(); + + client.approve_release(&a, &ctx.id); + assert_eq!(client.try_approve_release(&a, &ctx.id), Err(Ok(Error::AlreadyVoted))); + assert_eq!(client.try_reject_release(&a, &ctx.id), Err(Ok(Error::AlreadyVoted))); + + // Tally unaffected by the rejected duplicates. + let approval = client.get_approval(&ctx.id).unwrap(); + assert_eq!(approval.approvals.len(), 1); + assert_eq!(approval.rejections.len(), 0); + } + + #[test] + fn test_interleaved_votes_tally_correctly() { + let ctx = setup_invoice(1_000); + let client = ctx.client(); + let s = signers(&ctx.env, 5); + client.set_cosigners(&ctx.id, &s, &3); + + client.approve_release(&s.get(0).unwrap(), &ctx.id); + client.reject_release(&s.get(1).unwrap(), &ctx.id); + client.approve_release(&s.get(2).unwrap(), &ctx.id); + client.reject_release(&s.get(3).unwrap(), &ctx.id); + assert_eq!(client.get_approval_status(&ctx.id), Some(ApprovalStatus::Pending)); + + assert_eq!(client.approve_release(&s.get(4).unwrap(), &ctx.id), ApprovalStatus::Approved); + let approval = client.get_approval(&ctx.id).unwrap(); + assert_eq!(approval.approvals.len(), 3); + assert_eq!(approval.rejections.len(), 2); + } + + #[test] + fn test_rejection_blocks_release_when_threshold_unreachable() { + let ctx = setup_invoice(1_000); + let client = ctx.client(); + let s = signers(&ctx.env, 3); + client.set_cosigners(&ctx.id, &s, &2); + client.deposit(&ctx.payer, &ctx.id, &1_000); + + assert_eq!(client.reject_release(&s.get(0).unwrap(), &ctx.id), ApprovalStatus::Pending); + assert_eq!(client.reject_release(&s.get(1).unwrap(), &ctx.id), ApprovalStatus::Rejected); + assert_eq!(client.try_release(&ctx.id), Err(Ok(Error::ApprovalRejected))); + } + + #[test] + fn test_revoke_transitions_status_back() { + let ctx = setup_invoice(1_000); + let client = ctx.client(); + let s = signers(&ctx.env, 2); + client.set_cosigners(&ctx.id, &s, &1); + let a = s.get(0).unwrap(); + let b = s.get(1).unwrap(); + + assert_eq!(client.approve_release(&a, &ctx.id), ApprovalStatus::Approved); + assert_eq!(client.revoke_vote(&a, &ctx.id), ApprovalStatus::Pending); + assert!(!client.has_approved(&ctx.id, &a)); + + client.reject_release(&a, &ctx.id); + assert_eq!(client.reject_release(&b, &ctx.id), ApprovalStatus::Rejected); + assert_eq!(client.revoke_vote(&b, &ctx.id), ApprovalStatus::Pending); + + assert_eq!(client.try_revoke_vote(&b, &ctx.id), Err(Ok(Error::NotVoted))); + } + + #[test] + fn test_votes_blocked_after_release() { + let ctx = setup_invoice(1_000); + let client = ctx.client(); + let s = signers(&ctx.env, 2); + client.set_cosigners(&ctx.id, &s, &1); + client.approve_release(&s.get(0).unwrap(), &ctx.id); + client.deposit(&ctx.payer, &ctx.id, &1_000); + assert_eq!(client.get_invoice(&ctx.id).status, EscrowStatus::Released); + + assert_eq!( + client.try_approve_release(&s.get(1).unwrap(), &ctx.id), + Err(Ok(Error::InvalidStatus)) + ); + assert_eq!( + client.try_revoke_vote(&s.get(0).unwrap(), &ctx.id), + Err(Ok(Error::InvalidStatus)) + ); + assert_eq!( + client.try_set_cosigners(&ctx.id, &s, &1), + Err(Ok(Error::InvalidStatus)) + ); + } + + #[test] + fn test_invoice_without_cosigners_unaffected() { + let ctx = setup_invoice(1_000); + let client = ctx.client(); + assert_eq!(client.get_approval_status(&ctx.id), None); + assert_eq!(client.approvals_remaining(&ctx.id), 0); + client.deposit(&ctx.payer, &ctx.id, &1_000); + assert_eq!(client.get_invoice(&ctx.id).status, EscrowStatus::Released); + } + + #[test] + fn test_approve_emits_events() { + let ctx = setup_invoice(1_000); + let client = ctx.client(); + let s = signers(&ctx.env, 1); + client.set_cosigners(&ctx.id, &s, &1); + client.approve_release(&s.get(0).unwrap(), &ctx.id); + + let events = ctx.env.events().all(); + let has_topic = |name: Symbol| { + events.events().iter().any(|e| { + xdr_event_topics(&ctx.env, e) + .map(|t| { + t.len() >= 2 + && Symbol::try_from_val(&ctx.env, &t.get(1).unwrap()) + .map_or(false, |sym| sym == name) + }) + .unwrap_or(false) + }) + }; + assert!(has_topic(symbol_short!("approved"))); + assert!(has_topic(symbol_short!("appr_stat"))); + } +} diff --git a/contracts/invoice-escrow/src/types.rs b/contracts/invoice-escrow/src/types.rs index 0a498e0..f6e3f14 100644 --- a/contracts/invoice-escrow/src/types.rs +++ b/contracts/invoice-escrow/src/types.rs @@ -1,6 +1,6 @@ //! Type definitions for the invoice-escrow contract. -use soroban_sdk::{contracttype, Address, BytesN}; +use soroban_sdk::{contracttype, Address, BytesN, Vec}; /// Lifecycle state of an escrow invoice. #[contracttype] @@ -125,3 +125,43 @@ pub struct BlacklistEntry { /// Hash of the reason for blacklisting (provided by admin). pub reason_hash: BytesN<32>, } + +// ────────────────────────────────────────────────────────────────────── +// Co-signer approval types +// ────────────────────────────────────────────────────────────────────── + +/// Aggregate state of an invoice's N-of-M co-signer approval. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ApprovalStatus { + /// Fewer than `threshold` approvals, and the threshold is still reachable. + Pending, + /// At least `threshold` co-signers have approved; release is unblocked. + Approved, + /// Enough co-signers rejected that `threshold` can no longer be reached. + Rejected, +} + +/// N-of-M co-signer approval record attached to an escrow invoice. +/// +/// While an approval record exists, the invoice can only be released once +/// `status` is [`ApprovalStatus::Approved`]. Each co-signer authenticates +/// with `require_auth`, may vote once (approve or reject), and may revoke +/// that vote until the invoice is released. `status` is recomputed after +/// every vote so it always reflects the current tallies. +#[contracttype] +#[derive(Clone, Debug)] +pub struct CosignerApproval { + /// Addresses allowed to vote on the release. + pub signers: Vec
, + /// Number of approvals required to release. + pub threshold: u32, + /// Co-signers that have approved. + pub approvals: Vec
, + /// Co-signers that have rejected. + pub rejections: Vec
, + /// Current aggregate status derived from the tallies. + pub status: ApprovalStatus, + /// Ledger sequence of the most recent change to this record. + pub updated_ledger: u32, +} From b03a6af7c4857d608a9219773a5a3fa65e95f863 Mon Sep 17 00:00:00 2001 From: OZILSOLAR Date: Sun, 18 Oct 2026 01:41:19 +0000 Subject: [PATCH 2/4] feat(invoice-escrow): implement oracle condition guards (#915) Add optional external price-feed validation before invoice release: - PriceOracle interface (SEP-40 style lastprice) in oracle.rs - OracleCondition with comparator, threshold, staleness window and an optional fallback oracle; set by the creator while Pending - oracle calls go through try_lastprice so panicking/missing oracles map to OracleUnavailable; stale, non-positive and future-dated prices are rejected; fallback oracle consulted when the primary fails - condition evaluated as a release gate (deposit auto-release and release), last reading stored for inspection - admin clear_oracle_condition override for permanently failed oracles - check_oracle_condition / get_oracle_condition / get_last_oracle_reading views, new error states and events, and integration tests with mock oracle contracts --- contracts/invoice-escrow/src/errors.rs | 13 + contracts/invoice-escrow/src/lib.rs | 209 ++++++++++++++++ contracts/invoice-escrow/src/oracle.rs | 20 ++ contracts/invoice-escrow/src/test.rs | 326 +++++++++++++++++++++++++ contracts/invoice-escrow/src/types.rs | 71 ++++++ 5 files changed, 639 insertions(+) create mode 100644 contracts/invoice-escrow/src/oracle.rs diff --git a/contracts/invoice-escrow/src/errors.rs b/contracts/invoice-escrow/src/errors.rs index 2964257..24f03be 100644 --- a/contracts/invoice-escrow/src/errors.rs +++ b/contracts/invoice-escrow/src/errors.rs @@ -63,4 +63,17 @@ pub enum Error { CosignersLocked = 26, /// No co-signer approval is configured for this invoice. NoCosigners = 27, + /// Oracle condition is malformed (non-positive threshold, zero staleness + /// window, or fallback identical to the primary oracle). + InvalidOracleConfig = 28, + /// No oracle could be reached or none returned a price for the asset. + OracleUnavailable = 29, + /// The oracle price is older than the condition's staleness window. + OracleStalePrice = 30, + /// The oracle returned a non-positive price or a future timestamp. + OracleInvalidPrice = 31, + /// The oracle price does not satisfy the release condition. + OracleConditionNotMet = 32, + /// No oracle condition is configured for this invoice. + NoOracleCondition = 33, } diff --git a/contracts/invoice-escrow/src/lib.rs b/contracts/invoice-escrow/src/lib.rs index 6ce90e2..170c8c7 100644 --- a/contracts/invoice-escrow/src/lib.rs +++ b/contracts/invoice-escrow/src/lib.rs @@ -6,6 +6,8 @@ //! - Deadline-based refund fallback //! - **Upgradable admin authority** via a two-step `transfer_admin` flow //! - **Co-signer approval**: optional N-of-M sign-off gating release +//! - **Oracle condition guards**: optional price-feed condition, with a +//! fallback oracle, that must hold before release //! //! # Admin Transfer Flow //! @@ -19,6 +21,7 @@ #![no_std] mod errors; +mod oracle; mod types; #[cfg(test)] @@ -28,8 +31,10 @@ use errors::Error; use soroban_sdk::{ contract, contractimpl, symbol_short, token, Address, BytesN, Env, Symbol, Vec, }; +use oracle::PriceOracleClient; use types::{ ApprovalStatus, BlacklistEntry, CosignerApproval, EscrowInvoice, EscrowReleased, EscrowStatus, + OracleCondition, OracleReading, OracleSource, PriceComparator, PriceData, }; // --------------------------------------------------------------------------- @@ -71,6 +76,16 @@ fn approval_key(id: u64) -> (Symbol, u64) { (symbol_short!("approval"), id) } +/// Persistent storage: oracle release condition for an invoice. +fn oracle_cond_key(id: u64) -> (Symbol, u64) { + (symbol_short!("orc_cond"), id) +} + +/// Persistent storage: most recent successful oracle evaluation for an invoice. +fn oracle_read_key(id: u64) -> (Symbol, u64) { + (symbol_short!("orc_read"), id) +} + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -134,8 +149,65 @@ fn recompute_approval_status(approval: &mut CosignerApproval) { }; } +fn get_oracle_condition(env: &Env, id: u64) -> Option { + env.storage().persistent().get(&oracle_cond_key(id)) +} + +/// Query a single oracle and validate the returned price. +/// +/// Uses `try_lastprice` so that a panicking, missing or incompatible oracle +/// contract surfaces as [`Error::OracleUnavailable`] instead of aborting. +fn read_oracle(env: &Env, oracle: &Address, asset: &Address, max_staleness_secs: u64) -> Result { + let client = PriceOracleClient::new(env, oracle); + let data = match client.try_lastprice(asset) { + Ok(Ok(Some(data))) => data, + _ => return Err(Error::OracleUnavailable), + }; + + let now = env.ledger().timestamp(); + if data.price <= 0 || data.timestamp > now { + return Err(Error::OracleInvalidPrice); + } + if now - data.timestamp > max_staleness_secs { + return Err(Error::OracleStalePrice); + } + Ok(data) +} + +/// Evaluate an oracle condition, falling back to the secondary oracle when +/// the primary cannot supply a usable price. +/// +/// When both oracles fail, the error from the last oracle tried is returned +/// (the fallback's if one is configured, otherwise the primary's). +fn evaluate_oracle_condition(env: &Env, id: u64, cond: &OracleCondition) -> Result { + let (data, source) = match read_oracle(env, &cond.oracle, &cond.asset, cond.max_staleness_secs) { + Ok(data) => (data, OracleSource::Primary), + Err(primary_err) => { + let fallback = cond.fallback_oracle.as_ref().ok_or(primary_err)?; + emit_oracle_fallback(env, id, primary_err); + let data = read_oracle(env, fallback, &cond.asset, cond.max_staleness_secs)?; + (data, OracleSource::Fallback) + } + }; + + let condition_met = match cond.comparator { + PriceComparator::GreaterOrEqual => data.price >= cond.threshold_price, + PriceComparator::LessOrEqual => data.price <= cond.threshold_price, + }; + Ok(OracleReading { + price: data.price, + price_timestamp: data.timestamp, + source, + condition_met, + checked_at: env.ledger().timestamp(), + }) +} + /// Check every release gate configured on the invoice. Returns `Ok(())` /// when the invoice may be released now. +/// +/// Gates are checked cheapest-first: co-signer approval, then the oracle +/// condition (which requires a cross-contract call). fn check_release_gates(env: &Env, id: u64) -> Result<(), Error> { if let Some(approval) = get_approval(env, id) { match approval.status { @@ -144,6 +216,13 @@ fn check_release_gates(env: &Env, id: u64) -> Result<(), Error> { ApprovalStatus::Rejected => return Err(Error::ApprovalRejected), } } + if let Some(cond) = get_oracle_condition(env, id) { + let reading = evaluate_oracle_condition(env, id, &cond)?; + env.storage().persistent().set(&oracle_read_key(id), &reading); + if !reading.condition_met { + return Err(Error::OracleConditionNotMet); + } + } Ok(()) } @@ -285,6 +364,35 @@ fn emit_approval_status_changed(env: &Env, id: u64, old: &ApprovalStatus, new: & ); } +/// Topics: `(escrow, orc_set, id)` — Data: `(oracle, fallback_oracle, asset, threshold_price)` +fn emit_oracle_condition_set(env: &Env, id: u64, cond: &OracleCondition) { + env.events().publish( + (symbol_short!("escrow"), symbol_short!("orc_set"), id), + ( + cond.oracle.clone(), + cond.fallback_oracle.clone(), + cond.asset.clone(), + cond.threshold_price, + ), + ); +} + +/// Topics: `(escrow, orc_clear, id)` — Data: `admin` +fn emit_oracle_condition_cleared(env: &Env, id: u64, admin: &Address) { + env.events().publish( + (symbol_short!("escrow"), symbol_short!("orc_clear"), id), + admin.clone(), + ); +} + +/// Topics: `(escrow, orc_fback, id)` — Data: primary oracle error code +fn emit_oracle_fallback(env: &Env, id: u64, primary_err: Error) { + env.events().publish( + (symbol_short!("escrow"), symbol_short!("orc_fback"), id), + primary_err as u32, + ); +} + /// Topics: `(blacklist, bl_add)` — Data: `(payer, reason_hash, banned_at)` fn emit_payer_blacklisted(env: &Env, payer: &Address, reason_hash: &BytesN<32>, banned_at: u64) { env.events().publish( @@ -584,6 +692,9 @@ impl InvoiceEscrowContract { /// * [`Error::InsufficientFunding`] — Not fully funded yet. /// * [`Error::ApprovalRequired`] — Co-signer threshold not yet reached. /// * [`Error::ApprovalRejected`] — Co-signers rejected the release. + /// * [`Error::OracleConditionNotMet`] — Oracle price fails the condition. + /// * [`Error::OracleUnavailable`] / [`Error::OracleStalePrice`] / + /// [`Error::OracleInvalidPrice`] — No usable oracle price. pub fn release(env: Env, invoice_id: u64) -> Result<(), Error> { let mut invoice = get_invoice(&env, invoice_id)?; @@ -831,6 +942,93 @@ impl InvoiceEscrowContract { Ok(approval.status) } + // ----------------------------------------------------------------------- + // Oracle condition guards + // ----------------------------------------------------------------------- + + /// Attach (or replace) a price condition that must hold before release. + /// + /// Only the creator may set the condition, and only while the invoice is + /// still `Pending` (no deposits yet), so payers always fund against a + /// known condition. + /// + /// # Events + /// Emits `(escrow, orc_set, id)`. + /// + /// # Errors + /// * [`Error::InvoiceNotFound`] — Unknown invoice ID. + /// * [`Error::InvalidStatus`] — Invoice already has deposits or is closed. + /// * [`Error::InvalidOracleConfig`] — Non-positive threshold, zero + /// staleness window, or fallback equal to the primary oracle. + pub fn set_oracle_condition(env: Env, invoice_id: u64, condition: OracleCondition) -> Result<(), Error> { + let invoice = get_invoice(&env, invoice_id)?; + invoice.creator.require_auth(); + if invoice.status != EscrowStatus::Pending { + return Err(Error::InvalidStatus); + } + if condition.threshold_price <= 0 + || condition.max_staleness_secs == 0 + || condition.fallback_oracle.as_ref() == Some(&condition.oracle) + { + return Err(Error::InvalidOracleConfig); + } + + env.storage() + .persistent() + .set(&oracle_cond_key(invoice_id), &condition); + emit_oracle_condition_set(&env, invoice_id, &condition); + Ok(()) + } + + /// Admin override: remove an invoice's oracle condition. + /// + /// Intended as the last-resort fallback when both the primary and + /// fallback oracles are permanently unavailable, so funds are not locked + /// forever. Any stored reading is cleared as well. + /// + /// # Events + /// Emits `(escrow, orc_clear, id)` with the admin address. + /// + /// # Errors + /// * [`Error::NotInitialized`] — Contract not initialized. + /// * [`Error::InvoiceNotFound`] — Unknown invoice ID. + /// * [`Error::InvalidStatus`] — Invoice is no longer open. + /// * [`Error::NoOracleCondition`] — No condition to clear. + pub fn clear_oracle_condition(env: Env, invoice_id: u64) -> Result<(), Error> { + if !env.storage().instance().has(&admin_key()) { + return Err(Error::NotInitialized); + } + let admin = require_admin(&env); + let invoice = get_invoice(&env, invoice_id)?; + if !is_open(&invoice) { + return Err(Error::InvalidStatus); + } + if get_oracle_condition(&env, invoice_id).is_none() { + return Err(Error::NoOracleCondition); + } + + env.storage().persistent().remove(&oracle_cond_key(invoice_id)); + env.storage().persistent().remove(&oracle_read_key(invoice_id)); + emit_oracle_condition_cleared(&env, invoice_id, &admin); + Ok(()) + } + + /// Evaluate the invoice's oracle condition now without changing state. + /// + /// Returns the reading (including whether the condition holds) or the + /// oracle error that would block release. + /// + /// # Errors + /// * [`Error::InvoiceNotFound`] — Unknown invoice ID. + /// * [`Error::NoOracleCondition`] — No condition configured. + /// * [`Error::OracleUnavailable`] / [`Error::OracleStalePrice`] / + /// [`Error::OracleInvalidPrice`] — No usable oracle price. + pub fn check_oracle_condition(env: Env, invoice_id: u64) -> Result { + get_invoice(&env, invoice_id)?; + let cond = get_oracle_condition(&env, invoice_id).ok_or(Error::NoOracleCondition)?; + evaluate_oracle_condition(&env, invoice_id, &cond) + } + // ----------------------------------------------------------------------- // Payer blacklist // ----------------------------------------------------------------------- @@ -1107,6 +1305,17 @@ impl InvoiceEscrowContract { get_approval(&env, invoice_id).map_or(0, |a| a.threshold.saturating_sub(a.approvals.len())) } + /// Return the oracle condition attached to an invoice, if any. + pub fn get_oracle_condition(env: Env, invoice_id: u64) -> Option { + get_oracle_condition(&env, invoice_id) + } + + /// Return the last oracle reading recorded during a release attempt + /// (e.g. a deposit that completed funding), if any. + pub fn get_last_oracle_reading(env: Env, invoice_id: u64) -> Option { + env.storage().persistent().get(&oracle_read_key(invoice_id)) + } + /// Return `true` if the payer is blacklisted with a finalised and upheld entry. pub fn is_payer_blacklisted(env: Env, payer: Address) -> bool { env.storage() diff --git a/contracts/invoice-escrow/src/oracle.rs b/contracts/invoice-escrow/src/oracle.rs new file mode 100644 index 0000000..403edf4 --- /dev/null +++ b/contracts/invoice-escrow/src/oracle.rs @@ -0,0 +1,20 @@ +//! External price-oracle interface used by oracle condition guards. +//! +//! The interface follows the SEP-40 `lastprice` shape: an oracle returns the +//! most recent price for an asset together with the timestamp at which that +//! price was observed, or `None` when it has no price for the asset. +//! +//! The escrow contract always calls oracles through the generated +//! `try_lastprice`, so a misbehaving oracle (panic, missing contract, wrong +//! return type) is reported as [`crate::errors::Error::OracleUnavailable`] +//! rather than aborting the caller's transaction. + +use soroban_sdk::{contractclient, Address, Env}; + +use crate::types::PriceData; + +#[contractclient(name = "PriceOracleClient")] +pub trait PriceOracle { + /// Return the latest price for `asset`, or `None` if unavailable. + fn lastprice(env: Env, asset: Address) -> Option; +} diff --git a/contracts/invoice-escrow/src/test.rs b/contracts/invoice-escrow/src/test.rs index a340f6f..33fdb95 100644 --- a/contracts/invoice-escrow/src/test.rs +++ b/contracts/invoice-escrow/src/test.rs @@ -1170,3 +1170,329 @@ mod cosigner_tests { assert!(has_topic(symbol_short!("appr_stat"))); } } + +// --------------------------------------------------------------------------- +// Oracle condition guards (#915) +// --------------------------------------------------------------------------- + +mod oracle_tests { + use super::*; + use crate::types::{OracleCondition, OracleSource, PriceComparator, PriceData}; + use soroban_sdk::{contract, contractimpl, Symbol, TryFromVal}; + + /// Configurable oracle: returns whatever `set_price` last stored. + #[contract] + pub struct MockOracle; + + #[contractimpl] + impl MockOracle { + pub fn set_price(env: Env, price: Option) { + env.storage().instance().set(&symbol_short!("price"), &price); + } + + pub fn lastprice(env: Env, _asset: Address) -> Option { + env.storage() + .instance() + .get::<_, Option>(&symbol_short!("price")) + .flatten() + } + } + + /// Oracle that always traps, simulating an unreachable feed. + #[contract] + pub struct BrokenOracle; + + #[contractimpl] + impl BrokenOracle { + pub fn lastprice(_env: Env, _asset: Address) -> Option { + panic!("oracle offline") + } + } + + struct Ctx { + env: Env, + contract_id: Address, + payer: Address, + asset: Address, + id: u64, + } + + impl Ctx { + fn client(&self) -> InvoiceEscrowContractClient<'_> { + InvoiceEscrowContractClient::new(&self.env, &self.contract_id) + } + } + + const NOW: u64 = 1_000_000; + + fn setup_invoice() -> Ctx { + let (env, contract_id) = setup(); + env.ledger().with_mut(|li| li.timestamp = NOW); + let client = InvoiceEscrowContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let token_admin = Address::generate(&env); + let token = create_token(&env, &token_admin); + mint(&env, &token, &token_admin, &payer, 10_000); + + client.initialize(&admin); + let id = client.create_invoice(&creator, &token, &1_000, &(NOW + 10_000)); + Ctx { env, contract_id, payer, asset: Address::generate(&env), id } + } + + fn mock_oracle(env: &Env, price: Option) -> Address { + let id = env.register_contract(None, MockOracle); + MockOracleClient::new(env, &id).set_price(&price); + id + } + + fn price(p: i128, ts: u64) -> Option { + Some(PriceData { price: p, timestamp: ts }) + } + + fn condition(ctx: &Ctx, oracle: &Address, fallback: Option
) -> OracleCondition { + OracleCondition { + oracle: oracle.clone(), + fallback_oracle: fallback, + asset: ctx.asset.clone(), + comparator: PriceComparator::GreaterOrEqual, + threshold_price: 100, + max_staleness_secs: 300, + } + } + + #[test] + fn test_set_oracle_condition_stores_condition() { + let ctx = setup_invoice(); + let oracle = mock_oracle(&ctx.env, price(150, NOW)); + let client = ctx.client(); + client.set_oracle_condition(&ctx.id, &condition(&ctx, &oracle, None)); + + let stored = client.get_oracle_condition(&ctx.id).unwrap(); + assert_eq!(stored.oracle, oracle); + assert_eq!(stored.threshold_price, 100); + } + + #[test] + fn test_set_oracle_condition_invalid_config() { + let ctx = setup_invoice(); + let oracle = mock_oracle(&ctx.env, None); + let client = ctx.client(); + + let mut c = condition(&ctx, &oracle, None); + c.threshold_price = 0; + assert_eq!(client.try_set_oracle_condition(&ctx.id, &c), Err(Ok(Error::InvalidOracleConfig))); + + let mut c = condition(&ctx, &oracle, None); + c.max_staleness_secs = 0; + assert_eq!(client.try_set_oracle_condition(&ctx.id, &c), Err(Ok(Error::InvalidOracleConfig))); + + let c = condition(&ctx, &oracle, Some(oracle.clone())); + assert_eq!(client.try_set_oracle_condition(&ctx.id, &c), Err(Ok(Error::InvalidOracleConfig))); + } + + #[test] + fn test_set_oracle_condition_locked_after_deposit() { + let ctx = setup_invoice(); + let oracle = mock_oracle(&ctx.env, price(150, NOW)); + let client = ctx.client(); + client.deposit(&ctx.payer, &ctx.id, &100); + assert_eq!( + client.try_set_oracle_condition(&ctx.id, &condition(&ctx, &oracle, None)), + Err(Ok(Error::InvalidStatus)) + ); + } + + #[test] + fn test_condition_met_allows_auto_release() { + let ctx = setup_invoice(); + let oracle = mock_oracle(&ctx.env, price(150, NOW - 10)); + let client = ctx.client(); + client.set_oracle_condition(&ctx.id, &condition(&ctx, &oracle, None)); + + client.deposit(&ctx.payer, &ctx.id, &1_000); + assert_eq!(client.get_invoice(&ctx.id).status, EscrowStatus::Released); + + let reading = client.get_last_oracle_reading(&ctx.id).unwrap(); + assert_eq!(reading.price, 150); + assert_eq!(reading.source, OracleSource::Primary); + assert!(reading.condition_met); + } + + #[test] + fn test_condition_not_met_blocks_release_until_price_moves() { + let ctx = setup_invoice(); + let oracle = mock_oracle(&ctx.env, price(50, NOW)); + let client = ctx.client(); + client.set_oracle_condition(&ctx.id, &condition(&ctx, &oracle, None)); + + client.deposit(&ctx.payer, &ctx.id, &1_000); + assert_eq!(client.get_invoice(&ctx.id).status, EscrowStatus::Active); + assert!(!client.get_last_oracle_reading(&ctx.id).unwrap().condition_met); + assert_eq!(client.try_release(&ctx.id), Err(Ok(Error::OracleConditionNotMet))); + + MockOracleClient::new(&ctx.env, &oracle).set_price(&price(100, NOW)); + client.release(&ctx.id); + assert_eq!(client.get_invoice(&ctx.id).status, EscrowStatus::Released); + } + + #[test] + fn test_less_or_equal_comparator() { + let ctx = setup_invoice(); + let oracle = mock_oracle(&ctx.env, price(150, NOW)); + let client = ctx.client(); + let mut c = condition(&ctx, &oracle, None); + c.comparator = PriceComparator::LessOrEqual; + client.set_oracle_condition(&ctx.id, &c); + + assert!(!client.check_oracle_condition(&ctx.id).condition_met); + MockOracleClient::new(&ctx.env, &oracle).set_price(&price(100, NOW)); + assert!(client.check_oracle_condition(&ctx.id).condition_met); + } + + #[test] + fn test_stale_price_rejected() { + let ctx = setup_invoice(); + let oracle = mock_oracle(&ctx.env, price(150, NOW - 301)); + let client = ctx.client(); + client.set_oracle_condition(&ctx.id, &condition(&ctx, &oracle, None)); + client.deposit(&ctx.payer, &ctx.id, &1_000); + + assert_eq!(client.try_release(&ctx.id), Err(Ok(Error::OracleStalePrice))); + assert_eq!(client.try_check_oracle_condition(&ctx.id), Err(Ok(Error::OracleStalePrice))); + } + + #[test] + fn test_invalid_prices_rejected() { + let ctx = setup_invoice(); + let oracle = mock_oracle(&ctx.env, price(0, NOW)); + let client = ctx.client(); + client.set_oracle_condition(&ctx.id, &condition(&ctx, &oracle, None)); + assert_eq!(client.try_check_oracle_condition(&ctx.id), Err(Ok(Error::OracleInvalidPrice))); + + // Future-dated price. + MockOracleClient::new(&ctx.env, &oracle).set_price(&price(150, NOW + 1)); + assert_eq!(client.try_check_oracle_condition(&ctx.id), Err(Ok(Error::OracleInvalidPrice))); + } + + #[test] + fn test_missing_price_is_unavailable() { + let ctx = setup_invoice(); + let oracle = mock_oracle(&ctx.env, None); + let client = ctx.client(); + client.set_oracle_condition(&ctx.id, &condition(&ctx, &oracle, None)); + assert_eq!(client.try_check_oracle_condition(&ctx.id), Err(Ok(Error::OracleUnavailable))); + } + + #[test] + fn test_broken_oracle_does_not_abort_deposit() { + let ctx = setup_invoice(); + let broken = ctx.env.register_contract(None, BrokenOracle); + let client = ctx.client(); + client.set_oracle_condition(&ctx.id, &condition(&ctx, &broken, None)); + + // Deposit still succeeds; release is simply deferred. + client.deposit(&ctx.payer, &ctx.id, &1_000); + let invoice = client.get_invoice(&ctx.id); + assert_eq!(invoice.status, EscrowStatus::Active); + assert_eq!(invoice.funded_amount, 1_000); + assert_eq!(client.try_release(&ctx.id), Err(Ok(Error::OracleUnavailable))); + } + + #[test] + fn test_fallback_used_when_primary_broken() { + let ctx = setup_invoice(); + let broken = ctx.env.register_contract(None, BrokenOracle); + let fallback = mock_oracle(&ctx.env, price(200, NOW)); + let client = ctx.client(); + client.set_oracle_condition(&ctx.id, &condition(&ctx, &broken, Some(fallback))); + + client.deposit(&ctx.payer, &ctx.id, &1_000); + assert_eq!(client.get_invoice(&ctx.id).status, EscrowStatus::Released); + let reading = client.get_last_oracle_reading(&ctx.id).unwrap(); + assert_eq!(reading.source, OracleSource::Fallback); + assert_eq!(reading.price, 200); + } + + #[test] + fn test_fallback_used_when_primary_stale() { + let ctx = setup_invoice(); + let primary = mock_oracle(&ctx.env, price(150, NOW - 1_000)); + let fallback = mock_oracle(&ctx.env, price(120, NOW)); + let client = ctx.client(); + client.set_oracle_condition(&ctx.id, &condition(&ctx, &primary, Some(fallback))); + + let reading = client.check_oracle_condition(&ctx.id); + assert_eq!(reading.source, OracleSource::Fallback); + assert_eq!(reading.price, 120); + + let events = ctx.env.events().all(); + let fallback_emitted = events.events().iter().any(|e| { + xdr_event_topics(&ctx.env, e) + .map(|t| { + t.len() >= 2 + && Symbol::try_from_val(&ctx.env, &t.get(1).unwrap()) + .map_or(false, |sym| sym == symbol_short!("orc_fback")) + }) + .unwrap_or(false) + }); + assert!(fallback_emitted, "orc_fback event not emitted"); + } + + #[test] + fn test_both_oracles_failing_blocks_release() { + let ctx = setup_invoice(); + let broken = ctx.env.register_contract(None, BrokenOracle); + let stale = mock_oracle(&ctx.env, price(150, NOW - 1_000)); + let client = ctx.client(); + client.set_oracle_condition(&ctx.id, &condition(&ctx, &broken, Some(stale))); + client.deposit(&ctx.payer, &ctx.id, &1_000); + + // Error reported is the fallback's (last oracle tried). + assert_eq!(client.try_release(&ctx.id), Err(Ok(Error::OracleStalePrice))); + } + + #[test] + fn test_primary_preferred_over_fallback() { + let ctx = setup_invoice(); + let primary = mock_oracle(&ctx.env, price(150, NOW)); + let fallback = mock_oracle(&ctx.env, price(50, NOW)); + let client = ctx.client(); + client.set_oracle_condition(&ctx.id, &condition(&ctx, &primary, Some(fallback))); + + let reading = client.check_oracle_condition(&ctx.id); + assert_eq!(reading.source, OracleSource::Primary); + assert!(reading.condition_met); + } + + #[test] + fn test_admin_clear_condition_unblocks_release() { + let ctx = setup_invoice(); + let broken = ctx.env.register_contract(None, BrokenOracle); + let client = ctx.client(); + client.set_oracle_condition(&ctx.id, &condition(&ctx, &broken, None)); + client.deposit(&ctx.payer, &ctx.id, &1_000); + assert_eq!(client.try_release(&ctx.id), Err(Ok(Error::OracleUnavailable))); + + client.clear_oracle_condition(&ctx.id); + assert!(client.get_oracle_condition(&ctx.id).is_none()); + client.release(&ctx.id); + assert_eq!(client.get_invoice(&ctx.id).status, EscrowStatus::Released); + + assert_eq!(client.try_clear_oracle_condition(&ctx.id), Err(Ok(Error::InvalidStatus))); + } + + #[test] + fn test_clear_without_condition_fails() { + let ctx = setup_invoice(); + assert_eq!( + ctx.client().try_clear_oracle_condition(&ctx.id), + Err(Ok(Error::NoOracleCondition)) + ); + assert_eq!( + ctx.client().try_check_oracle_condition(&ctx.id), + Err(Ok(Error::NoOracleCondition)) + ); + } +} diff --git a/contracts/invoice-escrow/src/types.rs b/contracts/invoice-escrow/src/types.rs index f6e3f14..99a5a34 100644 --- a/contracts/invoice-escrow/src/types.rs +++ b/contracts/invoice-escrow/src/types.rs @@ -165,3 +165,74 @@ pub struct CosignerApproval { /// Ledger sequence of the most recent change to this record. pub updated_ledger: u32, } + +// ────────────────────────────────────────────────────────────────────── +// Oracle condition guard types +// ────────────────────────────────────────────────────────────────────── + +/// Price observation returned by an oracle's `lastprice`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PriceData { + /// Price in the oracle's fixed-point units. Must be positive. + pub price: i128, + /// Unix timestamp at which the price was observed. + pub timestamp: u64, +} + +/// How the observed price is compared against `threshold_price`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum PriceComparator { + /// Condition holds when `price >= threshold_price`. + GreaterOrEqual, + /// Condition holds when `price <= threshold_price`. + LessOrEqual, +} + +/// Which oracle produced a reading. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum OracleSource { + Primary, + Fallback, +} + +/// Price condition that must hold before an invoice can be released. +/// +/// The `oracle` is queried first. If it is unavailable, returns an invalid +/// price, or returns a price older than `max_staleness_secs`, the optional +/// `fallback_oracle` is queried instead. If neither yields a usable price the +/// release is blocked with the corresponding oracle error. +#[contracttype] +#[derive(Clone, Debug)] +pub struct OracleCondition { + /// Primary oracle contract. + pub oracle: Address, + /// Secondary oracle consulted when the primary fails. + pub fallback_oracle: Option
, + /// Asset whose price is checked. + pub asset: Address, + /// Comparison applied to the observed price. + pub comparator: PriceComparator, + /// Price the observation is compared against. Must be positive. + pub threshold_price: i128, + /// Maximum accepted age (seconds) of an oracle price. + pub max_staleness_secs: u64, +} + +/// Result of evaluating an [`OracleCondition`]. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct OracleReading { + /// Price used for the evaluation. + pub price: i128, + /// Timestamp reported by the oracle for `price`. + pub price_timestamp: u64, + /// Oracle that supplied the price. + pub source: OracleSource, + /// Whether the condition held for `price`. + pub condition_met: bool, + /// Ledger timestamp at which the evaluation happened. + pub checked_at: u64, +} From 886e8d306904c5ce25560812f9dde6ce84cdb5e3 Mon Sep 17 00:00:00 2001 From: OZILSOLAR Date: Sun, 18 Oct 2026 01:42:47 +0000 Subject: [PATCH 3/4] feat(invoice-escrow): add scheduled release functionality (#916) Support delayed release of invoice funds: - ReleaseSchedule with release_at timestamp, unlock_ledger computed from a minimum ledger count, creation metadata and a Scheduled/Executed/Cancelled status - schedule_release / cancel_release_schedule (creator-only, open invoices); release blocked until both time and ledger bounds pass - schedule and invoice TTLs extended through the unlock ledger so the schedule persists until due - schedule marked Executed on release; is_release_due, seconds_until_release and ledgers_until_release views - new error states, events and edge-case tests --- contracts/invoice-escrow/src/errors.rs | 9 + contracts/invoice-escrow/src/lib.rs | 203 +++++++++++++++++- contracts/invoice-escrow/src/test.rs | 271 +++++++++++++++++++++++++ contracts/invoice-escrow/src/types.rs | 37 ++++ 4 files changed, 517 insertions(+), 3 deletions(-) diff --git a/contracts/invoice-escrow/src/errors.rs b/contracts/invoice-escrow/src/errors.rs index 24f03be..48336fe 100644 --- a/contracts/invoice-escrow/src/errors.rs +++ b/contracts/invoice-escrow/src/errors.rs @@ -76,4 +76,13 @@ pub enum Error { OracleConditionNotMet = 32, /// No oracle condition is configured for this invoice. NoOracleCondition = 33, + /// Schedule is malformed: `release_at` not in the future, or the ledger + /// delay exceeds the maximum / overflows. + InvalidSchedule = 34, + /// The scheduled release time has not been reached yet. + ReleaseNotYetDue = 35, + /// The scheduled minimum ledger count has not elapsed yet. + ScheduleLedgerNotReached = 36, + /// No active release schedule exists for this invoice. + ScheduleNotFound = 37, } diff --git a/contracts/invoice-escrow/src/lib.rs b/contracts/invoice-escrow/src/lib.rs index 170c8c7..16cbd60 100644 --- a/contracts/invoice-escrow/src/lib.rs +++ b/contracts/invoice-escrow/src/lib.rs @@ -8,6 +8,7 @@ //! - **Co-signer approval**: optional N-of-M sign-off gating release //! - **Oracle condition guards**: optional price-feed condition, with a //! fallback oracle, that must hold before release +//! - **Scheduled release**: optional time + ledger-count delay before release //! //! # Admin Transfer Flow //! @@ -34,7 +35,8 @@ use soroban_sdk::{ use oracle::PriceOracleClient; use types::{ ApprovalStatus, BlacklistEntry, CosignerApproval, EscrowInvoice, EscrowReleased, EscrowStatus, - OracleCondition, OracleReading, OracleSource, PriceComparator, PriceData, + OracleCondition, OracleReading, OracleSource, PriceComparator, PriceData, ReleaseSchedule, + ScheduleStatus, }; // --------------------------------------------------------------------------- @@ -86,6 +88,11 @@ fn oracle_read_key(id: u64) -> (Symbol, u64) { (symbol_short!("orc_read"), id) } +/// Persistent storage: release schedule for an invoice. +fn schedule_key(id: u64) -> (Symbol, u64) { + (symbol_short!("schedule"), id) +} + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -98,6 +105,13 @@ const APPEAL_WINDOW_LEDGERS: u32 = 360; /// membership scans performed on every vote. const MAX_COSIGNERS: u32 = 20; +/// Maximum ledger delay for a release schedule (~1 year at 5s ledgers). +const MAX_SCHEDULE_LEDGERS: u32 = 6_312_000; + +/// Extra ledgers of TTL kept on a scheduled invoice beyond its unlock +/// ledger (~1 day), so the record is still live when it becomes due. +const SCHEDULE_TTL_BUFFER_LEDGERS: u32 = 17_280; + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- @@ -203,12 +217,42 @@ fn evaluate_oracle_condition(env: &Env, id: u64, cond: &OracleCondition) -> Resu }) } +fn get_schedule(env: &Env, id: u64) -> Option { + env.storage().persistent().get(&schedule_key(id)) +} + +/// Persist a schedule and keep it (and its invoice) alive at least until +/// the unlock ledger plus a buffer, capped at the network's maximum TTL. +fn save_schedule(env: &Env, id: u64, schedule: &ReleaseSchedule) { + let storage = env.storage().persistent(); + storage.set(&schedule_key(id), schedule); + + let ledgers_left = schedule + .unlock_ledger + .saturating_sub(env.ledger().sequence()); + let extend_to = ledgers_left + .saturating_add(SCHEDULE_TTL_BUFFER_LEDGERS) + .min(env.storage().max_ttl()); + storage.extend_ttl(&schedule_key(id), extend_to, extend_to); + storage.extend_ttl(&invoice_key(id), extend_to, extend_to); +} + /// Check every release gate configured on the invoice. Returns `Ok(())` /// when the invoice may be released now. /// -/// Gates are checked cheapest-first: co-signer approval, then the oracle -/// condition (which requires a cross-contract call). +/// Gates are checked cheapest-first: release schedule, co-signer approval, +/// then the oracle condition (which requires a cross-contract call). fn check_release_gates(env: &Env, id: u64) -> Result<(), Error> { + if let Some(schedule) = get_schedule(env, id) { + if schedule.status == ScheduleStatus::Scheduled { + if env.ledger().timestamp() < schedule.release_at { + return Err(Error::ReleaseNotYetDue); + } + if env.ledger().sequence() < schedule.unlock_ledger { + return Err(Error::ScheduleLedgerNotReached); + } + } + } if let Some(approval) = get_approval(env, id) { match approval.status { ApprovalStatus::Approved => {} @@ -270,6 +314,14 @@ fn execute_release(env: &Env, id: u64, invoice: &mut EscrowInvoice) -> (Address, let token_client = token::Client::new(env, &invoice.token); token_client.transfer(&env.current_contract_address(), &creator, &total); emit_released(env, id, total); + + if let Some(mut schedule) = get_schedule(env, id) { + if schedule.status == ScheduleStatus::Scheduled { + schedule.status = ScheduleStatus::Executed; + env.storage().persistent().set(&schedule_key(id), &schedule); + emit_schedule_executed(env, id); + } + } (creator, total) } @@ -393,6 +445,30 @@ fn emit_oracle_fallback(env: &Env, id: u64, primary_err: Error) { ); } +/// Topics: `(escrow, sched_set, id)` — Data: `(release_at, unlock_ledger)` +fn emit_schedule_set(env: &Env, id: u64, schedule: &ReleaseSchedule) { + env.events().publish( + (symbol_short!("escrow"), symbol_short!("sched_set"), id), + (schedule.release_at, schedule.unlock_ledger), + ); +} + +/// Topics: `(escrow, sched_cnl, id)` — Data: ledger sequence +fn emit_schedule_cancelled(env: &Env, id: u64) { + env.events().publish( + (symbol_short!("escrow"), symbol_short!("sched_cnl"), id), + env.ledger().sequence(), + ); +} + +/// Topics: `(escrow, sched_exe, id)` — Data: `(timestamp, ledger)` +fn emit_schedule_executed(env: &Env, id: u64) { + env.events().publish( + (symbol_short!("escrow"), symbol_short!("sched_exe"), id), + (env.ledger().timestamp(), env.ledger().sequence()), + ); +} + /// Topics: `(blacklist, bl_add)` — Data: `(payer, reason_hash, banned_at)` fn emit_payer_blacklisted(env: &Env, payer: &Address, reason_hash: &BytesN<32>, banned_at: u64) { env.events().publish( @@ -692,6 +768,8 @@ impl InvoiceEscrowContract { /// * [`Error::InsufficientFunding`] — Not fully funded yet. /// * [`Error::ApprovalRequired`] — Co-signer threshold not yet reached. /// * [`Error::ApprovalRejected`] — Co-signers rejected the release. + /// * [`Error::ReleaseNotYetDue`] — Scheduled release time not reached. + /// * [`Error::ScheduleLedgerNotReached`] — Scheduled ledger delay not elapsed. /// * [`Error::OracleConditionNotMet`] — Oracle price fails the condition. /// * [`Error::OracleUnavailable`] / [`Error::OracleStalePrice`] / /// [`Error::OracleInvalidPrice`] — No usable oracle price. @@ -1029,6 +1107,94 @@ impl InvoiceEscrowContract { evaluate_oracle_condition(&env, invoice_id, &cond) } + // ----------------------------------------------------------------------- + // Scheduled release + // ----------------------------------------------------------------------- + + /// Delay the release of an invoice until a specific time and ledger. + /// + /// Release (including auto-release on full funding) is blocked until the + /// ledger timestamp is `>= release_at` **and** the ledger sequence is + /// `>= current_sequence + min_ledgers`. Only the creator may schedule, + /// and only while the invoice is open. Calling again replaces any + /// existing schedule. + /// + /// The schedule and invoice TTLs are extended so both entries remain + /// live until the schedule is due. + /// + /// # Arguments + /// * `invoice_id` — Target invoice. + /// * `release_at` — Unix timestamp; must be strictly in the future. + /// * `min_ledgers` — Minimum ledgers to wait (≤ `MAX_SCHEDULE_LEDGERS`). + /// + /// # Events + /// Emits `(escrow, sched_set, id)` with `(release_at, unlock_ledger)`. + /// + /// # Errors + /// * [`Error::InvoiceNotFound`] — Unknown invoice ID. + /// * [`Error::InvalidStatus`] — Invoice is no longer open. + /// * [`Error::InvalidSchedule`] — `release_at` not in the future or + /// `min_ledgers` too large. + pub fn schedule_release( + env: Env, + invoice_id: u64, + release_at: u64, + min_ledgers: u32, + ) -> Result { + let invoice = get_invoice(&env, invoice_id)?; + invoice.creator.require_auth(); + if !is_open(&invoice) { + return Err(Error::InvalidStatus); + } + + let now = env.ledger().timestamp(); + let seq = env.ledger().sequence(); + if release_at <= now || min_ledgers > MAX_SCHEDULE_LEDGERS { + return Err(Error::InvalidSchedule); + } + let unlock_ledger = seq.checked_add(min_ledgers).ok_or(Error::InvalidSchedule)?; + + let schedule = ReleaseSchedule { + release_at, + unlock_ledger, + scheduled_at: now, + scheduled_ledger: seq, + status: ScheduleStatus::Scheduled, + }; + save_schedule(&env, invoice_id, &schedule); + emit_schedule_set(&env, invoice_id, &schedule); + Ok(schedule) + } + + /// Cancel an active release schedule so it no longer gates release. + /// + /// Only the creator may cancel, and only while the invoice is open. + /// + /// # Events + /// Emits `(escrow, sched_cnl, id)`. + /// + /// # Errors + /// * [`Error::InvoiceNotFound`] — Unknown invoice ID. + /// * [`Error::InvalidStatus`] — Invoice is no longer open. + /// * [`Error::ScheduleNotFound`] — No active schedule. + pub fn cancel_release_schedule(env: Env, invoice_id: u64) -> Result<(), Error> { + let invoice = get_invoice(&env, invoice_id)?; + invoice.creator.require_auth(); + if !is_open(&invoice) { + return Err(Error::InvalidStatus); + } + let mut schedule = get_schedule(&env, invoice_id) + .filter(|s| s.status == ScheduleStatus::Scheduled) + .ok_or(Error::ScheduleNotFound)?; + + schedule.status = ScheduleStatus::Cancelled; + env.storage() + .persistent() + .set(&schedule_key(invoice_id), &schedule); + emit_schedule_cancelled(&env, invoice_id); + Ok(()) + } + // ----------------------------------------------------------------------- // Payer blacklist // ----------------------------------------------------------------------- @@ -1316,6 +1482,37 @@ impl InvoiceEscrowContract { env.storage().persistent().get(&oracle_read_key(invoice_id)) } + /// Return the release schedule for an invoice, if any. + pub fn get_release_schedule(env: Env, invoice_id: u64) -> Option { + get_schedule(&env, invoice_id) + } + + /// Return `true` when the invoice has no active schedule or its schedule + /// is due (both the time and ledger bounds have passed). + pub fn is_release_due(env: Env, invoice_id: u64) -> bool { + get_schedule(&env, invoice_id).map_or(true, |s| { + s.status != ScheduleStatus::Scheduled + || (env.ledger().timestamp() >= s.release_at + && env.ledger().sequence() >= s.unlock_ledger) + }) + } + + /// Seconds remaining until the scheduled release time (`0` when due or + /// when there is no active schedule). + pub fn seconds_until_release(env: Env, invoice_id: u64) -> u64 { + get_schedule(&env, invoice_id) + .filter(|s| s.status == ScheduleStatus::Scheduled) + .map_or(0, |s| s.release_at.saturating_sub(env.ledger().timestamp())) + } + + /// Ledgers remaining until the scheduled unlock ledger (`0` when reached + /// or when there is no active schedule). + pub fn ledgers_until_release(env: Env, invoice_id: u64) -> u32 { + get_schedule(&env, invoice_id) + .filter(|s| s.status == ScheduleStatus::Scheduled) + .map_or(0, |s| s.unlock_ledger.saturating_sub(env.ledger().sequence())) + } + /// Return `true` if the payer is blacklisted with a finalised and upheld entry. pub fn is_payer_blacklisted(env: Env, payer: Address) -> bool { env.storage() diff --git a/contracts/invoice-escrow/src/test.rs b/contracts/invoice-escrow/src/test.rs index 33fdb95..30d969c 100644 --- a/contracts/invoice-escrow/src/test.rs +++ b/contracts/invoice-escrow/src/test.rs @@ -1496,3 +1496,274 @@ mod oracle_tests { ); } } + +// --------------------------------------------------------------------------- +// Scheduled release (#916) +// --------------------------------------------------------------------------- + +mod schedule_tests { + use super::*; + use crate::types::ScheduleStatus; + use soroban_sdk::testutils::storage::Persistent as _; + + struct Ctx { + env: Env, + contract_id: Address, + payer: Address, + id: u64, + } + + impl Ctx { + fn client(&self) -> InvoiceEscrowContractClient<'_> { + InvoiceEscrowContractClient::new(&self.env, &self.contract_id) + } + + fn advance(&self, secs: u64, ledgers: u32) { + self.env.ledger().with_mut(|li| { + li.timestamp += secs; + li.sequence_number += ledgers; + }); + } + } + + const NOW: u64 = 1_000_000; + + fn setup_invoice() -> Ctx { + let (env, contract_id) = setup(); + env.ledger().with_mut(|li| { + li.timestamp = NOW; + li.sequence_number = 100; + }); + let client = InvoiceEscrowContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let token_admin = Address::generate(&env); + let token = create_token(&env, &token_admin); + mint(&env, &token, &token_admin, &payer, 10_000); + + client.initialize(&admin); + let id = client.create_invoice(&creator, &token, &1_000, &(NOW + 100_000)); + Ctx { env, contract_id, payer, id } + } + + #[test] + fn test_schedule_release_records_schedule() { + let ctx = setup_invoice(); + let client = ctx.client(); + let schedule = client.schedule_release(&ctx.id, &(NOW + 3_600), &720); + + assert_eq!(schedule.release_at, NOW + 3_600); + assert_eq!(schedule.unlock_ledger, 820); + assert_eq!(schedule.scheduled_at, NOW); + assert_eq!(schedule.scheduled_ledger, 100); + assert_eq!(schedule.status, ScheduleStatus::Scheduled); + assert_eq!(client.get_release_schedule(&ctx.id), Some(schedule)); + } + + #[test] + fn test_schedule_release_invalid_inputs() { + let ctx = setup_invoice(); + let client = ctx.client(); + assert_eq!( + client.try_schedule_release(&ctx.id, &NOW, &0), + Err(Ok(Error::InvalidSchedule)) + ); + assert_eq!( + client.try_schedule_release(&ctx.id, &(NOW - 1), &0), + Err(Ok(Error::InvalidSchedule)) + ); + assert_eq!( + client.try_schedule_release(&ctx.id, &(NOW + 1), &6_312_001), + Err(Ok(Error::InvalidSchedule)) + ); + assert_eq!( + client.try_schedule_release(&99, &(NOW + 1), &0), + Err(Ok(Error::InvoiceNotFound)) + ); + } + + #[test] + fn test_schedule_blocks_auto_release_until_due() { + let ctx = setup_invoice(); + let client = ctx.client(); + client.schedule_release(&ctx.id, &(NOW + 3_600), &10); + + client.deposit(&ctx.payer, &ctx.id, &1_000); + assert_eq!(client.get_invoice(&ctx.id).status, EscrowStatus::Active); + assert_eq!(client.try_release(&ctx.id), Err(Ok(Error::ReleaseNotYetDue))); + assert!(!client.is_release_due(&ctx.id)); + + ctx.advance(3_600, 10); + assert!(client.is_release_due(&ctx.id)); + client.release(&ctx.id); + assert_eq!(client.get_invoice(&ctx.id).status, EscrowStatus::Released); + assert_eq!( + client.get_release_schedule(&ctx.id).unwrap().status, + ScheduleStatus::Executed + ); + } + + #[test] + fn test_time_reached_but_ledgers_not_elapsed() { + let ctx = setup_invoice(); + let client = ctx.client(); + client.schedule_release(&ctx.id, &(NOW + 60), &100); + client.deposit(&ctx.payer, &ctx.id, &1_000); + + // Timestamp jumps ahead faster than ledgers close. + ctx.advance(10_000, 99); + assert_eq!(client.try_release(&ctx.id), Err(Ok(Error::ScheduleLedgerNotReached))); + assert_eq!(client.ledgers_until_release(&ctx.id), 1); + assert_eq!(client.seconds_until_release(&ctx.id), 0); + + ctx.advance(5, 1); + client.release(&ctx.id); + assert_eq!(client.get_invoice(&ctx.id).status, EscrowStatus::Released); + } + + #[test] + fn test_ledgers_elapsed_but_time_not_reached() { + let ctx = setup_invoice(); + let client = ctx.client(); + client.schedule_release(&ctx.id, &(NOW + 3_600), &10); + client.deposit(&ctx.payer, &ctx.id, &1_000); + + ctx.advance(3_599, 1_000); + assert_eq!(client.try_release(&ctx.id), Err(Ok(Error::ReleaseNotYetDue))); + assert_eq!(client.seconds_until_release(&ctx.id), 1); + assert_eq!(client.ledgers_until_release(&ctx.id), 0); + } + + #[test] + fn test_release_exactly_at_boundaries() { + let ctx = setup_invoice(); + let client = ctx.client(); + client.schedule_release(&ctx.id, &(NOW + 500), &50); + client.deposit(&ctx.payer, &ctx.id, &1_000); + + ctx.advance(500, 50); + assert_eq!(client.seconds_until_release(&ctx.id), 0); + assert_eq!(client.ledgers_until_release(&ctx.id), 0); + client.release(&ctx.id); + assert_eq!(client.get_invoice(&ctx.id).status, EscrowStatus::Released); + } + + #[test] + fn test_countdown_views() { + let ctx = setup_invoice(); + let client = ctx.client(); + client.schedule_release(&ctx.id, &(NOW + 1_000), &200); + + assert_eq!(client.seconds_until_release(&ctx.id), 1_000); + assert_eq!(client.ledgers_until_release(&ctx.id), 200); + ctx.advance(250, 50); + assert_eq!(client.seconds_until_release(&ctx.id), 750); + assert_eq!(client.ledgers_until_release(&ctx.id), 150); + } + + #[test] + fn test_due_schedule_allows_auto_release_on_deposit() { + let ctx = setup_invoice(); + let client = ctx.client(); + client.schedule_release(&ctx.id, &(NOW + 10), &1); + ctx.advance(10, 1); + + client.deposit(&ctx.payer, &ctx.id, &1_000); + assert_eq!(client.get_invoice(&ctx.id).status, EscrowStatus::Released); + assert_eq!( + client.get_release_schedule(&ctx.id).unwrap().status, + ScheduleStatus::Executed + ); + } + + #[test] + fn test_reschedule_replaces_schedule() { + let ctx = setup_invoice(); + let client = ctx.client(); + client.schedule_release(&ctx.id, &(NOW + 10_000), &1_000); + client.schedule_release(&ctx.id, &(NOW + 10), &0); + client.deposit(&ctx.payer, &ctx.id, &1_000); + + ctx.advance(10, 0); + client.release(&ctx.id); + assert_eq!(client.get_invoice(&ctx.id).status, EscrowStatus::Released); + } + + #[test] + fn test_cancel_schedule_unblocks_release() { + let ctx = setup_invoice(); + let client = ctx.client(); + client.schedule_release(&ctx.id, &(NOW + 10_000), &1_000); + client.deposit(&ctx.payer, &ctx.id, &1_000); + assert_eq!(client.try_release(&ctx.id), Err(Ok(Error::ReleaseNotYetDue))); + + client.cancel_release_schedule(&ctx.id); + assert_eq!( + client.get_release_schedule(&ctx.id).unwrap().status, + ScheduleStatus::Cancelled + ); + assert!(client.is_release_due(&ctx.id)); + assert_eq!(client.seconds_until_release(&ctx.id), 0); + + client.release(&ctx.id); + assert_eq!(client.get_invoice(&ctx.id).status, EscrowStatus::Released); + // Cancelled schedule stays cancelled rather than becoming executed. + assert_eq!( + client.get_release_schedule(&ctx.id).unwrap().status, + ScheduleStatus::Cancelled + ); + } + + #[test] + fn test_cancel_without_schedule_fails() { + let ctx = setup_invoice(); + let client = ctx.client(); + assert_eq!( + client.try_cancel_release_schedule(&ctx.id), + Err(Ok(Error::ScheduleNotFound)) + ); + client.schedule_release(&ctx.id, &(NOW + 10), &0); + client.cancel_release_schedule(&ctx.id); + assert_eq!( + client.try_cancel_release_schedule(&ctx.id), + Err(Ok(Error::ScheduleNotFound)) + ); + } + + #[test] + fn test_schedule_blocked_after_release() { + let ctx = setup_invoice(); + let client = ctx.client(); + client.deposit(&ctx.payer, &ctx.id, &1_000); + assert_eq!( + client.try_schedule_release(&ctx.id, &(NOW + 10), &0), + Err(Ok(Error::InvalidStatus)) + ); + } + + #[test] + fn test_schedule_persists_until_due() { + let ctx = setup_invoice(); + let client = ctx.client(); + client.schedule_release(&ctx.id, &(NOW + 10), &100_000); + + let ttl = ctx.env.as_contract(&ctx.contract_id, || { + ctx.env + .storage() + .persistent() + .get_ttl(&(symbol_short!("schedule"), ctx.id)) + }); + assert!(ttl >= 100_000, "schedule TTL {} shorter than delay", ttl); + } + + #[test] + fn test_no_schedule_views() { + let ctx = setup_invoice(); + let client = ctx.client(); + assert_eq!(client.get_release_schedule(&ctx.id), None); + assert!(client.is_release_due(&ctx.id)); + assert_eq!(client.seconds_until_release(&ctx.id), 0); + assert_eq!(client.ledgers_until_release(&ctx.id), 0); + } +} diff --git a/contracts/invoice-escrow/src/types.rs b/contracts/invoice-escrow/src/types.rs index 99a5a34..b539b0d 100644 --- a/contracts/invoice-escrow/src/types.rs +++ b/contracts/invoice-escrow/src/types.rs @@ -236,3 +236,40 @@ pub struct OracleReading { /// Ledger timestamp at which the evaluation happened. pub checked_at: u64, } + +// ────────────────────────────────────────────────────────────────────── +// Scheduled release types +// ────────────────────────────────────────────────────────────────────── + +/// Lifecycle state of a release schedule. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ScheduleStatus { + /// Waiting for `release_at` / `unlock_ledger`; release is blocked until both pass. + Scheduled, + /// The invoice was released under this schedule. + Executed, + /// The creator cancelled the schedule; it no longer gates release. + Cancelled, +} + +/// Delayed-release schedule attached to an escrow invoice. +/// +/// Release is blocked until **both** the ledger timestamp reaches +/// `release_at` and the ledger sequence reaches `unlock_ledger`. The ledger +/// bound gives a manipulation-resistant minimum delay that does not depend +/// on validator-reported close times. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ReleaseSchedule { + /// Earliest Unix timestamp at which release is allowed. + pub release_at: u64, + /// Earliest ledger sequence at which release is allowed. + pub unlock_ledger: u32, + /// Ledger timestamp when the schedule was created. + pub scheduled_at: u64, + /// Ledger sequence when the schedule was created. + pub scheduled_ledger: u32, + /// Current schedule status. + pub status: ScheduleStatus, +} From b1e3f5648fb14cfdcf92d176d67c736f972a2dda Mon Sep 17 00:00:00 2001 From: OZILSOLAR Date: Sun, 18 Oct 2026 01:44:37 +0000 Subject: [PATCH 4/4] feat(invoice-escrow): implement refund grace period (#917) Add a configurable grace period after the deadline before refunds can be processed: - admin default (set_default_refund_grace) snapshotted onto each new invoice so later changes never alter existing terms - creator per-invoice override (set_refund_grace) while Pending - admin overrides: extend_refund_grace (bounded by 30 days total) and waive_refund_grace for immediate refunds after the deadline - refund returns RefundGraceActive while the timer runs - NoGrace/NotStarted/Active/Elapsed/Waived status via get_refund_grace_status, plus refund_available_after and refund_grace_remaining countdown views - new error states, events and tests --- contracts/invoice-escrow/src/errors.rs | 6 + contracts/invoice-escrow/src/lib.rs | 306 ++++++++++++++++++++++- contracts/invoice-escrow/src/test.rs | 320 +++++++++++++++++++++++++ contracts/invoice-escrow/src/types.rs | 38 +++ 4 files changed, 665 insertions(+), 5 deletions(-) diff --git a/contracts/invoice-escrow/src/errors.rs b/contracts/invoice-escrow/src/errors.rs index 48336fe..5bcca23 100644 --- a/contracts/invoice-escrow/src/errors.rs +++ b/contracts/invoice-escrow/src/errors.rs @@ -85,4 +85,10 @@ pub enum Error { ScheduleLedgerNotReached = 36, /// No active release schedule exists for this invoice. ScheduleNotFound = 37, + /// The refund grace period is still running; refund is not permitted yet. + RefundGraceActive = 38, + /// Grace period exceeds `MAX_REFUND_GRACE_SECS`. + InvalidGracePeriod = 39, + /// The refund grace period has already elapsed or been waived. + RefundGraceElapsed = 40, } diff --git a/contracts/invoice-escrow/src/lib.rs b/contracts/invoice-escrow/src/lib.rs index 16cbd60..a27b434 100644 --- a/contracts/invoice-escrow/src/lib.rs +++ b/contracts/invoice-escrow/src/lib.rs @@ -9,6 +9,8 @@ //! - **Oracle condition guards**: optional price-feed condition, with a //! fallback oracle, that must hold before release //! - **Scheduled release**: optional time + ledger-count delay before release +//! - **Refund grace period**: configurable delay after the deadline before +//! refunds can be processed, with admin extend / waive overrides //! //! # Admin Transfer Flow //! @@ -35,8 +37,8 @@ use soroban_sdk::{ use oracle::PriceOracleClient; use types::{ ApprovalStatus, BlacklistEntry, CosignerApproval, EscrowInvoice, EscrowReleased, EscrowStatus, - OracleCondition, OracleReading, OracleSource, PriceComparator, PriceData, ReleaseSchedule, - ScheduleStatus, + OracleCondition, OracleReading, OracleSource, PriceComparator, PriceData, RefundGrace, + RefundGraceStatus, ReleaseSchedule, ScheduleStatus, }; // --------------------------------------------------------------------------- @@ -58,6 +60,11 @@ fn counter_key() -> Symbol { symbol_short!("counter") } +/// Instance storage: default refund grace period (seconds) applied to new invoices. +fn default_grace_key() -> Symbol { + symbol_short!("grace_def") +} + /// Persistent storage: per-invoice record keyed by `(escrow, id)`. fn invoice_key(id: u64) -> (Symbol, u64) { (symbol_short!("escrow"), id) @@ -93,6 +100,11 @@ fn schedule_key(id: u64) -> (Symbol, u64) { (symbol_short!("schedule"), id) } +/// Persistent storage: refund grace configuration for an invoice. +fn grace_key(id: u64) -> (Symbol, u64) { + (symbol_short!("grace"), id) +} + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -112,6 +124,9 @@ const MAX_SCHEDULE_LEDGERS: u32 = 6_312_000; /// ledger (~1 day), so the record is still live when it becomes due. const SCHEDULE_TTL_BUFFER_LEDGERS: u32 = 17_280; +/// Maximum refund grace period, including admin extensions (30 days). +const MAX_REFUND_GRACE_SECS: u64 = 30 * 24 * 60 * 60; + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- @@ -305,6 +320,43 @@ fn cast_vote(env: &Env, signer: Address, invoice_id: u64, approve: bool) -> Resu Ok(approval.status) } +fn get_grace(env: &Env, id: u64) -> Option { + env.storage().persistent().get(&grace_key(id)) +} + +fn save_grace(env: &Env, id: u64, grace: &RefundGrace) { + env.storage().persistent().set(&grace_key(id), grace); +} + +/// Timestamp after which refunds are allowed: the deadline plus any +/// un-waived grace period. +fn refund_opens_at(invoice: &EscrowInvoice, grace: &Option) -> u64 { + match grace { + Some(g) if !g.waived => invoice + .deadline + .saturating_add(g.grace_secs) + .saturating_add(g.extension_secs), + _ => invoice.deadline, + } +} + +fn grace_status(env: &Env, invoice: &EscrowInvoice, grace: &Option) -> RefundGraceStatus { + let g = match grace { + Some(g) if g.grace_secs > 0 || g.extension_secs > 0 || g.waived => g, + _ => return RefundGraceStatus::NoGrace, + }; + let now = env.ledger().timestamp(); + if g.waived { + RefundGraceStatus::Waived + } else if now <= invoice.deadline { + RefundGraceStatus::NotStarted + } else if now <= refund_opens_at(invoice, grace) { + RefundGraceStatus::Active + } else { + RefundGraceStatus::Elapsed + } +} + /// Mark the invoice released and transfer `total_amount` to the creator. fn execute_release(env: &Env, id: u64, invoice: &mut EscrowInvoice) -> (Address, i128) { let total = invoice.total_amount; @@ -469,6 +521,24 @@ fn emit_schedule_executed(env: &Env, id: u64) { ); } +/// Topics: `(escrow, grace_def)` — Data: `grace_secs` +fn emit_default_grace_set(env: &Env, grace_secs: u64) { + env.events().publish( + (symbol_short!("escrow"), symbol_short!("grace_def")), + grace_secs, + ); +} + +/// Topics: `(escrow, , id)` — Data: `(grace_secs, extension_secs, waived)` +/// +/// `action` is one of `grace_set`, `grace_ext` or `grace_wvd`. +fn emit_grace_changed(env: &Env, action: Symbol, id: u64, grace: &RefundGrace) { + env.events().publish( + (symbol_short!("escrow"), action, id), + (grace.grace_secs, grace.extension_secs, grace.waived), + ); +} + /// Topics: `(blacklist, bl_add)` — Data: `(payer, reason_hash, banned_at)` fn emit_payer_blacklisted(env: &Env, payer: &Address, reason_hash: &BytesN<32>, banned_at: u64) { env.events().publish( @@ -673,6 +743,26 @@ impl InvoiceEscrowContract { status: EscrowStatus::Pending, }; save_invoice(&env, id, &invoice); + + // Snapshot the current default grace period so later changes to the + // default never alter the terms of an existing invoice. + let default_grace: u64 = env + .storage() + .instance() + .get(&default_grace_key()) + .unwrap_or(0); + if default_grace > 0 { + save_grace( + &env, + id, + &RefundGrace { + grace_secs: default_grace, + extension_secs: 0, + waived: false, + }, + ); + } + emit_invoice_created(&env, id, &creator, total_amount, deadline); Ok(id) } @@ -805,8 +895,8 @@ impl InvoiceEscrowContract { /// Refund all depositors after the deadline passes without full funding. /// /// Iterates over `payers` and returns each payer's recorded deposit. - /// Callable by anyone once the deadline has passed and the invoice is not - /// already released. + /// Callable by anyone once the deadline — and any refund grace period + /// that follows it — has passed and the invoice is not already released. /// /// # Arguments /// * `invoice_id` — Target invoice. @@ -816,6 +906,8 @@ impl InvoiceEscrowContract { /// * [`Error::InvoiceNotFound`] — Unknown invoice ID. /// * [`Error::InvalidStatus`] — Invoice already released or refunded. /// * [`Error::DeadlineNotPassed`] — Deadline has not expired yet. + /// * [`Error::RefundGraceActive`] — Deadline passed but the grace period + /// is still running (and has not been waived). pub fn refund(env: Env, invoice_id: u64, payers: Vec
) -> Result<(), Error> { let mut invoice = get_invoice(&env, invoice_id)?; @@ -825,9 +917,13 @@ impl InvoiceEscrowContract { { return Err(Error::InvalidStatus); } - if env.ledger().timestamp() <= invoice.deadline { + let now = env.ledger().timestamp(); + if now <= invoice.deadline { return Err(Error::DeadlineNotPassed); } + if now <= refund_opens_at(&invoice, &get_grace(&env, invoice_id)) { + return Err(Error::RefundGraceActive); + } let token_client = token::Client::new(&env, &invoice.token); for payer in payers.iter() { @@ -1195,6 +1291,167 @@ impl InvoiceEscrowContract { Ok(()) } + // ----------------------------------------------------------------------- + // Refund grace period + // ----------------------------------------------------------------------- + + /// Set the default refund grace period applied to invoices created from + /// now on. Existing invoices keep the grace period they were created with. + /// + /// # Events + /// Emits `(escrow, grace_def)` with `grace_secs`. + /// + /// # Errors + /// * [`Error::NotInitialized`] — Contract not initialized. + /// * [`Error::InvalidGracePeriod`] — `grace_secs > MAX_REFUND_GRACE_SECS`. + pub fn set_default_refund_grace(env: Env, grace_secs: u64) -> Result<(), Error> { + if !env.storage().instance().has(&admin_key()) { + return Err(Error::NotInitialized); + } + require_admin(&env); + if grace_secs > MAX_REFUND_GRACE_SECS { + return Err(Error::InvalidGracePeriod); + } + env.storage().instance().set(&default_grace_key(), &grace_secs); + emit_default_grace_set(&env, grace_secs); + Ok(()) + } + + /// Return the default refund grace period (seconds) for new invoices. + pub fn get_default_refund_grace(env: Env) -> u64 { + env.storage() + .instance() + .get(&default_grace_key()) + .unwrap_or(0) + } + + /// Set an invoice's own refund grace period, overriding the default it + /// was created with. + /// + /// Only the creator may do this, and only while the invoice is still + /// `Pending` (no deposits yet), so payers always fund against known + /// refund terms. Any admin extension is reset. + /// + /// # Events + /// Emits `(escrow, grace_set, id)`. + /// + /// # Errors + /// * [`Error::InvoiceNotFound`] — Unknown invoice ID. + /// * [`Error::InvalidStatus`] — Invoice already has deposits or is closed. + /// * [`Error::InvalidGracePeriod`] — `grace_secs > MAX_REFUND_GRACE_SECS`. + pub fn set_refund_grace(env: Env, invoice_id: u64, grace_secs: u64) -> Result<(), Error> { + let invoice = get_invoice(&env, invoice_id)?; + invoice.creator.require_auth(); + if invoice.status != EscrowStatus::Pending { + return Err(Error::InvalidStatus); + } + if grace_secs > MAX_REFUND_GRACE_SECS { + return Err(Error::InvalidGracePeriod); + } + + let grace = RefundGrace { + grace_secs, + extension_secs: 0, + waived: false, + }; + save_grace(&env, invoice_id, &grace); + emit_grace_changed(&env, symbol_short!("grace_set"), invoice_id, &grace); + Ok(()) + } + + /// Admin override: extend an invoice's refund grace period. + /// + /// Allowed while the invoice is open and before the grace period has + /// elapsed or been waived. The total grace (base + extensions) may not + /// exceed `MAX_REFUND_GRACE_SECS`. + /// + /// # Events + /// Emits `(escrow, grace_ext, id)`. + /// + /// # Errors + /// * [`Error::NotInitialized`] — Contract not initialized. + /// * [`Error::InvoiceNotFound`] — Unknown invoice ID. + /// * [`Error::InvalidStatus`] — Invoice is no longer open. + /// * [`Error::InvalidGracePeriod`] — Zero extension or total too long. + /// * [`Error::RefundGraceElapsed`] — Grace already elapsed or waived. + pub fn extend_refund_grace(env: Env, invoice_id: u64, extra_secs: u64) -> Result<(), Error> { + if !env.storage().instance().has(&admin_key()) { + return Err(Error::NotInitialized); + } + require_admin(&env); + let invoice = get_invoice(&env, invoice_id)?; + if !is_open(&invoice) { + return Err(Error::InvalidStatus); + } + + let current = get_grace(&env, invoice_id); + match grace_status(&env, &invoice, ¤t) { + RefundGraceStatus::Elapsed | RefundGraceStatus::Waived => { + return Err(Error::RefundGraceElapsed) + } + RefundGraceStatus::NoGrace if env.ledger().timestamp() > invoice.deadline => { + // No grace and deadline passed: refunds are already open. + return Err(Error::RefundGraceElapsed); + } + _ => {} + } + + let mut grace = current.unwrap_or(RefundGrace { + grace_secs: 0, + extension_secs: 0, + waived: false, + }); + let total = grace + .grace_secs + .checked_add(grace.extension_secs) + .and_then(|t| t.checked_add(extra_secs)) + .ok_or(Error::InvalidGracePeriod)?; + if extra_secs == 0 || total > MAX_REFUND_GRACE_SECS { + return Err(Error::InvalidGracePeriod); + } + + grace.extension_secs += extra_secs; + save_grace(&env, invoice_id, &grace); + emit_grace_changed(&env, symbol_short!("grace_ext"), invoice_id, &grace); + Ok(()) + } + + /// Admin override: waive an invoice's refund grace period so refunds can + /// be processed as soon as the deadline has passed. + /// + /// # Events + /// Emits `(escrow, grace_wvd, id)`. + /// + /// # Errors + /// * [`Error::NotInitialized`] — Contract not initialized. + /// * [`Error::InvoiceNotFound`] — Unknown invoice ID. + /// * [`Error::InvalidStatus`] — Invoice is no longer open. + /// * [`Error::RefundGraceElapsed`] — No grace to waive (none configured, + /// already elapsed, or already waived). + pub fn waive_refund_grace(env: Env, invoice_id: u64) -> Result<(), Error> { + if !env.storage().instance().has(&admin_key()) { + return Err(Error::NotInitialized); + } + require_admin(&env); + let invoice = get_invoice(&env, invoice_id)?; + if !is_open(&invoice) { + return Err(Error::InvalidStatus); + } + + let current = get_grace(&env, invoice_id); + match grace_status(&env, &invoice, ¤t) { + RefundGraceStatus::NotStarted | RefundGraceStatus::Active => {} + _ => return Err(Error::RefundGraceElapsed), + } + + // `grace_status` only reports NotStarted/Active when a record exists. + let mut grace = current.unwrap(); + grace.waived = true; + save_grace(&env, invoice_id, &grace); + emit_grace_changed(&env, symbol_short!("grace_wvd"), invoice_id, &grace); + Ok(()) + } + // ----------------------------------------------------------------------- // Payer blacklist // ----------------------------------------------------------------------- @@ -1513,6 +1770,45 @@ impl InvoiceEscrowContract { .map_or(0, |s| s.unlock_ledger.saturating_sub(env.ledger().sequence())) } + /// Return the refund grace configuration for an invoice, if any. + pub fn get_refund_grace(env: Env, invoice_id: u64) -> Option { + get_grace(&env, invoice_id) + } + + /// Return where the invoice is in its refund grace timeline. + /// + /// # Errors + /// * [`Error::InvoiceNotFound`] — Unknown invoice ID. + pub fn get_refund_grace_status(env: Env, invoice_id: u64) -> Result { + let invoice = get_invoice(&env, invoice_id)?; + Ok(grace_status(&env, &invoice, &get_grace(&env, invoice_id))) + } + + /// Return the timestamp after which refunds may be processed (deadline + /// plus any un-waived grace period). + /// + /// # Errors + /// * [`Error::InvoiceNotFound`] — Unknown invoice ID. + pub fn refund_available_after(env: Env, invoice_id: u64) -> Result { + let invoice = get_invoice(&env, invoice_id)?; + Ok(refund_opens_at(&invoice, &get_grace(&env, invoice_id))) + } + + /// Seconds left on the refund grace timer. + /// + /// Before the deadline this is the full grace window; after the deadline + /// it counts down to `0`. Always `0` when there is no grace period or it + /// has been waived. + /// + /// # Errors + /// * [`Error::InvoiceNotFound`] — Unknown invoice ID. + pub fn refund_grace_remaining(env: Env, invoice_id: u64) -> Result { + let invoice = get_invoice(&env, invoice_id)?; + let opens_at = refund_opens_at(&invoice, &get_grace(&env, invoice_id)); + let from = env.ledger().timestamp().max(invoice.deadline); + Ok(opens_at.saturating_sub(from)) + } + /// Return `true` if the payer is blacklisted with a finalised and upheld entry. pub fn is_payer_blacklisted(env: Env, payer: Address) -> bool { env.storage() diff --git a/contracts/invoice-escrow/src/test.rs b/contracts/invoice-escrow/src/test.rs index 30d969c..1feee8e 100644 --- a/contracts/invoice-escrow/src/test.rs +++ b/contracts/invoice-escrow/src/test.rs @@ -1767,3 +1767,323 @@ mod schedule_tests { assert_eq!(client.ledgers_until_release(&ctx.id), 0); } } + +// --------------------------------------------------------------------------- +// Refund grace period (#917) +// --------------------------------------------------------------------------- + +mod refund_grace_tests { + use super::*; + use crate::types::RefundGraceStatus; + use soroban_sdk::vec; + + struct Ctx { + env: Env, + contract_id: Address, + payer: Address, + token: Address, + creator: Address, + } + + impl Ctx { + fn client(&self) -> InvoiceEscrowContractClient<'_> { + InvoiceEscrowContractClient::new(&self.env, &self.contract_id) + } + + fn set_time(&self, ts: u64) { + self.env.ledger().with_mut(|li| li.timestamp = ts); + } + + fn create(&self) -> u64 { + self.client() + .create_invoice(&self.creator, &self.token, &1_000, &DEADLINE) + } + } + + const NOW: u64 = 1_000_000; + const DEADLINE: u64 = NOW + 1_000; + const GRACE: u64 = 3_600; + + fn setup_ctx() -> Ctx { + let (env, contract_id) = setup(); + env.ledger().with_mut(|li| li.timestamp = NOW); + let client = InvoiceEscrowContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let token_admin = Address::generate(&env); + let token = create_token(&env, &token_admin); + mint(&env, &token, &token_admin, &payer, 10_000); + client.initialize(&admin); + Ctx { env, contract_id, payer, token, creator } + } + + #[test] + fn test_default_grace_config() { + let ctx = setup_ctx(); + let client = ctx.client(); + assert_eq!(client.get_default_refund_grace(), 0); + client.set_default_refund_grace(&GRACE); + assert_eq!(client.get_default_refund_grace(), GRACE); + + assert_eq!( + client.try_set_default_refund_grace(&(30 * 24 * 60 * 60 + 1)), + Err(Ok(Error::InvalidGracePeriod)) + ); + } + + #[test] + fn test_default_grace_snapshotted_at_creation() { + let ctx = setup_ctx(); + let client = ctx.client(); + let without = ctx.create(); + client.set_default_refund_grace(&GRACE); + let with = ctx.create(); + client.set_default_refund_grace(&10); + + assert_eq!(client.get_refund_grace(&without), None); + assert_eq!(client.get_refund_grace(&with).unwrap().grace_secs, GRACE); + assert_eq!(client.refund_available_after(&without), DEADLINE); + assert_eq!(client.refund_available_after(&with), DEADLINE + GRACE); + } + + #[test] + fn test_no_grace_refund_at_deadline_unchanged() { + let ctx = setup_ctx(); + let client = ctx.client(); + let id = ctx.create(); + client.deposit(&ctx.payer, &id, &400); + assert_eq!(client.get_refund_grace_status(&id), RefundGraceStatus::NoGrace); + + ctx.set_time(DEADLINE + 1); + client.refund(&id, &vec![&ctx.env, ctx.payer.clone()]); + assert_eq!(client.get_invoice(&id).status, EscrowStatus::Refunded); + } + + #[test] + fn test_grace_blocks_refund_then_allows_after_elapsed() { + let ctx = setup_ctx(); + let client = ctx.client(); + client.set_default_refund_grace(&GRACE); + let id = ctx.create(); + client.deposit(&ctx.payer, &id, &400); + let payers = vec![&ctx.env, ctx.payer.clone()]; + + assert_eq!(client.get_refund_grace_status(&id), RefundGraceStatus::NotStarted); + assert_eq!(client.try_refund(&id, &payers), Err(Ok(Error::DeadlineNotPassed))); + + ctx.set_time(DEADLINE + 1); + assert_eq!(client.get_refund_grace_status(&id), RefundGraceStatus::Active); + assert_eq!(client.try_refund(&id, &payers), Err(Ok(Error::RefundGraceActive))); + + // Boundary: refunds open strictly after deadline + grace. + ctx.set_time(DEADLINE + GRACE); + assert_eq!(client.try_refund(&id, &payers), Err(Ok(Error::RefundGraceActive))); + + ctx.set_time(DEADLINE + GRACE + 1); + assert_eq!(client.get_refund_grace_status(&id), RefundGraceStatus::Elapsed); + client.refund(&id, &payers); + assert_eq!(client.get_invoice(&id).status, EscrowStatus::Refunded); + let tok = token::Client::new(&ctx.env, &ctx.token); + assert_eq!(tok.balance(&ctx.payer), 10_000); + } + + #[test] + fn test_timer_counts_down() { + let ctx = setup_ctx(); + let client = ctx.client(); + client.set_default_refund_grace(&GRACE); + let id = ctx.create(); + + // Before the deadline the full window is reported. + assert_eq!(client.refund_grace_remaining(&id), GRACE); + ctx.set_time(DEADLINE); + assert_eq!(client.refund_grace_remaining(&id), GRACE); + ctx.set_time(DEADLINE + 600); + assert_eq!(client.refund_grace_remaining(&id), GRACE - 600); + ctx.set_time(DEADLINE + GRACE); + assert_eq!(client.refund_grace_remaining(&id), 0); + ctx.set_time(DEADLINE + GRACE + 100); + assert_eq!(client.refund_grace_remaining(&id), 0); + } + + #[test] + fn test_creator_sets_invoice_grace_before_deposits() { + let ctx = setup_ctx(); + let client = ctx.client(); + client.set_default_refund_grace(&GRACE); + let id = ctx.create(); + + client.set_refund_grace(&id, &60); + assert_eq!(client.refund_available_after(&id), DEADLINE + 60); + + client.set_refund_grace(&id, &0); + assert_eq!(client.get_refund_grace_status(&id), RefundGraceStatus::NoGrace); + + assert_eq!( + client.try_set_refund_grace(&id, &(30 * 24 * 60 * 60 + 1)), + Err(Ok(Error::InvalidGracePeriod)) + ); + + client.deposit(&ctx.payer, &id, &100); + assert_eq!(client.try_set_refund_grace(&id, &60), Err(Ok(Error::InvalidStatus))); + } + + #[test] + fn test_admin_extend_grace() { + let ctx = setup_ctx(); + let client = ctx.client(); + client.set_default_refund_grace(&GRACE); + let id = ctx.create(); + client.deposit(&ctx.payer, &id, &400); + let payers = vec![&ctx.env, ctx.payer.clone()]; + + ctx.set_time(DEADLINE + 10); + client.extend_refund_grace(&id, &1_000); + assert_eq!(client.get_refund_grace(&id).unwrap().extension_secs, 1_000); + assert_eq!(client.refund_available_after(&id), DEADLINE + GRACE + 1_000); + + ctx.set_time(DEADLINE + GRACE + 1); + assert_eq!(client.try_refund(&id, &payers), Err(Ok(Error::RefundGraceActive))); + ctx.set_time(DEADLINE + GRACE + 1_001); + client.refund(&id, &payers); + assert_eq!(client.get_invoice(&id).status, EscrowStatus::Refunded); + } + + #[test] + fn test_extend_grace_limits() { + let ctx = setup_ctx(); + let client = ctx.client(); + client.set_default_refund_grace(&GRACE); + let id = ctx.create(); + + assert_eq!(client.try_extend_refund_grace(&id, &0), Err(Ok(Error::InvalidGracePeriod))); + assert_eq!( + client.try_extend_refund_grace(&id, &(30 * 24 * 60 * 60)), + Err(Ok(Error::InvalidGracePeriod)) + ); + assert_eq!( + client.try_extend_refund_grace(&id, &u64::MAX), + Err(Ok(Error::InvalidGracePeriod)) + ); + + ctx.set_time(DEADLINE + GRACE + 1); + assert_eq!( + client.try_extend_refund_grace(&id, &10), + Err(Ok(Error::RefundGraceElapsed)) + ); + } + + #[test] + fn test_extend_creates_grace_when_none_before_deadline() { + let ctx = setup_ctx(); + let client = ctx.client(); + let id = ctx.create(); + + client.extend_refund_grace(&id, &500); + assert_eq!(client.refund_available_after(&id), DEADLINE + 500); + + let other = ctx.create(); + ctx.set_time(DEADLINE + 1); + assert_eq!( + client.try_extend_refund_grace(&other, &500), + Err(Ok(Error::RefundGraceElapsed)) + ); + } + + #[test] + fn test_admin_waive_allows_immediate_refund() { + let ctx = setup_ctx(); + let client = ctx.client(); + client.set_default_refund_grace(&GRACE); + let id = ctx.create(); + client.deposit(&ctx.payer, &id, &400); + let payers = vec![&ctx.env, ctx.payer.clone()]; + + ctx.set_time(DEADLINE + 1); + assert_eq!(client.try_refund(&id, &payers), Err(Ok(Error::RefundGraceActive))); + + client.waive_refund_grace(&id); + assert_eq!(client.get_refund_grace_status(&id), RefundGraceStatus::Waived); + assert_eq!(client.refund_grace_remaining(&id), 0); + assert_eq!(client.refund_available_after(&id), DEADLINE); + + client.refund(&id, &payers); + assert_eq!(client.get_invoice(&id).status, EscrowStatus::Refunded); + } + + #[test] + fn test_waive_before_deadline_still_requires_deadline() { + let ctx = setup_ctx(); + let client = ctx.client(); + client.set_default_refund_grace(&GRACE); + let id = ctx.create(); + client.deposit(&ctx.payer, &id, &400); + + client.waive_refund_grace(&id); + assert_eq!( + client.try_refund(&id, &vec![&ctx.env, ctx.payer.clone()]), + Err(Ok(Error::DeadlineNotPassed)) + ); + } + + #[test] + fn test_waive_rejected_when_nothing_to_waive() { + let ctx = setup_ctx(); + let client = ctx.client(); + let no_grace = ctx.create(); + assert_eq!( + client.try_waive_refund_grace(&no_grace), + Err(Ok(Error::RefundGraceElapsed)) + ); + + client.set_default_refund_grace(&GRACE); + let id = ctx.create(); + client.waive_refund_grace(&id); + assert_eq!(client.try_waive_refund_grace(&id), Err(Ok(Error::RefundGraceElapsed))); + assert_eq!( + client.try_extend_refund_grace(&id, &10), + Err(Ok(Error::RefundGraceElapsed)) + ); + } + + #[test] + fn test_overrides_blocked_on_closed_invoice() { + let ctx = setup_ctx(); + let client = ctx.client(); + client.set_default_refund_grace(&GRACE); + let id = ctx.create(); + client.deposit(&ctx.payer, &id, &1_000); + assert_eq!(client.get_invoice(&id).status, EscrowStatus::Released); + + assert_eq!(client.try_extend_refund_grace(&id, &10), Err(Ok(Error::InvalidStatus))); + assert_eq!(client.try_waive_refund_grace(&id), Err(Ok(Error::InvalidStatus))); + } + + #[test] + fn test_release_still_possible_during_grace() { + let ctx = setup_ctx(); + let client = ctx.client(); + client.set_default_refund_grace(&GRACE); + let id = ctx.create(); + + // Funding completes but a schedule delays release past the deadline. + client.schedule_release(&id, &(DEADLINE + 100), &0); + client.deposit(&ctx.payer, &id, &1_000); + assert_eq!(client.get_invoice(&id).status, EscrowStatus::Active); + + ctx.set_time(DEADLINE + 100); + assert_eq!(client.get_refund_grace_status(&id), RefundGraceStatus::Active); + client.release(&id); + assert_eq!(client.get_invoice(&id).status, EscrowStatus::Released); + } + + #[test] + fn test_grace_views_unknown_invoice() { + let ctx = setup_ctx(); + let client = ctx.client(); + assert_eq!(client.try_get_refund_grace_status(&99), Err(Ok(Error::InvoiceNotFound))); + assert_eq!(client.try_refund_grace_remaining(&99), Err(Ok(Error::InvoiceNotFound))); + assert_eq!(client.try_refund_available_after(&99), Err(Ok(Error::InvoiceNotFound))); + } +} diff --git a/contracts/invoice-escrow/src/types.rs b/contracts/invoice-escrow/src/types.rs index b539b0d..fee7d17 100644 --- a/contracts/invoice-escrow/src/types.rs +++ b/contracts/invoice-escrow/src/types.rs @@ -273,3 +273,41 @@ pub struct ReleaseSchedule { /// Current schedule status. pub status: ScheduleStatus, } + +// ────────────────────────────────────────────────────────────────────── +// Refund grace period types +// ────────────────────────────────────────────────────────────────────── + +/// Per-invoice refund grace configuration. +/// +/// After an invoice's deadline passes, refunds stay blocked for +/// `grace_secs + extension_secs` more seconds, unless the admin waives the +/// grace period. This gives creators, co-signers and oracles time to clear +/// release gates before payers are refunded. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RefundGrace { + /// Base grace period in seconds (snapshotted from the default or set by the creator). + pub grace_secs: u64, + /// Additional seconds granted by admin extensions. + pub extension_secs: u64, + /// `true` once the admin waives the grace period; refunds are then + /// allowed as soon as the deadline passes. + pub waived: bool, +} + +/// Where an invoice is in its refund grace timeline. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum RefundGraceStatus { + /// No grace period applies; refunds open at the deadline. + NoGrace, + /// Deadline not yet reached; the grace timer has not started. + NotStarted, + /// Deadline passed and the grace timer is counting down. + Active, + /// Grace period is over; refunds can be processed. + Elapsed, + /// Admin waived the grace period; refunds can be processed after the deadline. + Waived, +}