From 9994dd5e160a92f7cc7bd573912fc3a4c250b22d Mon Sep 17 00:00:00 2001
From: Emmy6654 <160542482+Emmy6654@users.noreply.github.com>
Date: Sat, 27 Jun 2026 12:34:33 +0000
Subject: [PATCH] feat: merkle root allowlist, reentrancy lock, group refund,
subscription billing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Task 1 — Merkle root for caller allowlist:
Replace the stored Vec
allowed_callers with a merkle root
(BytesN<32>). The creator stores only the root on-chain; each payer
submits a merkle proof when paying via pay_with_proof(). Added
verify_merkle_proof(), set_allowed_callers_root(), and wired the
proof parameter through _pay().
Task 2 — Reentrancy protection:
Added require_non_reentrant() / clear_reentrant() to all state-mutating
public functions that were missing them (~55 functions). Fixed broken
set_self_limit() and resolve_dispute() implementations in the process.
Task 3 — Group all-or-nothing refund:
Modified refund() to detect group membership. When any member of a
group is underfunded past the deadline, all group invoices are refunded
together. Extracted _refund_single() as an internal helper.
Task 4 — Subscription billing:
Extended SubscriptionParams with interval_secs, next_invoice_at, active
flag, and last_invoice_id. Updated create_subscription() to accept a
billing interval. Added opt_into_subscription(), cancel_subscription(),
cancel_subscription_creator(), and process_subscription() which creates
the next invoice and charges all opted-in payers each cycle.
---
contracts/split/src/lib.rs | 754 ++++++++++++++++++++++++++---------
contracts/split/src/types.rs | 91 ++++-
2 files changed, 645 insertions(+), 200 deletions(-)
diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs
index fedccdd..efb8025 100644
--- a/contracts/split/src/lib.rs
+++ b/contracts/split/src/lib.rs
@@ -100,6 +100,9 @@ fn audit_log_key(id: u64) -> (Symbol, u64) {
fn subscription_params_key(id: u64) -> (Symbol, u64) {
(symbol_short!("sub"), id)
}
+fn subscription_subscribers_key(sub_id: u64) -> (Symbol, u64) {
+ (symbol_short!("sub_sub"), sub_id)
+}
fn ext_vote_key(id: u64) -> (Symbol, u64) {
(symbol_short!("ext_vote"), id)
}
@@ -339,6 +342,52 @@ fn pay_shard_key(invoice_id: u64, shard_id: u64) -> (Symbol, u64, u64) {
(symbol_short!("pay_sh"), invoice_id, shard_id)
}
+fn reentrancy_key() -> Symbol {
+ symbol_short!("re_guard")
+}
+
+fn require_non_reentrant(env: &Env) {
+ assert!(
+ !env.storage().instance().get(&reentrancy_key()).unwrap_or(false),
+ "reentrancy detected"
+ );
+ env.storage().instance().set(&reentrancy_key(), &true);
+}
+
+fn clear_reentrant(env: &Env) {
+ env.storage().instance().set(&reentrancy_key(), &false);
+}
+
+fn verify_merkle_proof(env: &Env, root: &BytesN<32>, leaf: &BytesN<32>, proof: Vec>) -> bool {
+ let mut computed = leaf.clone();
+ for sibling in proof.iter() {
+ let a_bytes: Bytes = computed.clone().into();
+ let b_bytes: Bytes = sibling.clone().into();
+ let mut concat = Bytes::new(env);
+ let mut a_first = true;
+ for i in 0..32 {
+ let av = a_bytes.get(i as u32).unwrap_or(0);
+ let bv = b_bytes.get(i as u32).unwrap_or(0);
+ if av < bv {
+ a_first = true;
+ break;
+ } else if bv < av {
+ a_first = false;
+ break;
+ }
+ }
+ if a_first {
+ concat.append(&a_bytes);
+ concat.append(&b_bytes);
+ } else {
+ concat.append(&b_bytes);
+ concat.append(&a_bytes);
+ }
+ computed = env.crypto().sha256(&concat);
+ }
+ computed == *root
+}
+
fn compute_shard_id(env: &Env, payer: &Address) -> u64 {
let bytes = payer.to_xdr(env);
let len = bytes.len();
@@ -477,7 +526,7 @@ fn load_invoice(env: &Env, id: u64) -> Invoice {
payment_window_secs: None,
scheduled_release_at: None,
penalty_tiers: Vec::new(env),
- allowed_callers: None,
+ allowed_callers_root: None,
refund_grace_secs: None,
fallback_action: None,
external_prerequisite: None,
@@ -793,6 +842,7 @@ impl SplitContract {
/// Add a new admin with a given role. Requires SuperAdmin auth.
pub fn add_admin(env: Env, admin: Address, new_admin: Address, role: AdminRole) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::SuperAdmin);
let mut admins: Map = env
.storage()
@@ -801,11 +851,13 @@ impl SplitContract {
.expect("admins not set");
admins.set(new_admin, role);
env.storage().instance().set(&admins_key(), &admins);
+ clear_reentrant(&env);
}
/// Remove an admin. Requires SuperAdmin auth.
/// Panics if removing the last SuperAdmin.
pub fn remove_admin(env: Env, admin: Address, target: Address) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::SuperAdmin);
let mut admins: Map = env
.storage()
@@ -829,19 +881,24 @@ impl SplitContract {
/// Pause the contract. Requires admin auth.
pub fn pause(env: Env, admin: Address) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::Operator);
env.storage().persistent().set(&paused_key(), &true);
+ clear_reentrant(&env);
}
/// Unpause the contract. Requires admin auth.
pub fn unpause(env: Env, admin: Address) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::Operator);
env.storage().persistent().set(&paused_key(), &false);
+ clear_reentrant(&env);
}
/// Pause a specific function by name. Requires Operator+ auth.
/// While paused, the function panics with "function paused" when called.
pub fn pause_function(env: Env, admin: Address, function: Symbol) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::Operator);
let mut paused_fns: Vec = env
.storage()
@@ -852,10 +909,12 @@ impl SplitContract {
paused_fns.push_back(function);
}
env.storage().persistent().set(&paused_fns_key(), &paused_fns);
+ clear_reentrant(&env);
}
/// Unpause a specific function by name. Requires Operator+ auth.
pub fn unpause_function(env: Env, admin: Address, function: Symbol) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::Operator);
let paused_fns: Vec = env
.storage()
@@ -869,38 +928,47 @@ impl SplitContract {
}
}
env.storage().persistent().set(&paused_fns_key(), &new_list);
+ clear_reentrant(&env);
}
/// Set an address as exempt from the global pause for invoice creation.
/// Requires admin auth.
pub fn set_pause_exempt(env: Env, admin: Address, address: Address, exempt: bool) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::Operator);
if exempt {
env.storage().persistent().set(&pause_exempt_key(&address), &true);
} else {
env.storage().persistent().remove(&pause_exempt_key(&address));
}
+ clear_reentrant(&env);
}
/// Set the global payer aggregate limit and window. Requires admin auth.
pub fn set_global_payer_limit(env: Env, admin: Address, limit: i128, window_secs: u64) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::Operator);
assert!(limit >= 0, "limit must be non-negative");
env.storage().persistent().set(&global_payer_limit_key(), &limit);
env.storage().persistent().set(&global_payer_window_key(), &window_secs);
+ clear_reentrant(&env);
}
/// Update the creation fee. Requires admin auth.
pub fn set_creation_fee(env: Env, admin: Address, creation_fee: i128) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::Operator);
assert!(creation_fee >= 0, "creation_fee must be non-negative");
env.storage().instance().set(&creation_fee_key(), &creation_fee);
+ clear_reentrant(&env);
}
/// Update the treasury address. Requires admin auth.
pub fn set_treasury(env: Env, admin: Address, treasury: Address) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::SuperAdmin);
env.storage().instance().set(&treasury_key(), &treasury);
+ clear_reentrant(&env);
}
// -----------------------------------------------------------------------
@@ -911,10 +979,12 @@ impl SplitContract {
/// Deposit of 0 disables the feature. min_age_secs is the minimum time
/// an invoice must exist before the deposit can be refunded on cancel.
pub fn set_spam_deposit(env: Env, admin: Address, amount: i128, min_age_secs: u64) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::Operator);
assert!(amount >= 0, "spam deposit must be non-negative");
env.storage().persistent().set(&spam_deposit_key(), &amount);
env.storage().persistent().set(&spam_deposit_min_age_key(), &min_age_secs);
+ clear_reentrant(&env);
}
/// Get the current spam deposit amount and minimum age.
@@ -930,14 +1000,18 @@ impl SplitContract {
/// Store the address of the Stellar payment streaming contract. Requires admin auth.
pub fn set_stream_contract(env: Env, admin: Address, contract: Address) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::Operator);
env.storage().persistent().set(&stream_contract_key(), &contract);
+ clear_reentrant(&env);
}
/// Store the DEX contract address used for token swaps in pay_with_token(). Requires admin auth.
pub fn set_dex_contract(env: Env, admin: Address, contract: Address) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::Operator);
env.storage().persistent().set(&soroban_sdk::symbol_short!("dex_ctr"), &contract);
+ clear_reentrant(&env);
}
// -----------------------------------------------------------------------
@@ -946,13 +1020,16 @@ impl SplitContract {
/// Propose a new admin. Requires current admin auth.
pub fn propose_admin(env: Env, admin: Address, new_admin: Address) {
+ require_non_reentrant(&env);
require_admin(&env);
let _ = admin;
env.storage().instance().set(&pending_admin_key(), &new_admin);
+ clear_reentrant(&env);
}
/// Accept the admin role. Requires the proposed admin to authenticate.
pub fn accept_admin(env: Env) {
+ require_non_reentrant(&env);
let pending: Address = env
.storage()
.instance()
@@ -961,6 +1038,7 @@ impl SplitContract {
pending.require_auth();
env.storage().instance().set(&admin_key(), &pending);
env.storage().instance().remove(&pending_admin_key());
+ clear_reentrant(&env);
}
// -----------------------------------------------------------------------
@@ -970,10 +1048,12 @@ impl SplitContract {
/// Set a volume cap for a specific creator. Requires admin auth.
/// A cap of 0 means no limit.
pub fn set_creator_volume_cap(env: Env, admin: Address, creator: Address, cap: i128) {
+ require_non_reentrant(&env);
require_admin(&env);
let _ = admin;
assert!(cap >= 0, "cap must be non-negative");
env.storage().persistent().set(&creator_volume_cap_key(&creator), &cap);
+ clear_reentrant(&env);
}
/// Return the volume cap for a creator (0 = no limit).
@@ -1007,34 +1087,8 @@ impl SplitContract {
/// * `creator` - The creator address (must authenticate)
/// * `new_limit` - The new daily spending limit (must be >= 0)
pub fn set_self_limit(env: Env, creator: Address, new_limit: i128) {
+ require_non_reentrant(&env);
creator.require_auth();
- events::monitor_event(
- &env,
- Symbol::new(&env, "create_invoice"),
- 0,
- &creator,
- env.ledger().timestamp(),
- );
- Self::_create_invoice_inner(
- &env,
- creator,
- recipients,
- amounts,
- token,
- deadline,
- options.co_creators,
- options.allow_early_withdrawal,
- options.bonus_pool,
- options.bonus_max_payers,
- options.prerequisite_id,
- options.tranches,
- options.co_signers,
- options.required_signatures,
- options.penalty_bps.unwrap_or(0),
- options.penalty_deadline.unwrap_or(0),
- options.min_funding_bps.unwrap_or(0),
- )
- }
assert!(new_limit >= 0, "self limit must be non-negative");
let current_limit: i128 = env
@@ -1058,9 +1112,11 @@ impl SplitContract {
// Reset the daily usage counter when changing the limit
env.storage()
.persistent()
- .set(&creator_self_used_key(&creator), &0i128);
-
- append_audit_entry(&env, 0, symbol_short!("slf_lim"), &creator);
+ .remove(&creator_self_used_key(&creator));
+ env.storage()
+ .persistent()
+ .remove(&creator_self_limit_day_key(&creator));
+ clear_reentrant(&env);
}
/// Request to raise the creator's self-imposed daily spending limit.
@@ -1077,6 +1133,7 @@ impl SplitContract {
/// # Returns
/// The action_id of the queued raise request.
pub fn request_raise_self_limit(env: Env, creator: Address, new_limit: i128) -> u64 {
+ require_non_reentrant(&env);
creator.require_auth();
assert!(new_limit >= 0, "new limit must be non-negative");
@@ -1114,6 +1171,7 @@ impl SplitContract {
append_audit_entry(&env, 0, symbol_short!("req_rse"), &creator);
+ clear_reentrant(&env);
counter
}
@@ -1153,17 +1211,20 @@ impl SplitContract {
/// Set an arbiter address for an invoice. Requires admin auth.
/// Only the arbiter may raise and resolve disputes on this invoice.
pub fn set_arbiter(env: Env, admin: Address, invoice_id: u64, arbiter: Address) {
+ require_non_reentrant(&env);
require_admin(&env);
let _ = admin;
let mut invoice = load_invoice(&env, invoice_id);
invoice.arbiter = Some(arbiter.clone());
save_invoice(&env, invoice_id, &invoice);
append_audit_entry(&env, invoice_id, symbol_short!("set_arb"), &arbiter);
+ clear_reentrant(&env);
}
/// Raise a dispute on an invoice. Only the configured arbiter may call this.
/// When disputed, all actions (pay, release, refund, cancel) are blocked.
pub fn raise_dispute(env: Env, invoice_id: u64, arbiter: Address) {
+ require_non_reentrant(&env);
require_not_paused(&env);
arbiter.require_auth();
@@ -1181,25 +1242,14 @@ impl SplitContract {
invoice.disputed = true;
save_invoice(&env, invoice_id, &invoice);
append_audit_entry(&env, invoice_id, symbol_short!("dispute"), &arbiter);
+ clear_reentrant(&env);
}
/// Resolve a dispute — release or refund the invoice.
/// Only the designated arbiter may call this.
pub fn resolve_dispute(env: Env, invoice_id: u64, arbiter: Address, resolution: ResolveAction) {
+ require_non_reentrant(&env);
require_not_paused(&env);
- payer.require_auth();
- events::monitor_event(
- &env,
- Symbol::new(&env, "pay"),
- invoice_id,
- &payer,
- env.ledger().timestamp(),
- );
- Self::_pay(&env, &payer, invoice_id, amount, nonce, auto_convert);
- }
-
- fn _pay(env: &Env, payer: &Address, invoice_id: u64, amount: i128, nonce: u64, auto_convert: bool) {
- let mut invoice = load_invoice(env, invoice_id);
arbiter.require_auth();
let mut invoice = load_invoice(&env, invoice_id);
@@ -1220,6 +1270,7 @@ impl SplitContract {
invoice.status = InvoiceStatus::Cancelled;
save_invoice(&env, invoice_id, &invoice);
append_audit_entry(&env, invoice_id, symbol_short!("resolve"), &arbiter);
+ clear_reentrant(&env);
return;
}
@@ -1242,21 +1293,13 @@ impl SplitContract {
total_refunded_amount += amount;
events::payer_refunded(&env, invoice_id, &payer, amount);
}
-
- if invoice.bonus_pool > 0 {
- token_client.transfer(
- &env.current_contract_address(),
- &invoice.creator,
- &invoice.bonus_pool,
- );
- }
-
invoice.status = InvoiceStatus::Refunded;
invoice.completion_time = Some(env.ledger().timestamp());
save_invoice(&env, invoice_id, &invoice);
append_audit_entry(&env, invoice_id, symbol_short!("resolve"), &arbiter);
events::invoice_refunded(&env, invoice_id);
-
+ notify_invoice(&env, invoice_id, symbol_short!("refund"), &invoice.notification_contract);
+ maybe_record_refunded(&env, &invoice.creator);
let total_refunded: i128 = env
.storage()
.persistent()
@@ -1264,10 +1307,9 @@ impl SplitContract {
.unwrap_or(0i128);
env.storage().persistent().set(
&total_refunded_key(),
- &total_refunded
- .checked_add(total_refunded_amount)
- .expect("total_refunded overflow"),
+ &total_refunded.checked_add(total_refunded_amount).expect("total_refunded overflow"),
);
+ clear_reentrant(&env);
}
}
}
@@ -1279,8 +1321,10 @@ impl SplitContract {
/// Store the address of the receipt token factory contract. Requires admin auth.
/// The factory must expose: mint_receipt(invoice_id: u64, payer: Address, amount: i128) -> Address
pub fn set_receipt_factory(env: Env, admin: Address, factory: Address) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::Operator);
env.storage().persistent().set(&receipt_factory_key(), &factory);
+ clear_reentrant(&env);
}
/// Return the receipt token address minted for a specific payer on a specific invoice.
@@ -1294,9 +1338,11 @@ impl SplitContract {
/// Set the dashboard contract address for aggregating creator stats.
/// Requires admin auth.
pub fn set_dashboard_contract(env: Env, admin: Address, dashboard: Address) {
+ require_non_reentrant(&env);
require_admin(&env);
let _ = admin;
env.storage().persistent().set(&dashboard_contract_key(), &dashboard);
+ clear_reentrant(&env);
}
/// Return the dashboard contract address, or None if not set.
@@ -1318,6 +1364,7 @@ impl SplitContract {
/// ascending by threshold. A creator gets the highest tier their lifetime volume qualifies for.
/// Discount applies only to creation_fee, not platform_fee_bps.
pub fn set_fee_tiers(env: Env, admin: Address, tiers: Vec<(i128, u32)>) {
+ require_non_reentrant(&env);
require_admin(&env);
let _ = admin;
@@ -1336,31 +1383,17 @@ impl SplitContract {
}
env.storage().persistent().set(&fee_tiers_key(), &tiers);
+ clear_reentrant(&env);
}
// -----------------------------------------------------------------------
// Issue #4: creator whitelist
// -----------------------------------------------------------------------
- /// Release funds to recipients.
- ///
- /// For tranche invoices, only distributes tranches whose timestamp ≤ now.
- /// Blocks with "prerequisite not released" until the prerequisite invoice is Released.
- /// If an approver is set, requires the invoice to be approved first (issue #25).
- pub fn release(env: Env, invoice_id: u64) {
- require_not_paused(&env);
- let caller = env.current_contract_address();
- let mut invoice = load_invoice(&env, invoice_id);
- events::monitor_event(
- &env,
- Symbol::new(&env, "release"),
- invoice_id,
- &caller,
- env.ledger().timestamp(),
- );
/// Add an address to the creator whitelist. Requires admin auth.
/// When the whitelist is non-empty, only listed addresses may call create_invoice().
pub fn whitelist_creator(env: Env, admin: Address, address: Address) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::SuperAdmin);
let mut wl: Vec = env
.storage()
@@ -1371,10 +1404,12 @@ impl SplitContract {
wl.push_back(address);
}
env.storage().persistent().set(&creator_whitelist_key(), &wl);
+ clear_reentrant(&env);
}
/// Remove an address from the creator whitelist. Requires admin auth.
pub fn remove_creator(env: Env, admin: Address, address: Address) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::SuperAdmin);
let wl: Vec = env
.storage()
@@ -1388,6 +1423,7 @@ impl SplitContract {
}
}
env.storage().persistent().set(&creator_whitelist_key(), &new_wl);
+ clear_reentrant(&env);
}
// -----------------------------------------------------------------------
@@ -1397,6 +1433,7 @@ impl SplitContract {
/// Add an address to the platform fee waiver list. Requires admin auth.
/// Addresses on this list will not be charged platform fees when they are recipients.
pub fn add_platform_fee_waiver(env: Env, admin: Address, address: Address) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::SuperAdmin);
let mut waivers: Vec = env
.storage()
@@ -1407,10 +1444,12 @@ impl SplitContract {
waivers.push_back(address);
}
env.storage().persistent().set(&platform_fee_waiver_list_key(), &waivers);
+ clear_reentrant(&env);
}
/// Remove an address from the platform fee waiver list. Requires admin auth.
pub fn remove_platform_fee_waiver(env: Env, admin: Address, address: Address) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::SuperAdmin);
let waivers: Vec = env
.storage()
@@ -1424,6 +1463,7 @@ impl SplitContract {
}
}
env.storage().persistent().set(&platform_fee_waiver_list_key(), &new_waivers);
+ clear_reentrant(&env);
}
/// Check if an address is on the platform fee waiver list.
@@ -1495,7 +1535,7 @@ impl SplitContract {
payment_window_secs: None,
scheduled_release_at: None,
penalty_tiers: Vec::new(&env),
- allowed_callers: None,
+ allowed_callers_root: None,
refund_grace_secs: None,
external_prerequisite: None,
})
@@ -1575,11 +1615,13 @@ impl SplitContract {
/// (via `balance_of(creator) > 0`) may create invoices. Pass `None` to disable.
/// Requires admin auth.
pub fn set_nft_gate(env: Env, admin: Address, contract: Option) {
+ require_non_reentrant(&env);
let admin_addr = require_admin(&env);
let _ = admin;
env.storage().persistent().set(&nft_gate_key(), &contract);
events::nft_gate_set(&env, &contract, &admin_addr);
+ clear_reentrant(&env);
}
// -----------------------------------------------------------------------
@@ -1589,16 +1631,19 @@ impl SplitContract {
/// Set the timelock duration in seconds. All queued actions must wait at
/// least this long before they can be executed. Requires admin auth.
pub fn set_timelock_secs(env: Env, admin: Address, secs: u64) {
+ require_non_reentrant(&env);
let admin_addr = require_admin(&env);
let _ = admin;
env.storage().persistent().set(&timelock_secs_key(), &secs);
append_audit_entry(&env, 0, Symbol::new(&env, "set_tl"), &admin_addr);
+ clear_reentrant(&env);
}
/// Queue an admin action for future execution after the timelock delay.
/// Returns the unique `action_id`. Requires admin auth.
pub fn queue_action(env: Env, admin: Address, action: TimelockAction) -> u64 {
+ require_non_reentrant(&env);
let admin_addr = require_admin(&env);
let _ = admin;
@@ -1622,12 +1667,14 @@ impl SplitContract {
append_audit_entry(&env, 0, Symbol::new(&env, "queue"), &admin_addr);
events::action_queued(&env, counter, &action, &admin_addr);
+ clear_reentrant(&env);
counter
}
/// Execute a queued timelock action. Anyone may call this once the
/// timelock delay has elapsed since the action was queued.
pub fn execute_action(env: Env, action_id: u64) {
+ require_non_reentrant(&env);
let mut queued: QueuedAction = env
.storage()
.persistent()
@@ -1688,10 +1735,12 @@ impl SplitContract {
append_audit_entry(&env, 0, Symbol::new(&env, "exec"), &env.current_contract_address());
events::action_executed(&env, action_id, &queued.action);
+ clear_reentrant(&env);
}
/// Cancel a queued timelock action before it executes. Requires admin auth.
pub fn cancel_action(env: Env, admin: Address, action_id: u64) {
+ require_non_reentrant(&env);
let admin_addr = require_admin(&env);
let _ = admin;
@@ -1707,6 +1756,7 @@ impl SplitContract {
append_audit_entry(&env, 0, Symbol::new(&env, "cancel"), &admin_addr);
events::action_cancelled(&env, action_id, &queued.action, &admin_addr);
+ clear_reentrant(&env);
}
// -----------------------------------------------------------------------
@@ -1719,6 +1769,7 @@ impl SplitContract {
/// `version = 1` and all other fields preserved. Safe to call multiple
/// times — already-migrated invoices are a no-op. Requires admin auth.
pub fn migrate_invoice(env: Env, admin: Address, invoice_id: u64) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::SuperAdmin);
// Already migrated?
@@ -1728,6 +1779,7 @@ impl SplitContract {
.get::<_, InvoiceCore>(&invoice_key(invoice_id))
{
if core.version >= 1 {
+ clear_reentrant(&env);
return;
}
}
@@ -1741,6 +1793,7 @@ impl SplitContract {
let invoice = Invoice::from_legacy(legacy, &env);
save_invoice(&env, invoice_id, &invoice);
+ clear_reentrant(&env);
}
/// Migrate all escrowed funds to a new contract address atomically.
@@ -1750,6 +1803,7 @@ impl SplitContract {
/// transfers everything to `new_contract`. Requires admin auth.
/// Panics if the calculated total does not match the actual token balance.
pub fn migrate_escrow(env: Env, admin: Address, new_contract: Address) {
+ require_non_reentrant(&env);
let admin_addr = require_admin(&env);
let _ = admin;
@@ -1796,6 +1850,7 @@ impl SplitContract {
events::escrow_migrated(&env, grand_total, &new_contract);
append_audit_entry(&env, 0, symbol_short!("migrate"), &admin_addr);
+ clear_reentrant(&env);
}
// -----------------------------------------------------------------------
@@ -1817,6 +1872,7 @@ impl SplitContract {
deadline: u64,
options: InvoiceOptions,
) -> u64 {
+ require_non_reentrant(&env);
// Check if contract is paused, but allow exempt creators
let is_paused = is_paused(&env);
let is_exempt = env.storage().persistent().get::<_, bool>(&pause_exempt_key(&creator)).unwrap_or(false);
@@ -1894,6 +1950,7 @@ impl SplitContract {
options.scheduled_release_at,
options.fallback_action,
options.external_prerequisite,
+ options.allowed_callers_root,
)
}
@@ -1946,6 +2003,7 @@ impl SplitContract {
scheduled_release_at: Option,
fallback_action: Option,
external_prerequisite: Option<(Address, u64)>,
+ allowed_callers_root: Option>,
) -> u64 {
assert!(
recipients.len() == amounts.len(),
@@ -2248,7 +2306,7 @@ impl SplitContract {
parent_invoice_id: None,
priorities,
penalty_tiers: Vec::new(env),
- allowed_callers: None,
+ allowed_callers_root,
admin_frozen: false,
min_funding_amount: 0,
creation_timestamp: env.ledger().timestamp(),
@@ -2388,21 +2446,25 @@ impl SplitContract {
false, // require_kyc
None, // scheduled_release_at
None, // external_prerequisite
+ None, // allowed_callers_root
);
ids.push_back(id);
}
ids
}
- /// Create a subscription chain of invoices for recurring monthly billing.
+ /// Create a subscription with a billing interval. Payers opt in once and are
+ /// billed per cycle via process_subscription() until they cancel.
pub fn create_subscription(
env: Env,
creator: Address,
recipients: Vec,
amounts: Vec,
token: Address,
- months: u32,
+ interval_secs: u64,
+ num_cycles: u32,
) -> u64 {
+ require_non_reentrant(&env);
creator.require_auth();
assert!(
@@ -2410,12 +2472,13 @@ impl SplitContract {
"recipients and amounts length mismatch"
);
assert!(!recipients.is_empty(), "must have at least one recipient");
- assert!(months > 0 && months <= 12, "months must be between 1 and 12");
+ assert!(interval_secs >= 86_400, "interval must be at least 1 day");
+ assert!(num_cycles > 0 && num_cycles <= 365, "cycles must be between 1 and 365");
for amt in amounts.iter() {
assert!(amt > 0, "amounts must be positive");
}
- let deadline = env.ledger().timestamp() + 30 * 24 * 60 * 60;
+ let deadline = env.ledger().timestamp() + interval_secs;
let id = Self::_create_invoice_inner(
&env,
creator.clone(),
@@ -2463,28 +2526,227 @@ impl SplitContract {
false, // require_kyc
None, // scheduled_release_at
None, // external_prerequisite
+ None, // allowed_callers_root
);
- if months > 1 {
- // Build tokens vec for subscription params storage.
- let mut tokens_vec: Vec = Vec::new(&env);
- for _ in recipients.iter() {
- tokens_vec.push_back(token.clone());
- }
- let params = SubscriptionParams {
- creator,
- recipients,
- amounts,
- tokens: tokens_vec,
- };
- env.storage()
- .persistent()
- .set(&subscription_params_key(id), ¶ms);
+ // Build tokens vec for subscription params storage (always store params).
+ let mut tokens_vec: Vec = Vec::new(&env);
+ for _ in recipients.iter() {
+ tokens_vec.push_back(token.clone());
}
+ let params = SubscriptionParams {
+ creator,
+ recipients,
+ amounts,
+ tokens: tokens_vec,
+ interval_secs,
+ next_invoice_at: env.ledger().timestamp() + interval_secs,
+ active: true,
+ last_invoice_id: Some(id),
+ };
+ env.storage()
+ .persistent()
+ .set(&subscription_params_key(id), ¶ms);
+ clear_reentrant(&env);
id
}
+ /// Opt into a subscription. The payer will be billed each cycle until they cancel.
+ pub fn opt_into_subscription(env: Env, payer: Address, sub_id: u64) {
+ require_non_reentrant(&env);
+ payer.require_auth();
+
+ let mut params: SubscriptionParams = env.storage()
+ .persistent()
+ .get(&subscription_params_key(sub_id))
+ .expect("subscription not found");
+ assert!(params.active, "subscription is not active");
+
+ let mut subscribers: Vec = env.storage()
+ .persistent()
+ .get(&subscription_subscribers_key(sub_id))
+ .unwrap_or_else(|| Vec::new(&env));
+
+ if !subscribers.iter().any(|s| s == payer) {
+ subscribers.push_back(payer.clone());
+ env.storage().persistent().set(&subscription_subscribers_key(sub_id), &subscribers);
+ }
+ clear_reentrant(&env);
+ }
+
+ /// Cancel subscription for a single payer. They will no longer be billed.
+ pub fn cancel_subscription(env: Env, payer: Address, sub_id: u64) {
+ require_non_reentrant(&env);
+ payer.require_auth();
+
+ let params: SubscriptionParams = env.storage()
+ .persistent()
+ .get(&subscription_params_key(sub_id))
+ .expect("subscription not found");
+ let _ = params; // just validate subscription exists
+
+ let mut subscribers: Vec = env.storage()
+ .persistent()
+ .get(&subscription_subscribers_key(sub_id))
+ .unwrap_or_else(|| Vec::new(&env));
+
+ let mut new_list: Vec = Vec::new(&env);
+ for s in subscribers.iter() {
+ if s != payer {
+ new_list.push_back(s);
+ }
+ }
+ env.storage().persistent().set(&subscription_subscribers_key(sub_id), &new_list);
+ clear_reentrant(&env);
+ }
+
+ /// Creator can cancel the entire subscription (deactivates it).
+ pub fn cancel_subscription_creator(env: Env, creator: Address, sub_id: u64) {
+ require_non_reentrant(&env);
+ creator.require_auth();
+
+ let mut params: SubscriptionParams = env.storage()
+ .persistent()
+ .get(&subscription_params_key(sub_id))
+ .expect("subscription not found");
+ assert!(params.creator == creator, "only creator can cancel subscription");
+ params.active = false;
+ env.storage().persistent().set(&subscription_params_key(sub_id), ¶ms);
+ clear_reentrant(&env);
+ }
+
+ /// Process the next billing cycle for a subscription. Creates a new invoice and
+ /// charges all opted-in payers. Can be called by anyone.
+ pub fn process_subscription(env: Env, sub_id: u64) -> Option {
+ require_non_reentrant(&env);
+ let mut params: SubscriptionParams = env.storage()
+ .persistent()
+ .get(&subscription_params_key(sub_id))
+ .expect("subscription not found");
+
+ if !params.active {
+ clear_reentrant(&env);
+ return None;
+ }
+
+ let now = env.ledger().timestamp();
+ if now < params.next_invoice_at {
+ clear_reentrant(&env);
+ return None;
+ }
+
+ let first_token = params.tokens.get(0).expect("no token in subscription");
+
+ let deadline = now + params.interval_secs;
+ let new_id = Self::_create_invoice_inner(
+ &env,
+ params.creator.clone(),
+ params.recipients.clone(),
+ params.amounts.clone(),
+ first_token,
+ deadline,
+ Vec::new(&env),
+ false,
+ 0,
+ 0,
+ None,
+ Vec::new(&env),
+ Vec::new(&env),
+ 0,
+ 0,
+ 0,
+ 0,
+ Vec::new(&env),
+ None,
+ Vec::new(&env),
+ None,
+ 0,
+ None,
+ 0,
+ false,
+ None,
+ OverflowBehavior::Reject,
+ false,
+ Vec::new(&env),
+ None,
+ None,
+ None,
+ 0,
+ 0,
+ Vec::new(&env),
+ Vec::new(&env),
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ Vec::new(&env), // priorities
+ false, // require_kyc
+ None, // scheduled_release_at
+ None, // external_prerequisite
+ None, // allowed_callers_root
+ );
+
+ params.next_invoice_at = now + params.interval_secs;
+ params.last_invoice_id = Some(new_id);
+ env.storage().persistent().set(&subscription_params_key(sub_id), ¶ms);
+
+ // Charge all opted-in subscribers for the new invoice.
+ let subscribers: Vec = env.storage()
+ .persistent()
+ .get(&subscription_subscribers_key(sub_id))
+ .unwrap_or_else(|| Vec::new(&env));
+
+ let token_client = token::Client::new(&env, &first_token);
+ let total: i128 = params.amounts.iter().sum();
+ for payer in subscribers.iter() {
+ // Try to transfer from payer; skip if insufficient balance.
+ let balance = token_client.balance(&payer);
+ if balance >= total {
+ token_client.transfer(&payer, &env.current_contract_address(), &total);
+
+ // Record payment in sharded storage (issue #177).
+ let shard_id = compute_shard_id(&env, &payer);
+ let mut shard_payments: Vec = env.storage()
+ .persistent()
+ .get::<(Symbol, u64, u64), Vec>(&pay_shard_key(new_id, shard_id))
+ .unwrap_or_else(|| Vec::new(&env));
+ shard_payments.push_back(Payment {
+ payer: payer.clone(),
+ amount: total,
+ tip: 0,
+ attestation_hash: None,
+ donate_on_failure: false,
+ });
+ env.storage().persistent().set(&pay_shard_key(new_id, shard_id), &shard_payments);
+
+ // Update invoice funded amount
+ let mut invoice = load_invoice(&env, new_id);
+ invoice.funded += total;
+ save_invoice(&env, new_id, &invoice);
+
+ events::payment_received(&env, new_id, &payer, total);
+ }
+ }
+
+ // Auto-release if fully funded and no guards.
+ let mut new_invoice = load_invoice(&env, new_id);
+ let invoice_total: i128 = new_invoice.amounts.iter().sum();
+ let guarded = new_invoice.prerequisite_id.is_some()
+ || !new_invoice.tranches.is_empty()
+ || !new_invoice.release_stages.is_empty()
+ || !new_invoice.co_signers.is_empty();
+ if new_invoice.funded >= invoice_total && !guarded {
+ let caller = env.current_contract_address();
+ Self::_release(&env, new_id, &mut new_invoice, &caller);
+ }
+
+ clear_reentrant(&env);
+ Some(new_id)
+ }
+
// -----------------------------------------------------------------------
// Invoice cloning
// -----------------------------------------------------------------------
@@ -2504,6 +2766,7 @@ impl SplitContract {
source_id: u64,
overrides: CloneOverrides,
) -> u64 {
+ require_non_reentrant(&env);
require_not_paused(&env);
creator.require_auth();
@@ -2592,7 +2855,7 @@ impl SplitContract {
penalty_bps: source.penalty_bps,
penalty_deadline: source.penalty_deadline,
penalty_tiers: source.penalty_tiers.clone(),
- allowed_callers: source.allowed_callers.clone(),
+ allowed_callers_root: source.allowed_callers_root.clone(),
min_funding_bps: source.min_funding_bps,
release_stages: source.release_stages.clone(),
released_stages: source.released_stages,
@@ -2653,6 +2916,7 @@ impl SplitContract {
env.storage().persistent().set(&key, &ids);
}
+ clear_reentrant(&env);
id
}
@@ -2671,6 +2935,7 @@ impl SplitContract {
/// Compress payments by aggregating all payments from the same payer into a single entry.
pub fn compress_payments(env: Env, invoice_id: u64) {
+ require_non_reentrant(&env);
require_not_paused(&env);
let invoice = load_invoice(&env, invoice_id);
@@ -2713,6 +2978,7 @@ impl SplitContract {
shard_payments.push_back(payment.clone());
env.storage().persistent().set(&pay_shard_key(invoice_id, shard_id), &shard_payments);
}
+ clear_reentrant(&env);
}
@@ -2721,6 +2987,7 @@ impl SplitContract {
// -----------------------------------------------------------------------
pub fn open_channel(env: Env, payer: Address, invoice_id: u64, deposit: i128) {
+ require_non_reentrant(&env);
require_not_paused(&env);
payer.require_auth();
assert!(deposit > 0, "deposit must be positive");
@@ -2735,9 +3002,11 @@ impl SplitContract {
// Store (balance, deposited)
let state: (i128, i128) = (deposit, deposit);
env.storage().persistent().set(&channel_key(invoice_id, &payer), &state);
+ clear_reentrant(&env);
}
pub fn channel_pay(env: Env, payer: Address, invoice_id: u64, amount: i128) {
+ require_non_reentrant(&env);
require_not_paused(&env);
payer.require_auth();
assert!(amount > 0, "amount must be positive");
@@ -2747,9 +3016,11 @@ impl SplitContract {
state.0 -= amount;
env.storage().persistent().set(&channel_key(invoice_id, &payer), &state);
+ clear_reentrant(&env);
}
pub fn close_channel(env: Env, payer: Address, invoice_id: u64) {
+ require_non_reentrant(&env);
require_not_paused(&env);
payer.require_auth();
@@ -2807,16 +3078,20 @@ impl SplitContract {
}
env.storage().persistent().remove(&channel_key(invoice_id, &payer));
+ clear_reentrant(&env);
}
pub fn pay(env: Env, payer: Address, invoice_id: u64, amount: i128, nonce: u64, _auto_convert: bool, donate_on_failure: bool) {
+ require_non_reentrant(&env);
require_fn_not_paused(&env, &symbol_short!("pay"));
payer.require_auth();
- Self::_pay(&env, &payer, invoice_id, amount, nonce, _auto_convert, None, None, donate_on_failure);
+ Self::_pay(&env, &payer, invoice_id, amount, nonce, _auto_convert, None, None, donate_on_failure, Vec::new(&env));
+ clear_reentrant(&env);
}
/// Pay with a signed attestation binding the payment to an off-chain identity
pub fn pay_with_attestation(env: Env, payer: Address, invoice_id: u64, amount: i128, nonce: u64, attestation_hash: BytesN<32>, signature: BytesN<64>, signer_pubkey: BytesN<32>, _auto_convert: bool) {
+ require_non_reentrant(&env);
require_fn_not_paused(&env, &symbol_short!("pay"));
payer.require_auth();
@@ -2825,10 +3100,11 @@ impl SplitContract {
env.crypto().ed25519_verify(&signer_pubkey, &attestation_msg, &signature);
// Proceed with payment, storing the attestation hash
- Self::_pay(&env, &payer, invoice_id, amount, nonce, _auto_convert, None, Some(attestation_hash), false);
+ Self::_pay(&env, &payer, invoice_id, amount, nonce, _auto_convert, None, Some(attestation_hash), false, Vec::new(&env));
+ clear_reentrant(&env);
}
- fn _pay(env: &Env, payer: &Address, invoice_id: u64, amount: i128, nonce: u64, _auto_convert: bool, via: Option, attestation_hash: Option>, donate_on_failure: bool) {
+ fn _pay(env: &Env, payer: &Address, invoice_id: u64, amount: i128, nonce: u64, _auto_convert: bool, via: Option, attestation_hash: Option>, donate_on_failure: bool, proof: Vec>) {
let mut invoice = load_invoice(env, invoice_id);
assert!(
@@ -2870,10 +3146,17 @@ impl SplitContract {
assert!(whitelist.contains(payer), "payer not allowed");
}
- // Issue #208: source contract allowlist check.
- if let Some(ref callers) = invoice.allowed_callers {
+ // Issue #208: source contract allowlist check using Merkle root.
+ if let Some(ref merkle_root) = invoice.allowed_callers_root {
match via {
- Some(ref addr) => assert!(callers.contains(addr), "caller not allowed"),
+ Some(ref addr) => {
+ let addr_bytes = addr.to_xdr(env);
+ let leaf = env.crypto().sha256(&addr_bytes);
+ assert!(
+ verify_merkle_proof(env, merkle_root, &leaf, proof),
+ "caller not allowed by merkle proof"
+ );
+ }
None => panic!("direct payments not allowed when caller allowlist is set"),
}
}
@@ -3164,6 +3447,7 @@ impl SplitContract {
amount: i128,
nonce: u64,
) {
+ require_non_reentrant(&env);
require_fn_not_paused(&env, &symbol_short!("pay_tok"));
payer.require_auth();
@@ -3254,6 +3538,7 @@ impl SplitContract {
} else {
save_invoice(&env, invoice_id, &invoice);
}
+ clear_reentrant(&env);
}
/// Pay with an alternate token by swapping via the configured DEX contract.
@@ -3265,6 +3550,7 @@ impl SplitContract {
source_token: Address,
source_amount: i128,
) {
+ require_non_reentrant(&env);
require_fn_not_paused(&env, &symbol_short!("brg_pay"));
payer.require_auth();
@@ -3340,6 +3626,7 @@ impl SplitContract {
/// Any invalid payment (wrong status, over limit) reverts the entire call.
/// Invoices that become fully funded trigger auto-release where applicable.
pub fn pool_pay(env: Env, payer: Address, payments: Vec) {
+ require_non_reentrant(&env);
require_not_paused(&env);
payer.require_auth();
@@ -3411,12 +3698,15 @@ impl SplitContract {
} else {
Self::_release(&env, p.invoice_id, &mut inv, &payer);
}
- } else {
- save_invoice(&env, p.invoice_id, &inv);
+ } else {
+ save_invoice(&env, invoice_id, &inv);
}
}
+ clear_reentrant(&env);
}
+ // -----------------------------------------------------------------------
+ // Co-signer approval & Release
// -----------------------------------------------------------------------
// Co-signer approval & Release
// -----------------------------------------------------------------------
@@ -3426,6 +3716,7 @@ impl SplitContract {
/// Only addresses in `co_signers` may call this. Once `required_signatures`
/// unique co-signers have approved, the release guard is satisfied.
pub fn sign_release(env: Env, invoice_id: u64, signer: Address) {
+ require_non_reentrant(&env);
require_not_paused(&env);
signer.require_auth();
@@ -3449,6 +3740,7 @@ impl SplitContract {
invoice.signatures.push_back(signer.clone());
save_invoice(&env, invoice_id, &invoice);
append_audit_entry(&env, invoice_id, symbol_short!("sign_rel"), &signer);
+ clear_reentrant(&env);
}
// -----------------------------------------------------------------------
@@ -3461,6 +3753,7 @@ impl SplitContract {
/// Blocks with "prerequisite not released" until the prerequisite invoice is Released.
/// If an approver is set, requires the invoice to be approved first (issue #25).
pub fn release(env: Env, invoice_id: u64) {
+ require_non_reentrant(&env);
require_fn_not_paused(&env, &symbol_short!("release"));
let caller = env.current_contract_address();
let mut invoice = load_invoice(&env, invoice_id);
@@ -3539,10 +3832,12 @@ impl SplitContract {
}
Self::_release(&env, invoice_id, &mut invoice, &caller);
+ clear_reentrant(&env);
}
/// Trigger a scheduled release at the configured timestamp, respecting min_funding_bps
pub fn trigger_scheduled_release(env: Env, invoice_id: u64) {
+ require_non_reentrant(&env);
require_not_paused(&env);
let mut invoice = load_invoice(&env, invoice_id);
@@ -3594,6 +3889,7 @@ impl SplitContract {
let caller = env.current_contract_address();
Self::_release(&env, invoice_id, &mut invoice, &caller);
+ clear_reentrant(&env);
}
fn _release(env: &Env, invoice_id: u64, invoice: &mut Invoice, actor: &Address) {
@@ -3630,6 +3926,7 @@ impl SplitContract {
///
/// Requires authentication from the approver address.
pub fn approve_invoice(env: Env, invoice_id: u64) {
+ require_non_reentrant(&env);
require_not_paused(&env);
let mut invoice = load_invoice(&env, invoice_id);
@@ -3639,6 +3936,7 @@ impl SplitContract {
invoice.approved = true;
save_invoice(&env, invoice_id, &invoice);
append_audit_entry(&env, invoice_id, symbol_short!("aprv"), approver);
+ clear_reentrant(&env);
}
// -----------------------------------------------------------------------
@@ -3656,6 +3954,7 @@ impl SplitContract {
reason: String,
auto_resume_at: Option,
) {
+ require_non_reentrant(&env);
require_not_paused(&env);
creator.require_auth();
@@ -3679,12 +3978,14 @@ impl SplitContract {
append_audit_entry(&env, invoice_id, symbol_short!("paused"), &creator);
events::invoice_paused(&env, invoice_id, &creator, &reason, &auto_resume_at);
+ clear_reentrant(&env);
}
/// Unfreeze a paused invoice. Clears the stored reason and auto-resume time.
///
/// Only the creator (or a co-creator) may call this.
pub fn resume_invoice(env: Env, creator: Address, invoice_id: u64) {
+ require_non_reentrant(&env);
require_not_paused(&env);
creator.require_auth();
@@ -3703,6 +4004,7 @@ impl SplitContract {
append_audit_entry(&env, invoice_id, symbol_short!("resumed"), &creator);
events::invoice_resumed(&env, invoice_id, &creator);
+ clear_reentrant(&env);
}
/// Remove a payer from the invoice's allowed_payers allowlist.
@@ -3711,6 +4013,7 @@ impl SplitContract {
/// (open invoice), this is a no-op and does not error. Already-made payments
/// from the removed payer are untouched; this only blocks future payments.
pub fn remove_allowed_payer(env: Env, creator: Address, invoice_id: u64, payer: Address) {
+ require_non_reentrant(&env);
require_not_paused(&env);
creator.require_auth();
@@ -3733,6 +4036,25 @@ impl SplitContract {
save_invoice(&env, invoice_id, &invoice);
append_audit_entry(&env, invoice_id, symbol_short!("rem_payer"), &creator);
}
+ clear_reentrant(&env);
+ }
+
+ /// Set the Merkle root for the caller allowlist on an invoice.
+ /// Only the creator (or a co-creator) may call this.
+ pub fn set_allowed_callers_root(env: Env, creator: Address, invoice_id: u64, root: Option>) {
+ require_non_reentrant(&env);
+ require_not_paused(&env);
+ creator.require_auth();
+ let mut invoice = load_invoice(&env, invoice_id);
+ assert!(
+ invoice.creator == creator
+ || invoice.co_creators.iter().any(|c| c == creator),
+ "only creator can modify caller allowlist"
+ );
+ invoice.allowed_callers_root = root;
+ save_invoice(&env, invoice_id, &invoice);
+ append_audit_entry(&env, invoice_id, symbol_short!("set_caller"), &creator);
+ clear_reentrant(&env);
}
/// Add a payer to the invoice's allowed_payers allowlist.
@@ -3741,6 +4063,7 @@ impl SplitContract {
/// (open invoice), this is a no-op and does not error. If the payer is already
/// in the allowlist, this is a no-op.
pub fn add_allowed_payer(env: Env, creator: Address, invoice_id: u64, payer: Address) {
+ require_non_reentrant(&env);
require_not_paused(&env);
creator.require_auth();
@@ -3760,6 +4083,7 @@ impl SplitContract {
append_audit_entry(&env, invoice_id, symbol_short!("add_payer"), &creator);
}
}
+ clear_reentrant(&env);
}
/// Admin override: force-resume any paused invoice regardless of who paused it.
@@ -3767,6 +4091,7 @@ impl SplitContract {
/// Requires admin auth. Clears the frozen flag, reason, and auto-resume time,
/// and emits a force_resumed event with the admin address.
pub fn admin_force_resume(env: Env, admin: Address, invoice_id: u64) {
+ require_non_reentrant(&env);
require_role(&env, &admin, AdminRole::Operator);
let mut invoice = load_invoice(&env, invoice_id);
@@ -3779,11 +4104,13 @@ impl SplitContract {
append_audit_entry(&env, invoice_id, symbol_short!("frc_rsm"), &admin);
events::invoice_force_resumed(&env, invoice_id, &admin);
+ clear_reentrant(&env);
}
/// Admin freeze an invoice with a reason (overrides creator freeze).
/// Requires admin auth. Sets `admin_frozen = true` on InvoiceExt.
pub fn admin_freeze(env: Env, admin: Address, invoice_id: u64, reason: String) {
+ require_non_reentrant(&env);
let admin_addr = require_admin(&env);
let _ = admin;
@@ -3800,11 +4127,13 @@ impl SplitContract {
append_audit_entry(&env, invoice_id, symbol_short!("adm_frz"), &admin_addr);
events::invoice_admin_frozen(&env, invoice_id, &admin_addr, &reason);
+ clear_reentrant(&env);
}
/// Admin unfreeze an invoice (clears admin_frozen).
/// Requires admin auth.
pub fn admin_unfreeze(env: Env, admin: Address, invoice_id: u64) {
+ require_non_reentrant(&env);
let admin_addr = require_admin(&env);
let _ = admin;
@@ -3819,11 +4148,13 @@ impl SplitContract {
append_audit_entry(&env, invoice_id, symbol_short!("adm_unf"), &admin_addr);
events::invoice_admin_unfrozen(&env, invoice_id, &admin_addr);
+ clear_reentrant(&env);
}
/// Oracle confirms a condition for a gated invoice.
/// Requires the configured oracle address to authenticate.
pub fn confirm_condition(env: Env, invoice_id: u64) {
+ require_non_reentrant(&env);
require_not_paused(&env);
let mut invoice = load_invoice(&env, invoice_id);
assert!(!invoice.disputed, "invoice is disputed");
@@ -3832,11 +4163,13 @@ impl SplitContract {
invoice.condition_met = true;
save_invoice(&env, invoice_id, &invoice);
append_audit_entry(&env, invoice_id, symbol_short!("oracle_ok"), oracle);
+ clear_reentrant(&env);
}
/// Set a payment reminder for an address on a specific invoice.
/// The `who` address must authenticate.
pub fn set_reminder(env: Env, who: Address, invoice_id: u64, remind_at: u64) {
+ require_non_reentrant(&env);
require_not_paused(&env);
who.require_auth();
env.storage()
@@ -3862,6 +4195,7 @@ impl SplitContract {
/// Create a treasury group linking multiple invoice IDs to a single treasury address.
/// Returns the new group id.
pub fn group_treasury_create(env: Env, creator: Address, invoice_ids: Vec, treasury: Address) -> u64 {
+ require_non_reentrant(&env);
require_not_paused(&env);
creator.require_auth();
let id: u64 = env
@@ -3877,6 +4211,7 @@ impl SplitContract {
env.storage().persistent().set(&invoice_treasury_key(iid), &id);
append_audit_entry(&env, iid, symbol_short!("grp_tr"), &creator);
}
+ clear_reentrant(&env);
id
}
@@ -3887,7 +4222,7 @@ impl SplitContract {
payer.require_auth();
// Validate memo corresponds to an existing invoice.
let _ = load_invoice(&env, memo);
- Self::_pay(&env, &payer, memo, amount, nonce, _auto_convert, via, None, false);
+ Self::_pay(&env, &payer, memo, amount, nonce, _auto_convert, via, None, false, Vec::new(&env));
events::payment_matched(&env, memo, memo, &payer);
}
@@ -3896,6 +4231,7 @@ impl SplitContract {
/// Requires that the invoice status is Released and the cliff (if set) has passed.
/// Each recipient can claim exactly once.
pub fn claim(env: Env, invoice_id: u64, recipient: Address) {
+ require_non_reentrant(&env);
require_not_paused(&env);
recipient.require_auth();
@@ -3979,11 +4315,13 @@ impl SplitContract {
}
append_audit_entry(&env, invoice_id, symbol_short!("claim"), &recipient);
+ clear_reentrant(&env);
}
/// Claim a pending payout that was not transferred during release (issue #209).
/// Recipient can claim their payout after the invoice is Released.
pub fn claim_pending_payout(env: Env, invoice_id: u64, recipient: Address) {
+ require_non_reentrant(&env);
recipient.require_auth();
let invoice = load_invoice(&env, invoice_id);
@@ -4008,6 +4346,7 @@ impl SplitContract {
.remove(&pending_payout_key(invoice_id, &recipient));
events::pending_payout_claimed(&env, invoice_id, &recipient, pending);
+ clear_reentrant(&env);
}
/// Distribute tranches unlocked by the current ledger time (issue #23).
@@ -4158,6 +4497,7 @@ impl SplitContract {
/// Requires creator auth. Each call distributes the next stage's proportion
/// of the total funded amount. The final stage sets the invoice status to Released.
pub fn stage_release(env: Env, invoice_id: u64, creator: Address) {
+ require_non_reentrant(&env);
require_not_paused(&env);
creator.require_auth();
@@ -4298,6 +4638,7 @@ impl SplitContract {
}
save_invoice(&env, invoice_id, &invoice);
+ clear_reentrant(&env);
}
/// Partially release `amount` from a pending invoice to recipients in priority order.
@@ -4306,6 +4647,7 @@ impl SplitContract {
/// When no priorities are set, funds are distributed proportionally (original behaviour).
/// Requires creator auth. Does not change invoice status (remains Pending).
pub fn partial_release(env: Env, invoice_id: u64, creator: Address, amount: i128) {
+ require_non_reentrant(&env);
require_not_paused(&env);
creator.require_auth();
@@ -4390,6 +4732,7 @@ impl SplitContract {
save_invoice(&env, invoice_id, &invoice);
append_audit_entry(&env, invoice_id, symbol_short!("part_rel"), &creator);
events::invoice_partially_released(&env, invoice_id, &invoice.recipients);
+ clear_reentrant(&env);
}
/// Full immediate release (no tranches).
@@ -4787,66 +5130,15 @@ impl SplitContract {
&creator_released.checked_add(1).expect("creator_released overflow"),
);
- // Spin up next subscription invoice if one is scheduled.
- if let Some(params) = env
+ clear_reentrant(env);
+
+ // Notify subscription params that this invoice was released.
+ // The subscription is now managed via process_subscription().
+ // We keep the params so the subscription continues to work.
+ let _ = env
.storage()
.persistent()
- .get::<(Symbol, u64), SubscriptionParams>(&subscription_params_key(invoice_id))
- {
- let next_deadline = env.ledger().timestamp() + 30 * 24 * 60 * 60;
- let first_token = params.tokens.get(0).expect("no token in subscription");
- let _next_id = Self::_create_invoice_inner(
- env,
- params.creator.clone(),
- params.recipients.clone(),
- params.amounts.clone(),
- first_token,
- next_deadline,
- Vec::new(env),
- false,
- 0,
- 0,
- None,
- Vec::new(env),
- Vec::new(env),
- 0,
- 0,
- 0,
- 0,
- Vec::new(env),
- None,
- Vec::new(env),
- None,
- 0,
- None,
- 0,
- false,
- None,
- OverflowBehavior::Reject,
- false,
- Vec::new(env),
- None,
- None,
- None,
- 0,
- 0,
- Vec::new(env),
- Vec::new(env),
- None,
- None,
- None,
- None,
- None,
- None,
- Vec::new(env), // priorities
- false, // require_kyc
- None, // scheduled_release_at
- None, // external_prerequisite
- );
- env.storage()
- .persistent()
- .remove(&subscription_params_key(invoice_id));
- }
+ .get::<(Symbol, u64), SubscriptionParams>(&subscription_params_key(invoice_id));
}
// -----------------------------------------------------------------------
@@ -4860,10 +5152,12 @@ impl SplitContract {
/// If no rule matches and no fallback_action is configured, it's a no-op (idempotent).
/// Panics only if invoice is not pending or is disputed.
pub fn auto_resolve(env: Env, invoice_id: u64) {
+ require_non_reentrant(&env);
require_not_paused(&env);
let mut invoice = load_invoice(&env, invoice_id);
if invoice.status != InvoiceStatus::Pending {
+ clear_reentrant(&env);
return;
}
assert!(!invoice.disputed, "invoice is disputed");
@@ -4923,6 +5217,7 @@ impl SplitContract {
);
}
}
+ clear_reentrant(&env);
return;
}
}
@@ -4977,6 +5272,7 @@ impl SplitContract {
}
}
// else: no-op, intentional and idempotent.
+ clear_reentrant(&env);
}
// -----------------------------------------------------------------------
@@ -4986,6 +5282,7 @@ impl SplitContract {
/// Creator-initiated partial refund proportional to each payer's contribution.
/// Distributes `funded * bps / 10_000` back to payers and decrements `invoice.funded`.
pub fn partial_refund(env: Env, creator: Address, invoice_id: u64, bps: u32) {
+ require_non_reentrant(&env);
require_not_paused(&env);
creator.require_auth();
@@ -5024,10 +5321,13 @@ impl SplitContract {
save_invoice(&env, invoice_id, &invoice);
append_audit_entry(&env, invoice_id, symbol_short!("part_ref"), &creator);
events::partial_refund_issued(&env, invoice_id, &creator, bps, total_refunded);
+ clear_reentrant(&env);
}
/// Refund all payers if the deadline has passed and the invoice is not fully funded.
+ /// If the invoice belongs to a group and any member is underfunded, all group members are refunded.
pub fn refund(env: Env, invoice_id: u64) {
+ require_non_reentrant(&env);
require_fn_not_paused(&env, &symbol_short!("refund"));
let mut invoice = load_invoice(&env, invoice_id);
@@ -5058,19 +5358,54 @@ impl SplitContract {
invoice.auction_end = now.saturating_add(24 * 60 * 60);
save_invoice(&env, invoice_id, &invoice);
append_audit_entry(&env, invoice_id, symbol_short!("auc_strt"), &env.current_contract_address());
+ clear_reentrant(&env);
return;
}
assert!(now > invoice.auction_end, "auction in progress");
panic!("auction ended; settle auction");
}
+ // Group refund: if invoice belongs to a group and any member is underfunded, refund all.
+ if let Some(group_id) = env
+ .storage()
+ .persistent()
+ .get::<(Symbol, u64), u64>(&invoice_group_key(invoice_id))
+ {
+ let group_ids = load_group(&env, group_id);
+ let mut any_underfunded = false;
+ for mid in group_ids.iter() {
+ let member = load_invoice(&env, mid);
+ let total: i128 = member.amounts.iter().sum();
+ if member.status == InvoiceStatus::Pending && member.funded < total {
+ any_underfunded = true;
+ break;
+ }
+ }
+ if any_underfunded {
+ for mid in group_ids.iter() {
+ let mut member = load_invoice(&env, mid);
+ if member.status == InvoiceStatus::Pending {
+ Self::_refund_single(&env, mid, &mut member);
+ }
+ }
+ clear_reentrant(&env);
+ return;
+ }
+ }
+
+ Self::_refund_single(&env, invoice_id, &mut invoice);
+ clear_reentrant(&env);
+ }
+
+ /// Internal helper: refund a single invoice without group logic.
+ fn _refund_single(env: &Env, invoice_id: u64, invoice: &mut Invoice) {
let token_client =
- token::Client::new(&env, &invoice.tokens.get(0).expect("no token"));
+ token::Client::new(env, &invoice.tokens.get(0).expect("no token"));
// Aggregate payments from all shards (issue #177).
- let mut totals: Map = Map::new(&env);
+ let mut totals: Map = Map::new(env);
// Issue #204: separate map for donate-on-failure contributions.
- let mut donate_totals: Map = Map::new(&env);
+ let mut donate_totals: Map = Map::new(env);
for shard_id in 0..SHARD_COUNT {
if let Some(shard_payments) = env.storage().persistent().get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) {
for payment in shard_payments.iter() {
@@ -5110,23 +5445,19 @@ impl SplitContract {
// Issue #243: Fair partial refund handling
if shortfall > 0 {
- // Build vector of (payer, amount) and sort by amount ascending
- let mut payers_vec: Vec<(Address, i128)> = Vec::new(&env);
+ let mut payers_vec: Vec<(Address, i128)> = Vec::new(env);
for (payer, amount) in totals.iter() {
if amount > 0 {
payers_vec.push_back((payer, amount));
}
}
- // Sort by amount ascending (smallest contributors first)
- // Simple bubble sort since we're dealing with payments data
let len = payers_vec.len();
for i in 0..len {
for j in 0..(len - 1 - i) {
let (_, amt_j) = payers_vec.get(j);
let (_, amt_j_plus_1) = payers_vec.get(j + 1);
if amt_j > amt_j_plus_1 {
- // Swap
let temp_j = payers_vec.get(j);
let temp_j_plus_1 = payers_vec.get(j + 1);
payers_vec.set(j, temp_j_plus_1);
@@ -5135,40 +5466,33 @@ impl SplitContract {
}
}
- // Refund in ascending order of contribution
for (payer, amount) in payers_vec.iter() {
if balance_for_refunds <= 0 {
break;
}
-
let refund_amount = if amount <= balance_for_refunds {
amount
} else {
balance_for_refunds
};
-
if refund_amount > 0 {
token_client.transfer(&env.current_contract_address(), &payer, &refund_amount);
total_refunded_amount += refund_amount;
balance_for_refunds -= refund_amount;
- events::payer_refunded(&env, invoice_id, &payer, refund_amount);
+ events::payer_refunded(env, invoice_id, &payer, refund_amount);
}
}
-
- // Emit shortfall event
- events::refund_shortfall(&env, invoice_id, shortfall);
+ events::refund_shortfall(env, invoice_id, shortfall);
} else {
- // Issue #243: Full refund path (unchanged from before)
for (payer, amount) in totals.iter() {
if amount > 0 {
token_client.transfer(&env.current_contract_address(), &payer, &amount);
total_refunded_amount += amount;
- events::payer_refunded(&env, invoice_id, &payer, amount);
+ events::payer_refunded(env, invoice_id, &payer, amount);
}
}
}
- // Issue #204: send all donate-on-failure contributions to the creator.
if creator_receives > 0 {
token_client.transfer(
&env.current_contract_address(),
@@ -5179,14 +5503,13 @@ impl SplitContract {
invoice.status = InvoiceStatus::Refunded;
invoice.completion_time = Some(env.ledger().timestamp());
- save_invoice(&env, invoice_id, &invoice);
+ save_invoice(env, invoice_id, invoice);
let actor = env.current_contract_address();
- append_audit_entry(&env, invoice_id, symbol_short!("refund"), &actor);
- events::invoice_refunded(&env, invoice_id);
- notify_invoice(&env, invoice_id, symbol_short!("refund"), &invoice.notification_contract);
- maybe_record_refunded(&env, &invoice.creator);
+ append_audit_entry(env, invoice_id, symbol_short!("refund"), &actor);
+ events::invoice_refunded(env, invoice_id);
+ notify_invoice(env, invoice_id, symbol_short!("refund"), &invoice.notification_contract);
+ maybe_record_refunded(env, &invoice.creator);
- // Increment total_refunded counter (issue #28).
let total_refunded: i128 = env
.storage()
.persistent()
@@ -5197,7 +5520,6 @@ impl SplitContract {
&total_refunded.checked_add(total_refunded_amount).expect("total_refunded overflow"),
);
- // Increment creator refund counter (issue #106).
let creator_refunded: u64 = env
.storage()
.persistent()
@@ -5214,6 +5536,7 @@ impl SplitContract {
/// Invoices not eligible for refund are skipped (not panicked on).
/// Returns Vec of IDs actually refunded.
pub fn refund_batch(env: Env, invoice_ids: Vec) -> Vec {
+ require_non_reentrant(&env);
require_fn_not_paused(&env, &symbol_short!("refund"));
assert!(invoice_ids.len() <= 20, "batch limit exceeded");
@@ -5316,11 +5639,13 @@ impl SplitContract {
refunded_ids.push_back(invoice_id);
}
+ clear_reentrant(&env);
refunded_ids
}
/// Place a bid on an active auction for an expired invoice.
pub fn place_bid(env: Env, bidder: Address, invoice_id: u64, amount: i128) {
+ require_non_reentrant(&env);
require_not_paused(&env);
bidder.require_auth();
@@ -5344,10 +5669,12 @@ impl SplitContract {
invoice.bids.push_back(Bid { bidder: bidder.clone(), amount });
save_invoice(&env, invoice_id, &invoice);
append_audit_entry(&env, invoice_id, symbol_short!("bid"), &bidder);
+ clear_reentrant(&env);
}
/// Settle an auction after the 24-hour auction window ends.
pub fn settle_auction(env: Env, invoice_id: u64) {
+ require_non_reentrant(&env);
require_not_paused(&env);
let mut invoice = load_invoice(&env, invoice_id);
@@ -5418,11 +5745,13 @@ impl SplitContract {
&total_refunded_key(),
&total_refunded.checked_add(total_refunded_amount).expect("total_refunded overflow"),
);
+ clear_reentrant(&env);
}
/// Cancel an invoice. Refunds any payments already made.
/// Issue #89: If stake exists, distributes it equally among unique payers.
pub fn cancel_invoice(env: Env, caller: Address, invoice_id: u64) {
+ require_non_reentrant(&env);
require_not_paused(&env);
caller.require_auth();
@@ -5594,10 +5923,12 @@ impl SplitContract {
env.storage()
.persistent()
.set(&cancel_count_key(&caller), &(cnl_cnt + 1));
+ clear_reentrant(&env);
}
/// Transfer invoice ownership to a new creator.
pub fn transfer_invoice(env: Env, invoice_id: u64, new_creator: Address) {
+ require_non_reentrant(&env);
require_not_paused(&env);
let mut invoice = load_invoice(&env, invoice_id);
@@ -5610,10 +5941,12 @@ impl SplitContract {
invoice.creator.require_auth();
invoice.creator = new_creator;
save_invoice(&env, invoice_id, &invoice);
+ clear_reentrant(&env);
}
/// Extend the deadline for an invoice. Callable by the creator or an assigned delegate.
pub fn extend_deadline(env: Env, invoice_id: u64, new_deadline: u64, caller: Address) {
+ require_non_reentrant(&env);
require_not_paused(&env);
caller.require_auth();
@@ -5647,6 +5980,7 @@ impl SplitContract {
invoice.deadline = new_deadline;
save_invoice(&env, invoice_id, &invoice);
append_audit_entry(&env, invoice_id, symbol_short!("extend"), &caller);
+ clear_reentrant(&env);
}
/// Roll over a partially funded invoice to a new invoice with the same recipients,
@@ -5656,6 +5990,7 @@ impl SplitContract {
/// Requires creator auth. The old invoice must be Pending and past its deadline.
/// The new deadline must be in the future.
pub fn rollover_invoice(env: Env, caller: Address, invoice_id: u64, new_deadline: u64) -> u64 {
+ require_non_reentrant(&env);
require_not_paused(&env);
caller.require_auth();
@@ -5726,6 +6061,7 @@ impl SplitContract {
old_invoice.require_kyc,
old_invoice.scheduled_release_at,
old_invoice.external_prerequisite.clone(),
+ old_invoice.allowed_callers_root.clone(),
);
// Copy payments from shards to new invoice (issue #177).
@@ -5748,6 +6084,7 @@ impl SplitContract {
append_audit_entry(&env, invoice_id, symbol_short!("rollover"), &caller);
append_audit_entry(&env, new_id, symbol_short!("rollover"), &caller);
+ clear_reentrant(&env);
new_id
}
@@ -5774,6 +6111,7 @@ impl SplitContract {
recipient: Address,
amount: i128,
) {
+ require_non_reentrant(&env);
require_not_paused(&env);
caller.require_auth();
@@ -5808,6 +6146,7 @@ impl SplitContract {
.unwrap_or_else(|| Vec::new(&env));
ids.push_back(invoice_id);
env.storage().persistent().set(&key, &ids);
+ clear_reentrant(&env);
}
/// Issue #230: Substitute a recipient address (e.g., if original address was compromised).
@@ -5821,6 +6160,7 @@ impl SplitContract {
old_recipient: Address,
new_recipient: Address,
) {
+ require_non_reentrant(&env);
require_not_paused(&env);
caller.require_auth();
@@ -5880,11 +6220,13 @@ impl SplitContract {
.unwrap_or_else(|| Vec::new(&env));
new_ids.push_back(invoice_id);
env.storage().persistent().set(&new_key, &new_ids);
+ clear_reentrant(&env);
}
/// Approve a pending recipient substitution. Only a configured co-signer may call this.
/// This approval is separate from release approvals.
pub fn approve_substitute_recipient(env: Env, invoice_id: u64, co_signer: Address) {
+ require_non_reentrant(&env);
require_not_paused(&env);
co_signer.require_auth();
@@ -5903,6 +6245,7 @@ impl SplitContract {
invoice.substitute_recipient_approvals.push_back(co_signer.clone());
save_invoice(&env, invoice_id, &invoice);
append_audit_entry(&env, invoice_id, symbol_short!("app_sub"), &co_signer);
+ clear_reentrant(&env);
}
// -----------------------------------------------------------------------
@@ -5920,6 +6263,7 @@ impl SplitContract {
invoice_id: u64,
new_amounts: Vec,
) {
+ require_non_reentrant(&env);
require_not_paused(&env);
caller.require_auth();
@@ -5950,6 +6294,7 @@ impl SplitContract {
save_invoice(&env, invoice_id, &invoice);
append_audit_entry(&env, invoice_id, symbol_short!("adj_spl"), &caller);
events::split_adjusted(&env, invoice_id, &caller);
+ clear_reentrant(&env);
}
// -----------------------------------------------------------------------
@@ -5967,6 +6312,7 @@ impl SplitContract {
amounts: Vec,
token: Address,
) -> u32 {
+ require_non_reentrant(&env);
creator.require_auth();
assert!(
recipients.len() == amounts.len(),
@@ -6006,6 +6352,7 @@ impl SplitContract {
.persistent()
.set(&template_key(&creator, &name), &template);
+ clear_reentrant(&env);
version
}
@@ -6018,6 +6365,7 @@ impl SplitContract {
deadline: u64,
version: Option,
) -> u64 {
+ require_non_reentrant(&env);
creator.require_auth();
let tmpl: InvoiceTemplate = if let Some(v) = version {
@@ -6044,7 +6392,7 @@ impl SplitContract {
.expect("template not found")
}
};
- Self::_create_invoice_inner(
+ let id = Self::_create_invoice_inner(
&env,
creator,
tmpl.recipients,
@@ -6091,7 +6439,10 @@ impl SplitContract {
false, // require_kyc
None, // scheduled_release_at
None, // external_prerequisite
- )
+ None, // allowed_callers_root
+ );
+ clear_reentrant(&env);
+ id
}
/// Link invoices into a group.
@@ -6100,6 +6451,7 @@ impl SplitContract {
/// any can release (AllOrNothing). When `true`, a strict majority (>50%) being
/// fully funded is sufficient to unblock release (Issue #212).
pub fn create_invoice_group(env: Env, invoice_ids: Vec, majority: bool) -> u64 {
+ require_non_reentrant(&env);
assert!(invoice_ids.len() >= 2, "group needs at least 2 invoices");
let grp_cnt_key = symbol_short!("grp_cnt");
@@ -6122,6 +6474,7 @@ impl SplitContract {
.persistent()
.set(&group_key(group_id), &group);
+ clear_reentrant(&env);
group_id
}
@@ -6132,6 +6485,7 @@ impl SplitContract {
/// Allows a payer to reclaim their contribution before the deadline when
/// `allow_early_withdrawal` is enabled on the invoice.
pub fn withdraw(env: Env, invoice_id: u64, payer: Address) {
+ require_non_reentrant(&env);
payer.require_auth();
let mut invoice = load_invoice(&env, invoice_id);
@@ -6191,6 +6545,7 @@ impl SplitContract {
.set(&credit_key(&payer), &credit.saturating_sub(2));
save_invoice(&env, invoice_id, &invoice);
+ clear_reentrant(&env);
}
// -----------------------------------------------------------------------
@@ -6200,6 +6555,7 @@ impl SplitContract {
/// Vote to extend the invoice deadline by 7 days.
/// Once a strict majority of unique payers vote, the deadline is extended.
pub fn vote_extend_deadline(env: Env, invoice_id: u64, voter: Address) {
+ require_non_reentrant(&env);
voter.require_auth();
let invoice = load_invoice(&env, invoice_id);
@@ -6227,6 +6583,7 @@ impl SplitContract {
.unwrap_or_else(|| Vec::new(&env));
if votes.contains(&voter) {
+ clear_reentrant(&env);
return;
}
votes.push_back(voter);
@@ -6239,6 +6596,7 @@ impl SplitContract {
} else {
env.storage().persistent().set(&vote_key, &votes);
}
+ clear_reentrant(&env);
}
// -----------------------------------------------------------------------
@@ -6247,6 +6605,7 @@ impl SplitContract {
/// Claim the vested portion of a drip invoice for a recipient.
pub fn drip_claim(env: Env, invoice_id: u64, recipient: Address) {
+ require_non_reentrant(&env);
let mut invoice = load_invoice(&env, invoice_id);
assert!(
@@ -6281,6 +6640,7 @@ impl SplitContract {
let token_client =
token::Client::new(&env, &invoice.tokens.get(0).expect("no token"));
token_client.transfer(&env.current_contract_address(), &recipient, &claimable);
+ clear_reentrant(&env);
}
// -----------------------------------------------------------------------
@@ -6613,6 +6973,7 @@ impl SplitContract {
/// Panics with "invoice not completed" if the invoice is still Pending or Cancelled.
/// After archival, `get_invoice` still returns the invoice from instance storage.
pub fn archive_invoice(env: Env, invoice_id: u64) {
+ require_non_reentrant(&env);
let core: InvoiceCore = env
.storage()
.persistent()
@@ -6640,7 +7001,7 @@ impl SplitContract {
velocity_window: 0, parent_invoice_id: None, pause_reason: None, auto_resume_at: None,
payment_cooldown_secs: None, max_payments_per_window: None, payment_window_secs: None,
scheduled_release_at: None, refund_grace_secs: None,
- penalty_tiers: Vec::new(&env), allowed_callers: None, external_prerequisite: None,
+ penalty_tiers: Vec::new(&env), allowed_callers_root: None, external_prerequisite: None,
});
let ext2: InvoiceExt2 = env.storage().persistent()
.get(&invoice_ext2_key(invoice_id))
@@ -6665,11 +7026,13 @@ impl SplitContract {
env.storage().persistent().remove(&invoice_ext2_key(invoice_id));
events::invoice_archived(&env, invoice_id);
+ clear_reentrant(&env);
}
/// Batch archive sweep. Accepts up to 20 invoice IDs; archives those that are
/// Released or Refunded. Returns the list of IDs actually archived.
pub fn archive_invoices_batch(env: Env, invoice_ids: Vec) -> Vec {
+ require_non_reentrant(&env);
assert!(invoice_ids.len() <= 20, "batch limit exceeded");
let mut archived: Vec = Vec::new(&env);
@@ -6696,7 +7059,7 @@ impl SplitContract {
velocity_window: 0, parent_invoice_id: None, pause_reason: None, auto_resume_at: None,
payment_cooldown_secs: None, max_payments_per_window: None, payment_window_secs: None,
scheduled_release_at: None, refund_grace_secs: None,
- penalty_tiers: Vec::new(&env), allowed_callers: None, external_prerequisite: None,
+ penalty_tiers: Vec::new(&env), allowed_callers_root: None, external_prerequisite: None,
});
let ext2: InvoiceExt2 = env.storage().persistent()
.get(&invoice_ext2_key(id))
@@ -6724,6 +7087,7 @@ impl SplitContract {
}
events::batch_archived(&env, archived.len() as u64, &archived);
+ clear_reentrant(&env);
archived
}
@@ -6734,6 +7098,7 @@ impl SplitContract {
/// Assign a delegate address that may call management functions (e.g. extend_deadline)
/// on behalf of the creator. Requires creator auth.
pub fn delegate_invoice(env: Env, invoice_id: u64, delegate: Address) {
+ require_non_reentrant(&env);
let invoice = load_invoice(&env, invoice_id);
invoice.creator.require_auth();
@@ -6743,10 +7108,12 @@ impl SplitContract {
events::delegate_set(&env, invoice_id, &delegate);
append_audit_entry(&env, invoice_id, symbol_short!("delegate"), &invoice.creator);
+ clear_reentrant(&env);
}
/// Remove the delegate from an invoice. Requires creator auth.
pub fn revoke_delegate(env: Env, invoice_id: u64) {
+ require_non_reentrant(&env);
let invoice = load_invoice(&env, invoice_id);
invoice.creator.require_auth();
@@ -6756,6 +7123,7 @@ impl SplitContract {
events::delegate_revoked(&env, invoice_id);
append_audit_entry(&env, invoice_id, symbol_short!("rvk_del"), &invoice.creator);
+ clear_reentrant(&env);
}
/// Return the current delegate for an invoice, or None if none is set.
@@ -6768,6 +7136,7 @@ impl SplitContract {
/// Authorise an address to pay on behalf of the beneficiary.
/// Requires beneficiary auth.
pub fn authorise_delegate(env: Env, beneficiary: Address, delegate: Address) {
+ require_non_reentrant(&env);
require_not_paused(&env);
beneficiary.require_auth();
@@ -6781,6 +7150,7 @@ impl SplitContract {
delegates.push_back(delegate.clone());
env.storage().persistent().set(&delegate_pay_key(&beneficiary), &delegates);
}
+ clear_reentrant(&env);
}
/// Pay toward an invoice using an authorised delegate.
@@ -6792,6 +7162,7 @@ impl SplitContract {
invoice_id: u64,
amount: i128,
) {
+ require_non_reentrant(&env);
require_not_paused(&env);
delegate.require_auth();
@@ -6847,6 +7218,7 @@ impl SplitContract {
} else {
save_invoice(&env, invoice_id, &invoice);
}
+ clear_reentrant(&env);
}
fn enforce_payment_limits(
diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs
index 9366951..b3bebd0 100644
--- a/contracts/split/src/types.rs
+++ b/contracts/split/src/types.rs
@@ -103,6 +103,10 @@ pub struct SubscriptionParams {
pub recipients: Vec,
pub amounts: Vec,
pub tokens: Vec,
+ pub interval_secs: u64,
+ pub next_invoice_at: u64,
+ pub active: bool,
+ pub last_invoice_id: Option,
}
#[contracttype]
@@ -141,6 +145,8 @@ pub struct InvoiceTemplate {
/// Optional whitelist of addresses allowed to pay this invoice.
/// When None, any address may pay.
pub allowed_payers: Option>,
+ /// Merkle root for caller allowlist (replaces stored address list).
+ pub allowed_callers_root: Option>,
}
#[contracttype]
@@ -239,6 +245,8 @@ pub struct InvoiceOptions {
pub fallback_action: Option,
/// Issue #242: External prerequisite - (contract_address, invoice_id) on different contract instance.
pub external_prerequisite: Option<(Address, u64)>,
+ /// Merkle root for caller allowlist (replaces stored address list).
+ pub allowed_callers_root: Option>,
}
/// Legacy invoice layout used by stored invoices created before the `version`
@@ -344,7 +352,7 @@ pub struct InvoiceExt {
pub payment_window_secs: Option,
pub scheduled_release_at: Option,
pub penalty_tiers: Vec,
- pub allowed_callers: Option>,
+ pub allowed_callers_root: Option>,
pub refund_grace_secs: Option,
pub fallback_action: Option,
/// Issue #242: External prerequisite - (contract_address, invoice_id) on different contract instance.
@@ -471,8 +479,8 @@ pub struct Invoice {
pub refund_grace_secs: Option,
/// Issue #211: escalating penalty tiers.
pub penalty_tiers: Vec,
- /// Issue #208: restrict payments to specific calling contracts; None = open.
- pub allowed_callers: Option>,
+ /// Issue #208: Merkle root for caller allowlist (replaces stored address list).
+ pub allowed_callers_root: Option>,
pub notification_contract: Option,
pub overflow_behavior: OverflowBehavior,
pub cross_chain_ref: Option,
@@ -564,7 +572,7 @@ impl Invoice {
payment_window_secs: self.payment_window_secs,
scheduled_release_at: self.scheduled_release_at,
penalty_tiers: self.penalty_tiers,
- allowed_callers: self.allowed_callers,
+ allowed_callers_root: self.allowed_callers_root,
refund_grace_secs: self.refund_grace_secs,
external_prerequisite: self.external_prerequisite,
fallback_action: self.fallback_action,
@@ -652,7 +660,7 @@ impl Invoice {
payment_window_secs: ext.payment_window_secs,
scheduled_release_at: ext.scheduled_release_at,
penalty_tiers: ext.penalty_tiers,
- allowed_callers: ext.allowed_callers,
+ allowed_callers_root: ext.allowed_callers_root,
refund_grace_secs: ext.refund_grace_secs,
external_prerequisite: ext.external_prerequisite,
fallback_action: ext.fallback_action,
@@ -873,7 +881,7 @@ impl Invoice {
scheduled_release_at: None,
refund_grace_secs: None,
penalty_tiers: Vec::new(env),
- allowed_callers: None,
+ allowed_callers_root: None,
notification_contract: None,
overflow_behavior: OverflowBehavior::Reject,
cross_chain_ref: None,
@@ -884,12 +892,77 @@ impl Invoice {
clone_depth: 0,
fallback_action: None,
}
- } max_payments_per_window: None,
+ }
+
+ /// Create a default invoice from just an environment (used for legacy migration).
+ pub fn from_legacy_defaults(env: &Env) -> Self {
+ Invoice {
+ version: 1,
+ creator: panic!("must be overridden"),
+ co_creators: Vec::new(env),
+ recipients: Vec::new(env),
+ base_amounts: Vec::new(env),
+ amounts: Vec::new(env),
+ tokens: Vec::new(env),
+ deadline: 0,
+ funded: 0,
+ status: InvoiceStatus::Pending,
+ payments: Vec::new(env),
+ drip_duration: None,
+ release_timestamp: None,
+ claimed: Vec::new(env),
+ frozen: false,
+ completion_time: None,
+ allow_early_withdrawal: false,
+ bonus_pool: 0,
+ bonus_max_payers: 0,
+ prerequisite_id: None,
+ tranches: Vec::new(env),
+ released_bps: 0,
+ co_signers: Vec::new(env),
+ required_signatures: 0,
+ signatures: Vec::new(env),
+ approver: None,
+ approved: false,
+ oracle_address: None,
+ condition_met: false,
+ penalty_bps: 0,
+ penalty_deadline: 0,
+ min_funding_bps: 0,
+ release_stages: Vec::new(env),
+ released_stages: 0,
+ allowed_payers: None,
+ price_oracle: None,
+ swap_tokens: Vec::new(env),
+ tax_bps: 0,
+ tax_authority: None,
+ insurance_premium_bps: 0,
+ insurance_fund: 0,
+ smart_route: false,
+ convert_to_stream: false,
+ accepted_tokens: Vec::new(env),
+ arbiter: None,
+ disputed: false,
+ admin_frozen: false,
+ auction_on_expiry: false,
+ auction_end: 0,
+ bids: Vec::new(env),
+ min_payment: 0,
+ min_funding_amount: 0,
+ split_rules: Vec::new(env),
+ auto_resolve_rules: Vec::new(env),
+ creator_cosigner: None,
+ velocity_limit: 0,
+ velocity_window: 0,
+ pause_reason: None,
+ auto_resume_at: None,
+ payment_cooldown_secs: None,
+ max_payments_per_window: None,
payment_window_secs: None,
scheduled_release_at: None,
refund_grace_secs: None,
- penalty_tiers: Vec::::new(env),
- allowed_callers: None,
+ penalty_tiers: Vec::new(env),
+ allowed_callers_root: None,
notification_contract: None,
overflow_behavior: OverflowBehavior::Reject,
cross_chain_ref: None,