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 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" diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index f82ba17..d1e64d4 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -91,7 +91,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, }; @@ -1271,6 +1271,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 // --------------------------------------------------------------------------- @@ -2633,6 +2652,47 @@ 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, payer: Address, invoice_id: u64) -> Result<(), ContractError> { + 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); @@ -6463,6 +6523,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; @@ -7224,6 +7291,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) { // Block release when invoice is under active dispute. if invoice.status == InvoiceStatus::Disputed { @@ -8846,6 +8998,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(); @@ -8894,6 +9048,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. @@ -8915,6 +9083,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() @@ -8994,6 +9174,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 6a14d5e..b24ba07 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -1521,3 +1521,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, +} + 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) +}