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 @@ -1420,3 +1420,32 @@ pub fn cosigner_threshold_reached(env: &Env, invoice_id: u64) {
);
}


/// Issue #503: Emitted when admin updates the per-creator open-invoice cap.
/// Topics: (INVOICE_LIMIT_UPDATED_V, split, inv_lim)
/// Data: new_limit
pub fn invoice_limit_updated(env: &Env, new_limit: u32) {
env.events().publish(
(
INVOICE_LIMIT_UPDATED_V,
symbol_short!("split"),
symbol_short!("inv_lim"),
),
new_limit,
);
}

/// Issue #505: Emitted when a payout recipient account does not exist on-ledger.
/// Topics: (RECIPIENT_ACCOUNT_MISSING_V, split, rcp_mis, invoice_id)
/// Data: recipient
pub fn recipient_account_missing(env: &Env, invoice_id: u64, recipient: &Address) {
env.events().publish(
(
RECIPIENT_ACCOUNT_MISSING_V,
symbol_short!("split"),
symbol_short!("rcp_mis"),
invoice_id,
),
recipient.clone(),
);
}
227 changes: 219 additions & 8 deletions contracts/split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,21 @@ fn contributor_allowlist_key(invoice_id: u64) -> (Symbol, u64) {
(symbol_short!("ctr_al"), invoice_id)
}

/// Issue #503: number of currently-open invoices per creator — persistent storage.
fn open_invoice_count_key(creator: &Address) -> (Symbol, Address) {
(symbol_short!("op_inv_cn"), creator.clone())
}

/// Issue #503: admin-configured maximum open invoices per creator — instance storage.
fn max_open_invoices_key() -> Symbol {
symbol_short!("mx_op_inv")
}

/// Issue #505: list of recipients whose payout failed (missing account) — persistent storage.
fn failed_payouts_key(invoice_id: u64) -> (Symbol, u64) {
(symbol_short!("fail_pay"), invoice_id)
}

fn commitment_expiry_key() -> Symbol {
symbol_short!("com_exp")
}
Expand Down Expand Up @@ -2982,6 +2997,17 @@ impl SplitContract {
env.storage().instance().set(&storage_quota_key(), &bytes);
}

/// Issue #503: Set the maximum number of open invoices a single creator may hold at once.
/// Admin-only. Emits InvoiceLimitUpdated(new_limit).
pub fn set_max_open_invoices(env: Env, admin: Address, new_limit: u32) {
require_role(&env, &admin, AdminRole::Operator);
assert!(new_limit > 0, "new_limit must be positive");
env.storage()
.instance()
.set(&max_open_invoices_key(), &new_limit);
events::invoice_limit_updated(&env, new_limit);
}

