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,