From cdbff2bab749e7874b834668a1293e56b1693f18 Mon Sep 17 00:00:00 2001 From: jiajibin <17863938131@163.com> Date: Mon, 13 Jul 2026 19:18:02 +0800 Subject: [PATCH] Implement issue #163: dynamic recipient management (cap + replacement + quorum) - Add optional max_recipients cap on InvoiceOptions, enforced at create_invoice time. - Add propose_recipient_replacement / approve_recipient_replacement (co-signer quorum) + private execute_recipient_replacement that inherits the recipient slot (keeps amounts/claimed aligned) and migrates the paid set. - Emit recipient_replacement_proposed / _executed events. - Add 4 tests covering quorum, claimed-amount preservation, released-invoice rejection, and the recipient cap. --- contracts/split/src/events.rs | 30 +++++ contracts/split/src/lib.rs | 217 ++++++++++++++++++++++++++++++++++ contracts/split/src/test.rs | 196 ++++++++++++++++++++++++++++++ contracts/split/src/types.rs | 2 + 4 files changed, 445 insertions(+) diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 23b50cf..c6ac425 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -550,3 +550,33 @@ pub fn fee_paid(env: &Env, invoice_id: u64, amount: i128, treasury: &Address) { (amount, treasury.clone(), env.ledger().sequence()), ); } + +/// Issue #163: emitted when a recipient replacement is proposed. +/// Topics: (split, rep_prop, invoice_id) +/// Data: (old_recipient, new_recipient) +pub fn recipient_replacement_proposed( + env: &Env, + invoice_id: u64, + old_recipient: &Address, + new_recipient: &Address, +) { + env.events().publish( + (symbol_short!("split"), symbol_short!("rep_prop"), invoice_id), + (old_recipient.clone(), new_recipient.clone()), + ); +} + +/// Issue #163: emitted when a recipient replacement is executed. +/// Topics: (split, rep_exec, invoice_id) +/// Data: (old_recipient, new_recipient) +pub fn recipient_replacement_executed( + env: &Env, + invoice_id: u64, + old_recipient: &Address, + new_recipient: &Address, +) { + env.events().publish( + (symbol_short!("split"), symbol_short!("rep_exec"), invoice_id), + (old_recipient.clone(), new_recipient.clone()), + ); +} diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 348805a..074d25d 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -148,6 +148,17 @@ fn paid_flags_key(id: u64) -> (Symbol, u64) { (symbol_short!("paid_flg"), id) } +/// Issue #163: pending recipient-replacement proposal (old_recipient -> new_recipient). +fn replacement_proposal_key(id: u64) -> (Symbol, u64) { + (symbol_short!("rep_prop"), id) +} + +/// Issue #163: recorded co-signer approvals for a recipient replacement +/// (old_recipient -> Vec of approver addresses). +fn replacement_approvals_key(id: u64) -> (Symbol, u64) { + (symbol_short!("rep_appr"), id) +} + /// Issue #333: u8 bitmask of milestones already emitted — instance storage. /// Bit 0 = 25 %, Bit 1 = 50 %, Bit 2 = 75 %, Bit 3 = 100 %. fn milestone_flags_key(id: u64) -> (Symbol, u64) { @@ -2113,6 +2124,11 @@ impl SplitContract { creator.require_auth(); Self::_apply_rate_limit(&env, &creator); + // Issue #163: enforce an optional recipient cap at creation time. + if let Some(cap) = options.max_recipients { + assert!(recipients.len() <= cap, "exceeds max recipients"); + } + // Issue #4: reject creator if whitelist is non-empty and creator is not on it. let wl: Vec
= env .storage() @@ -4215,6 +4231,207 @@ impl SplitContract { } } + // ----------------------------------------------------------------------- + // Issue #163: dynamic recipient management (cap + replacement + quorum) + // ----------------------------------------------------------------------- + + /// Issue #163: Propose replacing an existing recipient with a new one. + /// + /// Only the creator or a co-creator may propose. The invoice must be Pending + /// and must not be a streaming (drip) invoice. If no co-signer quorum is + /// required (`required_signatures == 0`), the replacement is applied + /// immediately. Otherwise it is recorded as a pending proposal that + /// co-signers approve via [`Self::approve_recipient_replacement`]. + pub fn propose_recipient_replacement( + env: Env, + proposer: Address, + invoice_id: u64, + old_recipient: Address, + new_recipient: Address, + ) { + require_not_paused(&env); + proposer.require_auth(); + + let invoice = load_invoice(&env, invoice_id); + + assert!( + invoice.status == InvoiceStatus::Pending, + "replacement requires pending invoice" + ); + assert!( + invoice.drip_duration.is_none(), + "cannot replace recipients on a streaming invoice" + ); + assert!( + proposer == invoice.creator + || invoice.co_creators.iter().any(|c| c == proposer), + "not authorized to propose recipient replacement" + ); + assert!( + invoice.recipients.iter().any(|r| r == old_recipient), + "old recipient is not part of this invoice" + ); + assert!( + !invoice.recipients.iter().any(|r| r == new_recipient), + "new recipient is already part of this invoice" + ); + + let mut proposals: Map = env + .storage() + .persistent() + .get(&replacement_proposal_key(invoice_id)) + .unwrap_or_else(|| Map::new(&env)); + proposals.set(old_recipient.clone(), new_recipient.clone()); + env.storage() + .persistent() + .set(&replacement_proposal_key(invoice_id), &proposals); + + append_audit_entry(&env, invoice_id, symbol_short!("rep_prop"), &proposer); + events::recipient_replacement_proposed( + &env, + invoice_id, + &old_recipient, + &new_recipient, + ); + + // No quorum required -> apply immediately. + if invoice.required_signatures == 0 { + Self::execute_recipient_replacement(&env, invoice_id, &old_recipient); + } + } + + /// Issue #163: Approve a pending recipient replacement as a co-signer. + /// + /// Only an authorized co-signer may call. Once `required_signatures` distinct + /// co-signers have approved the same proposal, it is executed automatically. + pub fn approve_recipient_replacement( + env: Env, + approver: Address, + invoice_id: u64, + old_recipient: Address, + ) { + require_not_paused(&env); + approver.require_auth(); + + let invoice = load_invoice(&env, invoice_id); + + assert!( + invoice.status == InvoiceStatus::Pending, + "replacement requires pending invoice" + ); + assert!( + invoice.drip_duration.is_none(), + "cannot replace recipients on a streaming invoice" + ); + assert!( + !invoice.co_signers.is_empty(), + "no co-signers configured for replacement" + ); + assert!( + invoice.co_signers.iter().any(|c| c == approver), + "not an authorized co-signer" + ); + + let proposals: Map = env + .storage() + .persistent() + .get(&replacement_proposal_key(invoice_id)) + .expect("no recipient replacement proposal"); + assert!( + proposals.get(old_recipient.clone()).is_some(), + "no proposal for this recipient" + ); + + let mut approvals: Map> = env + .storage() + .persistent() + .get(&replacement_approvals_key(invoice_id)) + .unwrap_or_else(|| Map::new(&env)); + + let mut approvers: Vec
= approvals + .get(old_recipient.clone()) + .unwrap_or_else(|| Vec::new(&env)); + assert!( + !approvers.iter().any(|a| a == approver), + "co-signer already approved" + ); + approvers.push_back(approver.clone()); + approvals.set(old_recipient.clone(), approvers.clone()); + env.storage() + .persistent() + .set(&replacement_approvals_key(invoice_id), &approvals); + + append_audit_entry(&env, invoice_id, symbol_short!("rep_appr"), &approver); + + if approvers.len() >= invoice.required_signatures { + Self::execute_recipient_replacement(&env, invoice_id, &old_recipient); + } + } + + /// Issue #163 (private): execute a previously proposed recipient replacement. + fn execute_recipient_replacement(env: &Env, invoice_id: u64, old_recipient: &Address) { + let mut invoice = load_invoice(env, invoice_id); + + let proposals: Map = env + .storage() + .persistent() + .get(&replacement_proposal_key(invoice_id)) + .expect("no recipient replacement proposal"); + let new_recipient: Address = proposals + .get(old_recipient.clone()) + .expect("proposal missing for recipient"); + + // Locate the slot currently held by the old recipient. + let mut idx: Option = None; + for i in 0..invoice.recipients.len() { + if invoice.recipients.get(i).unwrap() == *old_recipient { + idx = Some(i); + break; + } + } + assert!(idx.is_some(), "old recipient not found in invoice"); + let idx = idx.unwrap(); + + // Inherit the existing slot (keeps amounts/claimed alignment). + invoice.recipients.set(idx, new_recipient.clone()); + + // Migrate the paid set so the new recipient is considered paid if the + // old one already was. + let mut paid: Vec
= env + .storage() + .persistent() + .get(&paid_recipients_key(invoice_id)) + .unwrap_or_else(|| Vec::new(env)); + if paid.iter().any(|p| p == *old_recipient) + && !paid.iter().any(|p| p == new_recipient) + { + paid.push_back(new_recipient.clone()); + env.storage() + .persistent() + .set(&paid_recipients_key(invoice_id), &paid); + } + + save_invoice(env, invoice_id, &invoice); + + // Keep the optimised recipient list in sync (issue #332). + save_recipients_list(env, invoice_id, &invoice.recipients, &invoice.amounts); + + // Clear the pending proposal and its approvals. + env.storage() + .persistent() + .remove(&replacement_proposal_key(invoice_id)); + env.storage() + .persistent() + .remove(&replacement_approvals_key(invoice_id)); + + events::recipient_replacement_executed( + env, + invoice_id, + old_recipient, + &new_recipient, + ); + } + /// Issue #329: Update the off-chain metadata hash for an invoice. /// /// Only the creator may call this. Emits `MetadataUpdated` with old and new hash. diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index b5dc2be..5be0188 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, + max_recipients: None, } } @@ -125,6 +126,7 @@ fn invoice_options( priorities: Vec::new(env), require_kyc: false, scheduled_release_at: None, + max_recipients: None, } } @@ -7419,3 +7421,197 @@ fn test_310_propose_overwrites_existing() { let p = c.get_upgrade_proposal().unwrap(); assert_eq!(p.new_wasm_hash, hash2); } + +// --------------------------------------------------------------------------- +// Issue #163: dynamic recipient management (cap + replacement + quorum) +// --------------------------------------------------------------------------- + +#[test] +fn test_recipient_replacement_requires_quorum() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let a = Address::generate(&env); + let b = Address::generate(&env); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let r3 = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(r1.clone()); + recipients.push_back(r2.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + amounts.push_back(100_i128); + + let mut opts = default_options(&env); + let mut cosigners = Vec::new(&env); + cosigners.push_back(a.clone()); + cosigners.push_back(b.clone()); + opts.co_signers = cosigners; + opts.required_signatures = 2; + opts.max_recipients = None; + + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); + + // Creator (not a co-signer) proposes replacing r1 with r3. + c.propose_recipient_replacement(&creator, &id, &r1, &r3); + + // First co-signer approval is insufficient (quorum = 2): no replacement yet. + c.approve_recipient_replacement(&a, &id, &r1); + assert_eq!(c.get_invoice(&id).recipients.get(0).unwrap(), r1); + + // Second co-signer approval reaches quorum and executes the replacement. + c.approve_recipient_replacement(&b, &id, &r1); + assert_eq!(c.get_invoice(&id).recipients.get(0).unwrap(), r3); + assert_eq!(c.get_invoice(&id).recipients.get(1).unwrap(), r2); +} + +#[test] +fn test_recipient_replacement_preserves_claimed_amounts() { + 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 a = Address::generate(&env); + let b = Address::generate(&env); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let r3 = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &200_i128); + env.ledger().set_timestamp(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(r1.clone()); + recipients.push_back(r2.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + amounts.push_back(100_i128); + + let mut opts = default_options(&env); + let mut cosigners = Vec::new(&env); + cosigners.push_back(a.clone()); + cosigners.push_back(b.clone()); + opts.co_signers = cosigners; + opts.required_signatures = 2; + opts.max_recipients = None; + + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); + + // Fully fund; stays Pending because co-signers are set. + c.pay(&payer, &id, &200_i128, &0_u64, &false, &false); + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Pending); + + // Pay r1 directly: r1 is now in the paid set, slot amount still 100. + c.release_to_recipient(&id, &r1); + assert_eq!(tk.balance(&r1), 100); + + // Propose + approve replacement r1 -> r3 (quorum = 2). + c.propose_recipient_replacement(&creator, &id, &r1, &r3); + c.approve_recipient_replacement(&a, &id, &r1); + c.approve_recipient_replacement(&b, &id, &r1); + + // get_invoice reflects the new recipient; the amount slot is inherited. + let inv = c.get_invoice(&id); + assert_eq!(inv.recipients.get(0).unwrap(), r3); + assert_eq!(inv.amounts.get(0).unwrap(), 100_i128); + + // The new recipient cannot be paid again (paid set was migrated). + let result = c.try_release_to_recipient(&id, &r3); + assert!(result.is_err()); +} + +#[test] +#[should_panic(expected = "replacement requires pending invoice")] +fn test_recipient_replacement_blocked_on_released_invoice() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let r3 = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &200_i128); + env.ledger().set_timestamp(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(r1.clone()); + recipients.push_back(r2.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + amounts.push_back(100_i128); + + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &default_options(&env), + ); + + // Fully fund -> auto-releases (no co-signers configured). + c.pay(&payer, &id, &200_i128, &0_u64, &false, &false); + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); + + // Replacement must be blocked on a released invoice. + c.propose_recipient_replacement(&creator, &id, &r1, &r3); +} + +#[test] +#[should_panic(expected = "exceeds max recipients")] +fn test_recipient_cap_enforced_at_creation() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let r3 = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(r1.clone()); + recipients.push_back(r2.clone()); + recipients.push_back(r3.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + amounts.push_back(100_i128); + amounts.push_back(100_i128); + + let mut opts = default_options(&env); + opts.max_recipients = Some(2); + + // 3 recipients exceeds the cap of 2 -> must panic. + c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); +} diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 367b54d..06d1588 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -261,6 +261,8 @@ pub struct InvoiceOptions { pub scheduled_release_at: Option, /// KYC verification requirement. pub require_kyc: bool, + /// Issue #163: optional cap on the number of recipients at creation time. + pub max_recipients: Option, } /// Overflow options for `create_invoice`, split off from [`InvoiceOptions`] to stay within