/// Returns the current global per-invoice storage quota in bytes (issue #425).
pub fn get_storage_quota(env: Env) -> u64 {
env.storage()
Expand Down Expand Up @@ -5045,6 +5071,25 @@ impl SplitContract {
.persistent()
.set(&invoice_count_key(&creator), &(inv_cnt + 1));

// Issue #503: enforce per-creator open-invoice cap.
const DEFAULT_MAX_OPEN_INVOICES: u32 = 100;
let max_open: u32 = env
.storage()
.instance()
.get(&max_open_invoices_key())
.unwrap_or(DEFAULT_MAX_OPEN_INVOICES);
let open_count: u32 = env
.storage()
.persistent()
.get(&open_invoice_count_key(&creator))
.unwrap_or(0u32);
if open_count >= max_open {
env.panic_with_error(ContractError::CreatorInvoiceLimitReached);
}
env.storage()
.persistent()
.set(&open_invoice_count_key(&creator), &(open_count + 1));

let total: i128 = amounts.iter().sum();

let gov_opt: Option<Option<Address>> =
Expand Down Expand Up @@ -8441,6 +8486,109 @@ impl SplitContract {

/// Claim a pending payout that was not transferred during release (issue #209).
/// Recipient can claim their payout after the invoice is Released.
/// Issue #505: Retry a payout to a recipient whose account was missing at release time.
///
/// Re-validates that the recipient account now exists on the ledger. If it does, executes
/// the transfer and removes them from FailedPayouts. If FailedPayouts is now empty, the
/// invoice is finalised (status → Released) and the open-invoice counter is decremented.
/// If the account is still missing, emits RecipientAccountMissing again and returns an error.
pub fn retry_failed_payout(env: Env, invoice_id: u64, recipient: Address) {
require_not_paused(&env);
let mut invoice = load_invoice(&env, invoice_id);

// Load and validate the failed-payouts list.
let mut failed: Vec<Address> = env
.storage()
.persistent()
.get(&failed_payouts_key(invoice_id))
.expect("no failed payouts for this invoice");

assert!(
failed.iter().any(|a| a == recipient),
"recipient not in failed payouts"
);

let funding_token_client = token::Client::new(&env, &funding_token_for(&invoice));

// Re-check account existence.
let account_exists = env
.try_invoke_contract::<i128, soroban_sdk::Error>(
&funding_token_client.address,
&symbol_short!("balance"),
(recipient.clone(),).into_val(&env),
)
.is_ok();

if !account_exists {
events::recipient_account_missing(&env, invoice_id, &recipient);
env.panic_with_error(ContractError::RecipientAccountMissing);
}

// Find recipient index and compute their payout amount.
let n = invoice.recipients.len();
let total: i128 = invoice.amounts.iter().sum();
let funded = invoice.funded;
let idx = invoice
.recipients
.iter()
.position(|r| r == recipient.clone())
.expect("recipient not in invoice") as u32;
let amount = invoice.amounts.get(idx).unwrap();
let payout = if n == 1 {
funded
} else {
checked_proportion(amount as u128, funded as u128, total as u128)
.expect("ArithmeticOverflow")
};

// Execute transfer.
funding_token_client.transfer(
&env.current_contract_address(),
&recipient,
&payout,
);

// Remove from failed list.
let mut new_failed: Vec<Address> = Vec::new(&env);
for a in failed.iter() {
if a != recipient {
new_failed.push_back(a);
}
}
if new_failed.is_empty() {
env.storage().persistent().remove(&failed_payouts_key(invoice_id));
} else {
env.storage()
.persistent()
.set(&failed_payouts_key(invoice_id), &new_failed);
}

// If all payouts are now complete, finalise the invoice.
if new_failed.is_empty() {
invoice.status = InvoiceStatus::Released;
invoice.completion_time = Some(env.ledger().timestamp());
save_invoice(&env, invoice_id, &invoice);
// Decrement open-invoice counter now that invoice is fully done.
let cnt: u32 = env
.storage()
.persistent()
.get(&open_invoice_count_key(&invoice.creator))
.unwrap_or(0u32);
env.storage()
.persistent()
.set(&open_invoice_count_key(&invoice.creator), &cnt.saturating_sub(1));
events::invoice_state_changed(
&env,
invoice_id,
Some(&InvoiceStatus::Pending),
&InvoiceStatus::Released,
&recipient,
);
}

events::recipient_paid(&env, invoice_id, &recipient, payout);
}

pub fn claim_pending_payout(env: Env, invoice_id: u64, recipient: Address) {
recipient.require_auth();

Expand Down Expand Up @@ -9358,6 +9506,33 @@ impl SplitContract {
continue;
}

// Issue #505: verify recipient account existence before attempting payout.
// Use try_invoke_contract to call balance() on the funding token; if it fails
// the account has never been initialised on the ledger.
let account_exists = env
.try_invoke_contract::<i128, soroban_sdk::Error>(
&funding_token_client.address,
&symbol_short!("balance"),
(recipient.clone(),).into_val(env),
)
.is_ok();
if !account_exists {
// Record the failure and emit event; skip transfer for this recipient.
let mut failed: Vec<Address> = env
.storage()
.persistent()
.get(&failed_payouts_key(invoice_id))
.unwrap_or_else(|| Vec::new(env));
if !failed.iter().any(|a| a == recipient) {
failed.push_back(recipient.clone());
}
env.storage()
.persistent()
.set(&failed_payouts_key(invoice_id), &failed);
events::recipient_account_missing(env, invoice_id, &recipient);
continue;
}

// Issue #482: use checked arithmetic to prevent overflow.
let tax = checked_bps_of(proportional, invoice.tax_bps, 10_000u128)
.expect("ArithmeticOverflow");
Expand Down Expand Up @@ -9697,7 +9872,18 @@ impl SplitContract {
}
}

invoice.status = InvoiceStatus::Released;
// Issue #505: only transition to Released if all recipients were paid.
// If any ended up in failed_payouts, stay Pending so retry_failed_payout can finish the job.
let has_failed_payouts = env
.storage()
.persistent()
.get::<(Symbol, u64), Vec<Address>>(&failed_payouts_key(invoice_id))
.map(|v| !v.is_empty())
.unwrap_or(false);

if !has_failed_payouts {
invoice.status = InvoiceStatus::Released;
}
invoice.completion_time = Some(env.ledger().timestamp());
if invoice.insurance_fund > 0 {
let token_client = token::Client::new(env, &invoice.tokens.get(0).expect("no token"));
Expand All @@ -9708,16 +9894,29 @@ impl SplitContract {
);
invoice.insurance_fund = 0;
}
// Issue #503: decrement per-creator open-invoice counter on release (only when fully done).
if !has_failed_payouts {
let cnt: u32 = env
.storage()
.persistent()
.get(&open_invoice_count_key(&invoice.creator))
.unwrap_or(0u32);
env.storage()
.persistent()
.set(&open_invoice_count_key(&invoice.creator), &cnt.saturating_sub(1));
}
save_invoice(env, invoice_id, invoice);
append_audit_entry(env, invoice_id, symbol_short!("release"), actor);
events::invoice_released(env, invoice_id, &invoice.recipients);
events::invoice_state_changed(
env,
invoice_id,
Some(&InvoiceStatus::Pending),
&InvoiceStatus::Released,
actor,
);
if !has_failed_payouts {
events::invoice_state_changed(
env,
invoice_id,
Some(&InvoiceStatus::Pending),
&InvoiceStatus::Released,
actor,
);
}
notify_invoice(
env,
invoice_id,
Expand Down Expand Up @@ -10752,6 +10951,18 @@ impl SplitContract {
);
}

// Issue #503: decrement per-creator open-invoice counter on cancel.
{
let cnt: u32 = env
.storage()
.persistent()
.get(&open_invoice_count_key(&invoice.creator))
.unwrap_or(0u32);
env.storage()
.persistent()
.set(&open_invoice_count_key(&invoice.creator), &cnt.saturating_sub(1));
}

save_invoice(&env, invoice_id, &invoice);
append_audit_entry(&env, invoice_id, symbol_short!("cancel"), &caller);

Expand Down
47 changes: 47 additions & 0 deletions contracts/split/src/storage_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,50 @@ pub fn contributor_allowlist_key(invoice_id: u64) -> (Symbol, u64) { (symbol_sho
/// Issue #473: Allowed payment tokens list — persistent storage.
pub fn allowed_tokens_key() -> Symbol { symbol_short!("alw_toks") }

// ---------------------------------------------------------------------------
// Issue #503: Per-creator open invoice count cap
// ---------------------------------------------------------------------------

/// Number of currently-open invoices per creator — persistent storage.
/// Incremented on create_invoice, decremented on cancel/release.
pub fn open_invoice_count_key(creator: &Address) -> (Symbol, Address) {
(symbol_short!("op_inv_cn"), creator.clone())
}

/// Admin-configurable maximum open invoices per creator — instance storage.
/// Default value when unset: DEFAULT_MAX_OPEN_INVOICES (100).
pub fn max_open_invoices_key() -> Symbol { symbol_short!("mx_op_inv") }

// ---------------------------------------------------------------------------
// Issue #505: Failed payout tracking
// ---------------------------------------------------------------------------

/// Per-invoice list of recipient addresses whose payout failed (missing account) — persistent storage.
/// Key: (Symbol, u64) → Vec<Address>
pub fn failed_payouts_key(invoice_id: u64) -> (Symbol, u64) {
(symbol_short!("fail_pay"), invoice_id)
}

// ---------------------------------------------------------------------------
// Issue #503: Per-creator open invoice count cap
// ---------------------------------------------------------------------------

/// Number of currently-open invoices per creator — persistent storage.
/// Incremented on create_invoice, decremented on cancel/release.
pub fn open_invoice_count_key(creator: &Address) -> (Symbol, Address) {
(symbol_short!("op_inv_cn"), creator.clone())
}

/// Admin-configurable maximum open invoices per creator — instance storage.
pub fn max_open_invoices_key() -> Symbol { symbol_short!("mx_op_inv") }

// ---------------------------------------------------------------------------
// Issue #505: Failed payout tracking
// ---------------------------------------------------------------------------

/// List of recipient addresses whose payout failed due to missing account — persistent storage.
/// Key: (Symbol, u64) → Vec<Address>
pub fn failed_payouts_key(invoice_id: u64) -> (Symbol, u64) {
(symbol_short!("fail_pay"), invoice_id)
}

3 changes: 3 additions & 0 deletions tests/snapshots/storage_keys.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"delegate_pay_key": "0000001000000001000000020000000f00000008646c67745f7061790000001200000000000000000000000000000000000000000000000000000000000000000000000000000000",
"ext_vote_key": "0000001000000001000000020000000f000000086578745f766f7465000000050000000000000001",
"factories_key": "0000000f00000009666163746f72696573000000",
"failed_payouts_key": "0000001000000001000000020000000f000000086661696c5f706179000000050000000000000001",
"fee_tiers_key": "0000000f000000076665655f74727300",
"funding_checkpoints_key": "0000000f00000007666e645f63686b00",
"global_payer_limit_key": "0000000f00000009675f76656c5f6c696d000000",
Expand All @@ -65,9 +66,11 @@
"kyc_contract_key": "0000000f000000076b79635f63747200",
"last_checkpoint_key": "0000001000000001000000020000000f000000086c6173745f63686b000000050000000000000001",
"max_cancel_bps_key": "0000000f000000096d785f636e6c5f6270000000",
"max_open_invoices_key": "0000000f000000096d785f6f705f696e76000000",
"milestone_flags_key": "0000001000000001000000020000000f000000076d735f666c677300000000050000000000000001",
"nft_gate_key": "0000000f000000076e66745f67746500",
"nonce_key": "0000001000000001000000030000000f000000056e6f6e63650000000000000500000000000000010000001200000000000000000000000000000000000000000000000000000000000000000000000000000000",
"open_invoice_count_key": "0000001000000001000000020000000f000000096f705f696e765f636e0000000000001200000000000000000000000000000000000000000000000000000000000000000000000000000000",
"paid_flags_key": "0000001000000001000000020000000f00000008706169645f666c67000000050000000000000001",
"pause_exempt_key": "0000001000000001000000020000000f00000008705f6578656d70740000001200000000000000000000000000000000000000000000000000000000000000000000000000000000",
"paused_fns_key": "0000000f0000000670735f666e730000",
Expand Down