From 005fe569786423a7298d387545e55cfa0b88304b Mon Sep 17 00:00:00 2001 From: balisdev <294588434+balisdev@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:58:41 +0100 Subject: [PATCH 1/3] feat(contracts): protocol fee engine with multi-party payout splits & treasury accounting Adds a governance-bounded fee engine to escrow: confirm_completion and the worker-favoring branch of resolve_dispute now split the settled amount across worker, protocol treasury, and an optional referrer using overflow-safe, floor-rounded basis-point math, with the worker absorbing the rounding remainder so payouts always reconcile exactly to the escrowed amount. Fee rates are capped by a hard-coded MAX_TOTAL_FEE_BPS that set_fee_config enforces regardless of caller, and refund paths (cancel_appointment, refund-to-client disputes) stay fee-free since no service was delivered. Protocol shares accrue in a new per-token treasury balance, withdrawable only via withdraw_treasury under the same governance-signer authorization as migrate. --- soroban-contracts/CHANGELOG.md | 40 ++ soroban-contracts/README.md | 80 ++- soroban-contracts/contracts/escrow/src/lib.rs | 311 ++++++++++- .../contracts/escrow/src/test.rs | 508 +++++++++++++++++- .../contracts/settlement-router/src/lib.rs | 1 + .../contracts/settlement-router/src/test.rs | 1 + 6 files changed, 884 insertions(+), 57 deletions(-) diff --git a/soroban-contracts/CHANGELOG.md b/soroban-contracts/CHANGELOG.md index 4bf0fac..2a08d61 100644 --- a/soroban-contracts/CHANGELOG.md +++ b/soroban-contracts/CHANGELOG.md @@ -16,6 +16,46 @@ sections start once something ships. ### Added +- **Protocol fee engine with multi-party payout splits & treasury accounting** + ([#39](https://github.com/workman-labs/guildworkman-core/issues/39), + PR #TODO). `escrow`'s `confirm_completion` and the worker-favoring branch + of `resolve_dispute` now split the escrowed amount across worker, protocol + treasury, and an optional referrer instead of paying it out whole: + - **`FeeConfig { protocol_bps, referrer_bps }`**, governance-bounded by a + hard-coded `MAX_TOTAL_FEE_BPS` (1,500 = 15%) that `set_fee_config` checks + unconditionally — no governance signer can configure a combined + take-rate above it, so the worker is guaranteed at least 85% of every + settled appointment. `initialize` writes no `FeeConfig` entry at all — + `get_fee_config` treats an absent entry as `{0, 0}` (no fees) — so + instance storage for a contract that never calls `set_fee_config` is + byte-for-byte unchanged from before this feature existed. + - **Deterministic, overflow-safe rounding**: each of the protocol and + referrer shares floor-rounds independently via a split-multiply identity + that never lets `amount * bps` overflow `i128`, even for + `i128::MAX`-adjacent amounts; the worker absorbs the remainder, so + `worker_share + protocol_share + referrer_share == amount` exactly for + every input, with no path able to pay out more than was escrowed and no + dust ever stranded. + - **Per-token treasury accounting**: the protocol share is credited to + `DataKey::Treasury(token)` and stays in the contract's own balance until + a governance signer calls the new `withdraw_treasury`. + - **Referrer share** is paid directly to `appointment.referrer` (a new + `Option
` field on `Appointment`, and a new final parameter on + `create_appointment`) when one is set; contributes nothing to the common + case of an appointment with no referrer. + - **Fees are charged only when a worker actually gets paid.** + `cancel_appointment` and the refund-to-client branch of `resolve_dispute` + are unchanged — they still return the full amount to the client with no + fee at all, on the reasoning that a refund for undelivered work should + leave the client whole. + - New entrypoints `set_fee_config`, `get_fee_config`, `get_treasury_balance`, + `withdraw_treasury`, all gated the same way `migrate` is (any single + current governance signer via `governance::require_signer`). + - New errors `FeeExceedsMaximum`, `ArithmeticOverflow`, + `InsufficientTreasuryBalance` (codes 42-44), appended so no existing code + moved. + - `settlement-router`'s mirrored `escrow::Appointment` type gained the same + `referrer` field to keep cross-contract decoding in lockstep. - **Cross-contract settlement router with auth-chained escrow → reputation → loyalty atomicity** ([#38](https://github.com/workman-labs/guildworkman-core/issues/38), [PR #50](https://github.com/workman-labs/guildworkman-core/pull/50)). diff --git a/soroban-contracts/README.md b/soroban-contracts/README.md index 3e5a9a7..f63d1ae 100644 --- a/soroban-contracts/README.md +++ b/soroban-contracts/README.md @@ -7,7 +7,7 @@ marketplace. This workspace has six independent contracts: | Contract | Path | Purpose | |---|---|---| -| `escrow` | `contracts/escrow` | Holds a client's payment for a booked appointment until the client confirms the job is done; releases funds to the skilled worker, refunds on cancellation, and supports admin-arbitrated disputes. | +| `escrow` | `contracts/escrow` | Holds a client's payment for a booked appointment until the client confirms the job is done; releases funds to the skilled worker (split across a protocol treasury cut and an optional referrer share — see [Protocol fee engine](#protocol-fee-engine)), refunds in full on cancellation, and supports admin-arbitrated disputes. | | `reputation` | `contracts/reputation` | Stores one immutable review per completed appointment and keeps a running rating aggregate per skilled worker. | | `loyalty-token` | `contracts/loyalty-token` | A SEP-41-style fungible token used to reward clients/workers with points on completed appointments. Only a designated `minter` (the backend's service account) can mint. | | `loyalty-emissions` | `contracts/loyalty-emissions` | An emission engine that owns the `loyalty-token`'s `minter` role. Instead of minting rewards in a lump sum, it streams them out of per-account linear vesting schedules, throttled by per-account and global rate limits, and lets the admin reclaim allocations left unclaimed past a deadline. | @@ -569,22 +569,61 @@ stellar contract invoke --id $REPUTATION --source admin --network testnet \ ### escrow -- `initialize(admin: Address, governance_init: GovernanceInit)` -- `create_appointment(appointment_id: u64, client: Address, worker: Address, token: Address, amount: i128)` -- `confirm_completion(appointment_id: u64)` — client-only, pays the worker -- `cancel_appointment(appointment_id: u64)` — client-only, refunds the client +- `initialize(admin: Address, governance_init: GovernanceInit)` — writes no `FeeConfig` entry; `get_fee_config` treats that as `{0, 0}` (no fees) until `set_fee_config` is called +- `create_appointment(appointment_id: u64, client: Address, worker: Address, token: Address, amount: i128, referrer: Option
)` +- `confirm_completion(appointment_id: u64)` — client-only, pays the worker (split per [Protocol fee engine](#protocol-fee-engine)) +- `cancel_appointment(appointment_id: u64)` — client-only, refunds the client **in full, no fee taken** - `raise_dispute(appointment_id: u64, caller: Address)` — client or worker -- `resolve_dispute(appointment_id: u64, refund_to_client: bool)` — admin-only +- `resolve_dispute(appointment_id: u64, refund_to_client: bool)` — admin-only; refunding the client takes no fee, paying the worker applies the same split as `confirm_completion` - `get_appointment(appointment_id: u64) -> Appointment` +- `set_fee_config(caller: Address, config: FeeConfig)` — any governance signer; rejects a combined `protocol_bps + referrer_bps` above `MAX_TOTAL_FEE_BPS` (1,500 = 15%) unconditionally +- `get_fee_config() -> FeeConfig` +- `get_treasury_balance(token: Address) -> i128` +- `withdraw_treasury(caller: Address, token: Address, to: Address, amount: i128)` — any governance signer - `propose_upgrade`, `approve_upgrade`, `cancel_upgrade`, `migrate`, `get_signers`, `get_upgrade_threshold`, `get_pending_upgrade`, `get_storage_version` — see [Upgrade governance](#upgrade-governance) - `pause`, `unpause`, `get_pause_state`, `paused_scopes`, `is_paused` — see [Emergency circuit breaker](#emergency-circuit-breaker) +#### Protocol fee engine + +`confirm_completion` and the worker-favoring branch of `resolve_dispute` +split the escrowed `amount` three ways instead of paying it out whole: + +- **Protocol share** — `floor(amount * fee_config.protocol_bps / 10_000)`, + credited to this contract's per-token treasury balance rather than + transferred out immediately. +- **Referrer share** — `floor(amount * fee_config.referrer_bps / 10_000)`, + paid directly to `appointment.referrer` when it's `Some`; zero when it's + `None` (the common case — most appointments have no referrer). +- **Worker share** — the remainder, `amount - protocol_share - + referrer_share`. The worker absorbs the rounding remainder by design: this + guarantees `worker_share + protocol_share + referrer_share == amount` + exactly for every `amount`, including `1`-unit amounts and + `i128::MAX`-adjacent ones — no path can ever pay out more than was + escrowed, and no dust is ever left stranded. + +`protocol_bps + referrer_bps` can never exceed `MAX_TOTAL_FEE_BPS` (1,500 bps += 15%) — checked in `set_fee_config` itself regardless of caller, so no +governance signer can configure a combined take-rate above it. The worker is +guaranteed at least 85% of every settled appointment. + +**Fees are not applied uniformly to every payout path, on purpose:** +`cancel_appointment` and the refund-to-client branch of `resolve_dispute` +return the full `amount` to the client with no fee at all — the protocol +only takes a cut when a service was actually delivered and the worker gets +paid. A client being refunded for work that never happened keeps their +money whole. + +Treasury balances accrue per token in `DataKey::Treasury(token)` and leave +only through `withdraw_treasury`, gated the same way as `set_fee_config`. + #### Storage layout | `DataKey` variant | Storage | Holds | |---|---|---| | `Admin` | instance | The dispute arbiter's `Address`, set once in `initialize`. | -| `Appointment(u64)` | persistent | An `Appointment { client, worker, token, amount, status }` keyed by `appointment_id`. `status` is one of `Funded`, `Completed`, `Cancelled`, `Disputed`, `Resolved`. | +| `Appointment(u64)` | persistent | An `Appointment { client, worker, token, amount, status, referrer }` keyed by `appointment_id`. `status` is one of `Funded`, `Completed`, `Cancelled`, `Disputed`, `Resolved`. `referrer` is `Some(Address)` for the minority of appointments that have one. | +| `FeeConfig` | instance | `FeeConfig { protocol_bps, referrer_bps }`. Absent until `set_fee_config` is first called — `initialize` deliberately writes no entry, so instance storage is unchanged for a contract that never sets fees — and `get_fee_config` treats absence as `{0, 0}`. | +| `Treasury(Address)` | persistent | The accumulated, withdrawable protocol fee balance for that token `Address`. | #### Errors @@ -608,6 +647,9 @@ stellar contract invoke --id $REPUTATION --source admin --network testnet \ | `HashMismatch` | 16 | `approve_upgrade` with a hash that doesn't match the pending proposal. | | `AlreadyMigrated` | 17 | `migrate` targeting a version already applied or behind the current one. | | `NothingToMigrate` | 18 | `migrate` called when the stored version is already current. | +| `FeeExceedsMaximum` | 42 | `set_fee_config` with `protocol_bps + referrer_bps` above `MAX_TOTAL_FEE_BPS`. | +| `ArithmeticOverflow` | 43 | A checked arithmetic step in the fee split or treasury bookkeeping would have overflowed `i128`. | +| `InsufficientTreasuryBalance` | 44 | `withdraw_treasury` requested more than the token's tracked treasury balance. | Codes 19-36 (milestone escrow and signer rotation) are documented in `src/lib.rs`; codes 37-41 are the circuit breaker's, listed in @@ -621,16 +663,16 @@ stellar contract invoke --id $ESCROW --source admin --network testnet \ -- initialize --admin $ADMIN_ADDR \ --governance_init '{"signers":["'$SIGNER_1'","'$SIGNER_2'","'$SIGNER_3'"],"threshold":2}' -# Client books worker WORKER_ADDR, depositing 10000 units of TOKEN_ADDR, appointment id 1 +# Client books worker WORKER_ADDR, depositing 10000 units of TOKEN_ADDR, appointment id 1, no referrer stellar contract invoke --id $ESCROW --source client --network testnet \ -- create_appointment --appointment_id 1 --client $CLIENT_ADDR \ - --worker $WORKER_ADDR --token $TOKEN_ADDR --amount 10000 + --worker $WORKER_ADDR --token $TOKEN_ADDR --amount 10000 --referrer null -# Client confirms the job is done -> pays the worker +# Client confirms the job is done -> pays the worker, split per the fee engine stellar contract invoke --id $ESCROW --source client --network testnet \ -- confirm_completion --appointment_id 1 -# Client cancels before completion -> refunds the client +# Client cancels before completion -> refunds the client in full, no fee stellar contract invoke --id $ESCROW --source client --network testnet \ -- cancel_appointment --appointment_id 1 @@ -638,13 +680,27 @@ stellar contract invoke --id $ESCROW --source client --network testnet \ stellar contract invoke --id $ESCROW --source client --network testnet \ -- raise_dispute --appointment_id 1 --caller $CLIENT_ADDR -# Admin resolves the dispute in the worker's favor +# Admin resolves the dispute in the worker's favor (fee split applies) stellar contract invoke --id $ESCROW --source admin --network testnet \ -- resolve_dispute --appointment_id 1 --refund_to_client false # Read appointment state stellar contract invoke --id $ESCROW --source admin --network testnet \ -- get_appointment --appointment_id 1 + +# A governance signer sets a 10% protocol fee + 5% referrer fee (15% = the cap) +stellar contract invoke --id $ESCROW --source signer_1 --network testnet \ + -- set_fee_config --caller $SIGNER_1_ADDR \ + --config '{"protocol_bps":1000,"referrer_bps":500}' + +# Read the accumulated protocol treasury balance for TOKEN_ADDR +stellar contract invoke --id $ESCROW --source admin --network testnet \ + -- get_treasury_balance --token $TOKEN_ADDR + +# A governance signer withdraws the treasury balance to TREASURY_WALLET +stellar contract invoke --id $ESCROW --source signer_1 --network testnet \ + -- withdraw_treasury --caller $SIGNER_1_ADDR --token $TOKEN_ADDR \ + --to $TREASURY_WALLET_ADDR --amount 1000 ``` ### reputation diff --git a/soroban-contracts/contracts/escrow/src/lib.rs b/soroban-contracts/contracts/escrow/src/lib.rs index 29ff95e..9964671 100644 --- a/soroban-contracts/contracts/escrow/src/lib.rs +++ b/soroban-contracts/contracts/escrow/src/lib.rs @@ -21,6 +21,8 @@ //! | `DataKey::Admin` | instance | `Address` | Admin/arbiter for dispute resolution | //! | `DataKey::Appointment(id)` | persistent | `Appointment` | Simple escrow state | //! | `DataKey::MilestoneEscrow(id)` | persistent | `MilestoneEscrow` | Milestone escrow state | +//! | `DataKey::FeeConfig` | instance | `FeeConfig` | Protocol/referrer fee rates, in basis points. Absent until `set_fee_config` is first called; `get_fee_config` treats absence as `{0, 0}` | +//! | `DataKey::Treasury(token)` | persistent | `i128` | Accumulated, per-token protocol fee balance awaiting withdrawal | //! | `GovernanceDataKey::*` | instance | governance-guard types | M-of-N upgrade governance, signer rotation, and the emergency pause record | //! //! ## Authorization model @@ -32,8 +34,54 @@ //! - `resolve_dispute` / `resolve_milestone_dispute`: admin/arbiter must authorize. //! - `release_milestone_funds`: permissionless once conditions are met. //! - `pause` / `unpause`: any single governance signer (not `admin`). +//! - `set_fee_config` / `withdraw_treasury`: any single governance signer +//! (the same authority that can `migrate` storage) — see "Protocol fee +//! engine" below. //! - All functions follow checks-effects-interactions to prevent reentrancy. //! +//! ## Protocol fee engine +//! +//! `confirm_completion` and the worker-favoring branch of `resolve_dispute` +//! split the escrowed `amount` three ways instead of paying it out whole: +//! +//! - **Protocol share** — `amount * fee_config.protocol_bps / 10_000`, +//! floor-rounded, credited to this contract's per-token treasury balance +//! (`DataKey::Treasury(token)`) rather than transferred out immediately. +//! - **Referrer share** — `amount * fee_config.referrer_bps / 10_000`, +//! floor-rounded, paid directly to `appointment.referrer` when it is +//! `Some`; zero when it is `None`. Most appointments have no referrer, and +//! this contributes nothing to their settlement path in that case. +//! - **Worker share** — the remainder, `amount - protocol_share - +//! referrer_share`. Assigning the rounding remainder to the worker (rather +//! than to the protocol or the referrer) is the deterministic rounding +//! policy: it guarantees `worker_share + protocol_share + referrer_share +//! == amount` exactly for every `amount`, including `1`-unit amounts and +//! `i128::MAX`-adjacent ones, with no path ever able to pay out more than +//! was escrowed and no dust ever left stranded in the contract. +//! +//! `fee_config.protocol_bps + fee_config.referrer_bps` can never exceed +//! [`MAX_TOTAL_FEE_BPS`] — a hard-coded ceiling checked in `set_fee_config` +//! itself, independent of who is calling it, so no governance signer (nor +//! anyone else) can configure a combined take-rate above it. That ceiling is +//! also what keeps the worker's floor share provable: since both individual +//! shares floor-round down and their basis points never sum past 10,000, +//! `protocol_share + referrer_share <= amount` always holds, so +//! `worker_share` can never go negative. +//! +//! **Fee behavior is deliberately not uniform across every payout path:** +//! `cancel_appointment` and the refund-to-client branch of `resolve_dispute` +//! transfer the full `amount` back to the client with **no fee taken at +//! all**. The protocol only takes a cut when a service was actually +//! delivered and the worker gets paid; a client being refunded for work +//! that never happened keeps their money whole. This is why the fee engine +//! is not "consistently applied everywhere" — it is consistently applied to +//! every *worker-paying* path and consistently absent from every *refund* +//! path. +//! +//! Treasury balances accrue per token and leave only through +//! `withdraw_treasury`, which requires the same governance-signer +//! authorization as `set_fee_config`. +//! //! ## Emergency circuit breaker //! //! Scoped, self-expiring pausability from `guildworkman-governance-guard`; @@ -90,6 +138,33 @@ pub struct Appointment { pub token: Address, pub amount: i128, pub status: Status, + /// Optional referrer/guild address entitled to `fee_config.referrer_bps` + /// of `amount` on a worker-paying settlement. `None` for the common case + /// of an appointment with no referrer. + pub referrer: Option
, +} + +/// Governance-bounded protocol fee configuration, in basis points (1 bps = +/// 0.01%). `protocol_bps + referrer_bps` can never exceed +/// [`MAX_TOTAL_FEE_BPS`]; see the "Protocol fee engine" section above. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] +pub struct FeeConfig { + /// Share of a worker-paying settlement credited to the protocol + /// treasury, in basis points. + pub protocol_bps: u32, + /// Share of a worker-paying settlement paid to `appointment.referrer` + /// when one is set, in basis points. Ignored when there is no referrer. + pub referrer_bps: u32, +} + +/// The exact three-way split of a settled `amount`. Always satisfies +/// `worker_share + protocol_share + referrer_share == amount`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct PayoutSplit { + worker_share: i128, + protocol_share: i128, + referrer_share: i128, } #[contracttype] @@ -149,6 +224,8 @@ pub enum DataKey { Admin, Appointment(u64), MilestoneEscrow(u64), + FeeConfig, + Treasury(Address), } #[contracterror] @@ -199,6 +276,10 @@ pub enum Error { InvalidPauseDuration = 39, NotPaused = 40, InvalidPauseReason = 41, + // --- Protocol fee engine --- + FeeExceedsMaximum = 42, + ArithmeticOverflow = 43, + InsufficientTreasuryBalance = 44, } impl From for Error { @@ -232,6 +313,17 @@ impl From for Error { const LEDGERS_THRESHOLD: u32 = 17_280; // ~1 day, in ledgers (5s/ledger) const LEDGERS_EXTEND_TO: u32 = 518_400; // ~30 days +/// Denominator for basis-point fee math: 1 bps = 1 / 10_000. +const BPS_DENOMINATOR: i128 = 10_000; + +/// Hard ceiling on `fee_config.protocol_bps + fee_config.referrer_bps` +/// (1,500 bps = 15%), enforced unconditionally in `set_fee_config` +/// regardless of caller. This is the protocol's documented maximum +/// combined take-rate: no governance signer can configure anything above +/// it, guaranteeing the worker always keeps at least 85% of a settled +/// appointment. +pub const MAX_TOTAL_FEE_BPS: u32 = 1_500; + #[contract] pub struct EscrowContract; @@ -253,12 +345,91 @@ impl EscrowContract { admin.require_auth(); governance::init_governance(&env, governance_init)?; env.storage().instance().set(&DataKey::Admin, &admin); + // Fees are opt-in: no `DataKey::FeeConfig` entry is written here, so + // instance storage for a contract that never calls `set_fee_config` + // is unchanged from before this feature existed. `get_fee_config` + // treats an absent entry as `{0, 0}` (no fees). env.storage() .instance() .extend_ttl(LEDGERS_THRESHOLD, LEDGERS_EXTEND_TO); Ok(()) } + /// Sets the protocol/referrer fee configuration. Authorized by any + /// single current governance signer (the same authority `migrate` and + /// `unpause` answer to) — but the combined rate is capped by + /// [`MAX_TOTAL_FEE_BPS`] unconditionally, so no signer can push it past + /// the documented maximum regardless of how many of them agree. + pub fn set_fee_config(env: Env, caller: Address, config: FeeConfig) -> Result<(), Error> { + governance::require_signer(&env, &caller)?; + + let total = (config.protocol_bps as u64) + .checked_add(config.referrer_bps as u64) + .ok_or(Error::ArithmeticOverflow)?; + if total > MAX_TOTAL_FEE_BPS as u64 { + return Err(Error::FeeExceedsMaximum); + } + + env.storage().instance().set(&DataKey::FeeConfig, &config); + Self::bump_instance(&env); + Ok(()) + } + + /// The current protocol fee configuration. + pub fn get_fee_config(env: Env) -> FeeConfig { + env.storage() + .instance() + .get(&DataKey::FeeConfig) + .unwrap_or_default() + } + + /// The protocol treasury's accumulated, withdrawable balance for `token`. + pub fn get_treasury_balance(env: Env, token: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::Treasury(token)) + .unwrap_or(0) + } + + /// Withdraws `amount` of `token` from the protocol treasury to `to`. + /// Authorized by any single current governance signer. + /// + /// **Deliberately unguarded by the circuit breaker** for the same + /// reason `resolve_dispute` is: it only ever moves money that is + /// already the protocol's, out to a destination the signers choose, and + /// has no path that can strand or seize anyone else's funds. + pub fn withdraw_treasury( + env: Env, + caller: Address, + token: Address, + to: Address, + amount: i128, + ) -> Result<(), Error> { + governance::require_signer(&env, &caller)?; + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + let key = DataKey::Treasury(token.clone()); + let balance: i128 = env.storage().persistent().get(&key).unwrap_or(0); + let remaining = balance + .checked_sub(amount) + .filter(|v| *v >= 0) + .ok_or(Error::InsufficientTreasuryBalance)?; + + // Effects before interactions. + env.storage().persistent().set(&key, &remaining); + env.storage() + .persistent() + .extend_ttl(&key, LEDGERS_THRESHOLD, LEDGERS_EXTEND_TO); + + let token_client = token::Client::new(&env, &token); + token_client.transfer(&env.current_contract_address(), &to, &amount); + + Ok(()) + } + // ----- Upgrade governance ----- pub fn propose_upgrade( @@ -413,6 +584,7 @@ impl EscrowContract { worker: Address, token: Address, amount: i128, + referrer: Option
, ) -> Result<(), Error> { // Guard first, before auth and before any storage read: a halted // operation should cost nothing and reveal nothing beyond the @@ -439,6 +611,7 @@ impl EscrowContract { token, amount, status: Status::Funded, + referrer, }; env.storage().persistent().set(&key, &appointment); env.storage() @@ -467,12 +640,7 @@ impl EscrowContract { return Err(Error::InvalidStatus); } - let token_client = token::Client::new(&env, &appointment.token); - token_client.transfer( - &env.current_contract_address(), - &appointment.worker, - &appointment.amount, - ); + Self::pay_worker_with_fee_split(&env, &appointment)?; appointment.status = Status::Completed; env.storage().persistent().set(&key, &appointment); @@ -556,17 +724,19 @@ impl EscrowContract { return Err(Error::InvalidStatus); } - let token_client = token::Client::new(&env, &appointment.token); - let recipient = if refund_to_client { - &appointment.client + if refund_to_client { + // No service was delivered, so — same reasoning as + // `cancel_appointment` — no fee is charged: the client gets the + // full amount back. + let token_client = token::Client::new(&env, &appointment.token); + token_client.transfer( + &env.current_contract_address(), + &appointment.client, + &appointment.amount, + ); } else { - &appointment.worker - }; - token_client.transfer( - &env.current_contract_address(), - recipient, - &appointment.amount, - ); + Self::pay_worker_with_fee_split(&env, &appointment)?; + } appointment.status = Status::Resolved; env.storage().persistent().set(&key, &appointment); @@ -953,6 +1123,115 @@ impl EscrowContract { .instance() .extend_ttl(LEDGERS_THRESHOLD, LEDGERS_EXTEND_TO); } + + // =========================================================================== + // Protocol fee engine — internal helpers + // =========================================================================== + + /// `floor(amount * bps / BPS_DENOMINATOR)`, computed without the + /// intermediate `amount * bps` ever overflowing `i128` even for + /// `amount` near `i128::MAX` and `bps` up to 10_000 (100%). + /// + /// Standard split-multiply identity: with `q = amount / D` and + /// `r = amount % D`, `amount * bps == q * bps * D + r * bps`, so + /// `floor(amount * bps / D) == q * bps + floor(r * bps / D)`. `q * bps` + /// is bounded by `amount`'s own magnitude (no growth from `D`), and + /// `r * bps < D * bps <= 10_000 * 10_000`, nowhere near overflowing. + fn floor_bps_share(amount: i128, bps: u32) -> Result { + if bps == 0 || amount == 0 { + return Ok(0); + } + let bps = bps as i128; + let quotient = amount / BPS_DENOMINATOR; + let remainder = amount % BPS_DENOMINATOR; + + let from_quotient = quotient.checked_mul(bps).ok_or(Error::ArithmeticOverflow)?; + let from_remainder = remainder + .checked_mul(bps) + .ok_or(Error::ArithmeticOverflow)? + / BPS_DENOMINATOR; + + from_quotient + .checked_add(from_remainder) + .ok_or(Error::ArithmeticOverflow) + } + + /// Splits `amount` into worker/protocol/referrer shares per the current + /// [`FeeConfig`]. The worker absorbs the rounding remainder, so the + /// three shares always sum to exactly `amount` — see the "Protocol fee + /// engine" module docs for the proof sketch. + fn compute_payout_split( + env: &Env, + amount: i128, + has_referrer: bool, + ) -> Result { + let config = Self::get_fee_config(env.clone()); + + let protocol_share = Self::floor_bps_share(amount, config.protocol_bps)?; + let referrer_share = if has_referrer { + Self::floor_bps_share(amount, config.referrer_bps)? + } else { + 0 + }; + + let worker_share = amount + .checked_sub(protocol_share) + .and_then(|v| v.checked_sub(referrer_share)) + .ok_or(Error::ArithmeticOverflow)?; + + Ok(PayoutSplit { + worker_share, + protocol_share, + referrer_share, + }) + } + + fn credit_treasury(env: &Env, token: &Address, amount: i128) -> Result<(), Error> { + if amount == 0 { + return Ok(()); + } + let key = DataKey::Treasury(token.clone()); + let balance: i128 = env.storage().persistent().get(&key).unwrap_or(0); + let updated = balance + .checked_add(amount) + .ok_or(Error::ArithmeticOverflow)?; + env.storage().persistent().set(&key, &updated); + env.storage() + .persistent() + .extend_ttl(&key, LEDGERS_THRESHOLD, LEDGERS_EXTEND_TO); + Ok(()) + } + + /// Pays out a settling `appointment` split across worker, protocol + /// treasury, and optional referrer. Effects (treasury credit) happen + /// before interactions (token transfers), matching the rest of this + /// contract's checks-effects-interactions discipline. + fn pay_worker_with_fee_split(env: &Env, appointment: &Appointment) -> Result<(), Error> { + let split = + Self::compute_payout_split(env, appointment.amount, appointment.referrer.is_some())?; + + Self::credit_treasury(env, &appointment.token, split.protocol_share)?; + + let token_client = token::Client::new(env, &appointment.token); + if let Some(referrer) = &appointment.referrer { + if split.referrer_share > 0 { + token_client.transfer( + &env.current_contract_address(), + referrer, + &split.referrer_share, + ); + } + } + if split.worker_share > 0 { + token_client.transfer( + &env.current_contract_address(), + &appointment.worker, + &split.worker_share, + ); + } + + Ok(()) + } } #[cfg(test)] diff --git a/soroban-contracts/contracts/escrow/src/test.rs b/soroban-contracts/contracts/escrow/src/test.rs index eb7265a..d7c9b63 100644 --- a/soroban-contracts/contracts/escrow/src/test.rs +++ b/soroban-contracts/contracts/escrow/src/test.rs @@ -76,7 +76,7 @@ fn happy_path_completion_pays_worker() { let ctx = setup(); ctx.contract - .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); assert_eq!(ctx.token_client.balance(&ctx.client), 990_000); assert_eq!(ctx.token_client.balance(&ctx.contract.address), 10_000); @@ -93,7 +93,7 @@ fn cancel_refunds_client() { let ctx = setup(); ctx.contract - .create_appointment(&2, &ctx.client, &ctx.worker, &ctx.token, &5_000); + .create_appointment(&2, &ctx.client, &ctx.worker, &ctx.token, &5_000, &None); ctx.contract.cancel_appointment(&2); assert_eq!(ctx.token_client.balance(&ctx.client), 1_000_000); @@ -106,7 +106,7 @@ fn dispute_resolved_in_favor_of_worker() { let ctx = setup(); ctx.contract - .create_appointment(&3, &ctx.client, &ctx.worker, &ctx.token, &7_000); + .create_appointment(&3, &ctx.client, &ctx.worker, &ctx.token, &7_000, &None); ctx.contract.raise_dispute(&3, &ctx.client); assert_eq!(ctx.contract.get_appointment(&3).status, Status::Disputed); @@ -119,11 +119,16 @@ fn dispute_resolved_in_favor_of_worker() { fn duplicate_appointment_id_rejected() { let ctx = setup(); ctx.contract - .create_appointment(&4, &ctx.client, &ctx.worker, &ctx.token, &1_000); + .create_appointment(&4, &ctx.client, &ctx.worker, &ctx.token, &1_000, &None); - let result = - ctx.contract - .try_create_appointment(&4, &ctx.client, &ctx.worker, &ctx.token, &1_000); + let result = ctx.contract.try_create_appointment( + &4, + &ctx.client, + &ctx.worker, + &ctx.token, + &1_000, + &None, + ); assert_eq!(result, Err(Ok(Error::AppointmentExists))); } @@ -131,13 +136,441 @@ fn duplicate_appointment_id_rejected() { fn cannot_confirm_twice() { let ctx = setup(); ctx.contract - .create_appointment(&5, &ctx.client, &ctx.worker, &ctx.token, &2_000); + .create_appointment(&5, &ctx.client, &ctx.worker, &ctx.token, &2_000, &None); ctx.contract.confirm_completion(&5); let result = ctx.contract.try_confirm_completion(&5); assert_eq!(result, Err(Ok(Error::InvalidStatus))); } +// =========================================================================== +// Protocol fee engine +// =========================================================================== + +fn set_fee(ctx: &TestCtx, protocol_bps: u32, referrer_bps: u32) { + ctx.contract.set_fee_config( + &ctx.signers.get_unchecked(0), + &FeeConfig { + protocol_bps, + referrer_bps, + }, + ); +} + +#[test] +fn fee_config_defaults_to_zero() { + let ctx = setup(); + let config = ctx.contract.get_fee_config(); + assert_eq!(config.protocol_bps, 0); + assert_eq!(config.referrer_bps, 0); +} + +#[test] +fn set_fee_config_by_signer_succeeds() { + let ctx = setup(); + set_fee(&ctx, 1_000, 500); + let config = ctx.contract.get_fee_config(); + assert_eq!(config.protocol_bps, 1_000); + assert_eq!(config.referrer_bps, 500); +} + +#[test] +fn set_fee_config_by_non_signer_rejected() { + let ctx = setup(); + let outsider = Address::generate(&ctx.env); + let res = ctx.contract.try_set_fee_config( + &outsider, + &FeeConfig { + protocol_bps: 100, + referrer_bps: 0, + }, + ); + assert_eq!(res, Err(Ok(Error::NotASigner))); +} + +#[test] +fn set_fee_config_by_admin_arbiter_rejected() { + // `admin` resolves disputes but is not a governance signer — the same + // separation of powers `the_admin_arbiter_is_not_a_pause_authority` + // pins down for the circuit breaker applies here too. + let ctx = setup(); + let res = ctx.contract.try_set_fee_config( + &ctx.admin, + &FeeConfig { + protocol_bps: 100, + referrer_bps: 0, + }, + ); + assert_eq!(res, Err(Ok(Error::NotASigner))); +} + +#[test] +fn set_fee_config_at_exact_cap_accepted() { + let ctx = setup(); + set_fee(&ctx, 1_000, MAX_TOTAL_FEE_BPS - 1_000); + let config = ctx.contract.get_fee_config(); + assert_eq!(config.protocol_bps + config.referrer_bps, MAX_TOTAL_FEE_BPS); +} + +#[test] +fn set_fee_config_above_cap_rejected() { + let ctx = setup(); + let res = ctx.contract.try_set_fee_config( + &ctx.signers.get_unchecked(0), + &FeeConfig { + protocol_bps: 1_000, + referrer_bps: MAX_TOTAL_FEE_BPS - 1_000 + 1, + }, + ); + assert_eq!(res, Err(Ok(Error::FeeExceedsMaximum))); + // Rejected atomically — the default is untouched. + let config = ctx.contract.get_fee_config(); + assert_eq!(config.protocol_bps, 0); + assert_eq!(config.referrer_bps, 0); +} + +#[test] +fn set_fee_config_protocol_alone_above_cap_rejected() { + let ctx = setup(); + let res = ctx.contract.try_set_fee_config( + &ctx.signers.get_unchecked(0), + &FeeConfig { + protocol_bps: MAX_TOTAL_FEE_BPS + 1, + referrer_bps: 0, + }, + ); + assert_eq!(res, Err(Ok(Error::FeeExceedsMaximum))); +} + +#[test] +fn confirm_completion_splits_protocol_fee_with_no_referrer() { + let ctx = setup(); + set_fee(&ctx, 1_000, 500); // 10% protocol, 5% referrer — no referrer here + + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); + ctx.contract.confirm_completion(&1); + + // No referrer on this appointment, so referrer_bps contributes nothing — + // the worker absorbs it as part of the remainder. + assert_eq!(ctx.token_client.balance(&ctx.worker), 9_000); + assert_eq!(ctx.contract.get_treasury_balance(&ctx.token), 1_000); + // The protocol share stays in the contract's own token balance until + // `withdraw_treasury` moves it out. + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 1_000); +} + +#[test] +fn confirm_completion_splits_three_ways_with_referrer() { + let ctx = setup(); + set_fee(&ctx, 1_000, 500); + let referrer = Address::generate(&ctx.env); + + ctx.contract.create_appointment( + &1, + &ctx.client, + &ctx.worker, + &ctx.token, + &10_000, + &Some(referrer.clone()), + ); + ctx.contract.confirm_completion(&1); + + assert_eq!(ctx.token_client.balance(&ctx.worker), 8_500); + assert_eq!(ctx.token_client.balance(&referrer), 500); + assert_eq!(ctx.contract.get_treasury_balance(&ctx.token), 1_000); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 1_000); +} + +#[test] +fn cancel_is_fee_free_even_with_nonzero_fee_config() { + let ctx = setup(); + set_fee(&ctx, 1_000, 500); + + ctx.contract + .create_appointment(&2, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); + ctx.contract.cancel_appointment(&2); + + assert_eq!(ctx.token_client.balance(&ctx.client), 1_000_000); + assert_eq!(ctx.contract.get_treasury_balance(&ctx.token), 0); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 0); +} + +#[test] +fn dispute_refund_to_client_is_fee_free() { + let ctx = setup(); + set_fee(&ctx, 1_000, 500); + + ctx.contract + .create_appointment(&3, &ctx.client, &ctx.worker, &ctx.token, &7_000, &None); + ctx.contract.raise_dispute(&3, &ctx.client); + ctx.contract.resolve_dispute(&3, &true); + + assert_eq!(ctx.token_client.balance(&ctx.client), 1_000_000); + assert_eq!(ctx.contract.get_treasury_balance(&ctx.token), 0); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 0); +} + +#[test] +fn dispute_resolved_to_worker_applies_fee_split() { + let ctx = setup(); + set_fee(&ctx, 1_000, 0); + + ctx.contract + .create_appointment(&3, &ctx.client, &ctx.worker, &ctx.token, &7_000, &None); + ctx.contract.raise_dispute(&3, &ctx.client); + ctx.contract.resolve_dispute(&3, &false); + + assert_eq!(ctx.token_client.balance(&ctx.worker), 6_300); // 90% of 7,000 + assert_eq!(ctx.contract.get_treasury_balance(&ctx.token), 700); +} + +#[test] +fn withdraw_treasury_by_signer_succeeds() { + let ctx = setup(); + set_fee(&ctx, 1_000, 0); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); + ctx.contract.confirm_completion(&1); + assert_eq!(ctx.contract.get_treasury_balance(&ctx.token), 1_000); + + let treasury_recipient = Address::generate(&ctx.env); + ctx.contract.withdraw_treasury( + &ctx.signers.get_unchecked(1), + &ctx.token, + &treasury_recipient, + &1_000, + ); + + assert_eq!(ctx.contract.get_treasury_balance(&ctx.token), 0); + assert_eq!(ctx.token_client.balance(&treasury_recipient), 1_000); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 0); +} + +#[test] +fn withdraw_treasury_more_than_balance_rejected() { + let ctx = setup(); + set_fee(&ctx, 1_000, 0); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); + ctx.contract.confirm_completion(&1); + + let treasury_recipient = Address::generate(&ctx.env); + let res = ctx.contract.try_withdraw_treasury( + &ctx.signers.get_unchecked(0), + &ctx.token, + &treasury_recipient, + &1_001, + ); + assert_eq!(res, Err(Ok(Error::InsufficientTreasuryBalance))); + // Untouched by the rejected attempt. + assert_eq!(ctx.contract.get_treasury_balance(&ctx.token), 1_000); +} + +#[test] +fn withdraw_treasury_by_non_signer_rejected() { + let ctx = setup(); + set_fee(&ctx, 1_000, 0); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); + ctx.contract.confirm_completion(&1); + + let outsider = Address::generate(&ctx.env); + let treasury_recipient = Address::generate(&ctx.env); + let res = + ctx.contract + .try_withdraw_treasury(&outsider, &ctx.token, &treasury_recipient, &1_000); + assert_eq!(res, Err(Ok(Error::NotASigner))); +} + +#[test] +fn withdraw_treasury_zero_amount_rejected() { + let ctx = setup(); + let treasury_recipient = Address::generate(&ctx.env); + let res = ctx.contract.try_withdraw_treasury( + &ctx.signers.get_unchecked(0), + &ctx.token, + &treasury_recipient, + &0, + ); + assert_eq!(res, Err(Ok(Error::InvalidAmount))); +} + +// ----- Adversarial edge cases ----- + +#[test] +fn one_unit_amount_rounds_entirely_to_worker() { + let ctx = setup(); + set_fee(&ctx, 1_000, 500); // 15% combined still floors to 0 on amount=1 + + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &1, &None); + ctx.contract.confirm_completion(&1); + + assert_eq!(ctx.token_client.balance(&ctx.worker), 1); + assert_eq!(ctx.contract.get_treasury_balance(&ctx.token), 0); +} + +#[test] +fn near_i128_max_amount_splits_without_overflow_or_dust() { + let ctx = setup(); + set_fee(&ctx, 1_000, 500); + let referrer = Address::generate(&ctx.env); + let big_client = Address::generate(&ctx.env); + + // A fresh address minted exactly this amount, so the token's own total + // supply never has to represent more than `i128::MAX`. + let huge = i128::MAX - 7; + ctx.token_admin.mint(&big_client, &huge); + + ctx.contract.create_appointment( + &1, + &big_client, + &ctx.worker, + &ctx.token, + &huge, + &Some(referrer.clone()), + ); + ctx.contract.confirm_completion(&1); + + let worker_bal = ctx.token_client.balance(&ctx.worker); + let referrer_bal = ctx.token_client.balance(&referrer); + let treasury_bal = ctx.contract.get_treasury_balance(&ctx.token); + + assert_eq!(worker_bal + referrer_bal + treasury_bal, huge); + assert_eq!( + ctx.token_client.balance(&ctx.contract.address), + treasury_bal + ); +} + +#[test] +fn zero_fee_config_pays_worker_in_full() { + let ctx = setup(); + // Fee config defaults to zero — explicit here for clarity. + set_fee(&ctx, 0, 0); + let referrer = Address::generate(&ctx.env); + + ctx.contract.create_appointment( + &1, + &ctx.client, + &ctx.worker, + &ctx.token, + &10_000, + &Some(referrer.clone()), + ); + ctx.contract.confirm_completion(&1); + + assert_eq!(ctx.token_client.balance(&ctx.worker), 10_000); + assert_eq!(ctx.token_client.balance(&referrer), 0); + assert_eq!(ctx.contract.get_treasury_balance(&ctx.token), 0); +} + +#[test] +fn max_fee_config_still_leaves_worker_the_floor_share() { + let ctx = setup(); + set_fee(&ctx, MAX_TOTAL_FEE_BPS, 0); // entire cap taken by the protocol + + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); + ctx.contract.confirm_completion(&1); + + assert_eq!(ctx.token_client.balance(&ctx.worker), 8_500); + assert_eq!(ctx.contract.get_treasury_balance(&ctx.token), 1_500); +} + +#[test] +fn split_invariant_holds_across_amounts_and_fee_configs() { + // sum(worker_share, protocol_share, referrer_share) == amount, for every + // combination of adversarial amounts (1-unit and i128::MAX-adjacent, plus + // a spread in between) and fee configs at the edges of what's allowed: + // zero, max-protocol-only, max-referrer-only, and split down the middle. + let ctx = setup(); + + let amounts: [i128; 8] = [ + 1, + 2, + 9_999, + 10_000, + 10_001, + 1_000_000_000_000, + i128::MAX - 1, + i128::MAX, + ]; + let configs = [ + FeeConfig { + protocol_bps: 0, + referrer_bps: 0, + }, + FeeConfig { + protocol_bps: MAX_TOTAL_FEE_BPS, + referrer_bps: 0, + }, + FeeConfig { + protocol_bps: 0, + referrer_bps: MAX_TOTAL_FEE_BPS, + }, + FeeConfig { + protocol_bps: 750, + referrer_bps: 750, + }, + ]; + + let mut appointment_id = 100u64; + for config in configs { + set_fee(&ctx, config.protocol_bps, config.referrer_bps); + + for &amount in amounts.iter() { + for has_referrer in [false, true] { + // A fresh token per case, so no prior iteration's residual + // contract balance (e.g. an unwithdrawn protocol share) can + // interact with this amount. That matters most for the + // `i128::MAX`-adjacent cases: even a few units of leftover + // balance on a shared token would overflow the token's own + // `i128` balance field the moment the contract tried to + // receive another near-max transfer. + let (token, token_admin, token_client) = + create_token_contract(&ctx.env, &Address::generate(&ctx.env)); + + let payer = Address::generate(&ctx.env); + token_admin.mint(&payer, &amount); + + let worker = Address::generate(&ctx.env); + let referrer_addr = Address::generate(&ctx.env); + let referrer = has_referrer.then(|| referrer_addr.clone()); + + appointment_id += 1; + ctx.contract.create_appointment( + &appointment_id, + &payer, + &worker, + &token, + &amount, + &referrer, + ); + ctx.contract.confirm_completion(&appointment_id); + + let worker_bal = token_client.balance(&worker); + let referrer_bal = if has_referrer { + token_client.balance(&referrer_addr) + } else { + 0 + }; + // This token is brand new to this iteration, so the treasury + // balance it reports *is* this appointment's protocol share. + let treasury_bal = ctx.contract.get_treasury_balance(&token); + + assert!(worker_bal >= 0 && referrer_bal >= 0 && treasury_bal >= 0); + assert_eq!( + worker_bal + referrer_bal + treasury_bal, + amount, + "split did not reconcile for amount={amount}, config={config:?}, has_referrer={has_referrer}" + ); + } + } + } +} + // =========================================================================== // Upgrade governance — see the equivalent block in reputation/src/test.rs // for why these all stop one approval short of the configured threshold. @@ -821,9 +1254,14 @@ fn paused_intake_blocks_new_appointments_and_moves_no_money() { let ctx = setup(); pause_everything(&ctx); - let res = - ctx.contract - .try_create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + let res = ctx.contract.try_create_appointment( + &1, + &ctx.client, + &ctx.worker, + &ctx.token, + &10_000, + &None, + ); assert_eq!(res, Err(Ok(Error::OperationPaused))); // No funds entered the contract. @@ -865,7 +1303,7 @@ fn a_client_can_still_cancel_and_be_refunded_while_everything_is_paused() { // every scope the breaker knows about halted at once. let ctx = setup(); ctx.contract - .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); assert_eq!(ctx.token_client.balance(&ctx.contract.address), 10_000); pause_everything(&ctx); @@ -884,7 +1322,7 @@ fn a_client_can_still_cancel_and_be_refunded_while_everything_is_paused() { fn disputes_can_still_be_raised_and_resolved_while_everything_is_paused() { let ctx = setup(); ctx.contract - .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); pause_everything(&ctx); @@ -920,7 +1358,7 @@ fn milestone_disputes_resolve_while_everything_is_paused() { fn pausing_intake_alone_leaves_settlement_working() { let ctx = setup(); ctx.contract - .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); set_time(&ctx.env, 1_000); ctx.contract.pause( @@ -939,7 +1377,7 @@ fn pausing_intake_alone_leaves_settlement_working() { fn pausing_settlement_alone_blocks_payout_but_not_new_appointments() { let ctx = setup(); ctx.contract - .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); set_time(&ctx.env, 1_000); ctx.contract.pause( @@ -955,7 +1393,7 @@ fn pausing_settlement_alone_blocks_payout_but_not_new_appointments() { // Intake was never named, so it is untouched. ctx.contract - .create_appointment(&2, &ctx.client, &ctx.worker, &ctx.token, &5_000); + .create_appointment(&2, &ctx.client, &ctx.worker, &ctx.token, &5_000, &None); } #[test] @@ -993,9 +1431,14 @@ fn intake_resumes_on_its_own_once_the_pause_expires() { let ctx = setup(); let start = pause_everything(&ctx); - let res = - ctx.contract - .try_create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + let res = ctx.contract.try_create_appointment( + &1, + &ctx.client, + &ctx.worker, + &ctx.token, + &10_000, + &None, + ); assert_eq!(res, Err(Ok(Error::OperationPaused))); // No unpause transaction. Only the ledger clock advances. @@ -1004,7 +1447,7 @@ fn intake_resumes_on_its_own_once_the_pause_expires() { assert_eq!(ctx.contract.paused_scopes(), 0); assert!(ctx.contract.get_pause_state().is_none()); ctx.contract - .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); assert_eq!(ctx.token_client.balance(&ctx.contract.address), 10_000); } @@ -1038,7 +1481,7 @@ fn a_non_signer_cannot_pause_the_escrow() { // And business is genuinely unaffected, not merely reported as open. ctx.contract - .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); } #[test] @@ -1076,7 +1519,7 @@ fn any_single_signer_can_lift_a_pause_another_signer_placed() { .unpause(&ctx.signers.get_unchecked(2), &ALL_SCOPES); assert_eq!(remaining, 0); ctx.contract - .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); } // ----- Partial lift & views ----- @@ -1085,7 +1528,7 @@ fn any_single_signer_can_lift_a_pause_another_signer_placed() { fn unpausing_intake_alone_reopens_bookings_while_settlement_stays_halted() { let ctx = setup(); ctx.contract - .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); pause_everything(&ctx); let remaining = ctx @@ -1095,7 +1538,7 @@ fn unpausing_intake_alone_reopens_bookings_while_settlement_stays_halted() { assert_eq!(remaining & SCOPE_SETTLEMENT, SCOPE_SETTLEMENT); ctx.contract - .create_appointment(&2, &ctx.client, &ctx.worker, &ctx.token, &5_000); + .create_appointment(&2, &ctx.client, &ctx.worker, &ctx.token, &5_000, &None); let res = ctx.contract.try_confirm_completion(&1); assert_eq!(res, Err(Ok(Error::OperationPaused))); } @@ -1192,6 +1635,7 @@ fn guard_overhead_on_create_appointment_stays_bounded() { &baseline_ctx.worker, &baseline_ctx.token, &10_000, + &None, ); }); @@ -1213,6 +1657,7 @@ fn guard_overhead_on_create_appointment_stays_bounded() { &loaded_ctx.worker, &loaded_ctx.token, &10_000, + &None, ); }); @@ -1235,7 +1680,7 @@ fn a_paused_call_costs_less_than_a_successful_one() { let ctx = setup(); let allowed = cpu_cost_of(&ctx.env, || { ctx.contract - .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); }); set_time(&ctx.env, 1_000); @@ -1246,9 +1691,14 @@ fn a_paused_call_costs_less_than_a_successful_one() { &reason(&ctx.contract.env), ); let rejected = cpu_cost_of(&ctx.env, || { - let res = - ctx.contract - .try_create_appointment(&2, &ctx.client, &ctx.worker, &ctx.token, &10_000); + let res = ctx.contract.try_create_appointment( + &2, + &ctx.client, + &ctx.worker, + &ctx.token, + &10_000, + &None, + ); assert_eq!(res, Err(Ok(Error::OperationPaused))); }); @@ -1279,7 +1729,7 @@ fn a_scope_escrow_has_no_entrypoints_for_is_a_well_formed_no_op() { // Accepted and recorded, but escrow has no attestation entrypoint, so // every one of its own paths stays open. ctx.contract - .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000, &None); ctx.contract.confirm_completion(&1); assert_eq!(ctx.token_client.balance(&ctx.worker), 10_000); } diff --git a/soroban-contracts/contracts/settlement-router/src/lib.rs b/soroban-contracts/contracts/settlement-router/src/lib.rs index 7257752..bce452c 100644 --- a/soroban-contracts/contracts/settlement-router/src/lib.rs +++ b/soroban-contracts/contracts/settlement-router/src/lib.rs @@ -221,6 +221,7 @@ pub struct Appointment { pub token: Address, pub amount: i128, pub status: Status, + pub referrer: Option
, } /// The subset of `escrow::Error` reachable through `get_appointment` and diff --git a/soroban-contracts/contracts/settlement-router/src/test.rs b/soroban-contracts/contracts/settlement-router/src/test.rs index 850e299..3cd7165 100644 --- a/soroban-contracts/contracts/settlement-router/src/test.rs +++ b/soroban-contracts/contracts/settlement-router/src/test.rs @@ -160,6 +160,7 @@ fn fund_appointment(f: &Fixture, appointment_id: u64) { &f.worker, &f.payment_token.address, &APPOINTMENT_AMOUNT, + &None, ); } From d05cd14dd64d6fd5b858b961f49c4a33ee159378 Mon Sep 17 00:00:00 2001 From: balisdev <294588434+balisdev@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:00:16 +0100 Subject: [PATCH 2/3] docs(contracts): reference PR #53 in the fee engine changelog entry --- soroban-contracts/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/soroban-contracts/CHANGELOG.md b/soroban-contracts/CHANGELOG.md index 2a08d61..bc4f597 100644 --- a/soroban-contracts/CHANGELOG.md +++ b/soroban-contracts/CHANGELOG.md @@ -18,7 +18,8 @@ sections start once something ships. - **Protocol fee engine with multi-party payout splits & treasury accounting** ([#39](https://github.com/workman-labs/guildworkman-core/issues/39), - PR #TODO). `escrow`'s `confirm_completion` and the worker-favoring branch + [PR #53](https://github.com/workman-labs/guildworkman-core/pull/53)). + `escrow`'s `confirm_completion` and the worker-favoring branch of `resolve_dispute` now split the escrowed amount across worker, protocol treasury, and an optional referrer instead of paying it out whole: - **`FeeConfig { protocol_bps, referrer_bps }`**, governance-bounded by a From 86be9a3f5f1af19b36fddfcd006e41eaee6023db Mon Sep 17 00:00:00 2001 From: balisdev <294588434+balisdev@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:01:19 +0100 Subject: [PATCH 3/3] fix(contracts): reject a referrer equal to the client or worker Not exploitable on its own -- the split's sum == amount invariant holds regardless of who referrer is -- but it's a meaningless self-referral that's cheap to reject in create_appointment before it lands on chain. Addresses review feedback on PR #53. --- soroban-contracts/CHANGELOG.md | 9 ++++-- soroban-contracts/README.md | 3 +- soroban-contracts/contracts/escrow/src/lib.rs | 16 ++++++++++ .../contracts/escrow/src/test.rs | 31 +++++++++++++++++++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/soroban-contracts/CHANGELOG.md b/soroban-contracts/CHANGELOG.md index bc4f597..5506c6f 100644 --- a/soroban-contracts/CHANGELOG.md +++ b/soroban-contracts/CHANGELOG.md @@ -43,7 +43,10 @@ sections start once something ships. - **Referrer share** is paid directly to `appointment.referrer` (a new `Option
` field on `Appointment`, and a new final parameter on `create_appointment`) when one is set; contributes nothing to the common - case of an appointment with no referrer. + case of an appointment with no referrer. `create_appointment` rejects a + `referrer` equal to `client` or `worker` up front — not exploitable (the + split invariant holds regardless), just a meaningless self-referral + that's cheap to reject rather than let onto the chain. - **Fees are charged only when a worker actually gets paid.** `cancel_appointment` and the refund-to-client branch of `resolve_dispute` are unchanged — they still return the full amount to the client with no @@ -53,8 +56,8 @@ sections start once something ships. `withdraw_treasury`, all gated the same way `migrate` is (any single current governance signer via `governance::require_signer`). - New errors `FeeExceedsMaximum`, `ArithmeticOverflow`, - `InsufficientTreasuryBalance` (codes 42-44), appended so no existing code - moved. + `InsufficientTreasuryBalance`, `InvalidReferrer` (codes 42-45), appended + so no existing code moved. - `settlement-router`'s mirrored `escrow::Appointment` type gained the same `referrer` field to keep cross-contract decoding in lockstep. - **Cross-contract settlement router with auth-chained escrow → reputation → diff --git a/soroban-contracts/README.md b/soroban-contracts/README.md index f63d1ae..06e28f2 100644 --- a/soroban-contracts/README.md +++ b/soroban-contracts/README.md @@ -570,7 +570,7 @@ stellar contract invoke --id $REPUTATION --source admin --network testnet \ ### escrow - `initialize(admin: Address, governance_init: GovernanceInit)` — writes no `FeeConfig` entry; `get_fee_config` treats that as `{0, 0}` (no fees) until `set_fee_config` is called -- `create_appointment(appointment_id: u64, client: Address, worker: Address, token: Address, amount: i128, referrer: Option
)` +- `create_appointment(appointment_id: u64, client: Address, worker: Address, token: Address, amount: i128, referrer: Option
)` — rejects a `referrer` equal to `client` or `worker` - `confirm_completion(appointment_id: u64)` — client-only, pays the worker (split per [Protocol fee engine](#protocol-fee-engine)) - `cancel_appointment(appointment_id: u64)` — client-only, refunds the client **in full, no fee taken** - `raise_dispute(appointment_id: u64, caller: Address)` — client or worker @@ -650,6 +650,7 @@ only through `withdraw_treasury`, gated the same way as `set_fee_config`. | `FeeExceedsMaximum` | 42 | `set_fee_config` with `protocol_bps + referrer_bps` above `MAX_TOTAL_FEE_BPS`. | | `ArithmeticOverflow` | 43 | A checked arithmetic step in the fee split or treasury bookkeeping would have overflowed `i128`. | | `InsufficientTreasuryBalance` | 44 | `withdraw_treasury` requested more than the token's tracked treasury balance. | +| `InvalidReferrer` | 45 | `create_appointment` with `referrer` equal to `client` or `worker`. | Codes 19-36 (milestone escrow and signer rotation) are documented in `src/lib.rs`; codes 37-41 are the circuit breaker's, listed in diff --git a/soroban-contracts/contracts/escrow/src/lib.rs b/soroban-contracts/contracts/escrow/src/lib.rs index 9964671..1820867 100644 --- a/soroban-contracts/contracts/escrow/src/lib.rs +++ b/soroban-contracts/contracts/escrow/src/lib.rs @@ -51,6 +51,11 @@ //! floor-rounded, paid directly to `appointment.referrer` when it is //! `Some`; zero when it is `None`. Most appointments have no referrer, and //! this contributes nothing to their settlement path in that case. +//! `create_appointment` rejects a `referrer` equal to either `client` or +//! `worker` up front: not exploitable (the `sum == amount` invariant holds +//! regardless of who `referrer` is), just a meaningless self-referral +//! that's cheap to reject before it can confuse anyone reading the chain +//! state later. //! - **Worker share** — the remainder, `amount - protocol_share - //! referrer_share`. Assigning the rounding remainder to the worker (rather //! than to the protocol or the referrer) is the deterministic rounding @@ -280,6 +285,7 @@ pub enum Error { FeeExceedsMaximum = 42, ArithmeticOverflow = 43, InsufficientTreasuryBalance = 44, + InvalidReferrer = 45, } impl From for Error { @@ -595,6 +601,16 @@ impl EscrowContract { if amount <= 0 { return Err(Error::InvalidAmount); } + // A referrer sharing an address with either settlement party isn't + // exploitable — the split's `sum == amount` invariant holds no + // matter who `referrer` is — but it's a meaningless configuration + // that's cheap to reject outright rather than let through as a + // confusing no-op self-referral. + if let Some(referrer) = &referrer { + if *referrer == client || *referrer == worker { + return Err(Error::InvalidReferrer); + } + } let key = DataKey::Appointment(appointment_id); if env.storage().persistent().has(&key) { diff --git a/soroban-contracts/contracts/escrow/src/test.rs b/soroban-contracts/contracts/escrow/src/test.rs index d7c9b63..2f9bc90 100644 --- a/soroban-contracts/contracts/escrow/src/test.rs +++ b/soroban-contracts/contracts/escrow/src/test.rs @@ -242,6 +242,37 @@ fn set_fee_config_protocol_alone_above_cap_rejected() { assert_eq!(res, Err(Ok(Error::FeeExceedsMaximum))); } +#[test] +fn create_appointment_rejects_referrer_equal_to_worker() { + let ctx = setup(); + let res = ctx.contract.try_create_appointment( + &1, + &ctx.client, + &ctx.worker, + &ctx.token, + &10_000, + &Some(ctx.worker.clone()), + ); + assert_eq!(res, Err(Ok(Error::InvalidReferrer))); + // Rejected before any funds move. + assert_eq!(ctx.token_client.balance(&ctx.client), 1_000_000); +} + +#[test] +fn create_appointment_rejects_referrer_equal_to_client() { + let ctx = setup(); + let res = ctx.contract.try_create_appointment( + &1, + &ctx.client, + &ctx.worker, + &ctx.token, + &10_000, + &Some(ctx.client.clone()), + ); + assert_eq!(res, Err(Ok(Error::InvalidReferrer))); + assert_eq!(ctx.token_client.balance(&ctx.client), 1_000_000); +} + #[test] fn confirm_completion_splits_protocol_fee_with_no_referrer() { let ctx = setup();