From 6dfaf45e2f0439188f8673b4112c41b949e6ec91 Mon Sep 17 00:00:00 2001 From: Sunday Abel Date: Tue, 28 Jul 2026 15:55:59 +0000 Subject: [PATCH 1/5] feat: add recipient share locking and contribution withdrawal - Add RecipientShare struct with locked flag to types.rs - Add RecipientNotFound error variant (discriminant 36) - Add lock_recipient_share/unlock_recipient_share admin-only methods - Accumulate locked-recipient shares in UnreleasedFunds storage key - Add release_locked_funds for unlocking accumulated shares - Emit RecipientShareLocked/RecipientShareUnlocked events - Add withdraw_contribution for Pending-status invoices - Record per-payer contributions in _pay for withdrawal support - Emit ContributionWithdrawn event - Fix corrupted recipient_address_rotated event body --- contracts/split/src/error.rs | 2 + contracts/split/src/events.rs | 47 ++++++++- contracts/split/src/lib.rs | 191 +++++++++++++++++++++++++++++++++- contracts/split/src/types.rs | 10 ++ 4 files changed, 244 insertions(+), 6 deletions(-) diff --git a/contracts/split/src/error.rs b/contracts/split/src/error.rs index 9c540c2..71300ae 100644 --- a/contracts/split/src/error.rs +++ b/contracts/split/src/error.rs @@ -76,5 +76,7 @@ pub enum ContractError { CreatorCooldownActive = 34, /// Issue #473: Token is not in the allowed tokens list. UnauthorisedToken = 35, + /// Recipient address not found in invoice's recipient list. + RecipientNotFound = 36, } diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index f149f58..ba22806 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -1225,10 +1225,10 @@ pub fn recipient_address_rotated( env.events().publish( ( symbol_short!("split"), - symbol_short!("adm_appr"), - action_hash.clone(), + soroban_sdk::Symbol::new(env, "RecipientAddressRotated"), + invoice_id, ), - (approver.clone(), approval_count, env.ledger().sequence()), + (old_address.clone(), new_address.clone()), ); } @@ -1287,10 +1287,47 @@ pub fn invoice_from_template(env: &Env, invoice_id: u64, creator: &Address, temp (creator.clone(), template_id, env.ledger().sequence()), ); } - soroban_sdk::Symbol::new(env, "RecipientAddressRotated"), + +/// Emitted when a recipient's share is locked (e.g. during a dispute). +/// Topics: (split, rcp_locked, invoice_id) +/// Data: (recipient, admin) +pub fn recipient_share_locked(env: &Env, invoice_id: u64, recipient: &Address, admin: &Address) { + env.events().publish( + ( + symbol_short!("split"), + symbol_short!("rcp_locked"), invoice_id, ), - (old_address.clone(), new_address.clone()), + (recipient.clone(), admin.clone()), + ); +} + +/// Emitted when a recipient's share is unlocked after a dispute resolves. +/// Topics: (split, rcp_unlock, invoice_id) +/// Data: (recipient, admin) +pub fn recipient_share_unlocked(env: &Env, invoice_id: u64, recipient: &Address, admin: &Address) { + env.events().publish( + ( + symbol_short!("split"), + symbol_short!("rcp_unlock"), + invoice_id, + ), + (recipient.clone(), admin.clone()), + ); +} + +/// Emitted when a contributor withdraws their contribution before an invoice +/// is fully funded. +/// Topics: (split, contr_wdr, invoice_id) +/// Data: (payer, amount) +pub fn contribution_withdrawn(env: &Env, invoice_id: u64, payer: &Address, amount: i128) { + env.events().publish( + ( + symbol_short!("split"), + symbol_short!("contr_wdr"), + invoice_id, + ), + (payer.clone(), amount), ); } diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 3a9c2b1..75831f0 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -81,7 +81,7 @@ use types::{ ProtocolFeeConfig, QueuedAction, Recipient, RebateTier, RepScore, ResolveAction, ResolveRule, Role, SimulateReleaseResult, SplitRule, SubscriptionParams, TimelockAction, Tranche, TreasuryRecord, UpgradeProposal, - ProtocolFeeConfig, QueuedAction, Recipient, RecipientAddress, RebateTier, RepScore, ResolveAction, ResolveRule, + ProtocolFeeConfig, QueuedAction, Recipient, RecipientAddress, RecipientShare, RebateTier, RepScore, ResolveAction, ResolveRule, SimulateReleaseResult, SplitRule, SubscriptionParams, TimelockAction, Tranche, TreasuryRecord, UpgradeProposal, BASIS_POINTS_TOTAL, }; @@ -1227,6 +1227,25 @@ fn trusted_callers_key() -> Symbol { symbol_short!("trstd_cal") } +/// Unreleased funds accumulator for an invoice — persistent storage. +/// When a recipient share is locked, funds that would have gone to that +/// recipient are accumulated here until released via `release_locked_funds`. +fn unreleased_funds_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("unrl_fnd"), invoice_id) +} + +/// Per-invoice per-recipient share lock flag — persistent storage. +/// Key: (invoice_id, recipient). Value: true if locked, absent = unlocked. +fn recipient_lock_key(invoice_id: u64, recipient: &Address) -> (Symbol, u64, Address) { + (symbol_short!("rcp_lock"), invoice_id, recipient.clone()) +} + +/// Per-invoice per-contributor contribution record — persistent storage. +/// Key: (invoice_id, payer). Value: i128 amount contributed. +fn contribution_key(invoice_id: u64, payer: &Address) -> (Symbol, u64, Address) { + (symbol_short!("contrb"), invoice_id, payer.clone()) +} + // --------------------------------------------------------------------------- // Invoice storage helpers // --------------------------------------------------------------------------- @@ -2549,6 +2568,48 @@ impl SplitContract { } } + /// Withdraw a contribution before the invoice is fully funded. + /// Only permitted while the invoice is in Pending status (Open/PartiallyFunded). + /// The caller receives exactly the amount they contributed, the contribution + /// storage entry is deleted, and `invoice.funded` is decremented. + pub fn withdraw_contribution(env: Env, invoice_id: u64) -> Result<(), ContractError> { + let payer = env.current_contract_address(); + payer.require_auth(); + + let mut invoice = load_invoice(&env, invoice_id); + + // Only allow withdrawal while invoice is in Pending (Open) status. + if invoice.status != InvoiceStatus::Pending { + return Err(ContractError::InvalidStatus); + } + + let contrib_key = contribution_key(invoice_id, &payer); + let amount: i128 = env + .storage() + .persistent() + .get(&contrib_key) + .unwrap_or(0i128); + + if amount <= 0 { + return Err(ContractError::ZeroAmountNotAllowed); + } + + // Delete the contribution record. + env.storage().persistent().remove(&contrib_key); + + // Decrease funded amount. + invoice.funded = invoice.funded.saturating_sub(amount); + save_invoice(&env, invoice_id, &invoice); + + // Transfer the contribution back to the payer. + let funding_token = funding_token_for(&invoice); + let token_client = token::Client::new(&env, &funding_token); + token_client.transfer(&env.current_contract_address(), &payer, &amount); + + events::contribution_withdrawn(&env, invoice_id, &payer, amount); + Ok(()) + } + /// Issue #471: Rotate a registered recipient's payout address before invoice finalisation. pub fn rotate_recipient_address(env: Env, invoice_id: u64, old_address: Address, new_address: Address) { check_not_paused(&env); @@ -6205,6 +6266,13 @@ impl SplitContract { .persistent() .set(&cumulative_key, &(cumulative + credited_amount)); + // Record per-payer contribution for withdrawal support. + let contrib_key = contribution_key(invoice_id, payer); + let prev_contrib: i128 = env.storage().persistent().get(&contrib_key).unwrap_or(0); + env.storage() + .persistent() + .set(&contrib_key, &(prev_contrib + credited_amount)); + // Increment per-address reputation counter (issue #24, #349). let is_late = invoice.penalty_deadline > 0 && env.ledger().timestamp() > invoice.penalty_deadline; @@ -6966,6 +7034,91 @@ impl SplitContract { Self::_release(&env, invoice_id, &mut invoice, &caller); } + /// Lock a recipient's share for an invoice (admin-only). + /// Locked recipients are skipped during release and their share is accumulated + /// in `UnreleasedFunds`. Returns `RecipientNotFound` if the recipient is not in + /// the invoice's recipient list. + pub fn lock_recipient_share(env: Env, invoice_id: u64, recipient: Address) -> Result<(), ContractError> { + let _admin = require_admin(&env); + let invoice = load_invoice(&env, invoice_id); + + // Verify the recipient exists in this invoice. + let mut found = false; + for r in invoice.recipients.iter() { + if r == recipient { + found = true; + break; + } + } + if !found { + return Err(ContractError::RecipientNotFound); + } + + env.storage() + .persistent() + .set(&recipient_lock_key(invoice_id, &recipient), &true); + + events::recipient_share_locked(&env, invoice_id, &recipient, &_admin); + Ok(()) + } + + /// Unlock a recipient's share for an invoice (admin-only). + /// After unlocking, the accumulated unreleased funds can be released via + /// `release_locked_funds`. + pub fn unlock_recipient_share(env: Env, invoice_id: u64, recipient: Address) -> Result<(), ContractError> { + let _admin = require_admin(&env); + let invoice = load_invoice(&env, invoice_id); + + let mut found = false; + for r in invoice.recipients.iter() { + if r == recipient { + found = true; + break; + } + } + if !found { + return Err(ContractError::RecipientNotFound); + } + + env.storage() + .persistent() + .remove(&recipient_lock_key(invoice_id, &recipient)); + + events::recipient_share_unlocked(&env, invoice_id, &recipient, &_admin); + Ok(()) + } + + /// Release accumulated unreleased funds for an invoice after locked shares are + /// unlocked. Transfers the total accumulated amount to the invoice's token + /// contract for proportional distribution among now-unlocked recipients. + pub fn release_locked_funds(env: Env, invoice_id: u64) -> Result<(), ContractError> { + let _admin = require_admin(&env); + let mut invoice = load_invoice(&env, invoice_id); + + let accumulated: i128 = env + .storage() + .persistent() + .get(&unreleased_funds_key(invoice_id)) + .unwrap_or(0i128); + + if accumulated <= 0 { + return Ok(()); + } + + // Clear the accumulator before distribution. + env.storage() + .persistent() + .set(&unreleased_funds_key(invoice_id), &0i128); + + // Add the accumulated amount to funded so it gets distributed in + // the next release call. The caller must follow up with `release` + // to actually push funds to recipients. + invoice.funded = invoice.funded.saturating_add(accumulated); + save_invoice(&env, invoice_id, &invoice); + + Ok(()) + } + fn _release(env: &Env, invoice_id: u64, invoice: &mut Invoice, actor: &Address) { if invoice.tranches.is_empty() { Self::_release_full(env, invoice_id, invoice, actor); @@ -8584,6 +8737,8 @@ impl SplitContract { .get(&platform_fee_waiver_list_key()) .unwrap_or_else(|| Vec::new(env)); + let mut unreleased_locked: i128 = 0; + for i in 0..n { let recipient = invoice.recipients.get(i).unwrap(); let amount = invoice.amounts.get(i).unwrap(); @@ -8594,6 +8749,18 @@ impl SplitContract { continue; } + // Skip locked recipients and accumulate their share. + let is_locked: bool = env + .storage() + .persistent() + .get(&recipient_lock_key(invoice_id, &recipient)) + .unwrap_or(false); + if is_locked { + unreleased_locked = unreleased_locked.saturating_add(amount); + payouts.push_back(0i128); + continue; + } + // Issue: if split_rules are defined, compute payout from rule instead of amounts[]. let proportional = if !invoice.split_rules.is_empty() { let rule = invoice.split_rules.get(i).unwrap(); @@ -8653,6 +8820,18 @@ impl SplitContract { payouts.push_back(capped_proportional); } + // Accumulate locked recipients' share into UnreleasedFunds. + if unreleased_locked > 0 { + let stored: i128 = env + .storage() + .persistent() + .get(&unreleased_funds_key(invoice_id)) + .unwrap_or(0); + env.storage() + .persistent() + .set(&unreleased_funds_key(invoice_id), &stored.saturating_add(unreleased_locked)); + } + if surplus_total > 0 { let stored_surplus: i128 = env .storage() @@ -8732,6 +8911,16 @@ impl SplitContract { let recipient = invoice.recipients.get(i).unwrap(); let proportional = payouts.get(i).unwrap(); + // Skip locked recipients — their share is in UnreleasedFunds. + let is_locked: bool = env + .storage() + .persistent() + .get(&recipient_lock_key(invoice_id, &recipient)) + .unwrap_or(false); + if is_locked { + continue; + } + // Issue #330: skip recipients already paid via release_to_recipient. if proportional == 0 && !paid_set.is_empty() diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 08e9ae5..4a56d0b 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -1508,3 +1508,13 @@ pub struct ContributionResult { #[derive(Clone, Debug, PartialEq)] pub struct RecipientAddress(pub u64, pub Address); +/// Per-recipient share tracking with optional lock for disputed recipients. +/// When `locked` is true, the recipient's share is skipped during release +/// and accumulated under `UnreleasedFunds(invoice_id)` until unlocked. +#[contracttype] +#[derive(Clone, Debug)] +pub struct RecipientShare { + pub address: Address, + pub locked: bool, +} + From d163ef4b9ce0c0bfc34b810bbe80ba8a54ad5691 Mon Sep 17 00:00:00 2001 From: Sunday Abel Date: Tue, 28 Jul 2026 15:56:11 +0000 Subject: [PATCH 2/5] ci: add WASM binary size check to test workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Build WASM in release mode and verify size ≤ 64 KB threshold - Fails CI if deployed binary exceeds defined byte limit --- .github/workflows/test.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7ebbd5f..a105c23 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,6 +32,17 @@ jobs: - name: Run tests run: cargo test --workspace + - name: Build WASM and check size + run: | + cargo build --target wasm32-unknown-unknown --release --package split + WASM_SIZE=$(stat -c%s target/wasm32-unknown-unknown/release/split.wasm) + echo "WASM size: $WASM_SIZE bytes" + if [ $WASM_SIZE -gt 65536 ]; then + echo "ERROR: WASM size ($WASM_SIZE bytes) exceeds 64 KB threshold" + exit 1 + fi + echo "WASM size check passed ($WASM_SIZE bytes <= 65536 bytes)" + - name: Storage key snapshot test run: cargo test -p split storage_snapshot From 1d2f78ff0ad3f7a9f725096c95af55b11552ae0a Mon Sep 17 00:00:00 2001 From: Sunday Abel Date: Tue, 28 Jul 2026 15:56:11 +0000 Subject: [PATCH 3/5] perf: optimize WASM binary size with release profile settings - Add [profile.release] with opt-level='z', lto=true, codegen-units=1 - Enable panic='abort' and strip='symbols' for minimal binary size - Reduces deployment cost and speeds contract loading on validators --- contracts/split/Cargo.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/contracts/split/Cargo.toml b/contracts/split/Cargo.toml index 7585f38..84a9b7a 100644 --- a/contracts/split/Cargo.toml +++ b/contracts/split/Cargo.toml @@ -15,3 +15,10 @@ proptest = "1" [features] testutils = ["soroban-sdk/testutils"] + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = "symbols" From 59bb35e2c4b0d6441bfe7f80893014f185d7d706 Mon Sep 17 00:00:00 2001 From: Sunday Abel Date: Tue, 28 Jul 2026 15:56:12 +0000 Subject: [PATCH 4/5] test: add shared test harness with common helpers - Add create_env() returning Env with mock auth enabled - Add create_token() for Stellar asset contract setup - Add create_invoice_defaults() and create_invoice_custom() helpers - Add fund_invoice() and deploy_contract() utilities - Reduces boilerplate duplication across integration test files --- tests/helpers.rs | 88 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/helpers.rs diff --git a/tests/helpers.rs b/tests/helpers.rs new file mode 100644 index 0000000..b8d92b5 --- /dev/null +++ b/tests/helpers.rs @@ -0,0 +1,88 @@ +//! Shared test harness for StellarSplit integration tests. +//! +//! Provides a standard environment, token client, and invoice factory that all +//! integration tests reuse, eliminating boilerplate duplication across test files. + +#![cfg(test)] + +use soroban_sdk::{ + testutils::{Address as _, MockAuth, MockAuthInvoke}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, Vec, +}; + +mod contract { + soroban_sdk::contractimport!(file = "target/wasm32-unknown-unknown/release/split_contracts.wasm"); +} + +/// Create a default test environment with mock auth enabled. +pub fn create_env() -> Env { + let env = Env::default(); + env.mock_all_auths(); + env +} + +/// Create a Stellar asset (token) contract and return both the token admin and +/// the deployed token address. +/// +/// The admin receives a large initial mint (1 billion units). +pub fn create_token(env: &Env) -> (Address, Address) { + let admin = Address::generate(env); + let token_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + StellarAssetClient::new(env, &token_id).mint(&admin, &1_000_000_000); + (admin, token_id) +} + +/// Create a simple invoice with defaults. +/// +/// Returns the invoice ID. +pub fn create_invoice_defaults( + env: &Env, + client: &contract::Client, + creator: &Address, + token: &Address, +) -> u64 { + let recipient = Address::generate(env); + let mut recipients = Vec::new(env); + recipients.push_back(recipient); + let mut amounts = Vec::new(env); + amounts.push_back(1000); + + client.create_invoice(creator, &recipients, &amounts, token, &10000) +} + +/// Create an invoice with custom recipients, amounts, and deadline. +/// +/// Returns the invoice ID. +pub fn create_invoice_custom( + env: &Env, + client: &contract::Client, + creator: &Address, + recipients: &Vec
, + amounts: &Vec, + token: &Address, + deadline: u64, +) -> u64 { + client.create_invoice(creator, recipients, amounts, token, &deadline) +} + +/// Mint tokens to a payer and make a payment toward an invoice. +pub fn fund_invoice( + env: &Env, + client: &contract::Client, + token_id: &Address, + payer: &Address, + amount: i128, + invoice_id: u64, +) { + StellarAssetClient::new(env, token_id).mint(payer, &amount); + client.pay(payer, &invoice_id, &amount); +} + +/// Helper: register the contract WASM and return a client. +pub fn deploy_contract(env: &Env) -> contract::Client { + let contract_id = env.register_contract_wasm(None, contract::WASM); + contract::Client::new(env, &contract_id) +} From 1f04b82c7ce78bf3e69e7a30e0e2f993247275f4 Mon Sep 17 00:00:00 2001 From: Sunday Abel Date: Tue, 28 Jul 2026 16:00:10 +0000 Subject: [PATCH 5/5] fix: use proportional amount for locked shares and accept payer param in withdraw - Fix locked-recipient accumulation to use computed proportional share instead of raw amount (moved lock check after proportional computation) - Fix withdraw_contribution to accept payer Address parameter instead of incorrectly using env.current_contract_address() --- contracts/split/src/lib.rs | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 75831f0..a0a647d 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -2572,8 +2572,7 @@ impl SplitContract { /// Only permitted while the invoice is in Pending status (Open/PartiallyFunded). /// The caller receives exactly the amount they contributed, the contribution /// storage entry is deleted, and `invoice.funded` is decremented. - pub fn withdraw_contribution(env: Env, invoice_id: u64) -> Result<(), ContractError> { - let payer = env.current_contract_address(); + pub fn withdraw_contribution(env: Env, payer: Address, invoice_id: u64) -> Result<(), ContractError> { payer.require_auth(); let mut invoice = load_invoice(&env, invoice_id); @@ -8749,18 +8748,6 @@ impl SplitContract { continue; } - // Skip locked recipients and accumulate their share. - let is_locked: bool = env - .storage() - .persistent() - .get(&recipient_lock_key(invoice_id, &recipient)) - .unwrap_or(false); - if is_locked { - unreleased_locked = unreleased_locked.saturating_add(amount); - payouts.push_back(0i128); - continue; - } - // Issue: if split_rules are defined, compute payout from rule instead of amounts[]. let proportional = if !invoice.split_rules.is_empty() { let rule = invoice.split_rules.get(i).unwrap(); @@ -8799,6 +8786,20 @@ impl SplitContract { } else { proportional }; + + // Skip locked recipients: accumulate their computed proportional + // share into UnreleasedFunds instead of transferring it. + let is_locked: bool = env + .storage() + .persistent() + .get(&recipient_lock_key(invoice_id, &recipient)) + .unwrap_or(false); + if is_locked { + unreleased_locked = unreleased_locked.saturating_add(capped_proportional); + payouts.push_back(0i128); + continue; + } + distributed += capped_proportional; // Issue #482: use checked arithmetic to prevent overflow.