From ce48ff09dbcb3a11980f5bd5620155be6e1a66a4 Mon Sep 17 00:00:00 2001 From: DeborahOlaboye Date: Sun, 23 Aug 2026 20:07:47 +0100 Subject: [PATCH 1/2] feat(contracts): cross-contract settlement router with auth-chained escrow -> reputation -> loyalty atomicity Adds contracts/settlement-router, which atomically drives escrow release, reputation attestation, and loyalty emission from one settle() call instead of three independently callable (and independently spoofable) entrypoints. reputation gains an opt-in set_router/get_router gate so submit_attestation can no longer be forged for an appointment that was never funded and completed on-chain; loyalty-token needs no code change since minting is already gated purely by re-pointing its existing minter role at the router. Closes workman-labs/guildworkman-core#38 --- soroban-contracts/CHANGELOG.md | 31 + soroban-contracts/Cargo.lock | 11 + soroban-contracts/Cargo.toml | 1 + soroban-contracts/README.md | 246 +++++- .../contracts/reputation/src/lib.rs | 51 ++ .../contracts/reputation/src/test.rs | 57 +- .../contracts/settlement-router/Cargo.toml | 22 + .../contracts/settlement-router/src/lib.rs | 740 ++++++++++++++++++ .../contracts/settlement-router/src/test.rs | 444 +++++++++++ soroban-contracts/scripts/broadcast-pause.sh | 10 +- 10 files changed, 1589 insertions(+), 24 deletions(-) create mode 100644 soroban-contracts/contracts/settlement-router/Cargo.toml create mode 100644 soroban-contracts/contracts/settlement-router/src/lib.rs create mode 100644 soroban-contracts/contracts/settlement-router/src/test.rs diff --git a/soroban-contracts/CHANGELOG.md b/soroban-contracts/CHANGELOG.md index a065242..5709fc9 100644 --- a/soroban-contracts/CHANGELOG.md +++ b/soroban-contracts/CHANGELOG.md @@ -16,6 +16,37 @@ 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)). + 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 @@ ![CI](https://github.com/workman-labs/guildworkman-contracts/actions/workflows/ci.yml/badge.svg) 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 From 4c586ef203c61885b4c1be1655baf237bf6927d4 Mon Sep 17 00:00:00 2001 From: DeborahOlaboye Date: Sun, 23 Aug 2026 20:08:44 +0100 Subject: [PATCH 2/2] docs(contracts): link settlement-router changelog entry to PR #50 --- 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 5709fc9..4bf0fac 100644 --- a/soroban-contracts/CHANGELOG.md +++ b/soroban-contracts/CHANGELOG.md @@ -17,7 +17,8 @@ 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)). + 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