Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions contracts/split/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
192 changes: 191 additions & 1 deletion contracts/split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions contracts/split/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

88 changes: 88 additions & 0 deletions tests/helpers.rs
Original file line number Diff line number Diff line change
@@ -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<Address>,
amounts: &Vec<i128>,
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)
}
Loading