diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index f149f58..32c1709 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -1294,3 +1294,32 @@ pub fn invoice_from_template(env: &Env, invoice_id: u64, creator: &Address, temp ); } +/// Emitted on every individual cosigner approval recorded via `approve_release`. +/// Topics: (split, CosignerApproved, invoice_id) +/// Data: (cosigner, ledger) +pub fn cosigner_approved(env: &Env, invoice_id: u64, cosigner: &Address) { + env.events().publish( + ( + symbol_short!("split"), + soroban_sdk::Symbol::new(env, "CosignerApproved"), + invoice_id, + ), + (cosigner.clone(), env.ledger().sequence()), + ); +} + +/// Emitted once, the moment `approve_release` collects enough approvals to +/// meet the invoice's configured `cosigner_threshold`. +/// Topics: (split, CosignerThresholdReached, invoice_id) +/// Data: ledger +pub fn cosigner_threshold_reached(env: &Env, invoice_id: u64) { + env.events().publish( + ( + symbol_short!("split"), + soroban_sdk::Symbol::new(env, "CosignerThresholdReached"), + invoice_id, + ), + env.ledger().sequence(), + ); +} + diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 3a9c2b1..305161f 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -509,6 +509,22 @@ fn delegate_pay_key(beneficiary: &Address) -> (Symbol, Address) { (symbol_short!("dlgt_pay"), beneficiary.clone()) } +/// N-of-M release approval: configured cosigner addresses for an invoice. +/// Set at creation time from `InvoiceOptions::cosigners`; absent means the +/// gate is disabled for this invoice. +fn cosigners_key(id: u64) -> (Symbol, u64) { + (symbol_short!("cosigrs"), id) +} +/// N-of-M release approval: required number of `cosigners_key` approvals. +/// Set at creation time from `InvoiceOptions::cosigner_threshold`. +fn cosigner_thresh_key(id: u64) -> (Symbol, u64) { + (symbol_short!("cosig_th"), id) +} +/// N-of-M release approval: recorded approvals collected via `approve_release`. +fn cosign_key(id: u64) -> (Symbol, u64) { + (symbol_short!("cosign"), id) +} + /// Analytics counters (issue #28). fn total_invoices_key() -> Symbol { symbol_short!("tot_inv") @@ -1727,6 +1743,41 @@ fn apply_overfunding_policy(env: &Env, id: u64, policy: OverfundingPolicy) { } } +/// Persist a creator-configured N-of-M cosigner approval requirement on a +/// freshly created invoice. `None` leaves the gate disabled, so invoices that +/// never set `cosigners` pay no storage cost. +fn apply_cosigner_config(env: &Env, id: u64, cosigners: Option>, threshold: Option) { + if let Some(list) = cosigners { + assert!(!list.is_empty(), "cosigners cannot be empty when set"); + let required = threshold.unwrap_or(list.len()); + assert!( + required > 0 && required <= list.len(), + "cosigner_threshold must be between 1 and cosigners.len()" + ); + env.storage().persistent().set(&cosigners_key(id), &list); + env.storage() + .persistent() + .set(&cosigner_thresh_key(id), &required); + } +} + +/// Blocks release until the invoice's configured N-of-M cosigner quorum has +/// been met via `approve_release`. A no-op when the invoice has no +/// `cosigners` configured (the common case). +fn require_cosigner_threshold_met(env: &Env, id: u64) { + if let Some(threshold) = env.storage().persistent().get::<_, u32>(&cosigner_thresh_key(id)) { + let approvals: Vec
= env + .storage() + .persistent() + .get(&cosign_key(id)) + .unwrap_or_else(|| Vec::new(env)); + assert!( + approvals.len() >= threshold, + "cosigner approval threshold not met" + ); + } +} + fn funding_token_for(invoice: &Invoice) -> Address { invoice.funding_token.clone() } @@ -4072,6 +4123,10 @@ impl SplitContract { // Issue #420: captured before `options` is consumed below; applied to the // stored invoice once `_create_invoice_inner` has allocated its id. let overfunding_policy = options.ext.overfunding_policy.clone(); + // Captured before `options` is consumed below; applied to the stored + // invoice once `_create_invoice_inner` has allocated its id. + let cosigners = options.cosigners.clone(); + let cosigner_threshold = options.cosigner_threshold; let id = Self::_create_invoice_inner( // Validate split ratios (if provided) before any storage is touched. @@ -4145,6 +4200,7 @@ impl SplitContract { ); apply_overfunding_policy(&env, id, overfunding_policy); + apply_cosigner_config(&env, id, cosigners, cosigner_threshold); id options.ext.early_bird_window_ledgers, options.ext.early_bird_fee_bps, @@ -4207,6 +4263,9 @@ impl SplitContract { // Issue #420: see `create_invoice` — captured before `options` is consumed. let overfunding_policy = options.ext.overfunding_policy.clone(); + // See `create_invoice` — captured before `options` is consumed. + let cosigners = options.cosigners.clone(); + let cosigner_threshold = options.cosigner_threshold; let id = Self::_create_invoice_inner( &env, @@ -4272,6 +4331,7 @@ impl SplitContract { ); apply_overfunding_policy(&env, id, overfunding_policy); + apply_cosigner_config(&env, id, cosigners, cosigner_threshold); id options.ext.early_bird_window_ledgers, options.ext.early_bird_fee_bps, @@ -5620,6 +5680,7 @@ impl SplitContract { || !invoice.release_stages.is_empty() || in_group || !invoice.co_signers.is_empty() + || env.storage().persistent().has(&cosigners_key(invoice_id)) || (invoice.oracle_address.is_some() && !invoice.condition_met) || (invoice.min_funding_bps > 0 && invoice.funded @@ -6308,6 +6369,7 @@ impl SplitContract { || !invoice.milestones.is_empty() || in_group || !invoice.co_signers.is_empty() + || env.storage().persistent().has(&cosigners_key(invoice_id)) || (invoice.oracle_address.is_some() && !invoice.condition_met) || (invoice.min_funding_bps > 0 && invoice.funded @@ -6470,6 +6532,7 @@ impl SplitContract { || !invoice.release_stages.is_empty() || in_group || !invoice.co_signers.is_empty() + || env.storage().persistent().has(&cosigners_key(invoice_id)) || (invoice.min_funding_bps > 0 && invoice.funded < (invoice.amounts.iter().sum::() * invoice.min_funding_bps as i128 @@ -6574,6 +6637,7 @@ impl SplitContract { || !invoice.release_stages.is_empty() || in_group || !invoice.co_signers.is_empty() + || env.storage().persistent().has(&cosigners_key(invoice_id)) || (invoice.oracle_address.is_some() && !invoice.condition_met) || (invoice.min_funding_bps > 0 && invoice.funded @@ -6682,6 +6746,7 @@ impl SplitContract { || !inv.release_stages.is_empty() || in_group || !inv.co_signers.is_empty() + || env.storage().persistent().has(&cosigners_key(p.invoice_id)) || (inv.oracle_address.is_some() && !inv.condition_met) || (inv.min_funding_bps > 0 && inv.funded @@ -6732,6 +6797,60 @@ impl SplitContract { append_audit_entry(&env, invoice_id, symbol_short!("sign_rel"), &signer); } + /// Record a cosigner's approval toward the N-of-M release-approval quorum + /// configured via `InvoiceOptions::cosigners` / `cosigner_threshold`. + /// + /// Independent of the legacy `co_signers` / `sign_release` gate above. + /// Only addresses in `cosigners` may call this; each may approve at most + /// once. Emits `CosignerApproved` on every call, and `CosignerThresholdReached` + /// the moment the configured threshold is met. + pub fn approve_release(env: Env, invoice_id: u64, cosigner: Address) { + require_not_paused(&env); + cosigner.require_auth(); + + let invoice = load_invoice(&env, invoice_id); + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); + + let cosigners: Vec
= env + .storage() + .persistent() + .get(&cosigners_key(invoice_id)) + .expect("no cosigners configured for this invoice"); + assert!( + cosigners.iter().any(|c| c == cosigner), + "not an authorized cosigner" + ); + + let mut approvals: Vec
= env + .storage() + .persistent() + .get(&cosign_key(invoice_id)) + .unwrap_or_else(|| Vec::new(&env)); + assert!( + !approvals.iter().any(|a| a == cosigner), + "cosigner already approved" + ); + + approvals.push_back(cosigner.clone()); + env.storage() + .persistent() + .set(&cosign_key(invoice_id), &approvals); + events::cosigner_approved(&env, invoice_id, &cosigner); + append_audit_entry(&env, invoice_id, symbol_short!("cosign"), &cosigner); + + let threshold: u32 = env + .storage() + .persistent() + .get(&cosigner_thresh_key(invoice_id)) + .unwrap_or(0); + if threshold > 0 && approvals.len() >= threshold { + events::cosigner_threshold_reached(&env, invoice_id); + } + } + // ----------------------------------------------------------------------- // Release (#22 prerequisite, #23 tranches) // ----------------------------------------------------------------------- @@ -6898,6 +7017,9 @@ impl SplitContract { ); } + // N-of-M cosigner approval check (independent of the legacy co_signers gate). + require_cosigner_threshold_met(&env, invoice_id); + Self::_release(&env, invoice_id, &mut invoice, &caller); // Clear reentrancy lock on normal exit. env.storage().temporary().remove(&reentrancy_lock_key()); @@ -6962,6 +7084,9 @@ impl SplitContract { ); } + // N-of-M cosigner approval check (independent of the legacy co_signers gate). + require_cosigner_threshold_met(&env, invoice_id); + let caller = env.current_contract_address(); Self::_release(&env, invoice_id, &mut invoice, &caller); } @@ -9061,7 +9186,8 @@ impl SplitContract { || !target.tranches.is_empty() || !target.release_stages.is_empty() || in_group - || !target.co_signers.is_empty(); + || !target.co_signers.is_empty() + || env.storage().persistent().has(&cosigners_key(target_id)); if guarded { save_invoice(env, target_id, &target); } else { @@ -11654,7 +11780,8 @@ impl SplitContract { || !invoice.tranches.is_empty() || !invoice.release_stages.is_empty() || in_group - || !invoice.co_signers.is_empty(); + || !invoice.co_signers.is_empty() + || env.storage().persistent().has(&cosigners_key(invoice_id)); if invoice.funded >= total { if guarded { save_invoice(&env, invoice_id, &invoice); @@ -12355,6 +12482,7 @@ impl SplitContract { || !invoice.release_stages.is_empty() || in_group || !invoice.co_signers.is_empty() + || env.storage().persistent().has(&cosigners_key(invoice_id)) || (invoice.oracle_address.is_some() && !invoice.condition_met); if guarded { save_invoice(&env, invoice_id, &invoice); diff --git a/contracts/split/src/storage_snapshot.rs b/contracts/split/src/storage_snapshot.rs index 176d896..2932ae7 100644 --- a/contracts/split/src/storage_snapshot.rs +++ b/contracts/split/src/storage_snapshot.rs @@ -295,6 +295,11 @@ fn storage_key_snapshot() { // Issue #485: contributor allowlist key (Symbol, u64) keys.push(("contributor_allowlist_key", hex_xdr(&env, contributor_allowlist_key(1)))); + // N-of-M cosigner release-approval keys (Symbol, u64) + keys.push(("cosigners_key", hex_xdr(&env, cosigners_key(1)))); + keys.push(("cosigner_thresh_key", hex_xdr(&env, cosigner_thresh_key(1)))); + keys.push(("cosign_key", hex_xdr(&env, cosign_key(1)))); + // Sort by key name for deterministic output keys.sort_by(|a, b| a.0.cmp(b.0)); diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index acdd752..ce3fffc 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -112,6 +112,8 @@ fn default_options(env: &Env) -> InvoiceOptions { require_kyc: false, scheduled_release_at: None, ratios: Vec::new(env), + cosigners: None, + cosigner_threshold: None, ext: types::InvoiceOptions2 { target_usd_cents: None, payment_token: None, @@ -208,6 +210,8 @@ fn invoice_options( require_kyc: false, scheduled_release_at: None, ratios: Vec::new(env), + cosigners: None, + cosigner_threshold: None, ext: types::InvoiceOptions2 { target_usd_cents: None, payment_token: None, @@ -8634,6 +8638,122 @@ fn test_multisig_release_panics_below_threshold() { c.release(&id); // should panic: not enough co-signer approvals } +// --------------------------------------------------------------------------- +// N-of-M cosigner release approval (`cosigners` / `cosigner_threshold` / +// `approve_release`) — independent of the legacy `co_signers` / `sign_release` +// gate exercised above. +// --------------------------------------------------------------------------- + +fn cosigner_invoice( + env: &Env, + c: &SplitContractClient, + token_id: &Address, + payer: &Address, + amount: i128, + cosigners: &Vec
, + threshold: u32, +) -> u64 { + let creator = Address::generate(env); + let recipient = Address::generate(env); + + StellarAssetClient::new(env, token_id).mint(payer, &amount); + + let mut recipients = Vec::new(env); + recipients.push_back(recipient); + let mut amounts = Vec::new(env); + amounts.push_back(amount); + + let mut opts = default_options(env); + opts.cosigners = Some(cosigners.clone()); + opts.cosigner_threshold = Some(threshold); + + let id = c.create_invoice(&creator, &recipients, &amounts, token_id, &9_999_u64, &opts); + c.pay(payer, &id, &amount, &0_u64, &false, &false); + id +} + +#[test] +fn test_approve_release_threshold_met_allows_release() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let payer = Address::generate(&env); + let cosigner1 = Address::generate(&env); + let cosigner2 = Address::generate(&env); + env.ledger().set_timestamp(1_000); + + let mut cosigners = Vec::new(&env); + cosigners.push_back(cosigner1.clone()); + cosigners.push_back(cosigner2.clone()); + + let id = cosigner_invoice(&env, &c, &token_id, &payer, 100, &cosigners, 2); + // Fully funded but gated — must still be Pending until threshold is met. + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Pending); + + c.approve_release(&id, &cosigner1); + c.approve_release(&id, &cosigner2); + c.release(&id); + + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); +} + +#[test] +#[should_panic(expected = "cosigner approval threshold not met")] +fn test_approve_release_panics_below_threshold() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let payer = Address::generate(&env); + let cosigner1 = Address::generate(&env); + let cosigner2 = Address::generate(&env); + env.ledger().set_timestamp(1_000); + + let mut cosigners = Vec::new(&env); + cosigners.push_back(cosigner1.clone()); + cosigners.push_back(cosigner2.clone()); + + let id = cosigner_invoice(&env, &c, &token_id, &payer, 100, &cosigners, 2); + + c.approve_release(&id, &cosigner1); // only 1 of 2 + c.release(&id); // should panic: threshold not met +} + +#[test] +#[should_panic(expected = "not an authorized cosigner")] +fn test_approve_release_rejects_non_cosigner() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let payer = Address::generate(&env); + let cosigner1 = Address::generate(&env); + let imposter = Address::generate(&env); + env.ledger().set_timestamp(1_000); + + let mut cosigners = Vec::new(&env); + cosigners.push_back(cosigner1.clone()); + + let id = cosigner_invoice(&env, &c, &token_id, &payer, 100, &cosigners, 1); + + c.approve_release(&id, &imposter); // not in cosigners — should panic +} + +#[test] +#[should_panic(expected = "cosigner already approved")] +fn test_approve_release_rejects_duplicate_approval() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let payer = Address::generate(&env); + let cosigner1 = Address::generate(&env); + let cosigner2 = Address::generate(&env); + env.ledger().set_timestamp(1_000); + + let mut cosigners = Vec::new(&env); + cosigners.push_back(cosigner1.clone()); + cosigners.push_back(cosigner2.clone()); + + let id = cosigner_invoice(&env, &c, &token_id, &payer, 100, &cosigners, 2); + + c.approve_release(&id, &cosigner1); + c.approve_release(&id, &cosigner1); // duplicate — should panic +} + // --------------------------------------------------------------------------- // Issue #309: Recipient allowlist // --------------------------------------------------------------------------- diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 08e9ae5..2854b43 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -372,6 +372,14 @@ pub struct InvoiceOptions { /// Per-recipient split ratios in basis points (must sum to [`BASIS_POINTS_TOTAL`] = 10 000 /// when non-empty). Empty vec means "no ratio constraint — use amounts directly." pub ratios: Vec, + /// Co-signer addresses whose approval (via `approve_release`) is required + /// before this invoice can be released. Independent of the legacy + /// `co_signers` / `sign_release` gate above. `None` disables the gate. + pub cosigners: Option>, + /// Number of distinct `cosigners` approvals required before release is + /// permitted. Only meaningful when `cosigners` is `Some`; must be in + /// `1..=cosigners.len()`. + pub cosigner_threshold: Option, /// Overflow fields that would otherwise push this struct past Soroban's /// 40-field `#[contracttype]` limit — see [`InvoiceOptions2`]. pub ext: InvoiceOptions2, diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs index 98bd3b8..ab1a7e4 100644 --- a/fuzz/src/lib.rs +++ b/fuzz/src/lib.rs @@ -83,6 +83,8 @@ pub fn default_options(env: &Env) -> InvoiceOptions { scheduled_release_at: None, require_kyc: false, ratios: Vec::new(env), + cosigners: None, + cosigner_threshold: None, ext: InvoiceOptions2 { target_usd_cents: None, payment_token: None, diff --git a/tests/snapshots/storage_keys.json b/tests/snapshots/storage_keys.json index acac5b8..e947d2e 100644 --- a/tests/snapshots/storage_keys.json +++ b/tests/snapshots/storage_keys.json @@ -21,6 +21,9 @@ "confidential_count_key": "0000001000000001000000020000000f00000008636f6e665f636e74000000050000000000000001", "confidential_pay_key": "0000001000000001000000030000000f00000008636f6e665f7061790000000500000000000000010000001200000000000000000000000000000000000000000000000000000000000000000000000000000000", "contributor_allowlist_key": "0000001000000001000000020000000f000000066374725f616c0000000000050000000000000001", + "cosign_key": "0000001000000001000000020000000f00000006636f7369676e0000000000050000000000000001", + "cosigner_thresh_key": "0000001000000001000000020000000f00000008636f7369675f7468000000050000000000000001", + "cosigners_key": "0000001000000001000000020000000f00000007636f736967727300000000050000000000000001", "counter_key": "0000000f00000007636f756e74657200", "created_ledger_key": "0000001000000001000000020000000f0000000963725f6c6564676572000000000000050000000000000001", "creation_fee_key": "0000000f000000076372745f66656500",