diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 2f11520..c908f53 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -481,6 +481,7 @@ fn load_invoice(env: &Env, id: u64) -> Invoice { penalty_tiers: Vec::new(env), allowed_callers: None, refund_grace_secs: None, + fallback_action: None, external_prerequisite: None, }); let ext2: InvoiceExt2 = env.storage().persistent() @@ -1840,6 +1841,7 @@ impl SplitContract { options.priorities, options.require_kyc, options.scheduled_release_at, + options.fallback_action, options.external_prerequisite, ) } @@ -1891,6 +1893,7 @@ impl SplitContract { priorities: Vec, require_kyc: bool, scheduled_release_at: Option, + fallback_action: Option, external_prerequisite: Option<(Address, u64)>, ) -> u64 { assert!( @@ -2197,6 +2200,7 @@ impl SplitContract { allowed_callers: None, admin_frozen: false, min_funding_amount: 0, + fallback_action, external_prerequisite, }; @@ -4797,8 +4801,11 @@ impl SplitContract { // ----------------------------------------------------------------------- /// Evaluate auto_resolve_rules in order against the current funding ratio. - /// Executes the first matching rule — Release calls _release(), Refund refunds payers. - /// Panics with "no matching resolution rule" if no rule matches. + /// Automatically resolves an invoice based on funding progress and configured auto-resolve rules. + /// Evaluates rules in order; executes the first matching rule's action (Release or Refund). + /// If no rule matches and a fallback_action is configured, executes it. + /// If no rule matches and no fallback_action is configured, it's a no-op (idempotent). + /// Panics only if invoice is not pending or is disputed. pub fn auto_resolve(env: Env, invoice_id: u64) { require_not_paused(&env); let mut invoice = load_invoice(&env, invoice_id); @@ -4808,7 +4815,6 @@ impl SplitContract { "invoice is not pending" ); assert!(!invoice.disputed, "invoice is disputed"); - assert!(!invoice.auto_resolve_rules.is_empty(), "no auto-resolve rules defined"); let total: i128 = invoice.amounts.iter().sum(); assert!(total > 0, "invoice total must be positive"); @@ -4869,7 +4875,56 @@ impl SplitContract { } } - panic!("no matching resolution rule"); + // No matching rule — check for fallback_action. + if let Some(action) = invoice.fallback_action { + match action { + ResolveAction::Release => { + let caller = env.current_contract_address(); + Self::_release(&env, invoice_id, &mut invoice, &caller); + } + ResolveAction::Refund => { + let token_client = token::Client::new( + &env, + &invoice.tokens.get(0).expect("no token"), + ); + let mut totals: Map = Map::new(&env); + for payment in invoice.payments.iter() { + let prev = totals.get(payment.payer.clone()).unwrap_or(0); + totals.set(payment.payer.clone(), prev + payment.amount); + } + let mut total_refunded_amount: i128 = 0; + for (payer, amount) in totals.iter() { + token_client.transfer( + &env.current_contract_address(), + &payer, + &amount, + ); + total_refunded_amount += amount; + events::payer_refunded(&env, invoice_id, &payer, amount); + } + invoice.status = InvoiceStatus::Refunded; + invoice.completion_time = Some(env.ledger().timestamp()); + save_invoice(&env, invoice_id, &invoice); + let actor = env.current_contract_address(); + append_audit_entry(&env, invoice_id, symbol_short!("auto_ref"), &actor); + events::invoice_refunded(&env, invoice_id); + maybe_record_refunded(&env, &invoice.creator); + notify_invoice(&env, invoice_id, symbol_short!("refund"), &invoice.notification_contract); + let total_refunded: i128 = env + .storage() + .persistent() + .get(&total_refunded_key()) + .unwrap_or(0i128); + env.storage().persistent().set( + &total_refunded_key(), + &total_refunded + .checked_add(total_refunded_amount) + .expect("total_refunded overflow"), + ); + } + } + } + // else: no-op, intentional and idempotent. } // ----------------------------------------------------------------------- diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 7d66374..a1a2ee5 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -76,6 +76,7 @@ fn default_options(env: &Env) -> InvoiceOptions { priorities: Vec::new(env), require_kyc: false, scheduled_release_at: None, + fallback_action: None, external_prerequisite: None, } } @@ -126,6 +127,7 @@ fn invoice_options( priorities: Vec::new(env), require_kyc: false, scheduled_release_at: None, + fallback_action: None, } } @@ -5040,6 +5042,13 @@ fn test_all_or_nothing_group_still_requires_all_funded() { c.release(&id1); // should panic } + +// --------------------------------------------------------------------------- +// Fallback action for auto_resolve tests +// --------------------------------------------------------------------------- + +#[test] +fn test_auto_resolve_no_rules_match_fallback_refunds() { // --------------------------------------------------------------------------- // Escrow migration // --------------------------------------------------------------------------- @@ -5050,6 +5059,91 @@ fn test_migrate_escrow_transfers_balance() { let c = client(&env, &contract_id); let tk = token_client(&env, &token_id); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &100); + env.ledger().set_timestamp(1_000); + + // Create invoice with 50% rule (Release) but only 40% funded + // and fallback_action = Refund + let mut rules = Vec::new(&env); + rules.push_back(types::ResolveRule { + min_funded_bps: 5000, // 50% + action: types::ResolveAction::Release, + }); + let mut opts = default_options(&env); + opts.auto_resolve_rules = rules; + opts.fallback_action = Some(types::ResolveAction::Refund); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + + // Pay 40 (40% of 100) + c.pay(&payer, &id, &40_i128, &0_u64, &false, &false); + + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Pending); + assert_eq!(tk.balance(&payer), 60); + + // Call auto_resolve; should execute fallback_action (Refund) + c.auto_resolve(&id); + + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Refunded); + assert_eq!(tk.balance(&payer), 100); +} + +#[test] +fn test_auto_resolve_no_rules_match_fallback_releases() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &100); + env.ledger().set_timestamp(1_000); + + // Create invoice with 80% rule (Release) but only 50% funded + // and fallback_action = Release + let mut rules = Vec::new(&env); + rules.push_back(types::ResolveRule { + min_funded_bps: 8000, // 80% + action: types::ResolveAction::Release, + }); + let mut opts = default_options(&env); + opts.auto_resolve_rules = rules; + opts.fallback_action = Some(types::ResolveAction::Release); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + + // Pay 50 (50% of 100) + c.pay(&payer, &id, &50_i128, &0_u64, &false, &false); + + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Pending); + assert_eq!(tk.balance(&payer), 50); + + // Call auto_resolve; should execute fallback_action (Release) + c.auto_resolve(&id); + + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); + assert_eq!(tk.balance(&recipient), 50); +} + +#[test] +fn test_auto_resolve_no_rules_match_no_fallback_is_noop() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); let admin = Address::generate(&env); let creator = Address::generate(&env); let payer = Address::generate(&env); @@ -5186,6 +5280,93 @@ fn test_creator_self_limit_enforced_in_create_invoice() { let payer = Address::generate(&env); let recipient = Address::generate(&env); + StellarAssetClient::new(&env, &token_id).mint(&payer, &100); + env.ledger().set_timestamp(1_000); + + // Create invoice with 80% rule (Release) but only 50% funded + // and NO fallback_action + let mut rules = Vec::new(&env); + rules.push_back(types::ResolveRule { + min_funded_bps: 8000, // 80% + action: types::ResolveAction::Release, + }); + let mut opts = default_options(&env); + opts.auto_resolve_rules = rules; + opts.fallback_action = None; + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + + // Pay 50 (50% of 100) + c.pay(&payer, &id, &50_i128, &0_u64, &false, &false); + + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Pending); + assert_eq!(tk.balance(&payer), 50); + assert_eq!(tk.balance(&recipient), 0); + + // Call auto_resolve; should be a no-op (no rule matches, no fallback) + c.auto_resolve(&id); + + // Invoice should still be Pending, payments unchanged + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Pending); + assert_eq!(tk.balance(&payer), 50); + assert_eq!(tk.balance(&recipient), 0); + + // Calling auto_resolve again should still be a no-op (idempotent) + c.auto_resolve(&id); + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Pending); +} + +#[test] +fn test_auto_resolve_rule_matches_ignores_fallback() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &100); + env.ledger().set_timestamp(1_000); + + // Create invoice with 50% rule (Release) and 50% funded + // and fallback_action = Refund (but should be ignored) + let mut rules = Vec::new(&env); + rules.push_back(types::ResolveRule { + min_funded_bps: 5000, // 50% + action: types::ResolveAction::Release, + }); + let mut opts = default_options(&env); + opts.auto_resolve_rules = rules; + opts.fallback_action = Some(types::ResolveAction::Refund); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + + // Pay exactly 50 (50% of 100) + c.pay(&payer, &id, &50_i128, &0_u64, &false, &false); + + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Pending); + + // Call auto_resolve; should execute the rule (Release), not fallback + c.auto_resolve(&id); + + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); + assert_eq!(tk.balance(&recipient), 50); +} + +#[test] +fn test_auto_resolve_idempotency_second_call_noop() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); StellarAssetClient::new(&env, &token_id).mint(&payer, &1_000_i128); env.ledger().set_timestamp(1_000); @@ -5440,6 +5621,34 @@ fn test_local_only_invoice_unaffected_by_external_prerequisite() { let payer = Address::generate(&env); let recipient = Address::generate(&env); + StellarAssetClient::new(&env, &token_id).mint(&payer, &100); + env.ledger().set_timestamp(1_000); + + // Create invoice with fallback_action = Release + let mut opts = default_options(&env); + opts.fallback_action = Some(types::ResolveAction::Release); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + + // Pay 30 (30% of 100) + c.pay(&payer, &id, &30_i128, &0_u64, &false, &false); + + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Pending); + + // First auto_resolve call executes fallback (Release) + c.auto_resolve(&id); + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); + assert_eq!(tk.balance(&recipient), 30); + + // Second auto_resolve call should be a no-op (invoice not Pending) + // and not panic about "invoice is not pending" + c.auto_resolve(&id); + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); + assert_eq!(tk.balance(&recipient), 30); // unchanged StellarAssetClient::new(&env, &token_id).mint(&payer, &500); env.ledger().set_timestamp(1_000); diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 87d85f8..8024e66 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -235,6 +235,8 @@ pub struct InvoiceOptions { pub scheduled_release_at: Option, /// KYC verification requirement. pub require_kyc: bool, + /// Fallback action to execute if no auto_resolve_rules match (Release, Refund, or None). + pub fallback_action: Option, /// Issue #242: External prerequisite - (contract_address, invoice_id) on different contract instance. pub external_prerequisite: Option<(Address, u64)>, } @@ -344,6 +346,7 @@ pub struct InvoiceExt { pub penalty_tiers: Vec, pub allowed_callers: Option>, pub refund_grace_secs: Option, + pub fallback_action: Option, /// Issue #242: External prerequisite - (contract_address, invoice_id) on different contract instance. pub external_prerequisite: Option<(Address, u64)>, } @@ -478,6 +481,7 @@ pub struct Invoice { pub min_funding_amount: i128, pub priorities: Vec, pub clone_depth: u32, + pub fallback_action: Option, /// Issue #196: invoice creation timestamp for spam deposit age calculation. pub creation_timestamp: u64, /// Issue #201: minimum payment increment - reject payments below this threshold. @@ -558,6 +562,7 @@ impl Invoice { penalty_tiers: self.penalty_tiers, allowed_callers: self.allowed_callers, refund_grace_secs: self.refund_grace_secs, + fallback_action: self.fallback_action, }, InvoiceExt2 { notification_contract: self.notification_contract, @@ -641,6 +646,7 @@ impl Invoice { penalty_tiers: ext.penalty_tiers, allowed_callers: ext.allowed_callers, refund_grace_secs: ext.refund_grace_secs, + fallback_action: ext.fallback_action, notification_contract: ext2.notification_contract, overflow_behavior: ext2.overflow_behavior, cross_chain_ref: ext2.cross_chain_ref, @@ -852,6 +858,22 @@ impl Invoice { auto_resume_at: None, payment_cooldown_secs: None, max_payments_per_window: None, + payment_window_secs: None, + scheduled_release_at: None, + refund_grace_secs: None, + penalty_tiers: Vec::new(env), + allowed_callers: None, + notification_contract: None, + overflow_behavior: OverflowBehavior::Reject, + cross_chain_ref: None, + priorities: Vec::new(env), + forward_to: None, + forward_invoice_id: None, + parent_invoice_id: None, + clone_depth: 0, + fallback_action: None, + } + } max_payments_per_window: None, payment_window_secs: None, scheduled_release_at: None, refund_grace_secs: None,