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();