Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 59 additions & 4 deletions contracts/split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -1840,6 +1841,7 @@ impl SplitContract {
options.priorities,
options.require_kyc,
options.scheduled_release_at,
options.fallback_action,
options.external_prerequisite,
)
}
Expand Down Expand Up @@ -1891,6 +1893,7 @@ impl SplitContract {
priorities: Vec<u32>,
require_kyc: bool,
scheduled_release_at: Option<u64>,
fallback_action: Option<ResolveAction>,
external_prerequisite: Option<(Address, u64)>,
) -> u64 {
assert!(
Expand Down Expand Up @@ -2197,6 +2200,7 @@ impl SplitContract {
allowed_callers: None,
admin_frozen: false,
min_funding_amount: 0,
fallback_action,
external_prerequisite,
};

Expand Down Expand Up @@ -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);
Expand All @@ -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");
Expand Down Expand Up @@ -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<Address, i128> = 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.
}

// -----------------------------------------------------------------------
Expand Down
209 changes: 209 additions & 0 deletions contracts/split/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -126,6 +127,7 @@ fn invoice_options(
priorities: Vec::new(env),
require_kyc: false,
scheduled_release_at: None,
fallback_action: None,
}
}

Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down
Loading