Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions soroban-contracts/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions soroban-contracts/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions soroban-contracts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ members = [
"contracts/loyalty-emissions",
"contracts/governance-guard",
"contracts/dispute-resolution",
"contracts/settlement-router",
]

[workspace.dependencies]
Expand Down
246 changes: 228 additions & 18 deletions soroban-contracts/README.md

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions soroban-contracts/contracts/reputation/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<Address> {
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);
Expand Down Expand Up @@ -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);
Expand Down
57 changes: 56 additions & 1 deletion soroban-contracts/contracts/reputation/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Val> =
(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());
}
22 changes: 22 additions & 0 deletions soroban-contracts/contracts/settlement-router/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading