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
29 changes: 29 additions & 0 deletions contracts/split/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
);
}

132 changes: 130 additions & 2 deletions contracts/split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<Vec<Address>>, threshold: Option<u32>) {
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<Address> = 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()
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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::<i128>() * invoice.min_funding_bps as i128
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Address> = 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<Address> = 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)
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions contracts/split/src/storage_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
Loading
Loading