feat(contracts): cross-contract settlement router with auth-chained escrow -> reputation -> loyalty atomicity - #50
Conversation
…scrow -> 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#38
🎯 Excellent work on the cross-contract settlement routerThis PR is exactly how you build a multi-contract orchestrator in Soroban. The design is thoughtful, the implementation is clean, and the testing is genuinely comprehensive. What stands out:
The "fails shut" trade-off is intentional and correct, but it does mean operators must follow the deployment sequencing in the README, or a completed appointment's payout gets gated on sub-contract liveness. That's already clearly documented — just wanted to flag it as a load-bearing requirement before mainnet deployment. Ready to merge. Well done! |
Summary
Closes #38.
Introduces
contracts/settlement-router, which atomically drives escrow release, reputation attestation, and loyalty emission from a singlesettle(appointment_id, rating, attestation_hash)call, so a completed appointment settles as one indivisible on-chain unit instead of three independently callable (and independently spoofable) entrypoints.settleproves the appointment isFundedinescrowbefore doing anything else, then callsescrow.confirm_completion,reputation.submit_attestation, andloyalty-token.mintin one transaction. AnyErrfrom a sub-contract, or a pause on its side, panics the whole invocation — nothing partially commits.appointment_id, checked before any cross-contract call, and enforced twice over: this router's ownSettledmarker, plusescrow.confirm_completionindependently refusing a second call once the appointment is no longerFunded.reputationgains an opt-inset_router/get_router(admin-only). Once set,submit_attestationadditionally requires that router's own authorization alongside the client's — closing the gap where anyappointment_idcould be attested with no proof it was ever funded or completed on-chain. 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.mintneeds no code change — pointing its existingminterrole at this router (set_minter) is what gates it, the same migrationloyalty-emissionsalready models.RewardConfig— never taken from asettlecaller's own arguments, so a caller cannot name their own mint amount.SCOPE_SETTLEMENT(no new pause scope introduced).See the crate-level docs in
contracts/settlement-router/src/lib.rs(settlement flow, idempotency, reputation gating, loyalty minting, and the "Deploying under partial rollout" wiring order) and the new### settlement-routersection inREADME.mdfor the full design writeup, storage layout, and CLI usage.Test plan
cargo test --workspace— 289 tests pass, including 14 newsettlement-routertests (happy path, idempotent replay, rejected unfunded/cancelled/disputed/already-completed appointments, atomic revert whenreputation/loyalty-token/the router itself is paused, reward-config validation, zero-reward skip) and newreputationrouter-gating tests (legacy behavior preserved when unset, direct calls rejected once a router is configured).cargo fmt --checkcargo clippy --workspace --all-targets -- -D warningscargo build --workspace --release --target wasm32v1-none(optimized WASM build)