From 00b90729937a4da1b3ed116c531d63f48c0c55cf Mon Sep 17 00:00:00 2001 From: TYDev01 Date: Mon, 29 Jun 2026 23:08:13 +0100 Subject: [PATCH] feat: add immutable protocol fee split on SME withdrawal with tests and docs Add an optional, immutable protocol_fee_bps configured at init that splits funded_amount into an SME payout and a treasury fee on withdraw. - init: new Option protocol_fee_bps param, validated 0..=10_000 (ProtocolFeeBpsOutOfRange), stored under additive DataKey::ProtocolFeeBps (default 0). - withdraw: fee = funded_amount * fee_bps / 10_000 (floor, checked) routed to DataKey::Treasury; remainder to sme_address. Treasury transfer skipped when fee == 0 (byte-identical legacy path); SME transfer skipped when net == 0. DistributedPrincipal still advances by the full gross funded_amount. - SmeWithdrew: append-only `fee` field; `amount` is now the net SME payout. - New typed errors WithdrawFeeArithmeticOverflow / WithdrawNetArithmeticUnderflow; new getter get_protocol_fee_bps. - Tests: fee split, zero-fee default, max bps, floor rounding, tiny-bps floor, large overflow-safe amount, getter, event payload, init range rejection. - Docs: escrow-numeric-model, README, ESCROW_SME_WITHDRAWAL, error-messages, EVENT_SCHEMA. Conservation invariant holds for every withdrawal: sme_payout + fee == funded_amount. Refs #316 --- README.md | 36 ++- docs/ESCROW_SME_WITHDRAWAL.MD | 37 +++- docs/EVENT_SCHEMA.md | 12 +- docs/escrow-error-messages.md | 3 + docs/escrow-numeric-model.md | 35 +++ escrow/src/lib.rs | 170 +++++++++++++-- escrow/src/tests/integration.rs | 373 ++++++++++++++++++++++++++++++++ 7 files changed, 634 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 22afcbab..7a751f42 100644 --- a/README.md +++ b/README.md @@ -154,12 +154,13 @@ liquifact-contracts/ | Entrypoint | Auth Role | Description | |---|---|---| -| `init` | Admin (implicit) | Create an invoice escrow (invoice id, SME, amount, yield bps, maturity). | +| `init` | Admin (implicit) | Create an invoice escrow (invoice id, SME, amount, yield bps, maturity). Optional immutable `protocol_fee_bps` (`0..=10_000`, default `0`) splits the SME disbursement at `withdraw`. | | `fund` | Investor | Record investor principal and atomically pull the funding token from the investor; marks escrow funded when target is met. | | `fund_with_commitment` | Investor | First deposit with optional lock period (atomically pulling the funding token); selects tiered yield. | | `settle` | SME | Mark a funded escrow as settled (SME auth required; maturity enforced). | | `partial_settle` | SME | SME marks a portion of the escrow as settled before full settlement. | -| `withdraw` | SME | SME pulls funded liquidity (accounting record). | +| `withdraw` | SME | SME pulls funded liquidity, **net of the immutable protocol fee**: `fee = funded_amount * protocol_fee_bps / 10_000` goes to the treasury, the remainder to the SME. | +| `get_protocol_fee_bps` | — | Read the immutable protocol fee in basis points (defaults to `0`). | | `cancel_funding` | Admin | Admin cancels an open escrow (transitions status 0 → 4). | | `refund` | Investor | Investor pulls contributed liquidity from a cancelled escrow. Increments `DistributedPrincipal` liability. | | `claim_investor_payout` | Investor | Investor records a payout claim after settlement. | @@ -324,11 +325,42 @@ The escrow supports cancellation by the admin under specific criteria, unlocking - Residual dust sweeping and the liability floor protecting un-refunded investors - A worked execution sequence with multiple investors +## Protocol fee on SME withdrawal + +An **immutable** protocol fee can be configured at `init` via the optional +`protocol_fee_bps: Option` parameter (basis points, validated to `0..=10_000`, default `0`) +and stored under `DataKey::ProtocolFeeBps`. On `withdraw`, the funded principal is split: + +```text +fee = funded_amount * protocol_fee_bps / 10_000 (integer floor, checked) +sme_payout = funded_amount - fee +``` + +`fee` is routed to the immutable `DataKey::Treasury`; `sme_payout` to `sme_address`. Key +properties: + +- **Conservation:** `sme_payout + fee == funded_amount` (no principal created or destroyed). +- **Floor rounding:** sub-`10_000` residue stays with the SME; the treasury is never over-credited. +- **Backward compatible:** `protocol_fee_bps == 0` (or omitted) makes no treasury transfer and the + SME receives the full `funded_amount` — byte-for-byte the pre-fee behavior. +- **Overflow-safe:** the fee multiplication/division and the net subtraction use checked + arithmetic, with typed errors `WithdrawFeeArithmeticOverflow` and + `WithdrawNetArithmeticUnderflow`; out-of-range `init` values fail with `ProtocolFeeBpsOutOfRange`. +- **Depends on on-chain disbursement:** the fee is only taken on the on-chain `withdraw` path, not + on off-chain `settle`, investor `refund`, or `claim_investor_payout`. +- **Event:** `SmeWithdrew` is extended append-only with `fee`; its `amount` is the net SME payout. + +See [`docs/escrow-numeric-model.md`](docs/escrow-numeric-model.md) for the authoritative math and +[`docs/ESCROW_SME_WITHDRAWAL.MD`](docs/ESCROW_SME_WITHDRAWAL.MD) for the disbursement interaction. + ## Security notes - **Typed errors:** stable numeric [`EscrowError`](docs/escrow-error-messages.md) codes are append-only; SDKs must branch on `ContractError(code)`, not panic strings. See [`docs/escrow-error-messages.md`](docs/escrow-error-messages.md) for the full reference. +- **Protocol fee:** immutable `protocol_fee_bps` split at `withdraw` conserves principal + (`sme_payout + fee == funded_amount`), floors rounding toward the SME, and uses checked + arithmetic on the fee multiplication and net subtraction. - **Auth:** state-changing entrypoints use `require_auth()` for the appropriate role (admin, SME, investor, **treasury** for dust sweep). - **Legal hold:** governance-controlled; misuse risk is mitigated by using a diff --git a/docs/ESCROW_SME_WITHDRAWAL.MD b/docs/ESCROW_SME_WITHDRAWAL.MD index 8db699a1..4cdff41c 100644 --- a/docs/ESCROW_SME_WITHDRAWAL.MD +++ b/docs/ESCROW_SME_WITHDRAWAL.MD @@ -8,18 +8,37 @@ ## 1. What `withdraw` does (and does not do) -`withdraw` is a **pure on-chain state transition**. It moves the escrow from -`status 1` (funded) to `status 3` (withdrawn) by rewriting the -`DataKey::Escrow` entry atomically inside a single Soroban host-function call. +> **Schema note (on-chain disbursement):** On the current schema, `withdraw` performs the SEP-41 +> token transfer **on-chain**, drawing from the principal custodied in this contract. Sections +> below that describe `withdraw` as a "pure state transition" with no token movement reflect the +> older v1 model where disbursement was delegated to an integration layer. The behavior of record +> immutability (`funded_amount`, `FundingCloseSnapshot`, per-investor contributions) still holds. -It does **not**: +### 1.1 Protocol fee split (on-chain disbursement) -- Transfer tokens to the SME or any investor. -- Zero or modify `funded_amount`, `funding_target`, or any investor contribution record. -- Emit anything resembling a payment instruction. +When principal is custodied on-chain, `withdraw` splits `funded_amount` using the immutable +`protocol_fee_bps` configured at `init` (basis points, `0..=10_000`, default `0`, stored under +`DataKey::ProtocolFeeBps`): -Token movement is the responsibility of the **integration layer** that calls -into this contract. See `escrow/src/external_calls.rs` and the +```text +fee = funded_amount * protocol_fee_bps / 10_000 (integer floor, checked) +sme_payout = funded_amount - fee +``` + +- `fee` is transferred to the immutable `DataKey::Treasury`; `sme_payout` to `sme_address`. +- **Conservation:** `sme_payout + fee == funded_amount`. Floor rounding leaves any residue with the + SME, so the treasury is never over-credited. +- **Dependency:** the fee is only realized on this on-chain disbursement path. Off-chain `settle`, + investor `refund`, and `claim_investor_payout` are unaffected. +- **Zero-fee default:** with `protocol_fee_bps == 0` no treasury transfer occurs and the SME + receives the full `funded_amount`. +- **Event:** `SmeWithdrew` carries `amount` (net SME payout) and `fee` (treasury share) separately. + +See [`escrow-numeric-model.md`](escrow-numeric-model.md) for the authoritative math and overflow +analysis. + +Token movement (for deployments still using the v1 delegated model) is the responsibility of the +**integration layer** that calls into this contract. See `escrow/src/external_calls.rs` and the [Token Integration Checklist](../ESCROW_TOKEN_INTEGRATION_CHECKLIST.md). --- diff --git a/docs/EVENT_SCHEMA.md b/docs/EVENT_SCHEMA.md index 7d058cf1..2a25be67 100644 --- a/docs/EVENT_SCHEMA.md +++ b/docs/EVENT_SCHEMA.md @@ -336,9 +336,15 @@ Topics: Data: -| Field | Type | -|---|---| -| `amount` | `i128` | +| Field | Type | Meaning | +|---|---|---| +| `amount` | `i128` | **Net** principal transferred to the SME (`funded_amount - fee`) | +| `recipient` | `Address` | SME address that received `amount` | +| `fee` | `i128` | Protocol fee routed to the treasury (`0` when `protocol_fee_bps == 0`) | + +> **Append-only:** `fee` was appended after the original `(amount, recipient)` data layout. +> `amount + fee == funded_amount`. Legacy indexers reading only `amount` still observe the SME +> payout; add `fee` to reconstruct the gross disbursed principal. ### `InvestorPayoutClaimed` diff --git a/docs/escrow-error-messages.md b/docs/escrow-error-messages.md index adf863fe..d4c8390c 100644 --- a/docs/escrow-error-messages.md +++ b/docs/escrow-error-messages.md @@ -149,6 +149,9 @@ See also [`docs/escrow-legal-hold.md`](escrow-legal-hold.md), | 200 | `PartialSettleUnauthorizedCaller` | `partial_settle` | `caller` is neither `sme_address` nor `admin` | Call as the SME or admin | typed | | 201 | `LegalHoldBlocksPartialSettle` | `partial_settle` | legal hold active | Complete legal-hold clear workflow | typed | | 202 | `PartialSettleNotOpen` | `partial_settle` | escrow status `!= 0` (open) | Partial settle only while open | typed | +| 180 | `ProtocolFeeBpsOutOfRange` | `init` | `protocol_fee_bps` outside `0..=10_000` | Pass a fee in `0..=10_000` (or omit for `0`) | typed | +| 181 | `WithdrawFeeArithmeticOverflow` | `withdraw` | `funded_amount * protocol_fee_bps` overflows `i128` (over-funded escrow) | Keep `funded_amount` within the overflow-safe envelope | typed | +| 182 | `WithdrawNetArithmeticUnderflow` | `withdraw` | `funded_amount - fee` underflows (unreachable for in-range `fee_bps`) | N/A — defensive guard | typed | ### Legacy panic strings (migration aid) diff --git a/docs/escrow-numeric-model.md b/docs/escrow-numeric-model.md index 79c23451..1b84a991 100644 --- a/docs/escrow-numeric-model.md +++ b/docs/escrow-numeric-model.md @@ -19,6 +19,41 @@ This contract uses Soroban host values and Rust integer types directly. It does - A zero commitment stores `0`, meaning no additional investor claim-time gate. - Boundary values are inclusive: a timestamp plus commitment that equals `u64::MAX` is representable; only values above `u64::MAX` fail. +## Protocol fee on SME withdrawal: `i64` basis points + +The escrow supports an **immutable** protocol fee on the SME disbursement, configured once at +`init` via the optional `protocol_fee_bps: Option` parameter and stored under +`DataKey::ProtocolFeeBps`. + +- **Range:** `protocol_fee_bps` is validated to `0..=10_000` at `init` + (`EscrowError::ProtocolFeeBpsOutOfRange`). The default when omitted is `0` (no fee), which + preserves the legacy behavior of routing the full `funded_amount` to the SME. +- **Split math (`withdraw`):** + + ```text + fee = funded_amount * protocol_fee_bps / 10_000 (integer floor) + sme_payout = funded_amount - fee + ``` + + `fee` is transferred to `DataKey::Treasury` and `sme_payout` to `sme_address`. The treasury + transfer is skipped entirely when `fee == 0`, and the SME transfer is skipped when + `sme_payout == 0` (only reachable at `protocol_fee_bps == 10_000`). +- **Rounding:** the division floors, so any residue below one `10_000`-th of the principal stays + with the SME. The treasury is never over-credited by rounding. +- **Conservation:** `sme_payout + fee == funded_amount` for every withdrawal — the split neither + creates nor destroys principal. `DistributedPrincipal` still advances by the full gross + `funded_amount`. +- **Overflow safety:** the multiplication `funded_amount * protocol_fee_bps` and the division use + checked arithmetic. Because an escrow may be over-funded, `funded_amount` is not bounded by + `MAX_INVOICE_AMOUNT`; if `funded_amount * 10_000` would exceed `i128::MAX` the contract panics + with `EscrowError::WithdrawFeeArithmeticOverflow`. The subtraction is likewise checked + (`EscrowError::WithdrawNetArithmeticUnderflow`, unreachable for in-range `fee_bps`). +- **Dependency on on-chain disbursement:** the fee is only realized when principal is custodied + on-chain and the SME calls `withdraw`. It does **not** apply to off-chain `settle`, investor + `refund`, or `claim_investor_payout`. See [`ESCROW_SME_WITHDRAWAL.MD`](ESCROW_SME_WITHDRAWAL.MD). +- **Event:** `SmeWithdrew` is extended append-only with a `fee` field; its `amount` field carries + the **net** SME payout, and `amount + fee` reconstructs the gross `funded_amount`. + ## Funding invariants (property-based) This contract’s funding accounting and state transitions are intended to obey these invariants for all orderings of `fund` / `fund_with_commitment` calls. diff --git a/escrow/src/lib.rs b/escrow/src/lib.rs index 7689ed24..cd5640b6 100644 --- a/escrow/src/lib.rs +++ b/escrow/src/lib.rs @@ -107,6 +107,32 @@ //! (including over-funding past target), the target, and ledger timestamp/sequence. **Immutable** once //! written; see `docs/escrow-pro-rata.md` for the authoritative pro-rata payout math and rounding rules. //! Off-chain share for an investor is `get_contribution(addr) / snapshot.total_principal`. +//! +//! ## Immutable protocol fee (SME disbursement split) +//! +//! [`LiquifactEscrow::init`] accepts an optional `protocol_fee_bps` (basis points, `0..=10_000`, +//! default `0`) stored immutably under [`DataKey::ProtocolFeeBps`]. At +//! [`LiquifactEscrow::withdraw`] the funded principal is split: +//! +//! ```text +//! fee = funded_amount * protocol_fee_bps / 10_000 (floor, checked) +//! sme_payout = funded_amount - fee (checked) +//! ``` +//! +//! `fee` is routed to [`DataKey::Treasury`] and `sme_payout` to [`InvoiceEscrow::sme_address`]. +//! **Conservation invariant:** `sme_payout + fee == funded_amount` for every withdrawal, so no +//! principal is created or destroyed by the split. Rounding is **floor**, so any sub-`10_000` +//! residue stays with the SME (never over-charges the treasury). With `protocol_fee_bps == 0` +//! the behavior is byte-for-byte identical to the pre-fee contract: the full `funded_amount` +//! goes to the SME and no treasury transfer occurs. +//! +//! **Interaction with on-chain disbursement:** the fee is only realized when principal is +//! custodied on-chain and the SME calls [`LiquifactEscrow::withdraw`] — this feature depends on +//! the on-chain disbursement path. It does **not** apply to off-chain settlement +//! ([`LiquifactEscrow::settle`]), investor refunds ([`LiquifactEscrow::refund`]), or investor +//! claims ([`LiquifactEscrow::claim_investor_payout`]). The treasury here is the same immutable +//! address used by [`LiquifactEscrow::sweep_terminal_dust`]; the fee transfer reuses the same +//! SEP-41 balance-delta–checked path in [`external_calls`]. #![allow(clippy::too_many_arguments)] @@ -456,6 +482,17 @@ pub enum EscrowError { /// [`LiquifactEscrow::raise_maturity_max_horizon`] received a `new_horizon` that is /// not strictly greater than the current stored horizon. HorizonNotRaised = 201, + + /// [`LiquifactEscrow::init`] rejected `protocol_fee_bps` outside `0..=10_000`. + ProtocolFeeBpsOutOfRange = 180, + /// [`LiquifactEscrow::withdraw`] overflowed while computing the protocol fee + /// `funded_amount * fee_bps / 10_000`. Indicates an over-funded escrow whose + /// `funded_amount` exceeds the overflow-safe envelope for the fee multiplication. + WithdrawFeeArithmeticOverflow = 181, + /// [`LiquifactEscrow::withdraw`] underflowed while computing the SME net payout + /// `funded_amount - fee`. Unreachable for in-range `fee_bps`; guards against future + /// changes that could let the fee exceed the funded principal. + WithdrawNetArithmeticUnderflow = 182, } #[inline(always)] @@ -663,6 +700,13 @@ pub enum DataKey { /// **no** two-phase clear delay — it is a single-call admin switch for incidents such as a /// suspected token bug. Either flag independently blocks the gated entrypoints. Paused, + /// Immutable protocol fee in basis points (0..=10_000) applied to the SME disbursement + /// at [`LiquifactEscrow::withdraw`]; set once in [`LiquifactEscrow::init`]. + /// Written as `0` even when unconfigured so reads always succeed (`.unwrap_or(0)`). + /// Stored as `i64` to match the [`InvoiceEscrow::yield_bps`] basis-point convention. + /// **Additive key (ADR-007):** absent on instances predating this key ⇒ read as `0` + /// (no fee), preserving legacy full-principal disbursement semantics. + ProtocolFeeBps, } // --- Data types --- @@ -1086,14 +1130,25 @@ pub struct CollateralClearedEvt { pub amount: i128, } +/// Emitted by [`LiquifactEscrow::withdraw`] when the SME pulls funded liquidity. +/// +/// **Append-only schema:** the `fee` field was appended after the original +/// `(name, invoice_id, amount, recipient)` layout. `amount` is the **net** principal +/// routed to `recipient` (the SME); `fee` is the protocol fee routed to +/// [`DataKey::Treasury`]. Conservation holds: `amount + fee == funded_amount`. +/// Indexers reading the legacy layout still see the SME payout as `amount`; new +/// indexers should add `fee` to reconstruct the gross disbursed `funded_amount`. #[contractevent] pub struct SmeWithdrew { #[topic] pub name: Symbol, #[topic] pub invoice_id: Symbol, + /// Net principal transferred to the SME `recipient` (`funded_amount - fee`). pub amount: i128, pub recipient: Address, + /// Protocol fee routed to [`DataKey::Treasury`] (`0` when `protocol_fee_bps == 0`). + pub fee: i128, } #[contractevent] @@ -1475,6 +1530,7 @@ impl LiquifactEscrow { maturity_max_horizon: Option, _funding_deadline: Option, allowlist_active: Option, + protocol_fee_bps: Option, ) -> InvoiceEscrow { admin.require_auth(); @@ -1489,6 +1545,15 @@ impl LiquifactEscrow { (0..=10_000).contains(&yield_bps), EscrowError::YieldBpsOutOfRange, ); + // Immutable protocol fee in basis points (default 0 = no fee). Validated to the same + // 0..=10_000 envelope as `yield_bps`; `10_000` routes the entire `funded_amount` to the + // treasury at withdrawal. See `docs/escrow-numeric-model.md` for the split math. + let protocol_fee_bps = protocol_fee_bps.unwrap_or(0); + ensure( + &env, + (0..=10_000).contains(&protocol_fee_bps), + EscrowError::ProtocolFeeBpsOutOfRange, + ); ensure( &env, !env.storage().instance().has(&DataKey::Escrow), @@ -1525,6 +1590,10 @@ impl LiquifactEscrow { env.storage() .instance() .set(&DataKey::MinContributionFloor, &floor); + // Always persist the fee (even the `0` default) so `withdraw` reads never branch on absence. + env.storage() + .instance() + .set(&DataKey::ProtocolFeeBps, &protocol_fee_bps); env.storage() .instance() .set(&DataKey::UniqueFunderCount, &0u32); @@ -1959,6 +2028,18 @@ impl LiquifactEscrow { .unwrap_or(0) } + /// Immutable protocol fee in basis points (`0..=10_000`) applied to the SME disbursement at + /// [`LiquifactEscrow::withdraw`]; `0` means no fee (full `funded_amount` goes to the SME). + /// + /// Set once at [`LiquifactEscrow::init`] and never mutated. Reads `0` for instances predating + /// [`DataKey::ProtocolFeeBps`] (additive-key default), matching legacy disbursement behavior. + pub fn get_protocol_fee_bps(env: Env) -> i64 { + env.storage() + .instance() + .get(&DataKey::ProtocolFeeBps) + .unwrap_or(0) + } + /// Optional cap on **distinct** investor addresses (`prev == 0` at fund time); [`None`] if unlimited. /// /// Reflects the current stored cap, including any admin reduction via @@ -3838,24 +3919,41 @@ impl LiquifactEscrow { escrow } - /// SME pulls funded liquidity. Transfers `funded_amount` of the bound funding token - /// from this contract to `sme_address`, then transitions status to 3 (withdrawn). - /// Blocked when a legal hold is active. + /// SME pulls funded liquidity, net of the immutable protocol fee. + /// + /// Splits `funded_amount` of the bound funding token into a treasury **fee** and an SME + /// **net payout**, then transitions status to 3 (withdrawn). Blocked when a legal hold or + /// operational pause is active. + /// + /// # Fee split + /// ```text + /// fee_bps = DataKey::ProtocolFeeBps (0..=10_000, default 0) + /// fee = funded_amount * fee_bps / 10_000 (floor, checked) + /// sme_payout = funded_amount - fee (checked) + /// ``` + /// `fee` is sent to [`DataKey::Treasury`] (only when `> 0`) and `sme_payout` to + /// [`InvoiceEscrow::sme_address`]. **Conservation:** `sme_payout + fee == funded_amount`. + /// Floor rounding means any residue below one `10_000`-th stays with the SME. With + /// `fee_bps == 0` no treasury transfer is made and the SME receives the full `funded_amount`. /// /// # Guard ordering /// - /// 1. Legal-hold gate (read-only). + /// 1. Operational pause + legal-hold gates (read-only). /// 2. `sme_address.require_auth()` (via `load_escrow_require_sme`). /// 3. Status == 1 (funded) check. /// 4. Contract balance sufficiency check ([`EscrowError::InsufficientContractBalance`]). - /// 5. Status transition to 3, `DistributedPrincipal` update, storage write. - /// 6. SEP-41 token transfer with balance-delta verification. - /// 7. Event emission. + /// 5. Checked fee/net computation. + /// 6. Status transition to 3, `DistributedPrincipal` update (by the full gross + /// `funded_amount`), storage write. + /// 7. SEP-41 token transfers (fee → treasury, net → SME) with balance-delta verification. + /// 8. Event emission ([`SmeWithdrew`], carrying `amount = sme_payout` and `fee`). /// /// # Errors /// - [`EscrowError::LegalHoldBlocksWithdrawal`] — hold is active. /// - [`EscrowError::WithdrawalNotFunded`] — escrow not in funded state. /// - [`EscrowError::InsufficientContractBalance`] — contract holds less than `funded_amount`. + /// - [`EscrowError::WithdrawFeeArithmeticOverflow`] — `funded_amount * fee_bps` overflowed `i128`. + /// - [`EscrowError::WithdrawNetArithmeticUnderflow`] — `funded_amount - fee` underflowed (unreachable for in-range `fee_bps`). pub fn withdraw(env: Env) -> InvoiceEscrow { // Operational pause gate (read-only), before require_auth and orthogonal to legal hold. ensure( @@ -3876,13 +3974,31 @@ impl LiquifactEscrow { let amount = escrow.funded_amount; let sme = escrow.sme_address.clone(); + // Immutable protocol fee split. `fee = funded_amount * fee_bps / 10_000` (floor), with the + // remainder going to the SME. All arithmetic is checked: `funded_amount` may exceed the + // overflow-safe envelope when an escrow is over-funded, so the multiplication is the only + // place this can overflow. Conservation `net + fee == funded_amount` holds by construction. + let fee_bps: i64 = env + .storage() + .instance() + .get(&DataKey::ProtocolFeeBps) + .unwrap_or(0); + let fee: i128 = amount + .checked_mul(fee_bps as i128) + .and_then(|scaled| scaled.checked_div(10_000)) + .unwrap_or_else(|| fail(&env, EscrowError::WithdrawFeeArithmeticOverflow)); + let net: i128 = amount + .checked_sub(fee) + .unwrap_or_else(|| fail(&env, EscrowError::WithdrawNetArithmeticUnderflow)); + let token_addr: Address = env .storage() .instance() .get(&DataKey::FundingToken) .unwrap_or_else(|| fail(&env, EscrowError::FundingTokenNotSet)); - // Verify the contract holds enough before mutating state. + // Verify the contract holds enough before mutating state. The check uses the gross + // `funded_amount` because the contract must fund both the SME payout and the treasury fee. let this = env.current_contract_address(); let contract_balance = TokenClient::new(&env, &token_addr).balance(&this); ensure( @@ -3891,7 +4007,9 @@ impl LiquifactEscrow { EscrowError::InsufficientContractBalance, ); - // State transition and accounting (checks-effects-interactions). + // State transition and accounting (checks-effects-interactions). `DistributedPrincipal` + // advances by the full gross `funded_amount` (net + fee), keeping the liability accounting + // consistent regardless of how principal is split. escrow.status = 3; env.storage().instance().set(&DataKey::Escrow, &escrow); @@ -3905,20 +4023,36 @@ impl LiquifactEscrow { &prev_distributed.saturating_add(amount), ); - // Token transfer with SEP-41 balance-delta verification. - external_calls::transfer_funding_token_with_balance_checks( - &env, - &token_addr, - &this, - &sme, - amount, - ); + // Token transfers with SEP-41 balance-delta verification. The treasury transfer is skipped + // when `fee == 0` so the zero-fee path makes exactly one transfer (preserving legacy + // behavior and gas profile). `transfer_*` rejects non-positive amounts, so `net` could only + // be zero in the degenerate `fee_bps == 10_000` case — guard it the same way. + if fee > 0 { + let treasury = Self::treasury_or_fail(&env); + external_calls::transfer_funding_token_with_balance_checks( + &env, + &token_addr, + &this, + &treasury, + fee, + ); + } + if net > 0 { + external_calls::transfer_funding_token_with_balance_checks( + &env, + &token_addr, + &this, + &sme, + net, + ); + } SmeWithdrew { name: symbol_short!("sme_wd"), invoice_id: escrow.invoice_id.clone(), - amount, + amount: net, recipient: sme, + fee, } .publish(&env); diff --git a/escrow/src/tests/integration.rs b/escrow/src/tests/integration.rs index 6165f060..e627613b 100644 --- a/escrow/src/tests/integration.rs +++ b/escrow/src/tests/integration.rs @@ -59,6 +59,7 @@ fn test_legal_hold_midflow_blocks_and_resumes_with_ordered_events() { &None, &None, &None, + &None, ); // We will not fund or settle — just exercise legal hold at multiple points. @@ -167,6 +168,7 @@ fn test_escrow_gold_standard_happy_path_open_overfund_snapshot_settle_claim() { &None, &None, &None, + &None, ); let initial_escrow = client.get_escrow(); @@ -398,6 +400,7 @@ fn test_escrow_tiered_yield_with_commitment_locks() { &None, &None, &None, + &None, ); let investor_base = Address::generate(&env); @@ -526,6 +529,7 @@ fn test_collateral_record_is_metadata_only_and_does_not_invoke_token_contract() &None, &None, &None, + &None, ); let commitment = client.record_sme_collateral_commitment(&symbol_short!("USDC"), &5_000i128); @@ -759,6 +763,7 @@ fn test_legal_hold_midflow_blocks_then_resumes_with_ordered_events() { &None, &None, &None, + &None, ); // Initial funding succeeds while hold is off. @@ -884,6 +889,7 @@ fn setup_withdraw_with_token<'a>( &None, &None, &None, + &None, ); let investor = soroban_sdk::Address::generate(env); @@ -1002,6 +1008,7 @@ fn withdraw_rejected_wrong_status_open() { &None, &None, &None, + &None, ); // No funding — status is 0. client.withdraw(); // must panic: WithdrawalNotFunded @@ -1045,6 +1052,7 @@ fn withdraw_rejected_insufficient_contract_balance() { &None, &None, &None, + &None, ); let investor = soroban_sdk::Address::generate(&env); @@ -1091,6 +1099,8 @@ fn withdraw_event_includes_recipient() { invoice_id: escrow.invoice_id.clone(), amount: target, recipient: sme, + // Default escrow (no protocol_fee_bps): full funded_amount to the SME, zero fee. + fee: 0, } .to_xdr(&env, &escrow_id); @@ -1141,6 +1151,7 @@ fn test_cancellation_refund_sweep_lifecycle() { &None, &None, &None, + &None, ); let alice = soroban_sdk::Address::generate(&env); @@ -1205,3 +1216,365 @@ fn test_cancellation_refund_sweep_lifecycle() { // After all refunds, outstanding is 0. } + +// --------------------------------------------------------------------------- +// Protocol fee split on SME withdrawal (issue #316) +// +// These tests exercise the immutable `protocol_fee_bps` configured at init and the +// fee/net split performed by `withdraw`: +// +// fee = funded_amount * fee_bps / 10_000 (floor, checked) +// sme_payout = funded_amount - fee +// +// Invariants under test: conservation (sme_payout + fee == funded_amount), floor +// rounding (residue stays with the SME), zero-fee backward compatibility, the +// 10_000-bps boundary (entire principal to treasury), the configured getter, and +// init range validation. +// --------------------------------------------------------------------------- + +/// Set up a funded escrow with an explicit `protocol_fee_bps` and the full `target` +/// principal minted into the contract, ready for `withdraw`. +/// +/// Returns `(client, escrow_id, token, sme, treasury)`. +fn setup_withdraw_with_fee<'a>( + env: &'a Env, + target: i128, + fee_bps: i64, + invoice_id: &'a str, +) -> ( + LiquifactEscrowClient<'a>, + soroban_sdk::Address, + soroban_sdk::token::TokenClient<'a>, + soroban_sdk::Address, + soroban_sdk::Address, +) { + use crate::LiquifactEscrow; + use soroban_sdk::token::{StellarAssetClient, TokenClient}; + + let sac = env.register_stellar_asset_contract_v2(soroban_sdk::Address::generate(env)); + let token_id = sac.address(); + let sac_admin = StellarAssetClient::new(env, &token_id); + let token = TokenClient::new(env, &token_id); + + let escrow_id = env.register(LiquifactEscrow, ()); + let client = LiquifactEscrowClient::new(env, &escrow_id); + let admin = soroban_sdk::Address::generate(env); + let sme = soroban_sdk::Address::generate(env); + let treasury = soroban_sdk::Address::generate(env); + + client.init( + &admin, + &soroban_sdk::String::from_str(env, invoice_id), + &sme, + &target, + &0i64, + &0u64, + &token_id, + &None, + &treasury, + &None, + &None, + &None, + &None, + &None, + &None, + &None, + &None, + &Some(fee_bps), + ); + + let investor = soroban_sdk::Address::generate(env); + // Mint to the investor; fund() pulls the principal into the escrow (on-chain custody), + // leaving the contract holding exactly `target` for withdraw() to disburse. + sac_admin.mint(&investor, &target); + client.fund(&investor, &target); + + (client, escrow_id, token, sme, treasury) +} + +/// A 250-bps (2.5%) fee splits the principal: treasury receives the fee, the SME the +/// remainder, the contract is drained, and conservation holds exactly. +#[test] +fn withdraw_splits_principal_between_sme_and_treasury() { + let env = Env::default(); + env.mock_all_auths(); + + let target = 10_000_000i128; + let fee_bps = 250i64; // 2.5% + let (client, escrow_id, token, sme, treasury) = + setup_withdraw_with_fee(&env, target, fee_bps, "FEE_SPLIT1"); + + let expected_fee = target * fee_bps as i128 / 10_000; // 250_000 + let expected_net = target - expected_fee; // 9_750_000 + + let sme_before = token.balance(&sme); + let treasury_before = token.balance(&treasury); + + client.withdraw(); + + let sme_delta = token.balance(&sme) - sme_before; + let treasury_delta = token.balance(&treasury) - treasury_before; + + assert_eq!( + treasury_delta, expected_fee, + "treasury must receive the fee" + ); + assert_eq!( + sme_delta, expected_net, + "SME must receive funded_amount - fee" + ); + assert_eq!( + sme_delta + treasury_delta, + target, + "conservation: net + fee == funded_amount" + ); + assert_eq!( + token.balance(&escrow_id), + 0, + "escrow contract must be fully drained" + ); + assert_eq!( + client.get_escrow().status, + 3u32, + "status must be 3 (withdrawn)" + ); +} + +/// The default escrow (no `protocol_fee_bps`) routes the entire `funded_amount` to the +/// SME and makes no treasury transfer — byte-for-byte legacy behavior. +#[test] +fn withdraw_zero_fee_default_sends_all_to_sme() { + let env = Env::default(); + env.mock_all_auths(); + + let target = 7_000_000i128; + // fee_bps == 0 is the same as omitting the parameter. + let (client, escrow_id, token, sme, treasury) = + setup_withdraw_with_fee(&env, target, 0i64, "FEE_ZERO01"); + + let treasury_before = token.balance(&treasury); + let sme_before = token.balance(&sme); + + client.withdraw(); + + assert_eq!( + token.balance(&sme) - sme_before, + target, + "SME must receive the full funded_amount when fee_bps == 0" + ); + assert_eq!( + token.balance(&treasury), + treasury_before, + "treasury must be untouched when fee_bps == 0" + ); + assert_eq!(token.balance(&escrow_id), 0); + assert_eq!(client.get_protocol_fee_bps(), 0); +} + +/// A 10_000-bps (100%) fee routes the **entire** principal to the treasury, leaving the +/// SME with zero, while conservation still holds. +#[test] +fn withdraw_max_bps_routes_all_to_treasury() { + let env = Env::default(); + env.mock_all_auths(); + + let target = 4_000_000i128; + let (client, escrow_id, token, sme, treasury) = + setup_withdraw_with_fee(&env, target, 10_000i64, "FEE_MAX001"); + + let treasury_before = token.balance(&treasury); + let sme_before = token.balance(&sme); + + client.withdraw(); + + assert_eq!( + token.balance(&treasury) - treasury_before, + target, + "treasury must receive the entire principal at 10_000 bps" + ); + assert_eq!( + token.balance(&sme) - sme_before, + 0, + "SME must receive nothing at 10_000 bps" + ); + assert_eq!(token.balance(&escrow_id), 0); +} + +/// Floor rounding: when `funded_amount * fee_bps` is not divisible by 10_000 the residue +/// stays with the SME (the treasury is never over-credited). +#[test] +fn withdraw_fee_rounds_down_residue_to_sme() { + let env = Env::default(); + env.mock_all_auths(); + + // funded = 7, fee_bps = 5_000 -> 7 * 5_000 / 10_000 = 35_000 / 10_000 = 3 (floor of 3.5). + let target = 7i128; + let (client, _escrow_id, token, sme, treasury) = + setup_withdraw_with_fee(&env, target, 5_000i64, "FEE_ROUND1"); + + let treasury_before = token.balance(&treasury); + let sme_before = token.balance(&sme); + + client.withdraw(); + + let fee = token.balance(&treasury) - treasury_before; + let net = token.balance(&sme) - sme_before; + + assert_eq!(fee, 3, "fee must floor to 3"); + assert_eq!(net, 4, "residue (4) stays with the SME"); + assert_eq!( + fee + net, + target, + "conservation holds across the rounding boundary" + ); +} + +/// A sub-`10_000` principal with a tiny fee floors to zero fee: nothing goes to the +/// treasury and the SME receives everything. +#[test] +fn withdraw_fee_floors_to_zero_for_tiny_basis_points() { + let env = Env::default(); + env.mock_all_auths(); + + // funded = 9_999, fee_bps = 1 -> 9_999 / 10_000 = 0 (floor). + let target = 9_999i128; + let (client, _escrow_id, token, sme, treasury) = + setup_withdraw_with_fee(&env, target, 1i64, "FEE_TINY01"); + + let treasury_before = token.balance(&treasury); + let sme_before = token.balance(&sme); + + client.withdraw(); + + assert_eq!( + token.balance(&treasury) - treasury_before, + 0, + "fee floors to zero; no treasury transfer" + ); + assert_eq!( + token.balance(&sme) - sme_before, + target, + "SME receives the full principal" + ); +} + +/// A large in-range principal stays overflow-safe and conserves value. +#[test] +fn withdraw_fee_large_amount_is_overflow_safe() { + let env = Env::default(); + env.mock_all_auths(); + + // Well within MAX_INVOICE_AMOUNT (i128::MAX / 10_000); funded * 10_000 stays in i128. + let target = 1_000_000_000_000_000i128; + let fee_bps = 1_234i64; + let (client, escrow_id, token, sme, treasury) = + setup_withdraw_with_fee(&env, target, fee_bps, "FEE_BIG001"); + + let expected_fee = target * fee_bps as i128 / 10_000; + let treasury_before = token.balance(&treasury); + let sme_before = token.balance(&sme); + + client.withdraw(); + + let fee = token.balance(&treasury) - treasury_before; + let net = token.balance(&sme) - sme_before; + assert_eq!(fee, expected_fee); + assert_eq!(net, target - expected_fee); + assert_eq!(fee + net, target, "conservation holds for large principal"); + assert_eq!(token.balance(&escrow_id), 0); +} + +/// `get_protocol_fee_bps` reflects the immutable value configured at init. +#[test] +fn protocol_fee_bps_getter_reflects_init_value() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _escrow_id, _token, _sme, _treasury) = + setup_withdraw_with_fee(&env, 1_000_000i128, 375i64, "FEE_GET001"); + + assert_eq!(client.get_protocol_fee_bps(), 375i64); +} + +/// `SmeWithdrew` carries the net SME `amount` and the treasury `fee` separately, and +/// they reconstruct the gross `funded_amount`. +#[test] +fn withdraw_event_reports_net_amount_and_fee() { + use crate::SmeWithdrew; + use soroban_sdk::{symbol_short, testutils::Events}; + + let env = Env::default(); + env.mock_all_auths(); + + let target = 8_000_000i128; + let fee_bps = 500i64; // 5% + let (client, escrow_id, _token, sme, _treasury) = + setup_withdraw_with_fee(&env, target, fee_bps, "FEE_EVT001"); + + let invoice_id = client.get_escrow().invoice_id.clone(); + client.withdraw(); + + let expected_fee = target * fee_bps as i128 / 10_000; // 400_000 + let expected_net = target - expected_fee; // 7_600_000 + + let expected_xdr = SmeWithdrew { + name: symbol_short!("sme_wd"), + invoice_id, + amount: expected_net, + recipient: sme, + fee: expected_fee, + } + .to_xdr(&env, &escrow_id); + + let all_events = env.events().all().filter_by_contract(&escrow_id); + let found = all_events.events().iter().any(|e| *e == expected_xdr); + assert!( + found, + "SmeWithdrew must report net amount and fee separately" + ); +} + +/// `init` rejects a `protocol_fee_bps` above 10_000 with `ProtocolFeeBpsOutOfRange`. +#[test] +fn init_rejects_out_of_range_protocol_fee_bps() { + use crate::{EscrowError, LiquifactEscrow}; + use soroban_sdk::{Error, InvokeError}; + + let env = Env::default(); + env.mock_all_auths(); + + let token = env.register(MockToken, ()); + let escrow_id = env.register(LiquifactEscrow, ()); + let client = LiquifactEscrowClient::new(&env, &escrow_id); + let admin = soroban_sdk::Address::generate(&env); + let sme = soroban_sdk::Address::generate(&env); + let treasury = soroban_sdk::Address::generate(&env); + + let res = client.try_init( + &admin, + &soroban_sdk::String::from_str(&env, "FEE_BAD001"), + &sme, + &1_000_000i128, + &0i64, + &0u64, + &token, + &None, + &treasury, + &None, + &None, + &None, + &None, + &None, + &None, + &None, + &None, + &Some(10_001i64), // out of range + ); + + let expected_code = EscrowError::ProtocolFeeBpsOutOfRange as u32; + match res { + Err(Ok(error)) => assert_eq!(error, Error::from_contract_error(expected_code)), + Err(Err(InvokeError::Contract(code))) => assert_eq!(code, expected_code), + other => panic!("expected ProtocolFeeBpsOutOfRange, got {other:?}"), + } +}