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