From 778a4fed629e9cc156cf47a41684c465b96ce286 Mon Sep 17 00:00:00 2001 From: StellarDataLab Bot Date: Fri, 26 Jun 2026 23:14:51 +0100 Subject: [PATCH] feat:_add_guarded_recipient_substitution --- contracts/split/src/events.rs | 11 ++ contracts/split/src/lib.rs | 99 +++++++++++++++++ contracts/split/src/test.rs | 196 ++++++++++++++++++++++++++++++++++ contracts/split/src/types.rs | 5 + 4 files changed, 311 insertions(+) diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 8e2fcce..07c5f46 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -242,3 +242,14 @@ pub fn partial_refund_issued(env: &Env, invoice_id: u64, creator: &Address, bps: (creator.clone(), bps, amount), ); } + + +/// Emitted when a recipient is substituted (Issue #230). +/// Topics: (split, sub_rec, invoice_id) +/// Data: (old_recipient, new_recipient) +pub fn recipient_updated(env: &Env, invoice_id: u64, old_recipient: &Address, new_recipient: &Address) { + env.events().publish( + (symbol_short!("split"), symbol_short!("sub_rec"), invoice_id), + (old_recipient.clone(), new_recipient.clone()), + ); +} diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 0178919..a43cfd6 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -474,6 +474,7 @@ fn load_invoice(env: &Env, id: u64) -> Invoice { min_payment: 0, min_funding_amount: 0, priorities: Vec::new(env), + substitute_recipient_approvals: Vec::new(env), }); // Load compact representation if available @@ -1227,6 +1228,7 @@ impl SplitContract { min_payment: 0, min_funding_amount: 0, priorities: Vec::new(&env), + substitute_recipient_approvals: Vec::new(&env), }) }); let audit_log: Vec = get_audit_log(&env, invoice_id); @@ -5119,6 +5121,101 @@ impl SplitContract { env.storage().persistent().set(&key, &ids); } + /// Issue #230: Substitute a recipient address (e.g., if original address was compromised). + /// With co-signers configured, requires a fresh round of required_signatures approvals. + /// Without co-signers, creator auth alone suffices. + /// Recipient's corresponding amounts/claimed/tokens entries carry over to the new address. + pub fn substitute_recipient( + env: Env, + caller: Address, + invoice_id: u64, + old_recipient: Address, + new_recipient: Address, + ) { + require_not_paused(&env); + caller.require_auth(); + + let mut invoice = load_invoice(&env, invoice_id); + + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); + assert!(!invoice.disputed, "invoice is disputed"); + assert!(invoice.creator == caller, "only creator can substitute recipient"); + + // Find the old recipient's index + let mut recipient_idx: Option = None; + for (idx, recipient) in invoice.recipients.iter().enumerate() { + if recipient == &old_recipient { + recipient_idx = Some(idx as u64); + break; + } + } + let idx = recipient_idx.expect("recipient not found") as usize; + + // If co-signers are configured, require a fresh round of approvals for this substitution + if !invoice.co_signers.is_empty() { + // Require fresh approvals from co-signers for this specific substitution + // Track approvals separately from release approvals + if invoice.substitute_recipient_approvals.len() < invoice.required_signatures as usize { + panic!("insufficient approvals for recipient substitution"); + } + // Clear the approval list after successful substitution + invoice.substitute_recipient_approvals.clear(); + } + + // Perform the substitution: update the recipient at idx + invoice.recipients.set(idx, new_recipient.clone()); + + save_invoice(&env, invoice_id, &invoice); + append_audit_entry(&env, invoice_id, symbol_short!("sub_rec"), &caller); + events::recipient_updated(&env, invoice_id, &old_recipient, &new_recipient); + + // Update recipient index: remove old_recipient, add new_recipient + let old_key = recipient_invoice_ids_key(&old_recipient); + if let Some(mut old_ids) = env.storage().persistent().get::<_, Vec>(&old_key) { + old_ids.retain(|id| id != &invoice_id); + if old_ids.is_empty() { + env.storage().persistent().remove(&old_key); + } else { + env.storage().persistent().set(&old_key, &old_ids); + } + } + + let new_key = recipient_invoice_ids_key(&new_recipient); + let mut new_ids: Vec = env + .storage() + .persistent() + .get(&new_key) + .unwrap_or_else(|| Vec::new(&env)); + new_ids.push_back(invoice_id); + env.storage().persistent().set(&new_key, &new_ids); + } + + /// Approve a pending recipient substitution. Only a configured co-signer may call this. + /// This approval is separate from release approvals. + pub fn approve_substitute_recipient(env: Env, invoice_id: u64, co_signer: Address) { + require_not_paused(&env); + co_signer.require_auth(); + + let mut invoice = load_invoice(&env, invoice_id); + + assert!( + invoice.co_signers.iter().any(|cs| cs == &co_signer), + "not a co-signer for this invoice" + ); + + // Check if this co-signer has already approved + if invoice.substitute_recipient_approvals.iter().any(|addr| addr == &co_signer) { + panic!("co-signer has already approved substitution"); + } + + invoice.substitute_recipient_approvals.push_back(co_signer.clone()); + save_invoice(&env, invoice_id, &invoice); + append_audit_entry(&env, invoice_id, symbol_short!("app_sub"), &co_signer); + } + // ----------------------------------------------------------------------- // Adjust split // ----------------------------------------------------------------------- @@ -5813,6 +5910,7 @@ impl SplitContract { admin_frozen: false, auction_on_expiry: false, auction_end: 0, bids: Vec::new(&env), min_payment: 0, min_funding_amount: 0, priorities: Vec::new(&env), + substitute_recipient_approvals: Vec::new(&env), }); // Copy to instance storage. @@ -5867,6 +5965,7 @@ impl SplitContract { admin_frozen: false, auction_on_expiry: false, auction_end: 0, bids: Vec::new(&env), min_payment: 0, min_funding_amount: 0, priorities: Vec::new(&env), + substitute_recipient_approvals: Vec::new(&env), }); env.storage().instance().set(&invoice_key(id), &core); diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index d0cef7f..e1cad32 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -5367,3 +5367,199 @@ fn test_non_disputed_invoice_unaffected_by_dispute_logic() { assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Refunded); assert_eq!(tk.balance(&payer), 100); } + + +#[test] +fn test_substitute_recipient_no_cosigners() { + 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 old_recipient = Address::generate(&env); + let new_recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &100); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &old_recipient, 100, &token_id, 2_000); + + // Pay partial amount + c.pay(&payer, &id, &50_i128, &0_u64, &false, &false); + + // Substitute recipient (no co-signers, creator auth alone) + c.substitute_recipient(&creator, &id, &old_recipient, &new_recipient); + + let invoice = c.get_invoice(&id); + assert_eq!(invoice.recipients.get(0), Some(new_recipient.clone())); + + // Release to new recipient + c.release(&creator, &id); + assert_eq!(tk.balance(&new_recipient), 50); + assert_eq!(tk.balance(&old_recipient), 0); +} + +#[test] +#[should_panic(expected = "recipient not found")] +fn test_substitute_recipient_not_found() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let old_recipient = Address::generate(&env); + let new_recipient = Address::generate(&env); + let not_a_recipient = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &old_recipient, 100, &token_id, 2_000); + + // Try to substitute a non-existent recipient + c.substitute_recipient(&creator, &id, ¬_a_recipient, &new_recipient); +} + +#[test] +#[should_panic(expected = "insufficient approvals for recipient substitution")] +fn test_substitute_recipient_with_cosigners_requires_approvals() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let cosigner1 = Address::generate(&env); + let cosigner2 = Address::generate(&env); + let payer = Address::generate(&env); + let old_recipient = Address::generate(&env); + let new_recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &100); + env.ledger().set_timestamp(1_000); + + let mut opts = default_options(&env); + let mut signers = Vec::new(&env); + signers.push_back(cosigner1.clone()); + signers.push_back(cosigner2.clone()); + opts.co_signers = signers; + opts.required_signatures = 2; + + let mut recipients = Vec::new(&env); + recipients.push_back(old_recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u64, &opts); + + // Pay partial amount + c.pay(&payer, &id, &50_i128, &0_u64, &false, &false); + + // Try to substitute without approvals (should panic) + c.substitute_recipient(&creator, &id, &old_recipient, &new_recipient); +} + +#[test] +fn test_substitute_recipient_with_cosigners_after_approvals() { + 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 cosigner1 = Address::generate(&env); + let cosigner2 = Address::generate(&env); + let payer = Address::generate(&env); + let old_recipient = Address::generate(&env); + let new_recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &100); + env.ledger().set_timestamp(1_000); + + let mut opts = default_options(&env); + let mut signers = Vec::new(&env); + signers.push_back(cosigner1.clone()); + signers.push_back(cosigner2.clone()); + opts.co_signers = signers; + opts.required_signatures = 2; + + let mut recipients = Vec::new(&env); + recipients.push_back(old_recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u64, &opts); + + // Pay partial amount + c.pay(&payer, &id, &50_i128, &0_u64, &false, &false); + + // Get the required signatures (2) + // Get co-signers to approve the substitution + c.approve_substitute_recipient(&id, &cosigner1); + c.approve_substitute_recipient(&id, &cosigner2); + + // Now substitute should succeed + c.substitute_recipient(&creator, &id, &old_recipient, &new_recipient); + + let invoice = c.get_invoice(&id); + assert_eq!(invoice.recipients.get(0), Some(new_recipient.clone())); + + // Release to new recipient + c.release(&creator, &id); + assert_eq!(tk.balance(&new_recipient), 50); + assert_eq!(tk.balance(&old_recipient), 0); +} + +#[test] +#[should_panic(expected = "not a co-signer for this invoice")] +fn test_approve_substitute_recipient_not_cosigner() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let cosigner = Address::generate(&env); + let not_a_cosigner = Address::generate(&env); + let recipient = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let mut opts = default_options(&env); + let mut signers = Vec::new(&env); + signers.push_back(cosigner.clone()); + opts.co_signers = signers; + opts.required_signatures = 1; + + 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, &2_000_u64, &opts); + + // Non-cosigner tries to approve + c.approve_substitute_recipient(&id, ¬_a_cosigner); +} + +#[test] +#[should_panic(expected = "co-signer has already approved substitution")] +fn test_approve_substitute_recipient_duplicate_approval() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let cosigner = Address::generate(&env); + let recipient = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let mut opts = default_options(&env); + let mut signers = Vec::new(&env); + signers.push_back(cosigner.clone()); + opts.co_signers = signers; + opts.required_signatures = 1; + + 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, &2_000_u64, &opts); + + // Approve once + c.approve_substitute_recipient(&id, &cosigner); + + // Try to approve again (should panic) + c.approve_substitute_recipient(&id, &cosigner); +} diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index ec7c315..19216ba 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -365,6 +365,8 @@ pub struct InvoiceExt2 { pub min_payment: i128, pub min_funding_amount: i128, pub priorities: Vec, + /// Issue #230: co-signers who have approved the pending recipient substitution. + pub substitute_recipient_approvals: Vec
, } /// Issue #211: A single escalating penalty tier (seconds_after_deadline, bps). @@ -476,6 +478,8 @@ pub struct Invoice { pub priorities: Vec, pub clone_depth: u32, pub fallback_action: Option, + /// Issue #230: co-signers who have approved the pending recipient substitution. + pub substitute_recipient_approvals: Vec
, } impl Invoice { @@ -562,6 +566,7 @@ impl Invoice { min_payment: self.min_payment, min_funding_amount: self.min_funding_amount, priorities: self.priorities, + substitute_recipient_approvals: Vec::new(self.notification_contract.env()), }, ) }