feat(contracts): protocol fee engine with multi-party payout splits & treasury accounting - #53
Conversation
… treasury accounting Adds a governance-bounded fee engine to escrow: confirm_completion and the worker-favoring branch of resolve_dispute now split the settled amount across worker, protocol treasury, and an optional referrer using overflow-safe, floor-rounded basis-point math, with the worker absorbing the rounding remainder so payouts always reconcile exactly to the escrowed amount. Fee rates are capped by a hard-coded MAX_TOTAL_FEE_BPS that set_fee_config enforces regardless of caller, and refund paths (cancel_appointment, refund-to-client disputes) stay fee-free since no service was delivered. Protocol shares accrue in a new per-token treasury balance, withdrawable only via withdraw_treasury under the same governance-signer authorization as migrate.
Not exploitable on its own -- the split's sum == amount invariant holds regardless of who referrer is -- but it's a meaningless self-referral that's cheap to reject in create_appointment before it lands on chain. Addresses review feedback on PR workman-labs#53.
|
Solid PR — well-scoped and the invariant work is the standout part here. Strengths
Questions / possible follow-ups
Nice work — this is a well-tested, carefully-bounded change. Pending answers above, looks mergeable. |
|
Thanks for the review! Answering in order:
Single-signer authority for Referrer == worker/client validation — good catch, added in the latest commit (86be9a3). Wasn't exploitable (the Treasury enumeration across tokens — Pushed the referrer-validation fix; CI is green. Let me know if any of the above needs code changes rather than just an answer. |
meshackyaro
left a comment
There was a problem hiding this comment.
This PR reflects care that goes beyond just making tests pass — the split-multiply rounding identity, the checks-effects-interactions ordering on withdraw_treasury, and the deliberate choice to keep treasury enumeration off-chain all show someone thinking about failure modes before they're asked to. The follow-up answers were as rigorous as the PR itself — especially walking through why the reentrancy concern doesn't apply given Soroban's execution model, rather than just asserting it's fine. Approving.
Summary
Closes #39.
Adds a configurable, governance-bounded fee engine to
escrowthat splits every worker-paying settlement across worker, protocol treasury, and an optional referrer in exact integer arithmetic, with per-token treasury accounting and provable no-dust/no-loss invariants.FeeConfig { protocol_bps, referrer_bps }, governance-bounded by a hard-codedMAX_TOTAL_FEE_BPS(1,500 = 15%) thatset_fee_configenforces unconditionally — no governance signer can push the combined rate above it, so the worker always keeps at least 85% of a settled appointment. NoFeeConfigentry is written atinitialize;get_fee_configtreats an absent entry as{0, 0}(no fees), so a contract that never callsset_fee_confighas byte-for-byte unchanged instance storage.confirm_completionand the worker-favoring branch ofresolve_dispute.cancel_appointmentand the refund-to-client branch ofresolve_disputeare deliberately unchanged — they still return the full amount with no fee, since the client is being refunded for work that was never delivered. This asymmetry is documented in the module docs, README, and CHANGELOG rather than left incidental, per the issue's acceptance criteria.q*bps + floor(r*bps/D)) that never letsamount * bpsoverflowi128, even fori128::MAX-adjacent amounts. The worker absorbs the rounding remainder, guaranteeingworker_share + protocol_share + referrer_share == amountexactly for every input — no path can pay out more than was escrowed, and no dust is ever stranded.DataKey::Treasury(token)balance and stays in the contract's own token balance until a governance signer calls the newwithdraw_treasury.referrer: Option<Address>field onAppointmentand final parameter oncreate_appointment. Paid directly when set; contributes nothing to the common case of no referrer.settlement-router's mirroredescrow::Appointmenttype gained the same field to keep cross-contract decoding in lockstep (its doc comment already calls out that every field must match).set_fee_config,get_fee_config,get_treasury_balance,withdraw_treasury— all gated bygovernance::require_signer(any single current signer), the same authoritymigrateanswers to.FeeExceedsMaximum,ArithmeticOverflow,InsufficientTreasuryBalance(codes 42-44), appended so no existing error code moved.Swatinem/rust-cachewithcache-on-failureinsoroban-ci.yml) — no change needed there.Testing
i128::MAX-adjacent amount, zero-fee and max-fee configs, and a property/invariant test assertingsum(shares) == amountacross a spread of adversarial amounts (includingi128::MAX) crossed with fee configs at the edges of what's allowed.cargo fmt --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace(all 7 crates — 310 tests total, 0 failures), andcargo build --workspace --release --target wasm32v1-noneall pass locally, matchingsoroban-ci.ymlexactly.Test plan
cargo fmt --checkcargo clippy --workspace --all-targets -- -D warningscargo test --workspace(escrow: 80 passed; settlement-router: 14 passed; full workspace: 310 passed, 0 failed)cargo build --workspace --release --target wasm32v1-none(optimized WASM build)