diff --git a/soroban-contracts/CHANGELOG.md b/soroban-contracts/CHANGELOG.md
index a065242..4bf0fac 100644
--- a/soroban-contracts/CHANGELOG.md
+++ b/soroban-contracts/CHANGELOG.md
@@ -16,6 +16,38 @@ sections start once something ships.
### Added
+- **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)).
+ A new `contracts/settlement-router` crate that atomically drives escrow
+ release, reputation attestation, and loyalty emission from a single
+ `settle(appointment_id, rating, attestation_hash)` call, so a completed
+ appointment settles as one indivisible unit instead of three independently
+ callable — and independently spoofable — entrypoints:
+ - `settle` proves the appointment is `Funded` in `escrow` before doing
+ anything else, then calls `escrow.confirm_completion`,
+ `reputation.submit_attestation`, and `loyalty-token.mint` in one
+ transaction. Any `Err` from a sub-contract, or a pause on its side,
+ panics the whole invocation — nothing partially commits.
+ - **Idempotent per `appointment_id`**, checked before any cross-contract
+ call and enforced twice over: this router's own `Settled` marker, and
+ `escrow.confirm_completion` independently refusing a second call once
+ the appointment is no longer `Funded`.
+ - `reputation` gains an opt-in `set_router`/`get_router` (admin-only).
+ Once set, `submit_attestation` additionally requires that router's own
+ authorization alongside the client's — closing the previous gap where
+ any `appointment_id` could be attested with no proof it was ever funded
+ or completed. A contract address can only satisfy that requirement by
+ directly executing the call, so this cannot be forged by an
+ externally-owned account.
+ - `loyalty-token.mint` needs no code change: pointing its existing
+ `minter` role at this router (`set_minter`) is what gates it, the same
+ migration `loyalty-emissions` already models.
+ - Reward amounts are a fixed, admin-configured `RewardConfig` — never
+ taken from a `settle` caller's own arguments, so a caller cannot name
+ their own mint amount.
+ - Guarded by the existing shared `SCOPE_SETTLEMENT` (no new scope
+ introduced); see [Emergency circuit breaker](README.md#emergency-circuit-breaker).
- **Emergency circuit breaker across `escrow`, `reputation`, `loyalty-token`
and `loyalty-emissions`** ([#42](https://github.com/workman-labs/guildworkman-core/issues/42),
[PR #46](https://github.com/workman-labs/guildworkman-core/pull/46)). A shared pausability primitive in
diff --git a/soroban-contracts/Cargo.lock b/soroban-contracts/Cargo.lock
index 668c24c..a2a4755 100644
--- a/soroban-contracts/Cargo.lock
+++ b/soroban-contracts/Cargo.lock
@@ -772,6 +772,17 @@ dependencies = [
"soroban-sdk",
]
+[[package]]
+name = "guildworkman-settlement-router"
+version = "0.1.0"
+dependencies = [
+ "guildworkman-escrow",
+ "guildworkman-governance-guard",
+ "guildworkman-loyalty-token",
+ "guildworkman-reputation",
+ "soroban-sdk",
+]
+
[[package]]
name = "hash32"
version = "0.3.1"
diff --git a/soroban-contracts/Cargo.toml b/soroban-contracts/Cargo.toml
index cdee984..6e97a39 100644
--- a/soroban-contracts/Cargo.toml
+++ b/soroban-contracts/Cargo.toml
@@ -7,6 +7,7 @@ members = [
"contracts/loyalty-emissions",
"contracts/governance-guard",
"contracts/dispute-resolution",
+ "contracts/settlement-router",
]
[workspace.dependencies]
diff --git a/soroban-contracts/README.md b/soroban-contracts/README.md
index 6acbd45..3e5a9a7 100644
--- a/soroban-contracts/README.md
+++ b/soroban-contracts/README.md
@@ -3,7 +3,7 @@

Soroban (Stellar) smart contracts for GuildWorkman, the skilled-worker booking
-marketplace. This workspace has five independent contracts:
+marketplace. This workspace has six independent contracts:
| Contract | Path | Purpose |
|---|---|---|
@@ -11,8 +11,9 @@ marketplace. This workspace has five independent contracts:
| `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. |
+| `settlement-router` | `contracts/settlement-router` | Orchestrates `escrow`, `reputation` and `loyalty-token` atomically: a single `settle` call releases escrowed funds, records the client's attestation, and mints loyalty points, or none of it happens. Becomes the sole trust root `reputation`/`loyalty-token` accept a settlement from once wired in — see [settlement-router](#settlement-router). |
| `dispute-resolution` | `contracts/dispute-resolution` | Decentralized alternative to `escrow`'s single-admin arbitration: resolves a dispute via a **staked jury** using **commit-reveal** voting, then pays the majority out of the slashed stakes of the minority and no-shows. |
-| `governance-guard` | `contracts/governance-guard` | Not a deployed contract — a shared library that four of the contracts above (all except `dispute-resolution`) depend on, providing the multi-sig upgrade/migration pattern described in [Upgrade governance](#upgrade-governance). |
+| `governance-guard` | `contracts/governance-guard` | Not a deployed contract — a shared library that five of the contracts above (all except `dispute-resolution`) depend on, providing the multi-sig upgrade/migration pattern described in [Upgrade governance](#upgrade-governance). |
These mirror the domain already implemented server-side in the backend
([`../backend-api`](../backend-api): `AppointmentService`, `ReviewService`,
@@ -80,13 +81,22 @@ intended flow, if/when integrated, looks like:
`loyalty-emissions.create_schedule` and lets the recipient `claim` the
stream as it vests (see [loyalty-emissions](#loyalty-emissions)).
+Steps 2-4 can instead collapse into a single call once `settlement-router` is
+deployed and wired in (see [settlement-router](#settlement-router)): the
+backend (or the client's own wallet) calls `settlement-router.settle` once,
+which atomically drives `escrow.confirm_completion`,
+`reputation.submit_attestation` and `loyalty-token.mint` — funds, review and
+reward land together or not at all, and neither `reputation` nor
+`loyalty-token` will accept a write from anywhere else once that wiring is
+in place.
+
This requires the backend to hold a Stellar keypair per role (or per user, if
going non-custodial) and a Soroban RPC client — none of that exists in
`backend-api/` today.
## Upgrade governance
-All four contracts can have their code swapped in place via Soroban's
+All five contracts can have their code swapped in place via Soroban's
`update_current_contract_wasm`, gated behind an M-of-N multi-sig — set once
at `initialize` via a `signers: Vec
` + `threshold: u32` — instead of
being controlled by a single key or left permanently immutable. The pattern
@@ -138,7 +148,7 @@ in one pass. Left as a deliberate follow-up rather than rushed in here.
Each contract's per-error-code table below lists the governance error
variants it inherited from `governance-guard`, at whatever numeric offset
came next in that contract's existing `Error` enum — the variant names are
-identical across all four, only the numbers differ.
+identical across all five, only the numbers differ.
## Emergency circuit breaker
@@ -148,7 +158,7 @@ Jump to: [Pause authorization](#pause-authorization) ·
[Which clock](#which-clock) · [Hot-path cost](#hot-path-cost) ·
[Pausing from the CLI](#pausing-from-the-cli)
-The same four contracts can be **paused** during an incident. The primitive
+The same five contracts can be **paused** during an incident. The primitive
lives in `contracts/governance-guard`'s `pausable` module, next to the
upgrade guard and for the same reason: every contract that needs it already
depends on that crate.
@@ -162,7 +172,7 @@ freezing the contract:
| Scope | Bit | Meaning | Guarded entrypoints |
|---|---|---|---|
| `SCOPE_INTAKE` | `1` | New value or new obligations entering the system | `escrow`: `create_appointment`, `create_milestone_escrow`, `add_milestone` · `loyalty-token`: `mint` · `loyalty-emissions`: `create_schedule` |
-| `SCOPE_SETTLEMENT` | `2` | Discretionary happy-path payouts | `escrow`: `confirm_completion`, `approve_milestone`, `release_milestone_funds` · `loyalty-emissions`: `claim` |
+| `SCOPE_SETTLEMENT` | `2` | Discretionary happy-path payouts | `escrow`: `confirm_completion`, `approve_milestone`, `release_milestone_funds` · `loyalty-emissions`: `claim` · `settlement-router`: `settle` |
| `SCOPE_ATTESTATION` | `4` | Reputation writes | `reputation`: `submit_attestation` |
`ALL_SCOPES` (`7`) is all three. A contract with no entrypoint in some scope
@@ -220,7 +230,7 @@ who places a pause and then goes offline cannot wedge it in place.
#### Pause entrypoints
-Added to all four contracts:
+Added to all five contracts:
- `pause(caller: Address, scopes: u32, duration_secs: u64, reason: String) -> PauseState`
- `unpause(caller: Address, scopes: u32) -> u32` — clears only the named
@@ -307,13 +317,13 @@ not wait for an event that will never come.
Five variants per contract, at whatever offset came next in that
contract's existing `Error` enum — identical names, different numbers:
-| Variant | `escrow` | `reputation` | `loyalty-token` | `loyalty-emissions` | Meaning |
-|---|---|---|---|---|---|
-| `OperationPaused` | 37 | 29 | 24 | 30 | The entrypoint's scope is currently halted. A dedicated variant rather than a reused `InvalidStatus`: "the protocol is halted, retry later" and "this request was never valid" call for opposite reactions from a client. |
-| `InvalidPauseScope` | 38 | 30 | 25 | 31 | The scope mask was empty or contained bits outside `ALL_SCOPES`. Empty is rejected rather than treated as a no-op — during an incident a mask that halts nothing is a mistake the operator wants to hear about. |
-| `InvalidPauseDuration` | 39 | 31 | 26 | 32 | The duration was `0` or exceeded `MAX_PAUSE_DURATION`. |
-| `NotPaused` | 40 | 32 | 27 | 33 | `unpause` with nothing in effect, including a record that already auto-expired. |
-| `InvalidPauseReason` | 41 | 33 | 28 | 34 | The `reason` exceeded `MAX_PAUSE_REASON_LEN` (64 bytes). |
+| Variant | `escrow` | `reputation` | `loyalty-token` | `loyalty-emissions` | `settlement-router` | Meaning |
+|---|---|---|---|---|---|---|
+| `OperationPaused` | 37 | 29 | 24 | 30 | 23 | The entrypoint's scope is currently halted. A dedicated variant rather than a reused `InvalidStatus`: "the protocol is halted, retry later" and "this request was never valid" call for opposite reactions from a client. |
+| `InvalidPauseScope` | 38 | 30 | 25 | 31 | 24 | The scope mask was empty or contained bits outside `ALL_SCOPES`. Empty is rejected rather than treated as a no-op — during an incident a mask that halts nothing is a mistake the operator wants to hear about. |
+| `InvalidPauseDuration` | 39 | 31 | 26 | 32 | 25 | The duration was `0` or exceeded `MAX_PAUSE_DURATION`. |
+| `NotPaused` | 40 | 32 | 27 | 33 | 26 | `unpause` with nothing in effect, including a record that already auto-expired. |
+| `InvalidPauseReason` | 41 | 33 | 28 | 34 | 27 | The `reason` exceeded `MAX_PAUSE_REASON_LEN` (64 bytes). |
A non-signer calling `pause`/`unpause` gets the existing `NotASigner`.
@@ -409,15 +419,15 @@ stellar contract invoke --id $ESCROW --source signer1 --network testnet \
```
**Broadcasting to every contract.** The scope vocabulary is shared, so one
-mask goes to all four — including `reputation`, which has no intake
+mask goes to all five — including `reputation`, which has no intake
entrypoint, since a scope a contract doesn't use is a no-op rather than an
-error. The four are separate deployments with separate storage, so this is
+error. The five are separate deployments with separate storage, so this is
N transactions, not one: they needn't land in the same ledger, and a partial
sweep is a valid state rather than a corrupt one, because each contract's
guard reads only its own record. `scripts/broadcast-pause.sh` does the sweep:
```sh
-export ESCROW=… REPUTATION=… LOYALTY_TOKEN=… LOYALTY_EMISSIONS=…
+export ESCROW=… REPUTATION=… LOYALTY_TOKEN=… LOYALTY_EMISSIONS=… SETTLEMENT_ROUTER=…
SIGNER=my-key ./scripts/broadcast-pause.sh pause 7 21600 "INC-412 triage"
SIGNER=my-key ./scripts/broadcast-pause.sh status
SIGNER=my-key ./scripts/broadcast-pause.sh unpause 1
@@ -475,6 +485,18 @@ Each contract has unit tests under `contracts//src/test.rs` using
schedule params, reclaim-before-vesting-end, double-claim, double-reclaim,
claiming a missing/reclaimed schedule, and looping claims across many windows
never mints more than a schedule's `total`.
+- **settlement-router** (14 tests): a `settle` call atomically releases
+ escrowed funds, records the attestation, and mints loyalty to both parties
+ in one deployment wiring real `escrow`/`reputation`/`loyalty-token`
+ contracts together; a replay is rejected without a double release or mint;
+ settling an appointment that isn't `Funded` (missing, already completed
+ outside the router, cancelled, disputed) is rejected before any
+ cross-contract call; a pause on either sub-contract — or on the router's
+ own `SCOPE_SETTLEMENT` — reverts the whole invocation with nothing
+ committed; a negative `RewardConfig` is rejected at `initialize` and at
+ `set_reward_config`; a `0` reward skips that party's mint; and
+ `reputation.submit_attestation` rejects a direct caller lacking the
+ configured router's own authorization once `set_router` is wired in.
- **dispute-resolution** (27 tests): the full commit → reveal → resolve →
withdraw lifecycle pays a plaintiff- or defendant-majority jury out of the
losers' stakes; a no-show who committed but never revealed is slashed and
@@ -506,7 +528,8 @@ stellar contract invoke \
```
Repeat `deploy` for `guildworkman_reputation.wasm`,
-`guildworkman_loyalty_token.wasm`, `guildworkman_loyalty_emissions.wasm`, and
+`guildworkman_loyalty_token.wasm`, `guildworkman_loyalty_emissions.wasm`,
+`guildworkman_settlement_router.wasm`, and
`guildworkman_dispute_resolution.wasm`, then call each contract's `initialize`
once. For the emission engine to be
able to mint, point it at the token in its `initialize` and then hand it the
@@ -522,6 +545,26 @@ stellar contract invoke --id $LOYALTY --source admin --network testnet \
-- set_minter --new_minter $EMISSIONS
```
+To wire `settlement-router` in instead (see
+[settlement-router](#settlement-router) for the full "Deploying under
+partial rollout" order — this re-points `minter` away from
+`loyalty-emissions` above, so pick one mint authority per deployment):
+
+```sh
+stellar contract invoke --id $ROUTER --source admin --network testnet \
+ -- initialize --admin $ADMIN_ADDR --escrow $ESCROW --reputation $REPUTATION \
+ --loyalty_token $LOYALTY --reward_config '{ "client_reward": "50", "worker_reward": "100" }' \
+ --governance_init '{"signers":["'$SIGNER_1'","'$SIGNER_2'","'$SIGNER_3'"],"threshold":2}'
+
+# Hand the token's minter role to the router.
+stellar contract invoke --id $LOYALTY --source admin --network testnet \
+ -- set_minter --new_minter $ROUTER
+
+# Reputation only accepts an attestation the router vouches for from here on.
+stellar contract invoke --id $REPUTATION --source admin --network testnet \
+ -- set_router --router $ROUTER
+```
+
## Contract interfaces
### escrow
@@ -620,6 +663,14 @@ stellar contract invoke --id $ESCROW --source admin --network testnet \
> surface described in
> [Emergency circuit breaker](#emergency-circuit-breaker) — see the source
> and `src/test.rs` for the actual current interface.
+>
+> This PR adds one more piece on top of that already-drifted interface:
+> `set_router(router: Address)` (admin-only) and `get_router() -> Option`.
+> Once a router is set, `submit_attestation` additionally requires that
+> router's own authorization alongside the client's — see
+> [settlement-router](#settlement-router) for why, and for the `Router`
+> storage key this adds (instance, holds an `Option`, defaults
+> unset).
- `submit_review(appointment_id: u64, client: Address, worker: Address, rating: u32, comment: String)` — 1-5 stars, one review per `appointment_id`
- `get_rating(worker: Address) -> Rating { count, sum }`
@@ -874,6 +925,143 @@ stellar contract invoke --id $EMISSIONS --source admin --network testnet \
-- reclaim --beneficiary $WORKER_ADDR
```
+### settlement-router
+
+Orchestrates `escrow`, `reputation` and `loyalty-token` atomically. Before
+this contract, those three were independent doors: `escrow::confirm_completion`
+released funds and stopped there; `reputation::submit_attestation` accepted
+*any* `appointment_id` with no proof it was ever funded or completed;
+`loyalty-token::mint` trusted whichever address held the `minter` role. A
+client could review a worker for an appointment that never happened, and
+loyalty points were only as trustworthy as the backend's private key.
+
+A single `settle(appointment_id, rating, attestation_hash)` call now proves
+the appointment is `Funded` in `escrow`, then drives all three effects in one
+transaction: any `Err` from a sub-contract call — or a pause on its side —
+aborts the whole invocation, so a completed appointment settles as one
+indivisible unit (funds released, review recorded, loyalty minted) or none
+of it happens.
+
+- `initialize(admin: Address, escrow: Address, reputation: Address, loyalty_token: Address, reward_config: RewardConfig, governance_init: GovernanceInit)` —
+ `reward_config` is `{ client_reward: i128, worker_reward: i128 }`, the
+ fixed loyalty amounts minted on settlement (never taken from a `settle`
+ caller's own arguments)
+- `settle(appointment_id: u64, rating: u32, attestation_hash: BytesN<32>) -> ()` —
+ **permissionless caller**; the appointment's `client` must still authorize
+ the nested `escrow.confirm_completion` and `reputation.submit_attestation`
+ calls (see "Authorization" below)
+- `set_contracts(escrow: Address, reputation: Address, loyalty_token: Address)` — admin-only
+- `set_reward_config(reward_config: RewardConfig)` — admin-only
+- `is_settled(appointment_id: u64) -> bool`, `get_admin() -> Address`,
+ `get_escrow() -> Address`, `get_reputation() -> Address`,
+ `get_loyalty_token() -> Address`, `get_reward_config() -> RewardConfig` — read-only views
+- `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); only `SCOPE_SETTLEMENT` has teeth here (guards `settle`) — this router defines no scope of its own, since every effect it produces flows through a guard the sub-contract already enforces
+
+#### Deploying under partial rollout
+
+Wiring this router in is an ordered rollout, not a single flag flip, because
+a paused or unreachable sub-contract fails the whole `settle` call:
+
+1. Deploy this contract via `initialize`, pointing it at the already-deployed
+ `escrow`, `reputation` and `loyalty-token` addresses.
+2. `loyalty_token.set_minter(router_address)` — `loyalty-emissions`, if
+ deployed, loses mint access at this point.
+3. `reputation.set_router(router_address)` — the step that actually closes
+ the "any `appointment_id`" hole; direct `submit_attestation` calls that
+ omit the router's authorization stop working the instant this lands.
+4. Point front ends at `settle` instead of calling `escrow.confirm_completion`
+ directly — that entrypoint still works standalone (by design; see
+ `escrow`'s own docs on why fund-recovery-adjacent paths stay
+ permissionless) but bypasses the reputation/loyalty side effects.
+
+#### Authorization
+
+`settle` itself calls no `require_auth` — it is a permissionless relay, the
+same pattern `escrow::release_milestone_funds` uses. The authorizations it
+depends on (the appointment's `client`, required by the nested
+`escrow.confirm_completion` and `reputation.submit_attestation` calls) must
+already be present in the submitted transaction. Client-side tooling should
+simulate and sign against the `settle` entrypoint specifically, so the
+resulting authorization entry's root invocation is `settle`, with
+`confirm_completion` and `submit_attestation` as sub-invocations — that is
+what binds the signature to the whole atomic settlement.
+
+Reward amounts are fixed by the admin in `RewardConfig` and never taken from
+`settle`'s own arguments; letting a caller name their own mint amount would
+turn `settle` into an unbounded mint.
+
+#### Idempotency
+
+`DataKey::Settled(appointment_id)` is written before any cross-contract call
+is made (checks-effects-interactions, mirroring
+`escrow::release_milestone_funds`). A replayed `settle` for the same
+`appointment_id` is rejected before touching any other contract. This is
+defense in depth, not the only guard — `escrow::confirm_completion` itself
+refuses a second call once the appointment is no longer `Funded`.
+
+#### Storage layout
+
+| `DataKey` variant | Storage | Holds |
+|---|---|---|
+| `Admin` | instance | The admin `Address`; configures contract addresses and `RewardConfig`. |
+| `Escrow` / `Reputation` / `LoyaltyToken` | instance | The three contracts this router orchestrates. |
+| `RewardConfig` | instance | `RewardConfig { client_reward, worker_reward }`, the fixed loyalty mint amounts per settlement. |
+| `Settled(u64)` | persistent | A `bool` flag per `appointment_id`, written before any cross-contract call. |
+
+#### Errors
+
+| Variant | Code | Meaning |
+|---|---|---|
+| `AlreadyInitialized` | 1 | `initialize` called more than once. |
+| `NotInitialized` | 2 | A method needing state was called before `initialize`. |
+| `InvalidRewardConfig` | 3 | A negative `client_reward`/`worker_reward` passed to `initialize`/`set_reward_config`. |
+| `AlreadySettled` | 4 | `settle` called again for an `appointment_id` already settled. |
+| `AppointmentNotFunded` | 5 | The appointment `escrow` reports is not currently `Funded` (missing, already completed, cancelled, or disputed). |
+| `GovernanceAlreadyInitialized` | 6 | `initialize` called more than once (surfaced via the governance guard). |
+| `GovernanceNotInitialized` | 7 | A governance call before `initialize`. |
+| `InvalidThreshold` | 8 | `threshold` is `0` or exceeds the number of signers. |
+| `DuplicateSigner` | 9 | The same address appears twice in `signers`. |
+| `NotASigner` | 10 | `propose_upgrade`/`approve_upgrade`/`cancel_upgrade`/`migrate` called by a non-signer. |
+| `NoPendingUpgrade` | 11 | `approve_upgrade`/`cancel_upgrade` with nothing proposed. |
+| `AlreadyApproved` | 12 | The same signer approving the same proposal twice. |
+| `ProposalExpired` | 13 | `approve_upgrade` more than ~7 days after `propose_upgrade`. |
+| `HashMismatch` | 14 | `approve_upgrade` with a hash that doesn't match the pending proposal. |
+| `AlreadyMigrated` | 15 | `migrate` targeting a version already applied or behind the current one. |
+| `NothingToMigrate` | 16 | `migrate` called when the stored version is already current. |
+
+Codes 17-22 (signer rotation) are documented in `src/lib.rs`; codes 23-27 are
+the circuit breaker's, listed in
+[Emergency circuit breaker](#emergency-circuit-breaker).
+
+`settle` itself never returns any `escrow`/`reputation`/`loyalty-token` error
+value — a sub-contract's `Err`, or a pause on its side, always panics the
+whole transaction (see the crate's own docs for why: any non-`try_`
+cross-contract call does this by construction, which is exactly what makes
+the settlement atomic).
+
+#### CLI usage
+
+```sh
+# One-time setup (see "Deploying under partial rollout" above for the full
+# wiring order, including set_minter / set_router on the sub-contracts).
+stellar contract invoke --id $ROUTER --source admin --network testnet \
+ -- initialize --admin $ADMIN_ADDR --escrow $ESCROW --reputation $REPUTATION \
+ --loyalty_token $LOYALTY --reward_config '{ "client_reward": "50", "worker_reward": "100" }' \
+ --governance_init '{"signers":["'$SIGNER_1'","'$SIGNER_2'","'$SIGNER_3'"],"threshold":2}'
+
+# Atomically release funds, record the attestation, and mint loyalty.
+stellar contract invoke --id $ROUTER --source client --network testnet \
+ -- settle --appointment_id 1 --rating 5 \
+ --attestation_hash 0000000000000000000000000000000000000000000000000000000000000000
+
+# Read-only views.
+stellar contract invoke --id $ROUTER --source admin --network testnet \
+ -- is_settled --appointment_id 1
+stellar contract invoke --id $ROUTER --source admin --network testnet \
+ -- get_reward_config
+```
+
### dispute-resolution
> ⚠️ **v1, unaudited, no appeals** — single-round staked-jury voting with no sybil-resistant/weighted jury selection. Read [Security considerations / known limitations](#security-considerations--known-limitations) before integrating.
@@ -1044,6 +1232,22 @@ stellar contract invoke --id $DISPUTES --source juror --network testnet \
verified against its documented behavior rather than exercised live. A
testnet deploy-and-upgrade dry run is the natural next verification step
before this ships anywhere real funds move through.
+- **`settlement-router` fails shut, not open.** `settle` makes plain
+ (non-`try_`) cross-contract calls, so a paused `reputation`/`loyalty-token`,
+ or one simply not yet wired to trust this router
+ (`reputation.set_router`/`loyalty_token.set_minter`), aborts the whole
+ settlement rather than degrading to "release funds anyway." That is the
+ intended atomicity trade-off, but it does mean a completed appointment's
+ payout is gated on infrastructure — two other contracts' liveness and
+ correct configuration — that `escrow.confirm_completion` alone never
+ depended on. Operators should treat the "Deploying under partial rollout"
+ sequencing in [settlement-router](#settlement-router) as load-bearing, not
+ optional ordering advice.
+- **Router reward amounts are fixed, not proportional.** `RewardConfig`'s
+ `client_reward`/`worker_reward` are flat amounts set once by the router's
+ admin, unrelated to the appointment's escrowed `amount`. A protocol that
+ wants loyalty proportional to spend needs that logic added explicitly —
+ it is not inferred from escrow state today.
- **Comments are not authenticated content.** `reputation`'s `comment` field
is an arbitrary `String` supplied by the reviewer with no length cap or
content moderation — treat it as untrusted user input wherever it's
@@ -1089,6 +1293,12 @@ stellar contract invoke --id $DISPUTES --source juror --network testnet \
- Real dispute resolution for `escrow` beyond a single admin call, and a
genuine storage migration exercising `migrate`'s version-transform path
(nothing has needed one yet — every contract is still on storage version 1).
+- `settlement-router` is deployed and tested but not called from
+ `backend-api/` yet, same as everything else in
+ [Suggested backend integration](#suggested-backend-integration-not-yet-wired-in) —
+ wiring it in requires re-pointing `loyalty-token`'s `minter` and
+ `reputation`'s router away from whatever (if anything) currently holds
+ those roles, per its own "Deploying under partial rollout" notes.
## License
diff --git a/soroban-contracts/contracts/reputation/src/lib.rs b/soroban-contracts/contracts/reputation/src/lib.rs
index e9945c3..3236f53 100644
--- a/soroban-contracts/contracts/reputation/src/lib.rs
+++ b/soroban-contracts/contracts/reputation/src/lib.rs
@@ -4,6 +4,26 @@
//!
//! Computes time-decayed, stake-weighted scores from signed attestations
//! while resisting Sybil, collusion, and self-dealing attacks.
+//!
+//! ## Settlement router gating
+//!
+//! `submit_attestation` accepts *any* `appointment_id` on its own — nothing
+//! in this contract proves the appointment existed in `escrow`, was
+//! funded, or was completed. `set_router` (admin-only) closes that gap:
+//! once a router address is configured, `submit_attestation` additionally
+//! requires `router.require_auth()` alongside the client's own
+//! authorization. A contract address can only ever satisfy
+//! `require_auth()` for *itself*, and only by directly executing the call —
+//! there is no key an externally-owned account could sign with to forge
+//! it — so once `Router` is set to a deployed
+//! `guildworkman-settlement-router`, the only way this function can
+//! succeed is a call arriving from that router's own code, which by
+//! construction only happens after it has confirmed the appointment
+//! on-chain. See that crate's docs for the full settlement flow.
+//!
+//! `Router` defaults to unset, which preserves this contract's original,
+//! client-authorized-only behavior — the gate is opt-in per deployment
+//! until an operator finishes wiring a router in.
use soroban_sdk::{
contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, String, Vec,
@@ -91,6 +111,9 @@ pub enum DataKey {
// Instance (singletons)
Admin,
Config,
+ /// Optional settlement-router address (`guildworkman-settlement-router`).
+ /// `None` until an admin calls `set_router`. See `submit_attestation`.
+ Router,
// Persistent (entity data)
Reviewed(u64),
Review(Address, u32),
@@ -380,6 +403,25 @@ impl ReputationContract {
Ok(())
}
+ /// Admin-only: point this contract at a deployed settlement router.
+ /// Once set, `submit_attestation` requires that router's authorization
+ /// in addition to the client's — see the crate-level "Settlement
+ /// router gating" notes. Passing a fresh address here is also how a
+ /// router upgrade or redeploy gets wired in; there is no "unset" call,
+ /// since a reputation contract that has ever required router
+ /// authorization should not silently fall back to accepting
+ /// unverified attestations again.
+ pub fn set_router(env: Env, router: Address) -> Result<(), Error> {
+ Self::require_admin(&env)?;
+ env.storage().instance().set(&DataKey::Router, &router);
+ Self::bump_instance(&env);
+ Ok(())
+ }
+
+ pub fn get_router(env: Env) -> Option {
+ env.storage().instance().get(&DataKey::Router)
+ }
+
pub fn set_stake(env: Env, user: Address, stake: u64) -> Result<(), Error> {
Self::require_admin(&env)?;
let key = DataKey::Stake(user);
@@ -410,6 +452,15 @@ impl ReputationContract {
// 1. Authorization (signed attestation via Soroban auth).
client.require_auth();
+ // 1b. If a settlement router is configured, this call must also be
+ // arriving from that router's own code — see the crate-level
+ // "Settlement router gating" notes for why this closes off
+ // submitting an attestation for an appointment that was never
+ // verified against escrow.
+ if let Some(router) = Self::get_router(env.clone()) {
+ router.require_auth();
+ }
+
// 2. Self-dealing prevention.
if client == worker {
return Err(Error::SelfDealing);
diff --git a/soroban-contracts/contracts/reputation/src/test.rs b/soroban-contracts/contracts/reputation/src/test.rs
index b48e119..1c0abba 100644
--- a/soroban-contracts/contracts/reputation/src/test.rs
+++ b/soroban-contracts/contracts/reputation/src/test.rs
@@ -3,7 +3,8 @@
use super::*;
use soroban_sdk::testutils::Address as _;
use soroban_sdk::testutils::Ledger;
-use soroban_sdk::{BytesN, Env};
+use soroban_sdk::testutils::{MockAuth, MockAuthInvoke};
+use soroban_sdk::{BytesN, Env, IntoVal, Val};
fn default_config() -> Config {
Config {
@@ -836,3 +837,57 @@ fn pause_views_report_the_active_window() {
assert_eq!(state.expires_at, 8_200);
assert!(contract.is_paused(&SCOPE_ATTESTATION));
}
+
+// ===========================================================================
+// Settlement router gating
+// ===========================================================================
+
+#[test]
+fn router_defaults_to_unset() {
+ let (_env, contract, _client, _worker) = setup();
+ assert_eq!(contract.get_router(), None);
+}
+
+#[test]
+fn set_router_then_get_router_reflects_it() {
+ let (env, contract, _client, _worker) = setup();
+ let router = Address::generate(&env);
+ contract.set_router(&router);
+ assert_eq!(contract.get_router(), Some(router));
+}
+
+#[test]
+fn submit_attestation_without_router_configured_keeps_legacy_behavior() {
+ // Regression: a deployment that never wires a router in behaves
+ // exactly as before this feature existed.
+ let (env, contract, client, worker) = setup();
+ contract.submit_attestation(&1, &client, &worker, &5, &dummy_hash(&env));
+ assert_eq!(contract.get_attestation_count(&worker), 1);
+}
+
+#[test]
+fn submit_attestation_direct_call_fails_once_router_is_configured() {
+ let (env, contract, client, worker) = setup();
+ let router = Address::generate(&env);
+ contract.set_router(&router);
+
+ // Only the client's own authorization is mocked -- a direct caller
+ // that is not the router contract itself has no way to satisfy
+ // `require_auth` for the router's address, since a contract address
+ // can only authorize by directly executing the call.
+ let hash = dummy_hash(&env);
+ let args: soroban_sdk::Vec =
+ (1u64, client.clone(), worker.clone(), 5u32, hash.clone()).into_val(&env);
+ env.mock_auths(&[MockAuth {
+ address: &client,
+ invoke: &MockAuthInvoke {
+ contract: &contract.address,
+ fn_name: "submit_attestation",
+ args,
+ sub_invokes: &[],
+ },
+ }]);
+
+ let res = contract.try_submit_attestation(&1, &client, &worker, &5, &hash);
+ assert!(res.is_err());
+}
diff --git a/soroban-contracts/contracts/settlement-router/Cargo.toml b/soroban-contracts/contracts/settlement-router/Cargo.toml
new file mode 100644
index 0000000..25ecbb7
--- /dev/null
+++ b/soroban-contracts/contracts/settlement-router/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "guildworkman-settlement-router"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+guildworkman-governance-guard = { path = "../governance-guard" }
+
+[dev-dependencies]
+soroban-sdk = { workspace = true, features = ["testutils"] }
+guildworkman-escrow = { path = "../escrow", features = ["testutils"] }
+guildworkman-reputation = { path = "../reputation", features = ["testutils"] }
+guildworkman-loyalty-token = { path = "../loyalty-token", features = ["testutils"] }
+
+[features]
+testutils = ["soroban-sdk/testutils"]
diff --git a/soroban-contracts/contracts/settlement-router/src/lib.rs b/soroban-contracts/contracts/settlement-router/src/lib.rs
new file mode 100644
index 0000000..7257752
--- /dev/null
+++ b/soroban-contracts/contracts/settlement-router/src/lib.rs
@@ -0,0 +1,740 @@
+#![no_std]
+
+//! Cross-contract settlement router for GuildWorkman.
+//!
+//! Before this contract existed, `escrow`, `reputation` and `loyalty-token`
+//! were three independent doors:
+//!
+//! - `escrow::confirm_completion` moves funds and marks the appointment
+//! `Completed` — and stops there.
+//! - `reputation::submit_attestation` accepted *any* `appointment_id` with no
+//! proof that the appointment existed, was funded, or was completed.
+//! - `loyalty-token::mint` is gated only on a single `minter` address, so
+//! reward points were only ever as trustworthy as whoever held that key.
+//!
+//! A client could review a worker for an appointment that never happened,
+//! and loyalty points were minted on the backend's word rather than on
+//! settled on-chain state. This contract closes both gaps by becoming the
+//! *only* authority that can walk escrow → reputation → loyalty in one
+//! atomic settlement.
+//!
+//! ## Settlement flow
+//!
+//! [`SettlementRouter::settle`] does exactly this, in order, all inside one
+//! host invocation:
+//!
+//! 1. Reject a replayed `appointment_id` outright ([`Error::AlreadySettled`]).
+//! 2. Read the appointment from `escrow` and require it to be `Funded`
+//! ([`Error::AppointmentNotFunded`]) — this is the on-chain proof the
+//! reputation and loyalty writes below are conditioned on.
+//! 3. Mark the appointment settled (see "Idempotency" below).
+//! 4. Call `escrow::confirm_completion` — releases the escrowed funds to the
+//! worker, exactly as it always did, still under the client's own
+//! authorization.
+//! 5. Call `reputation::submit_attestation` — this contract's own address
+//! stands as the router `reputation` has been configured to trust (see
+//! "Reputation gating" below), so the write only succeeds because step 2
+//! already proved the appointment was funded and complete.
+//! 6. Call `loyalty-token::mint` for the client and the worker, at the fixed
+//! amounts in [`RewardConfig`] — this contract must hold the token's
+//! `minter` role for this step to succeed (see "Loyalty minting" below).
+//!
+//! Because every one of steps 4-6 is a plain (non-`try_`) cross-contract
+//! call, **any `Err` returned by the sub-contract — or a pause on its
+//! side — aborts the whole transaction.** Soroban has no partial-commit
+//! concept: a panic anywhere unwinds the entire invocation, so a completed
+//! appointment either settles as one indivisible unit (funds released,
+//! review slot unlocked, loyalty minted) or none of it happens, including
+//! the idempotency marker written in step 3. There is nothing for an
+//! operator to reconcile after a failed `settle` — chain state is exactly
+//! as if it had never been called. The corollary is worth stating plainly:
+//! if `reputation` or `loyalty-token` is paused, or simply not yet deployed
+//! and wired at the addresses this router holds, `settle` fails shut rather
+//! than degrading — a completed appointment's funds do **not** release
+//! until reputation and loyalty are both reachable. That trade favors
+//! atomicity over availability; see "Deploying under partial rollout" below
+//! for how to sequence a first deployment around it.
+//!
+//! ## Idempotency
+//!
+//! [`DataKey::Settled`] is written *before* any cross-contract call is
+//! made, following the same checks-effects-interactions discipline as
+//! `escrow::release_milestone_funds`. A second `settle` call for the same
+//! `appointment_id` is rejected at step 1 before touching any other
+//! contract — a replay is never a double release or a double mint. This is
+//! defense in depth, not the only guard: even without the marker,
+//! `escrow::confirm_completion` itself refuses a second call once the
+//! appointment is no longer `Funded`, so a bug in this router's own
+//! bookkeeping could not resurrect a double payout on its own.
+//!
+//! ## Reputation gating
+//!
+//! `reputation::submit_attestation` accepts an *optional* router address
+//! (`reputation::set_router`, admin-gated). Once set, a call must satisfy
+//! **both**: `client.require_auth()` (unchanged — the client still consents
+//! to their own rating) **and** `router.require_auth()`. The second check
+//! is what closes the original hole. A contract address can only ever
+//! satisfy `require_auth()` for *itself*, and only by being the contract
+//! directly executing the call — there is no private key an
+//! externally-owned account could sign with to forge it. So once
+//! `reputation` is pointed at this router's address, the only way
+//! `submit_attestation` can succeed is a call arriving from this contract's
+//! own code, which by construction only ever happens after step 2 above has
+//! confirmed the appointment on-chain. Deploying this router does not, by
+//! itself, close the hole — an operator must also call
+//! `reputation.set_router(router_address)`; see "Deploying under partial
+//! rollout" below.
+//!
+//! ## Loyalty minting
+//!
+//! `loyalty-token::mint` already only trusts a single `minter` address
+//! (unchanged by this contract). The migration this router calls for is
+//! operational, not a code change: point that role at this router
+//! (`loyalty_token.set_minter(router_address)`) the same way
+//! `loyalty-emissions` already does for its own `claim` flow. `mint`'s
+//! internal `minter.require_auth()` then succeeds automatically once this
+//! contract's own address is that minter, for the same self-authorizing
+//! reason described above — no code in `loyalty-token` needed to change.
+//! Reward amounts are fixed in [`RewardConfig`], set by this contract's
+//! admin, and are **never** taken from a `settle` caller's arguments —
+//! letting a caller name their own mint amount would turn `settle` into an
+//! unbounded mint.
+//!
+//! ## Deploying under partial rollout
+//!
+//! Because a paused or unreachable sub-contract fails the whole settlement
+//! (see "Settlement flow"), wiring this router in is an ordered rollout,
+//! not a single flag flip:
+//!
+//! 1. Deploy this contract with `initialize`, pointing it at the already-
+//! deployed `escrow`, `reputation` and `loyalty-token` addresses.
+//! 2. Call `loyalty_token.set_minter(router_address)` — `loyalty-emissions`
+//! (if deployed) loses mint access at this point and must be
+//! re-pointed or retired first if it is still meant to run.
+//! 3. Call `reputation.set_router(router_address)` — this is the step that
+//! actually closes the "any appointment_id" hole; direct
+//! `submit_attestation` calls that omit the router's authorization stop
+//! working the instant this lands.
+//! 4. From here on, `escrow::confirm_completion` should only be reached
+//! through `settle` — a client calling it directly still works (it has
+//! no router gate of its own, by design: see `escrow`'s own docs on why
+//! fund-recovery-adjacent paths stay permissionless) but bypasses the
+//! reputation/loyalty side effects entirely, so front ends should be
+//! updated to call `settle` instead.
+//!
+//! ## Storage layout
+//!
+//! | Key | Durability | Type | Holds |
+//! |-----|-----------|------|-------|
+//! | `DataKey::Admin` | instance | `Address` | Configures contract addresses and `RewardConfig` |
+//! | `DataKey::Escrow` / `Reputation` / `LoyaltyToken` | instance | `Address` | The three contracts this router orchestrates |
+//! | `DataKey::RewardConfig` | instance | `RewardConfig` | Fixed loyalty mint amounts per settlement |
+//! | `DataKey::Settled(appointment_id)` | persistent | `bool` | Idempotency marker, written before any cross-contract call |
+//! | `GovernanceDataKey::*` | instance | governance-guard types | M-of-N upgrade governance and the emergency pause record |
+//!
+//! ## Authorization model
+//!
+//! - `initialize`: `admin` must authorize.
+//! - `settle`: **permissionless caller** — anyone may submit the
+//! transaction, but the required authorizations (the appointment's
+//! `client`, for both `escrow::confirm_completion` and
+//! `reputation::submit_attestation`) must already be present in it, the
+//! same permissionless-relay pattern `escrow::release_milestone_funds`
+//! uses. `settle` itself calls no `require_auth` of its own — client-side
+//! tooling should simulate and sign against the `settle` entrypoint
+//! (not `confirm_completion` directly), so the resulting authorization
+//! entry's root invocation is `settle` with `confirm_completion` and
+//! `submit_attestation` as its sub-invocations. That is what binds the
+//! client's signature to the whole atomic settlement rather than to
+//! `confirm_completion` alone, which — being independently
+//! client-authorized and permissionless-to-callers on `escrow`'s own
+//! side — remains directly callable regardless of this router's
+//! existence, exactly as it always was.
+//! - `set_contracts` / `set_reward_config`: admin must authorize.
+//! - `pause` / `unpause`: any single governance signer (not `admin`).
+//!
+//! ## Emergency circuit breaker
+//!
+//! `settle` is guarded by [`SCOPE_SETTLEMENT`] — the same shared scope
+//! `escrow::confirm_completion` and `loyalty-emissions::claim` already use,
+//! so an operator broadcasting a settlement-wide pause during an incident
+//! reaches this router with the identical call it already sends everywhere
+//! else. There is deliberately no separate scope: this contract mints and
+//! releases nothing of its own — every effect flows through the guards the
+//! sub-contracts already enforce on their own entrypoints — so a second,
+//! router-specific scope would only ever be paused in lockstep with
+//! `SCOPE_SETTLEMENT` and would just be one more broadcast target during an
+//! incident, not an independent control.
+
+use soroban_sdk::{
+ contract, contractclient, contracterror, contractimpl, contracttype, Address, BytesN, Env,
+ String, Vec,
+};
+
+use guildworkman_governance_guard as governance;
+pub use guildworkman_governance_guard::{
+ PauseState, PendingRotation, PendingUpgrade, ALL_SCOPES, MAX_PAUSE_DURATION,
+ MAX_PAUSE_REASON_LEN, SCOPE_SETTLEMENT,
+};
+
+/// Bump when this contract's storage layout actually changes shape and
+/// needs a real transformation in `migrate`. There's no such change yet.
+const CURRENT_STORAGE_VERSION: u32 = 1;
+
+// ---------------------------------------------------------------------------
+// Cross-contract clients
+// ---------------------------------------------------------------------------
+//
+// Declared locally rather than depending on the `escrow` / `reputation` /
+// `loyalty-token` crates directly, following the convention
+// `loyalty-emissions` already established for its `loyalty-token` call:
+// keeps each deployed contract's exported symbols from colliding at wasm
+// link time, and keeps this crate from having to recompile against every
+// sibling contract's full surface just to call the handful of functions it
+// actually needs. The error enums below mirror only the numeric
+// discriminants reachable through those specific calls — contract errors
+// decode by discriminant, not by variant name, so these values must stay in
+// lockstep with the corresponding `Error` variant in the sibling crate.
+
+/// Mirrors `escrow::Status`. Soroban encodes a plain (all-unit-variant)
+/// `#[contracttype]` enum by variant *name*, so this only has to match
+/// `escrow`'s variant names, not their declaration order or discriminants.
+#[contracttype]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum Status {
+ Funded,
+ Completed,
+ Cancelled,
+ Disputed,
+ Resolved,
+}
+
+/// Mirrors `escrow::Appointment`. `#[contracttype]` structs with named
+/// fields decode by field name, so every field `escrow::Appointment`
+/// declares must be present here with a matching name and type, even the
+/// ones this contract never reads.
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct Appointment {
+ pub client: Address,
+ pub worker: Address,
+ pub token: Address,
+ pub amount: i128,
+ pub status: Status,
+}
+
+/// The subset of `escrow::Error` reachable through `get_appointment` and
+/// `confirm_completion`.
+#[contracterror]
+#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
+pub enum EscrowError {
+ AppointmentNotFound = 4,
+ InvalidStatus = 5,
+ OperationPaused = 37,
+}
+
+#[contractclient(name = "EscrowClient")]
+pub trait EscrowInterface {
+ fn get_appointment(env: Env, appointment_id: u64) -> Result;
+ fn confirm_completion(env: Env, appointment_id: u64) -> Result<(), EscrowError>;
+}
+
+/// The subset of `reputation::Error` reachable through `submit_attestation`.
+#[contracterror]
+#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
+pub enum ReputationError {
+ InvalidRating = 1,
+ AlreadyReviewed = 2,
+ NotInitialized = 5,
+ InsufficientStake = 7,
+ ReviewerRateLimited = 8,
+ GlobalRateLimited = 9,
+ SelfDealing = 10,
+ OperationPaused = 29,
+}
+
+#[contractclient(name = "ReputationClient")]
+pub trait ReputationInterface {
+ fn submit_attestation(
+ env: Env,
+ appointment_id: u64,
+ client: Address,
+ worker: Address,
+ rating: u32,
+ attestation_hash: BytesN<32>,
+ ) -> Result<(), ReputationError>;
+}
+
+/// The subset of `loyalty-token::Error` reachable through `mint`.
+#[contracterror]
+#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
+pub enum LoyaltyTokenError {
+ NotInitialized = 2,
+ InvalidAmount = 5,
+ OperationPaused = 24,
+}
+
+#[contractclient(name = "LoyaltyTokenClient")]
+pub trait LoyaltyTokenInterface {
+ fn mint(env: Env, to: Address, amount: i128) -> Result<(), LoyaltyTokenError>;
+}
+
+// ---------------------------------------------------------------------------
+// Storage
+// ---------------------------------------------------------------------------
+
+#[contracttype]
+pub enum DataKey {
+ Admin,
+ Escrow,
+ Reputation,
+ LoyaltyToken,
+ RewardConfig,
+ /// `appointment_id` -> `true` once settled. Presence alone is the
+ /// signal; the value is always `true` when the key exists.
+ Settled(u64),
+}
+
+/// Fixed loyalty-point amounts minted on a successful settlement. Set by
+/// the admin at `initialize` and updatable via `set_reward_config` —
+/// **never** taken from a `settle` caller's arguments, since that would let
+/// any caller name their own mint amount.
+#[contracttype]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct RewardConfig {
+ /// Points minted to the client on settlement. `0` disables the client
+ /// mint entirely (no zero-amount `mint` call is made).
+ pub client_reward: i128,
+ /// Points minted to the worker on settlement. `0` disables the worker
+ /// mint entirely.
+ pub worker_reward: i128,
+}
+
+#[contracterror]
+#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
+pub enum Error {
+ AlreadyInitialized = 1,
+ NotInitialized = 2,
+ InvalidRewardConfig = 3,
+ AlreadySettled = 4,
+ AppointmentNotFunded = 5,
+ // --- Upgrade governance (see guildworkman-governance-guard) ---
+ GovernanceAlreadyInitialized = 6,
+ GovernanceNotInitialized = 7,
+ InvalidThreshold = 8,
+ DuplicateSigner = 9,
+ NotASigner = 10,
+ NoPendingUpgrade = 11,
+ AlreadyApproved = 12,
+ ProposalExpired = 13,
+ HashMismatch = 14,
+ AlreadyMigrated = 15,
+ NothingToMigrate = 16,
+ // --- Signer rotation (see guildworkman-governance-guard) ---
+ NoPendingRotation = 17,
+ RotationMismatch = 18,
+ RotationNotReady = 19,
+ RotationTimelockActive = 20,
+ RotationExpired = 21,
+ RotationInProgress = 22,
+ // --- Emergency circuit breaker (see guildworkman-governance-guard) ---
+ OperationPaused = 23,
+ InvalidPauseScope = 24,
+ InvalidPauseDuration = 25,
+ NotPaused = 26,
+ InvalidPauseReason = 27,
+}
+
+impl From for Error {
+ fn from(e: governance::GovernanceError) -> Self {
+ match e {
+ governance::GovernanceError::AlreadyInitialized => Error::GovernanceAlreadyInitialized,
+ governance::GovernanceError::NotInitialized => Error::GovernanceNotInitialized,
+ governance::GovernanceError::InvalidThreshold => Error::InvalidThreshold,
+ governance::GovernanceError::DuplicateSigner => Error::DuplicateSigner,
+ governance::GovernanceError::NotASigner => Error::NotASigner,
+ governance::GovernanceError::NoPendingUpgrade => Error::NoPendingUpgrade,
+ governance::GovernanceError::AlreadyApproved => Error::AlreadyApproved,
+ governance::GovernanceError::ProposalExpired => Error::ProposalExpired,
+ governance::GovernanceError::HashMismatch => Error::HashMismatch,
+ governance::GovernanceError::AlreadyMigrated => Error::AlreadyMigrated,
+ governance::GovernanceError::NoPendingRotation => Error::NoPendingRotation,
+ governance::GovernanceError::RotationMismatch => Error::RotationMismatch,
+ governance::GovernanceError::RotationNotReady => Error::RotationNotReady,
+ governance::GovernanceError::RotationTimelockActive => Error::RotationTimelockActive,
+ governance::GovernanceError::RotationExpired => Error::RotationExpired,
+ governance::GovernanceError::RotationInProgress => Error::RotationInProgress,
+ governance::GovernanceError::OperationPaused => Error::OperationPaused,
+ governance::GovernanceError::InvalidPauseScope => Error::InvalidPauseScope,
+ governance::GovernanceError::InvalidPauseDuration => Error::InvalidPauseDuration,
+ governance::GovernanceError::NotPaused => Error::NotPaused,
+ governance::GovernanceError::InvalidPauseReason => Error::InvalidPauseReason,
+ }
+ }
+}
+
+const DAY_IN_LEDGERS: u32 = 17_280; // ~5s per ledger
+const INSTANCE_BUMP_AMOUNT: u32 = DAY_IN_LEDGERS * 60;
+const INSTANCE_LIFETIME_THRESHOLD: u32 = DAY_IN_LEDGERS * 30;
+const SETTLED_BUMP_AMOUNT: u32 = DAY_IN_LEDGERS * 30;
+const SETTLED_LIFETIME_THRESHOLD: u32 = DAY_IN_LEDGERS * 29;
+
+#[contract]
+pub struct SettlementRouter;
+
+#[contractimpl]
+impl SettlementRouter {
+ /// One-time setup. `admin` configures the three orchestrated contract
+ /// addresses and the loyalty `RewardConfig`; it is unrelated to
+ /// `governance_init`, which gates upgrades and the pause, exactly as in
+ /// the sibling contracts.
+ ///
+ /// Wiring the sub-contracts to actually trust this router's address
+ /// (`loyalty_token.set_minter`, `reputation.set_router`) is a separate,
+ /// deliberately manual step — see the crate-level "Deploying under
+ /// partial rollout" notes.
+ #[allow(clippy::too_many_arguments)]
+ pub fn initialize(
+ env: Env,
+ admin: Address,
+ escrow: Address,
+ reputation: Address,
+ loyalty_token: Address,
+ reward_config: RewardConfig,
+ governance_init: governance::GovernanceInit,
+ ) -> Result<(), Error> {
+ if env.storage().instance().has(&DataKey::Admin) {
+ return Err(Error::AlreadyInitialized);
+ }
+ admin.require_auth();
+ Self::validate_reward_config(&reward_config)?;
+ governance::init_governance(&env, governance_init)?;
+
+ env.storage().instance().set(&DataKey::Admin, &admin);
+ env.storage().instance().set(&DataKey::Escrow, &escrow);
+ env.storage()
+ .instance()
+ .set(&DataKey::Reputation, &reputation);
+ env.storage()
+ .instance()
+ .set(&DataKey::LoyaltyToken, &loyalty_token);
+ env.storage()
+ .instance()
+ .set(&DataKey::RewardConfig, &reward_config);
+ Self::bump_instance(&env);
+ Ok(())
+ }
+
+ // ----- Upgrade governance -----
+
+ pub fn propose_upgrade(
+ env: Env,
+ proposer: Address,
+ wasm_hash: BytesN<32>,
+ ) -> Result {
+ let ready = governance::propose_upgrade(&env, proposer, wasm_hash.clone())?;
+ if ready {
+ env.deployer().update_current_contract_wasm(wasm_hash);
+ }
+ Ok(ready)
+ }
+
+ pub fn approve_upgrade(
+ env: Env,
+ approver: Address,
+ wasm_hash: BytesN<32>,
+ ) -> Result {
+ let ready = governance::approve_upgrade(&env, approver, wasm_hash.clone())?;
+ if ready {
+ env.deployer().update_current_contract_wasm(wasm_hash);
+ }
+ Ok(ready)
+ }
+
+ pub fn cancel_upgrade(env: Env, caller: Address) -> Result<(), Error> {
+ governance::cancel_upgrade(&env, caller).map_err(Into::into)
+ }
+
+ // ----- Signer rotation -----
+
+ pub fn propose_signer_rotation(
+ env: Env,
+ proposer: Address,
+ new_signers: Vec,
+ new_threshold: u32,
+ ) -> Result {
+ governance::propose_signer_rotation(&env, proposer, new_signers, new_threshold)
+ .map_err(Into::into)
+ }
+
+ pub fn approve_signer_rotation(
+ env: Env,
+ approver: Address,
+ new_signers: Vec,
+ new_threshold: u32,
+ ) -> Result {
+ governance::approve_signer_rotation(&env, approver, new_signers, new_threshold)
+ .map_err(Into::into)
+ }
+
+ pub fn execute_signer_rotation(env: Env, caller: Address) -> Result<(), Error> {
+ governance::execute_signer_rotation(&env, caller).map_err(Into::into)
+ }
+
+ pub fn get_pending_rotation(env: Env) -> Option {
+ governance::get_pending_rotation(&env)
+ }
+
+ pub fn migrate(env: Env, signer: Address) -> Result<(), Error> {
+ governance::require_signer(&env, &signer)?;
+ if governance::current_storage_version(&env) >= CURRENT_STORAGE_VERSION {
+ return Err(Error::NothingToMigrate);
+ }
+ // No storage shape has changed since v1 — nothing to transform yet.
+ governance::mark_migrated(&env, CURRENT_STORAGE_VERSION)?;
+ Ok(())
+ }
+
+ pub fn get_signers(env: Env) -> Vec {
+ governance::get_signers(&env)
+ }
+
+ pub fn get_upgrade_threshold(env: Env) -> u32 {
+ governance::get_threshold(&env)
+ }
+
+ pub fn get_pending_upgrade(env: Env) -> Option {
+ governance::get_pending_upgrade(&env)
+ }
+
+ pub fn get_storage_version(env: Env) -> u32 {
+ governance::current_storage_version(&env)
+ }
+
+ // ----- Emergency circuit breaker -----
+
+ /// Halts `scopes` for `duration_secs` seconds, authorized by any single
+ /// governance signer. Returns the resulting pause record.
+ ///
+ /// Only [`SCOPE_SETTLEMENT`] has teeth here, and it is the same shared
+ /// scope `escrow::confirm_completion` and `loyalty-emissions::claim`
+ /// use — see the crate-level "Emergency circuit breaker" notes for why
+ /// this router deliberately defines no scope of its own.
+ pub fn pause(
+ env: Env,
+ caller: Address,
+ scopes: u32,
+ duration_secs: u64,
+ reason: String,
+ ) -> Result {
+ governance::pause(&env, caller, scopes, duration_secs, reason).map_err(Into::into)
+ }
+
+ pub fn unpause(env: Env, caller: Address, scopes: u32) -> Result {
+ governance::unpause(&env, caller, scopes).map_err(Into::into)
+ }
+
+ pub fn get_pause_state(env: Env) -> Option {
+ governance::get_pause_state(&env)
+ }
+
+ pub fn paused_scopes(env: Env) -> u32 {
+ governance::paused_scopes(&env)
+ }
+
+ pub fn is_paused(env: Env, scope: u32) -> bool {
+ governance::is_paused(&env, scope)
+ }
+
+ // ----- Admin configuration -----
+
+ /// Admin-only: repoint the three orchestrated contract addresses, e.g.
+ /// after redeploying one of them. Does **not** re-run any of the
+ /// "Deploying under partial rollout" wiring on the sub-contracts
+ /// themselves (`set_minter`, `set_router`) — those must still be called
+ /// separately against the new addresses.
+ pub fn set_contracts(
+ env: Env,
+ escrow: Address,
+ reputation: Address,
+ loyalty_token: Address,
+ ) -> Result<(), Error> {
+ let admin = Self::require_admin(&env)?;
+ admin.require_auth();
+ env.storage().instance().set(&DataKey::Escrow, &escrow);
+ env.storage()
+ .instance()
+ .set(&DataKey::Reputation, &reputation);
+ env.storage()
+ .instance()
+ .set(&DataKey::LoyaltyToken, &loyalty_token);
+ Self::bump_instance(&env);
+ Ok(())
+ }
+
+ /// Admin-only: update the fixed loyalty reward amounts future
+ /// settlements mint. Does not affect appointments already settled.
+ pub fn set_reward_config(env: Env, reward_config: RewardConfig) -> Result<(), Error> {
+ let admin = Self::require_admin(&env)?;
+ admin.require_auth();
+ Self::validate_reward_config(&reward_config)?;
+ env.storage()
+ .instance()
+ .set(&DataKey::RewardConfig, &reward_config);
+ Self::bump_instance(&env);
+ Ok(())
+ }
+
+ // ----- Settlement -----
+
+ /// Atomically settles a completed appointment: releases escrowed funds,
+ /// records the client's attestation, and mints loyalty points to both
+ /// parties — or none of it happens. See the crate-level docs for the
+ /// full flow, idempotency, and failure semantics.
+ ///
+ /// Permissionless caller: `settle` itself performs no `require_auth`.
+ /// The authorizations it depends on — the appointment's `client`, via
+ /// `escrow::confirm_completion` and `reputation::submit_attestation` —
+ /// must already be present in the submitted transaction, the same
+ /// relay pattern `escrow::release_milestone_funds` uses. `rating` and
+ /// `attestation_hash` are exactly what a direct `submit_attestation`
+ /// caller would supply; this router adds no interpretation of its own.
+ ///
+ /// Guarded by [`SCOPE_SETTLEMENT`].
+ pub fn settle(
+ env: Env,
+ appointment_id: u64,
+ rating: u32,
+ attestation_hash: BytesN<32>,
+ ) -> Result<(), Error> {
+ governance::require_not_paused(&env, governance::SCOPE_SETTLEMENT)?;
+
+ let settled_key = DataKey::Settled(appointment_id);
+ if env.storage().persistent().has(&settled_key) {
+ return Err(Error::AlreadySettled);
+ }
+
+ let escrow_addr = Self::read_escrow(&env);
+ let escrow_client = EscrowClient::new(&env, &escrow_addr);
+ let appointment = escrow_client.get_appointment(&appointment_id);
+ if appointment.status != Status::Funded {
+ return Err(Error::AppointmentNotFunded);
+ }
+
+ // Effect before interactions: a replay never reaches any
+ // cross-contract call, and any panic below reverts this write too.
+ env.storage().persistent().set(&settled_key, &true);
+ env.storage().persistent().extend_ttl(
+ &settled_key,
+ SETTLED_LIFETIME_THRESHOLD,
+ SETTLED_BUMP_AMOUNT,
+ );
+
+ // Interactions: any Err here panics the whole transaction, so
+ // funds, the attestation, and loyalty minting land together or not
+ // at all.
+ escrow_client.confirm_completion(&appointment_id);
+
+ let reputation_client = ReputationClient::new(&env, &Self::read_reputation(&env));
+ reputation_client.submit_attestation(
+ &appointment_id,
+ &appointment.client,
+ &appointment.worker,
+ &rating,
+ &attestation_hash,
+ );
+
+ let reward_config = Self::read_reward_config(&env);
+ let loyalty_client = LoyaltyTokenClient::new(&env, &Self::read_loyalty_token(&env));
+ if reward_config.client_reward > 0 {
+ loyalty_client.mint(&appointment.client, &reward_config.client_reward);
+ }
+ if reward_config.worker_reward > 0 {
+ loyalty_client.mint(&appointment.worker, &reward_config.worker_reward);
+ }
+
+ Self::bump_instance(&env);
+ Ok(())
+ }
+
+ // ----- Views -----
+
+ pub fn is_settled(env: Env, appointment_id: u64) -> bool {
+ env.storage()
+ .persistent()
+ .has(&DataKey::Settled(appointment_id))
+ }
+
+ pub fn get_admin(env: Env) -> Result {
+ Self::require_admin(&env)
+ }
+
+ pub fn get_escrow(env: Env) -> Address {
+ Self::read_escrow(&env)
+ }
+
+ pub fn get_reputation(env: Env) -> Address {
+ Self::read_reputation(&env)
+ }
+
+ pub fn get_loyalty_token(env: Env) -> Address {
+ Self::read_loyalty_token(&env)
+ }
+
+ pub fn get_reward_config(env: Env) -> RewardConfig {
+ Self::read_reward_config(&env)
+ }
+
+ // ----- Internal helpers -----
+
+ fn require_admin(env: &Env) -> Result {
+ env.storage()
+ .instance()
+ .get(&DataKey::Admin)
+ .ok_or(Error::NotInitialized)
+ }
+
+ fn validate_reward_config(config: &RewardConfig) -> Result<(), Error> {
+ if config.client_reward < 0 || config.worker_reward < 0 {
+ return Err(Error::InvalidRewardConfig);
+ }
+ Ok(())
+ }
+
+ fn read_escrow(env: &Env) -> Address {
+ env.storage()
+ .instance()
+ .get(&DataKey::Escrow)
+ .expect("not initialized")
+ }
+
+ fn read_reputation(env: &Env) -> Address {
+ env.storage()
+ .instance()
+ .get(&DataKey::Reputation)
+ .expect("not initialized")
+ }
+
+ fn read_loyalty_token(env: &Env) -> Address {
+ env.storage()
+ .instance()
+ .get(&DataKey::LoyaltyToken)
+ .expect("not initialized")
+ }
+
+ fn read_reward_config(env: &Env) -> RewardConfig {
+ env.storage()
+ .instance()
+ .get(&DataKey::RewardConfig)
+ .expect("not initialized")
+ }
+
+ fn bump_instance(env: &Env) {
+ env.storage()
+ .instance()
+ .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
+ }
+}
+
+#[cfg(test)]
+mod test;
diff --git a/soroban-contracts/contracts/settlement-router/src/test.rs b/soroban-contracts/contracts/settlement-router/src/test.rs
new file mode 100644
index 0000000..850e299
--- /dev/null
+++ b/soroban-contracts/contracts/settlement-router/src/test.rs
@@ -0,0 +1,444 @@
+#![cfg(test)]
+
+use super::*;
+use soroban_sdk::testutils::{Address as _, MockAuth, MockAuthInvoke};
+use soroban_sdk::{token, Env, IntoVal};
+
+use guildworkman_escrow::{EscrowContract, EscrowContractClient};
+use guildworkman_loyalty_token::{
+ LoyaltyToken as RealLoyaltyToken, LoyaltyTokenClient as RealLoyaltyTokenClient,
+};
+use guildworkman_reputation::{
+ Config as ReputationConfig, ReputationContract, ReputationContractClient,
+};
+
+const CLIENT_REWARD: i128 = 50;
+const WORKER_REWARD: i128 = 100;
+const APPOINTMENT_AMOUNT: i128 = 10_000;
+
+fn create_token_contract<'a>(
+ env: &Env,
+ admin: &Address,
+) -> (Address, token::StellarAssetClient<'a>, token::Client<'a>) {
+ let sac = env.register_stellar_asset_contract_v2(admin.clone());
+ let address = sac.address();
+ (
+ address.clone(),
+ token::StellarAssetClient::new(env, &address),
+ token::Client::new(env, &address),
+ )
+}
+
+fn make_signers(env: &Env, n: u32) -> Vec {
+ let mut signers = Vec::new(env);
+ for _ in 0..n {
+ signers.push_back(Address::generate(env));
+ }
+ signers
+}
+
+fn dummy_hash(env: &Env) -> BytesN<32> {
+ BytesN::from_array(env, &[0u8; 32])
+}
+
+#[allow(dead_code)]
+struct Fixture<'a> {
+ env: Env,
+ router: SettlementRouterClient<'a>,
+ escrow: EscrowContractClient<'a>,
+ reputation: ReputationContractClient<'a>,
+ loyalty: RealLoyaltyTokenClient<'a>,
+ payment_token: token::Client<'a>,
+ client: Address,
+ worker: Address,
+}
+
+/// Deploys `escrow`, `reputation` and `loyalty-token`, wires each of them
+/// through this router (`loyalty.set_minter` / `reputation.set_router`),
+/// and hands back a ready-to-settle fixture. Mirrors the wiring order
+/// documented in the crate-level "Deploying under partial rollout" notes.
+fn setup() -> Fixture<'static> {
+ let env = Env::default();
+ // `settle` is deliberately permissionless at its own root (see the
+ // crate docs' "Authorization model"): the client's authorization is
+ // required by the *nested* `confirm_completion` / `submit_attestation`
+ // calls, not by `settle` itself. Plain `mock_all_auths` enforces a
+ // stricter "authorized at the root" heuristic that doesn't fit that
+ // shape; this variant exists precisely for a contract that bundles
+ // calls to others this way.
+ env.mock_all_auths_allowing_non_root_auth();
+
+ let client = Address::generate(&env);
+ let worker = Address::generate(&env);
+ let token_issuer = Address::generate(&env);
+ let (_payment_token_addr, payment_token_admin, payment_token) =
+ create_token_contract(&env, &token_issuer);
+ payment_token_admin.mint(&client, &1_000_000);
+
+ let escrow_id = env.register(EscrowContract, ());
+ let escrow = EscrowContractClient::new(&env, &escrow_id);
+ escrow.initialize(
+ &Address::generate(&env),
+ &governance::GovernanceInit {
+ signers: make_signers(&env, 1),
+ threshold: 1,
+ },
+ );
+
+ let reputation_id = env.register(ReputationContract, ());
+ let reputation = ReputationContractClient::new(&env, &reputation_id);
+ reputation.initialize(
+ &Address::generate(&env),
+ &ReputationConfig {
+ window: 100,
+ reviewer_cap: 5,
+ global_cap: 20,
+ min_stake: 0,
+ decay_rate_bps: 1,
+ max_age_ledgers: 10_000,
+ },
+ &governance::GovernanceInit {
+ signers: make_signers(&env, 1),
+ threshold: 1,
+ },
+ );
+
+ let loyalty_admin = Address::generate(&env);
+ let loyalty_id = env.register(RealLoyaltyToken, ());
+ let loyalty = RealLoyaltyTokenClient::new(&env, &loyalty_id);
+ loyalty.initialize(
+ &loyalty_admin,
+ &loyalty_admin, // temporary minter, rotated to the router below
+ &2,
+ &String::from_str(&env, "GuildWorkman Points"),
+ &String::from_str(&env, "GWP"),
+ &governance::GovernanceInit {
+ signers: make_signers(&env, 1),
+ threshold: 1,
+ },
+ );
+
+ let router_id = env.register(SettlementRouter, ());
+ let router = SettlementRouterClient::new(&env, &router_id);
+ router.initialize(
+ &Address::generate(&env),
+ &escrow_id,
+ &reputation_id,
+ &loyalty_id,
+ &RewardConfig {
+ client_reward: CLIENT_REWARD,
+ worker_reward: WORKER_REWARD,
+ },
+ &governance::GovernanceInit {
+ signers: make_signers(&env, 1),
+ threshold: 1,
+ },
+ );
+
+ // The two steps that actually close the pre-router holes: loyalty
+ // mints only through this router, and reputation only accepts
+ // attestations this router vouches for.
+ loyalty.set_minter(&router_id);
+ reputation.set_router(&router_id);
+
+ Fixture {
+ env,
+ router,
+ escrow,
+ reputation,
+ loyalty,
+ payment_token,
+ client,
+ worker,
+ }
+}
+
+fn fund_appointment(f: &Fixture, appointment_id: u64) {
+ f.escrow.create_appointment(
+ &appointment_id,
+ &f.client,
+ &f.worker,
+ &f.payment_token.address,
+ &APPOINTMENT_AMOUNT,
+ );
+}
+
+// ===========================================================================
+// Happy path
+// ===========================================================================
+
+#[test]
+fn settle_atomically_releases_funds_attests_and_mints() {
+ let f = setup();
+ fund_appointment(&f, 1);
+
+ f.router.settle(&1, &5, &dummy_hash(&f.env));
+
+ // Funds released to the worker.
+ assert_eq!(f.payment_token.balance(&f.worker), APPOINTMENT_AMOUNT);
+ assert_eq!(f.payment_token.balance(&f.escrow.address), 0);
+
+ // Attestation recorded against on-chain-verified parties.
+ assert_eq!(f.reputation.get_attestation_count(&f.worker), 1);
+ assert_eq!(f.reputation.get_reputation_score_x10000(&f.worker), 50_000);
+
+ // Loyalty minted to both parties at the fixed, admin-configured amounts.
+ assert_eq!(f.loyalty.balance(&f.client), CLIENT_REWARD);
+ assert_eq!(f.loyalty.balance(&f.worker), WORKER_REWARD);
+
+ assert!(f.router.is_settled(&1));
+}
+
+#[test]
+fn zero_reward_skips_minting_for_that_party() {
+ let f = setup();
+ f.router.set_reward_config(&RewardConfig {
+ client_reward: 0,
+ worker_reward: WORKER_REWARD,
+ });
+ fund_appointment(&f, 1);
+
+ f.router.settle(&1, &5, &dummy_hash(&f.env));
+
+ assert_eq!(f.loyalty.balance(&f.client), 0);
+ assert_eq!(f.loyalty.balance(&f.worker), WORKER_REWARD);
+}
+
+// ===========================================================================
+// Idempotency
+// ===========================================================================
+
+#[test]
+fn settle_is_idempotent_no_double_release_or_mint() {
+ let f = setup();
+ fund_appointment(&f, 1);
+ f.router.settle(&1, &5, &dummy_hash(&f.env));
+
+ let res = f.router.try_settle(&1, &5, &dummy_hash(&f.env));
+ assert_eq!(res, Err(Ok(Error::AlreadySettled)));
+
+ assert_eq!(f.payment_token.balance(&f.worker), APPOINTMENT_AMOUNT);
+ assert_eq!(f.loyalty.balance(&f.worker), WORKER_REWARD);
+ assert_eq!(f.reputation.get_attestation_count(&f.worker), 1);
+}
+
+// ===========================================================================
+// Rejected settlement states
+// ===========================================================================
+
+#[test]
+fn settle_rejects_unknown_appointment() {
+ let f = setup();
+ let res = f.router.try_settle(&1, &5, &dummy_hash(&f.env));
+ assert!(res.is_err());
+}
+
+#[test]
+fn settle_rejects_appointment_already_completed_outside_the_router() {
+ let f = setup();
+ fund_appointment(&f, 1);
+ // A client bypassing the router entirely and confirming directly.
+ f.escrow.confirm_completion(&1);
+
+ let res = f.router.try_settle(&1, &5, &dummy_hash(&f.env));
+ assert_eq!(res, Err(Ok(Error::AppointmentNotFunded)));
+
+ // The bypassed appointment paid the worker, but no attestation or
+ // loyalty mint was ever produced for it -- exactly the gap this
+ // router exists to close for callers who *do* go through `settle`.
+ assert_eq!(f.reputation.get_attestation_count(&f.worker), 0);
+ assert_eq!(f.loyalty.balance(&f.client), 0);
+ assert_eq!(f.loyalty.balance(&f.worker), 0);
+}
+
+#[test]
+fn settle_rejects_cancelled_appointment() {
+ let f = setup();
+ fund_appointment(&f, 1);
+ f.escrow.cancel_appointment(&1);
+
+ let res = f.router.try_settle(&1, &5, &dummy_hash(&f.env));
+ assert_eq!(res, Err(Ok(Error::AppointmentNotFunded)));
+}
+
+#[test]
+fn settle_rejects_disputed_appointment() {
+ let f = setup();
+ fund_appointment(&f, 1);
+ f.escrow.raise_dispute(&1, &f.client);
+
+ let res = f.router.try_settle(&1, &5, &dummy_hash(&f.env));
+ assert_eq!(res, Err(Ok(Error::AppointmentNotFunded)));
+}
+
+// ===========================================================================
+// Atomicity across sub-contract failures
+// ===========================================================================
+
+#[test]
+fn settle_reverts_entirely_when_reputation_is_paused() {
+ let f = setup();
+ fund_appointment(&f, 1);
+
+ let reputation_signers = f.reputation.get_signers();
+ f.reputation.pause(
+ &reputation_signers.get(0).unwrap(),
+ &governance::SCOPE_ATTESTATION,
+ &3600,
+ &String::from_str(&f.env, "incident"),
+ );
+
+ let res = f.router.try_settle(&1, &5, &dummy_hash(&f.env));
+ assert!(res.is_err());
+
+ // Nothing committed: escrow's confirm_completion ran earlier in the
+ // same invocation, but the whole transaction unwinds on the later
+ // panic, so funds are still exactly where they started.
+ assert!(!f.router.is_settled(&1));
+ assert_eq!(
+ f.payment_token.balance(&f.escrow.address),
+ APPOINTMENT_AMOUNT
+ );
+ assert_eq!(f.payment_token.balance(&f.worker), 0);
+ assert_eq!(f.loyalty.balance(&f.client), 0);
+}
+
+#[test]
+fn settle_reverts_entirely_when_loyalty_token_is_paused() {
+ let f = setup();
+ fund_appointment(&f, 1);
+
+ let loyalty_signers = f.loyalty.get_signers();
+ f.loyalty.pause(
+ &loyalty_signers.get(0).unwrap(),
+ &governance::SCOPE_INTAKE,
+ &3600,
+ &String::from_str(&f.env, "incident"),
+ );
+
+ let res = f.router.try_settle(&1, &5, &dummy_hash(&f.env));
+ assert!(res.is_err());
+
+ assert!(!f.router.is_settled(&1));
+ assert_eq!(
+ f.payment_token.balance(&f.escrow.address),
+ APPOINTMENT_AMOUNT
+ );
+ assert_eq!(f.reputation.get_attestation_count(&f.worker), 0);
+}
+
+#[test]
+fn settle_fails_while_router_settlement_scope_is_paused() {
+ let f = setup();
+ fund_appointment(&f, 1);
+
+ let signers = f.router.get_signers();
+ f.router.pause(
+ &signers.get(0).unwrap(),
+ &SCOPE_SETTLEMENT,
+ &3600,
+ &String::from_str(&f.env, "incident"),
+ );
+
+ let res = f.router.try_settle(&1, &5, &dummy_hash(&f.env));
+ assert_eq!(res, Err(Ok(Error::OperationPaused)));
+}
+
+// ===========================================================================
+// Reward configuration
+// ===========================================================================
+
+#[test]
+fn reward_config_rejects_negative_amounts() {
+ let f = setup();
+ let res = f.router.try_set_reward_config(&RewardConfig {
+ client_reward: -1,
+ worker_reward: WORKER_REWARD,
+ });
+ assert_eq!(res, Err(Ok(Error::InvalidRewardConfig)));
+}
+
+#[test]
+fn initialize_rejects_negative_reward_config() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let router_id = env.register(SettlementRouter, ());
+ let router = SettlementRouterClient::new(&env, &router_id);
+
+ let res = router.try_initialize(
+ &Address::generate(&env),
+ &Address::generate(&env),
+ &Address::generate(&env),
+ &Address::generate(&env),
+ &RewardConfig {
+ client_reward: 10,
+ worker_reward: -1,
+ },
+ &governance::GovernanceInit {
+ signers: make_signers(&env, 1),
+ threshold: 1,
+ },
+ );
+ assert_eq!(res, Err(Ok(Error::InvalidRewardConfig)));
+}
+
+// ===========================================================================
+// Reputation's router gate, exercised directly
+// ===========================================================================
+
+#[test]
+fn direct_reputation_call_without_router_auth_is_rejected() {
+ let f = setup();
+ // `reputation.set_router(&router_id)` already ran in `setup`. A caller
+ // that is not the router contract itself has no way to satisfy
+ // `require_auth` for the router's address -- only the client's own
+ // authorization is mocked here, deliberately omitting the router's.
+ let hash = dummy_hash(&f.env);
+ let args: Vec =
+ (1u64, f.client.clone(), f.worker.clone(), 5u32, hash.clone()).into_val(&f.env);
+ f.env.mock_auths(&[MockAuth {
+ address: &f.client,
+ invoke: &MockAuthInvoke {
+ contract: &f.reputation.address,
+ fn_name: "submit_attestation",
+ args,
+ sub_invokes: &[],
+ },
+ }]);
+
+ let res = f
+ .reputation
+ .try_submit_attestation(&1, &f.client, &f.worker, &5, &hash);
+ assert!(res.is_err());
+}
+
+#[test]
+fn reputation_without_router_configured_keeps_legacy_behavior() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let admin = Address::generate(&env);
+ let client = Address::generate(&env);
+ let worker = Address::generate(&env);
+
+ let reputation_id = env.register(ReputationContract, ());
+ let reputation = ReputationContractClient::new(&env, &reputation_id);
+ reputation.initialize(
+ &admin,
+ &ReputationConfig {
+ window: 100,
+ reviewer_cap: 5,
+ global_cap: 20,
+ min_stake: 0,
+ decay_rate_bps: 1,
+ max_age_ledgers: 10_000,
+ },
+ &governance::GovernanceInit {
+ signers: make_signers(&env, 1),
+ threshold: 1,
+ },
+ );
+
+ assert_eq!(reputation.get_router(), None);
+ reputation.submit_attestation(&1, &client, &worker, &5, &dummy_hash(&env));
+ assert_eq!(reputation.get_attestation_count(&worker), 1);
+}
diff --git a/soroban-contracts/scripts/broadcast-pause.sh b/soroban-contracts/scripts/broadcast-pause.sh
index 1cda578..fb48e9f 100755
--- a/soroban-contracts/scripts/broadcast-pause.sh
+++ b/soroban-contracts/scripts/broadcast-pause.sh
@@ -3,7 +3,7 @@
# Broadcast an emergency pause (or lift one) across every GuildWorkman
# contract that carries the circuit breaker.
#
-# The four contracts are separate deployments with separate storage, so this
+# The five contracts are separate deployments with separate storage, so this
# is N transactions, not one. They do not have to land in the same ledger and
# nothing breaks if they land out of order or if one fails — each contract's
# guard reads only its own record. That also means a partial sweep is a
@@ -16,7 +16,7 @@
# 7 ALL_SCOPES
#
# A scope a given contract has no entrypoints for is a well-formed no-op, so
-# the same mask goes to all four without special-casing.
+# the same mask goes to all five without special-casing.
#
# Fund-recovery paths are NEVER affected by any mask: escrow refunds and
# disputes, token transfer/transfer_from/burn of held balances, and every
@@ -31,7 +31,7 @@
# SIGNER stellar-cli key name or secret; must be a governance signer
# on each contract it is used against
# NETWORK defaults to testnet
-# ESCROW, REPUTATION, LOYALTY_TOKEN, LOYALTY_EMISSIONS
+# ESCROW, REPUTATION, LOYALTY_TOKEN, LOYALTY_EMISSIONS, SETTLEMENT_ROUTER
# contract ids; any left unset is skipped with a warning
#
# Note each contract has its OWN governance signer set. If they differ, run
@@ -57,7 +57,7 @@ fi
targets() {
local name id
- for name in ESCROW REPUTATION LOYALTY_TOKEN LOYALTY_EMISSIONS; do
+ for name in ESCROW REPUTATION LOYALTY_TOKEN LOYALTY_EMISSIONS SETTLEMENT_ROUTER; do
id="${!name:-}"
if [[ -z "$id" ]]; then
echo "warning: $name is unset — skipping" >&2
@@ -78,7 +78,7 @@ case "$ACTION" in
DURATION="${3:?usage: pause [reason]}"
REASON="${4:-}"
# MAX_PAUSE_DURATION is 7 days; the contract rejects anything longer, but
- # failing here is friendlier than four rejected transactions.
+ # failing here is friendlier than five rejected transactions.
if (( DURATION <= 0 || DURATION > 604800 )); then
echo "error: duration must be 1..604800 seconds (7 days)" >&2
exit 2