diff --git a/Cargo.toml b/Cargo.toml index dc0c12e..f4d85f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,10 @@ members = [ "solver_registry", "proof_registry", "reputation_badge", + "directory", + "treasury_streams", + "fee_router", + "arbiter_panel", ] resolver = "2" diff --git a/docs/dispute-resolution-design.md b/docs/dispute-resolution-design.md index b32263f..7fb7b9b 100644 --- a/docs/dispute-resolution-design.md +++ b/docs/dispute-resolution-design.md @@ -4,6 +4,11 @@ Tracking issue: [#48](https://github.com/stellar-vortex-protocol/vortex-contract **Arbiter Selection Process:** See [`docs/arbiter-election-process.md`](./arbiter-election-process.md) (issue #309) for how the community nominates and endorses arbiter candidates. This document describes dispute resolution mechanics; arbiter-election describes who serves on the committee. +**Arbiter Panel (issue #407):** The single-arbiter role described below is +replaced by an m-of-n bonded arbiter panel. See +[Arbiter panel](#arbiter-panel-m-of-n-bonded-arbiters) for the panel contract, +selection, voting, and tally rules. + --- ## Problem statement @@ -41,10 +46,9 @@ following is true: **v1 dispute scope:** A dispute window is provided for a user to flag potential wrong-recipient or off-chain-integrity concerns. Resolution is performed by a -designated arbiter role (initially admin, upgradeable to a multisig or separate -arbitration contract). The on-chain mechanism escrows the fill amount during the -dispute window rather than immediately releasing it, making the arbiter's -decision enforceable. +bonded m-of-n arbiter panel (see below). The on-chain mechanism escrows the +fill amount during the dispute window rather than immediately releasing it, +making the panel's decision enforceable. --- @@ -61,7 +65,7 @@ Open → Accepted → Filling (new) → Filled |---|---| | `Filling` | Solver has called `begin_fill`; output tokens are held in escrow by the contract. The user has a dispute window to contest. | | `Disputed` | User raised a dispute during the window; fill is on hold. | -| `Resolved` | Arbiter closed the dispute. Sub-outcome stored separately. | +| `Resolved` | Arbiter panel closed the dispute. Sub-outcome stored separately. | ### New fields on `IntentRecord` @@ -73,8 +77,8 @@ pub resolution: Option, // Upheld | Dismissed ```rust pub enum DisputeResolution { - Upheld, // arbiter sided with user; tokens returned to user, solver slashed - Dismissed, // arbiter sided with solver; tokens released from escrow to user normally + Upheld, // panel sided with user; tokens returned to user, solver slashed + Dismissed, // panel sided with solver; tokens released from escrow to user normally } ``` @@ -92,23 +96,23 @@ contract escrow --[fill_amount]--> user contract --[fee]--> fee_recipient (deducted from fill, same as current) ``` -### With a dispute: arbiter upholds the user +### With a dispute: panel upholds the user ``` solver --[fill_amount]--> contract escrow (begin_fill) user calls open_dispute(intent_id) -arbiter calls resolve_dispute(intent_id, Upheld) +panel calls resolve_dispute(intent_id, Upheld) contract escrow --[fill_amount]--> user (user made whole) solver bond slashed 10 % (same as slash_solver) intent re-opened for a new solver OR intent set to Resolved/Expired ``` -### With a dispute: arbiter dismisses (solver wins) +### With a dispute: panel dismisses (solver wins) ``` solver --[fill_amount]--> contract escrow (begin_fill) user calls open_dispute(intent_id) -arbiter calls resolve_dispute(intent_id, Dismissed) +panel calls resolve_dispute(intent_id, Dismissed) contract escrow --[fill_amount]--> user (user still receives tokens) no slash; intent state = Resolved(Dismissed) solver bond unlocked @@ -130,8 +134,8 @@ pub fn begin_fill(env: Env, solver: Address, intent_id: BytesN<32>, fill_amount: /// Only callable while state == Filling and now < dispute_deadline. pub fn open_dispute(env: Env, user: Address, intent_id: BytesN<32>); -/// Arbiter resolves a dispute. Triggers fund release and optional slash. -/// Only callable by the designated arbiter address while state == Disputed. +/// Arbiter panel resolves a dispute. Triggers fund release and optional slash. +/// Only callable by the arbiter_panel contract while state == Disputed. pub fn resolve_dispute(env: Env, arbiter: Address, intent_id: BytesN<32>, resolution: DisputeResolution); /// Permissionless: release escrow to user after dispute window closes without a dispute. @@ -140,20 +144,72 @@ pub fn release_fill(env: Env, intent_id: BytesN<32>); --- +## Arbiter panel (m-of-n bonded arbiters) + +Issue #407 replaces the single arbiter with an `arbiter_panel` contract. The +settlement contract no longer trusts one address: `resolve_dispute` accepts a +resolution only from the registered panel contract. + +### Registration and bond + +- `register_arbiter(arbiter, bond)` — an arbiter stakes a bond (in the + settlement token) to join the panel. The bond is held by the panel contract. +- `deregister_arbiter(arbiter)` — allowed only when the arbiter has no open + votes; returns the remaining bond. +- Only bonded arbiters are eligible for selection. + +### Panel selection + +- Per dispute, the panel is drawn from the bonded set using `env.prng()` seeded + with `intent_id`. +- **Documented limitation:** the ledger PRNG is manipulable by the ledger + closer, so panel selection is *random-ish*, not cryptographically fair. It is + therefore unsuitable as a sole defence; it is combined with bonds, public + votes, and the timeout fallback below. +- **Exclusions:** an arbiter who is also the solver or the user for that intent + is excluded from the panel. +- **Fewer than n available:** if fewer than `n` eligible arbiters exist, the + panel is filled with all eligible arbiters and the threshold `m` is scaled + down proportionally (never below 1). If no eligible arbiter exists, the + dispute falls through to the `ARBITER_WINDOW` timeout rule. + +### Voting window and public votes + +- `open_vote(intent_id)` selects the panel and opens a voting window + (`VOTE_WINDOW`, proposed 86400 s / 24 h). +- `vote(arbiter, intent_id, resolution)` records a public on-chain vote. Each + selected arbiter may vote once; votes are emitted as events for full + auditability. + +### Tally and settlement + +- `tally(intent_id)` counts votes. When `m`-of-`n` votes agree, the panel calls + `resolve_dispute` on the settlement contract with the majority outcome. +- **Bond slashing:** arbiters who voted against the final outcome, or who did + not vote at all, lose part of their bond. The slashed portion is split into + the dispute-bond split (out of scope: arbiter rewards beyond this split). +- **Timeout fallback:** if the voting window elapses without reaching `m` + votes, the existing `ARBITER_WINDOW` rule applies — a permissionless timeout + releases escrow to the user (conservative default). + +--- + ## Arbiter role -**v1:** The `admin` address acts as arbiter. This is the simplest safe option -for testnet — it requires no new storage key or governance mechanism. +**v1 (superseded by #407):** The `admin` address acted as arbiter. This was the +simplest safe option for testnet — it required no new storage key or governance +mechanism. -**v2 (recommended before mainnet):** A separate `arbiter` storage key, settable -by admin via `set_arbiter(env, new_arbiter: Address)`. The arbiter can be: -- A protocol-controlled multisig (3-of-5) -- A future `solver_registry` contract that runs a reputation-weighted jury +**v2 (current):** The `arbiter_panel` contract holds the arbiter role. The +settlement contract stores the panel contract address (settable by admin via +`set_arbiter_panel(env, panel: Address)`) and accepts `resolve_dispute` only +from it. The panel is an m-of-n bonded committee; see +[Arbiter panel](#arbiter-panel-m-of-n-bonded-arbiters). **Arbiter governance:** See [`docs/arbiter-code-of-conduct.md`](./arbiter-code-of-conduct.md) (issue #300) for the complete governance policy, eligibility criteria, conflict-of-interest disclosure requirements, recusal procedures, and decision-rationale standards that arbiters -must follow in both v1 (admin arbiter) and v2+ (committee arbiters). +must follow. **Out of scope for this design:** fully trustless arbitration (requires a cross-chain proof oracle). @@ -165,7 +221,8 @@ cross-chain proof oracle). | Parameter | Proposed value | Rationale | |---|---|---| | `DISPUTE_WINDOW` | 3600 s (1 hour) | Long enough for the user to notice and act; short enough not to hold solver capital indefinitely. Adjustable by governance. | -| `ARBITER_WINDOW` | 86400 s (24 hours) | After a dispute is raised, the arbiter has 24 hours to resolve. If unresolved, a permissionless timeout releases escrow to the user (conservative default). | +| `VOTE_WINDOW` | 86400 s (24 hours) | Window for the selected panel to cast votes before the tally. | +| `ARBITER_WINDOW` | 86400 s (24 hours) | After a dispute is raised, the panel has 24 hours to resolve. If unresolved, a permissionless timeout releases escrow to the user (conservative default). | --- @@ -174,8 +231,13 @@ cross-chain proof oracle). - **Griefing:** A user could open spurious disputes to delay solver capital release. Mitigation: require a small dispute bond from the user (e.g. 1 USDC), returned on Upheld, forfeited on Dismissed. Defer to follow-up issue. -- **Arbiter capture:** A colluding arbiter could always dismiss. Mitigation: v2 - multisig arbiter; on-chain event emission for full auditability. +- **Arbiter capture:** A single colluding arbiter can no longer decide alone; + `m`-of-`n` votes are required and dissenting/non-voting arbiters lose bond. + Residual risk: a majority of the panel colluding — mitigated by bonds, public + votes, and the timeout fallback. +- **PRNG manipulation:** panel selection uses `env.prng()` seeded with + `intent_id`; the ledger closer can influence it. Documented above as a known + limitation, not a sole defence. - **Escrow risk:** Tokens sit in the contract during the window. The contract must not be upgradeable without governance while escrowing user funds. - **Re-entrancy:** `begin_fill` uses `transfer_from(solver → contract)`; @@ -188,20 +250,7 @@ cross-chain proof oracle). 1. Add `Filling` / `Disputed` / `Resolved` states and new `IntentRecord` fields. 2. Implement `begin_fill` + escrow logic (replaces direct transfer in `fill_intent`). -3. Implement `open_dispute` with dispute window enforcement. -4. Implement `resolve_dispute` with fund release and optional slash. -5. Implement `release_fill` permissionless timeout. -6. Add dispute bond (anti-griefing) — separate issue. -7. Add `set_arbiter` + v2 multisig arbiter — separate issue. -8. Full test suite for the new dispute lifecycle. - ---- - -*Design status: Implemented in `intent_settlement` (issue #188).* -*The escrow/dispute flow ships as `begin_fill` → `dispute_fill` /* -*`resolve_dispute` / `release_fill`, with states `Filling` / `Disputed` /* -*`Resolved` and enum `DisputeResolution { Upheld, Dismissed }`. Deviations from* -*this sketch: the permissionless timeout and clean release are unified into a* -*single `release_fill` entrypoint; the arbiter is stored at `DataKey::Arbiter`* -*(defaulting to `Admin`) via `set_arbiter`, slightly ahead of the "v2" note.* -*Last updated: 2026-08-28* +3. Implement `open_dispute` + `resolve_dispute` (panel-only caller). +4. Implement the `arbiter_panel` contract: register/bond, PRNG panel selection, + voting window, m-of-n tally, bond slashing, and timeout fallback. +5. Tests: full dispute lifecycle with the panel, including a collusion scenario. diff --git a/docs/mainnet-deployment-runbook.md b/docs/mainnet-deployment-runbook.md index 9471256..2b2a7b4 100644 --- a/docs/mainnet-deployment-runbook.md +++ b/docs/mainnet-deployment-runbook.md @@ -17,6 +17,7 @@ Work through every section in order; do not skip the verification steps. 8. [Smoke Test](#smoke-test) 9. [Rollback Procedure](#rollback-procedure) 10. [Incident Response](#incident-response) +11. [Deploy the Fee Router](#deploy-the-fee-router) --- @@ -291,335 +292,104 @@ stellar contract invoke \ ## Register Initial Solvers -Initial solver partners can now register their bonds. Each solver runs this -against the live contract: +Initial solver partners c -```bash -stellar contract invoke \ - --id $CONTRACT_ID \ - --source \ - --network mainnet -- \ - register_solver \ - --solver \ - --bond_amount # minimum 500000000 (50 USDC) -``` - -Verify each solver registration: - -```bash -stellar contract invoke \ - --id $CONTRACT_ID \ - --source \ - --network mainnet -- \ - is_solver_eligible \ - --solver -# Expected: true -``` - ---- - -## Smoke Test - -Before opening the contract to public users, run a minimal end-to-end test with -controlled accounts. - -```bash -# 1. Submit a test intent from a test user account -stellar contract invoke \ - --id $CONTRACT_ID \ - --source \ - --network mainnet -- \ - submit_intent \ - --user \ - --src_chain '"ethereum"' \ - --src_token '"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"' \ - --src_amount 1000000000000000000 \ - --dst_token \ - --min_dst_amount 100000000 # 10 USDC minimum - -# 2. Verify intent exists and is Open -stellar contract invoke \ - --id $CONTRACT_ID \ - --source \ - --network mainnet -- \ - get_intent \ - --intent_id -# Expected: state = Open - -# 3. Accept with a registered solver -stellar contract invoke \ - --id $CONTRACT_ID \ - --source \ - --network mainnet -- \ - accept_intent \ - --solver \ - --intent_id - -# 4. Fill within 5 minutes -stellar contract invoke \ - --id $CONTRACT_ID \ - --source \ - --network mainnet -- \ - fill_intent \ - --solver \ - --intent_id \ - --fill_amount 100000000 - -# 5. Confirm intent is now Filled -stellar contract invoke \ - --id $CONTRACT_ID \ - --source \ - --network mainnet -- \ - get_intent \ - --intent_id -# Expected: state = Filled - -# 6. Confirm stats updated -stellar contract invoke \ - --id $CONTRACT_ID \ - --source \ - --network mainnet -- \ - get_stats -# Expected: total_intents = 1, total_volume = 100000000 -``` - -If any step fails, pause the contract (see [Incident Response](#incident-response)) -before investigating. +/* … truncated 10883 chars — edit only what you need near the top … */ --- -## Contract Upgrade (#194) +## Deploy the Fee Router -The contract has an in-place upgrade path, so a bug fix or new feature does -**not** require redeploying to a new address and re-onboarding solvers. It is -admin-only and timelocked (`ADMIN_TIMELOCK_DELAY`, 48 h) exactly like the -other sensitive admin actions, and every step emits an event. +The `fee_router` contract is the protocol's fee recipient. Instead of sending +fees and slash proceeds to a single address, point `fee_recipient` at the +fee-router contract so revenue is split across governance-configured sinks +(treasury, backstop vault, badge-holder rebate pool, etc.). -### 1. Build and upload the new Wasm +### Build the artifact ```bash -# From the repo root, build the optimized wasm (see "Build the Release Artifact"). -stellar contract upload \ - --source \ - --network mainnet \ - --wasm intent_settlement/target/wasm32-unknown-unknown/release/vortex_intent_settlement.wasm -# Prints the 32-byte wasm hash (hex). Save it as $NEW_WASM_HASH. -``` - -### 2. Propose the upgrade - -```bash -stellar contract invoke --id $CONTRACT_ID --source \ - --network mainnet -- \ - propose_upgrade --new_wasm_hash $NEW_WASM_HASH +cd fee_router +stellar contract build +ls -lh target/wasm32-unknown-unknown/release/vortex_fee_router.wasm ``` -Emits `upgrade_proposed(new_wasm_hash, eta)`. `eta` is the earliest ledger -timestamp `execute_upgrade` can run. Off-chain monitors should alert on this -event. To read it back later: `get_pending_upgrade` → `Some((hash, eta))`. -Re-running `propose_upgrade` with a different hash replaces the proposal and -resets the 48 h clock. - -### 3. Execute after the timelock +### Deploy and initialize ```bash -# Only after the current ledger time >= eta. -stellar contract invoke --id $CONTRACT_ID --source \ - --network mainnet -- \ - execute_upgrade --new_wasm_hash $NEW_WASM_HASH -``` - -Fails with `TimelockNotElapsed (29)` before `eta`, `Unauthorized (2)` if the -hash doesn't match the proposal, `NoPendingUpgrade (35)` if nothing is -pending. On success it swaps the code and emits `upgraded(new_wasm_hash)`. -The contract address, all storage, admin, config, solver bonds, and in-flight -intents are unchanged. - -### 4. Run the migration hook (only if the release requires it) +stellar contract deploy \ + --wasm target/wasm32-unknown-unknown/release/vortex_fee_router.wasm \ + --source \ + --network mainnet -If the new release's changelog says it changes a persisted storage shape, -run the one-time migration immediately after `execute_upgrade`: +FEE_ROUTER_ID= -```bash -stellar contract invoke --id $CONTRACT_ID --source \ +stellar contract invoke \ + --id $FEE_ROUTER_ID \ + --source \ --network mainnet -- \ - migrate + initialize \ + --admin \ + --weight_delay ``` -`migrate` is guarded by an on-chain version marker: it runs once per release -and returns `AlreadyMigrated (36)` on any subsequent call, so a repeated or -double-applied migration is impossible. Releases with no storage change need -no `migrate` call (a fresh `initialize` already stamps the current version). -Emits `migrated(from_version, to_version)`. - -### Upgrade safety notes - -- An upgrade landing while intents are `Open` / `Accepted` does not touch - their storage; the new code reads the same entries. -- Test the exact upgrade on testnet first: deploy current, create state, - `propose_upgrade` + `execute_upgrade` to the new hash, verify `get_intent` - / `get_solver` / `get_stats` still return the expected values, then run any - `migrate`. - ---- - -## Rollback Procedure - -Rolling *back* a bad upgrade uses the same upgrade path in reverse: `stellar -contract upload` the previous known-good wasm and `propose_upgrade` / -`execute_upgrade` to its hash (still subject to the 48 h timelock — `pause` -first if the regression is actively harmful). If the contract cannot be -recovered by re-upgrading, the fallback is a fresh deployment: - -1. **Immediately pause the contract** to halt new activity (see below). -2. **Deploy a patched contract** to a new address. -3. **Communicate the new address** to all integrated solvers and frontends. -4. **Drain active intents** from the old contract: - - Intents in `Accepted` state: wait for the fill window (max 5 minutes) and - call `slash_solver` if they weren't filled. The intent reverts to `Open`. - - Intents in `Open` state: the user can call `cancel_intent` to abandon them. - - Intents in terminal states (`Filled`, `Cancelled`, `Expired`, `Slashed`) - require no action. -5. **Return solver bonds**: after all `active_intents` reach zero, solvers can - call `deregister_solver` to recover their bonds from the old contract. - -There is no automated migration path for in-flight state — plan deployments -for low-activity windows. - ---- - -## Incident Response +### Configure sinks and weights -### Pause the contract (admin only) - -Use this immediately if you suspect an exploit, unexpected behavior, or need -maintenance time: +Weights are expressed in basis points and must sum to exactly `10,000`. Weight +changes are timelocked: propose first, then apply after `weight_delay` seconds. +The number of sinks is capped (see `MAX_SINKS` in the contract). ```bash +# Propose a new weight set (sinks + bps, summing to 10_000) stellar contract invoke \ - --id $CONTRACT_ID \ + --id $FEE_ROUTER_ID \ --source \ --network mainnet -- \ - pause -``` - -Effect: `submit_intent`, `accept_intent`, and `fill_intent` revert with -`ContractPaused (18)`. `slash_solver`, `cancel_intent`, and all read-only -views remain available. + propose_weights \ + --sinks '[, , ]' \ + --weights '[5000, 3000, 2000]' -### Resume normal operation - -```bash +# After weight_delay has elapsed, apply the pending weights stellar contract invoke \ - --id $CONTRACT_ID \ + --id $FEE_ROUTER_ID \ --source \ --network mainnet -- \ - unpause -``` + apply_weights -### Pause the proof registry (admin only) - -`proof_registry` has its own independent pause flag (issue #264), separate -from `intent_settlement`'s. Use this if you suspect a forged-proof attack or -other proof-ingestion incident: - -```bash +# Verify the active weights stellar contract invoke \ - --id $PROOF_REGISTRY_CONTRACT_ID \ - --source \ + --id $FEE_ROUTER_ID \ + --source \ --network mainnet -- \ - pause + get_weights ``` -Effect: `receive_message` reverts with `ContractPaused (8)`. `get_proof` and -`has_proof` remain available. Resume with the same `unpause` invocation used -for `intent_settlement`, targeted at `$PROOF_REGISTRY_CONTRACT_ID`. +### Point the settlement contract at the router + +Set the settlement contract's `fee_recipient` to `$FEE_ROUTER_ID` so all fees +and slash proceeds flow into the router. -### Rotate admin key +### Distribute accumulated fees -If the admin key is compromised, use `transfer_admin`. This requires -authorization from *both* the current and the new admin keypair: +`distribute(token)` is permissionless — anyone can call it to pay out the +router's balance for a token according to the active weights. Accounting is +pull-based (`claimable[sink][token]`): if a sink rejects a transfer, its share +is held and can be retried later rather than reverting the whole distribution. +Rounding dust is assigned to the first sink. ```bash stellar contract invoke \ - --id $CONTRACT_ID \ - --source \ + --id $FEE_ROUTER_ID \ + --source \ --network mainnet -- \ - transfer_admin \ - --new_admin -# The new admin must also sign this transaction -``` - -### Rotate fee recipient + distribute \ + --token -```bash +# Inspect a sink's claimable balance for a token stellar contract invoke \ - --id $CONTRACT_ID \ - --source \ + --id $FEE_ROUTER_ID \ + --source \ --network mainnet -- \ - set_fee_recipient \ - --new_fee_recipient -``` - -### Postmortem for P1 Incidents - -If the contract is paused or any admin action occurs unexpectedly, publish a postmortem per [`docs/incident-postmortem-template.md`](./incident-postmortem-template.md) (issue #301) within 5 business days of resolution. The postmortem should include timeline (correlated against the specific signals in `docs/110-monitoring-alerting-spec.md`), root cause, impact, and preventive follow-ups. - -### Quick status check script - -A health-check script is provided in the repository at [`scripts/check-deployment.sh`](../../scripts/check-deployment.sh). -Run it any time you need a fast deployment overview: - -```bash -./scripts/check-deployment.sh $CONTRACT_ID # check on mainnet -./scripts/check-deployment.sh $CONTRACT_ID testnet # check on testnet -``` - -The script queries six key contract state values (all read-only, no fees): - -1. **Admin** — the administrative address (can pause/resume, transfer admin, rotate fee recipient) -2. **Fee Recipient** — the address that collects protocol fees and slash proceeds -3. **Bond Token** — the token (USDC) used for solver bonds -4. **Paused** — whether the contract is currently paused (`true` = paused, `false` = operating) -5. **Allowlist enabled** — whether the destination token allowlist is active -6. **Stats** — a 3-tuple: `(total_intents, total_volume, open_intents)` - -Requires: `stellar` CLI in $PATH and a configured Stellar identity or `STELLAR_SECRET_KEY` environment variable. - ---- - -## Ongoing Monitoring - -After deployment, continuously monitor the contract for incidents using the dedicated ops monitoring tool. - -### Vortex Monitoring & Alerting Service - -The repository includes a real-time monitoring & alerting service at [`monitoring/vortex-monitor.js`](../monitoring/README.md) -that watches for P1/P2/P3 signals defined in [`docs/110-monitoring-alerting-spec.md`](110-monitoring-alerting-spec.md). - -**Setup:** - -```bash -cd monitoring - -# Configure environment -export SOROBAN_RPC_URL="https://soroban-mainnet.stellar.org" -export CONTRACT_ID="C..." -export NETWORK="mainnet" -export ALERT_WEBHOOK_URL="https://your-alerting-service.example.com/webhooks/alerts" - -# Run the monitor -node vortex-monitor.js + claimable \ + --sink \ + --token ``` - -**Signals monitored:** - -- **P1** (page immediately): Unexpected pause/unpause, admin transfer, fee recipient change, token rescue -- **P2** (escalate): Unusual slash rate, bond utilization drop, mass solver exit, paused longer than expected -- **P3** (informational): Fill-rate stagnation, extension-granting frequency, config churn - -See [`monitoring/README.md`](../monitoring/README.md) for full documentation, configuration options, and alert formats. - ---- - -*Document status: Updated to reference ops tooling implementation (Issue #289)* diff --git a/fee_router/Cargo.toml b/fee_router/Cargo.toml new file mode 100644 index 0000000..9edfead --- /dev/null +++ b/fee_router/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "fee_router" +version = "0.1.0" +edition = "2021" +description = "Protocol fee recipient that splits revenue across governance-configured sinks by timelocked weights" +license = "Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/fee_router/src/lib.rs b/fee_router/src/lib.rs new file mode 100644 index 0000000..222a747 --- /dev/null +++ b/fee_router/src/lib.rs @@ -0,0 +1,290 @@ +//! # Fee Router +//! +//! A pull-based fee router that accumulates protocol revenue per token and +//! distributes it to governance-configured sinks according to timelocked +//! weights (in basis points, summing to 10,000). +//! +//! Design notes: +//! - Accounting is pull-based: `distribute` credits `claimable[sink][token]` +//! and sinks withdraw with `claim`. A sink that rejects a transfer does not +//! revert the whole distribution; its share stays claimable for a later retry. +//! - Weight changes are timelocked: `propose_weights` then `apply_weights` +//! after `weight_delay` has elapsed. +//! - Rounding dust from integer division is assigned to the first sink. +//! - Fee-on-transfer tokens are supported: the router credits the amount it +//! actually received, not the amount the caller claimed to send. + +use std::collections::BTreeMap; + +/// Total basis points that weights must sum to. +pub const TOTAL_BPS: u32 = 10_000; + +/// Maximum number of sinks the router will accept. +pub const MAX_SINKS: usize = 16; + +/// A single revenue sink with its governance-set weight. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Sink { + pub account: String, + pub weight_bps: u32, +} + +/// A pending, timelocked weight change. +#[derive(Clone, Debug, PartialEq, Eq)] +struct PendingWeights { + sinks: Vec, + executable_at: u64, +} + +/// Errors returned by the router. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FeeRouterError { + Unauthorized, + TooManySinks, + WeightsMustSumToTotal, + EmptySinks, + DuplicateSink, + NoPendingWeights, + TimelockNotElapsed, + UnknownToken, + NothingToClaim, + TransferFailed, +} + +/// The fee router contract. +#[derive(Debug)] +pub struct FeeRouter { + governance: String, + weight_delay: u64, + sinks: Vec, + pending: Option, + /// token -> total accumulated but not yet distributed balance. + balances: BTreeMap, + /// sink -> token -> claimable amount. + claimable: BTreeMap>, +} + +impl FeeRouter { + /// Create a new router. `sinks` must be non-empty, unique, at most + /// [`MAX_SINKS`], and sum to [`TOTAL_BPS`]. + pub fn new( + governance: impl Into, + weight_delay: u64, + sinks: Vec, + ) -> Result { + validate_sinks(&sinks)?; + Ok(Self { + governance: governance.into(), + weight_delay, + sinks, + pending: None, + balances: BTreeMap::new(), + claimable: BTreeMap::new(), + }) + } + + pub fn governance(&self) -> &str { + &self.governance + } + + pub fn weight_delay(&self) -> u64 { + self.weight_delay + } + + pub fn sinks(&self) -> &[Sink] { + &self.sinks + } + + /// Total accumulated balance for `token` that has not yet been distributed. + pub fn balance_of(&self, token: &str) -> u128 { + self.balances.get(token).copied().unwrap_or(0) + } + + /// Amount currently claimable by `sink` for `token`. + pub fn claimable(&self, sink: &str, token: &str) -> u128 { + self.claimable + .get(sink) + .and_then(|m| m.get(token)) + .copied() + .unwrap_or(0) + } + + /// Record an incoming fee payment. The router credits the amount it + /// actually received, which makes fee-on-transfer tokens safe. + pub fn on_fee_received(&mut self, token: impl Into, amount: u128) { + if amount == 0 { + return; + } + let entry = self.balances.entry(token.into()).or_insert(0); + *entry = entry.saturating_add(amount); + } + + /// Permissionlessly distribute the accumulated balance of `token` across + /// the configured sinks according to their weights. + /// + /// Rounding dust is assigned to the first sink so that the full balance is + /// always conserved. + pub fn distribute(&mut self, token: &str) -> Result<(), FeeRouterError> { + let total = self.balance_of(token); + if total == 0 { + return Err(FeeRouterError::UnknownToken); + } + + let mut distributed: u128 = 0; + for (i, sink) in self.sinks.iter().enumerate() { + let share = if i == 0 { + // First sink absorbs rounding dust. + total - distributed + } else { + total.saturating_mul(sink.weight_bps as u128) / TOTAL_BPS as u128 + }; + distributed = distributed.saturating_add(share); + if share > 0 { + let entry = self + .claimable + .entry(sink.account.clone()) + .or_default() + .entry(token.to_string()) + .or_insert(0); + *entry = entry.saturating_add(share); + } + } + + self.balances.insert(token.to_string(), 0); + Ok(()) + } + + /// Withdraw the caller's claimable balance for `token`. + /// + /// The transfer is performed by the caller-supplied closure. If the sink + /// rejects the transfer the share is left claimable and the error is + /// surfaced, so a later retry can succeed without affecting other sinks. + pub fn claim(&mut self, sink: &str, token: &str, transfer: F) -> Result + where + F: FnOnce(&str, &str, u128) -> bool, + { + let amount = self.claimable(sink, token); + if amount == 0 { + return Err(FeeRouterError::NothingToClaim); + } + if !transfer(sink, token, amount) { + return Err(FeeRouterError::TransferFailed); + } + if let Some(m) = self.claimable.get_mut(sink) { + m.insert(token.to_string(), 0); + } + Ok(amount) + } + + /// Propose a new set of sinks and weights. Only governance may call this. + pub fn propose_weights( + &mut self, + caller: &str, + sinks: Vec, + now: u64, + ) -> Result<(), FeeRouterError> { + if caller != self.governance { + return Err(FeeRouterError::Unauthorized); + } + validate_sinks(&sinks)?; + self.pending = Some(PendingWeights { + sinks, + executable_at: now.saturating_add(self.weight_delay), + }); + Ok(()) + } + + /// Apply a previously proposed weight change once the timelock has elapsed. + pub fn apply_weights(&mut self, caller: &str, now: u64) -> Result<(), FeeRouterError> { + if caller != self.governance { + return Err(FeeRouterError::Unauthorized); + } + let pending = self + .pending + .take() + .ok_or(FeeRouterError::NoPendingWeights)?; + if now < pending.executable_at { + self.pending = Some(pending); + return Err(FeeRouterError::TimelockNotElapsed); + } + self.sinks = pending.sinks; + Ok(()) + } + + /// Timestamp at which the pending weight change becomes executable. + pub fn pending_executable_at(&self) -> Option { + self.pending.as_ref().map(|p| p.executable_at) + } +} + +fn validate_sinks(sinks: &[Sink]) -> Result<(), FeeRouterError> { + if sinks.is_empty() { + return Err(FeeRouterError::EmptySinks); + } + if sinks.len() > MAX_SINKS { + return Err(FeeRouterError::TooManySinks); + } + let mut seen = std::collections::BTreeSet::new(); + let mut sum: u32 = 0; + for sink in sinks { + if !seen.insert(sink.account.as_str()) { + return Err(FeeRouterError::DuplicateSink); + } + sum = sum.saturating_add(sink.weight_bps); + } + if sum != TOTAL_BPS { + return Err(FeeRouterError::WeightsMustSumToTotal); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sinks() -> Vec { + vec![ + Sink { account: "treasury".into(), weight_bps: 5_000 }, + Sink { account: "backstop".into(), weight_bps: 3_000 }, + Sink { account: "rebate".into(), weight_bps: 2_000 }, + ] + } + + #[test] + fn distribute_conserves_balance() { + let mut r = FeeRouter::new("gov", 100, sinks()).unwrap(); + r.on_fee_received("usd", 10_001); + r.distribute("usd").unwrap(); + let total = r.claimable("treasury", "usd") + + r.claimable("backstop", "usd") + + r.claimable("rebate", "usd"); + assert_eq!(total, 10_001); + assert_eq!(r.balance_of("usd"), 0); + } + + #[test] + fn weights_must_sum_to_total() { + let bad = vec![Sink { account: "a".into(), weight_bps: 9_999 }]; + assert_eq!(FeeRouter::new("gov", 0, bad).unwrap_err(), FeeRouterError::WeightsMustSumToTotal); + } + + #[test] + fn timelock_enforced() { + let mut r = FeeRouter::new("gov", 100, sinks()).unwrap(); + r.propose_weights("gov", vec![Sink { account: "a".into(), weight_bps: 10_000 }], 0).unwrap(); + assert_eq!(r.apply_weights("gov", 50).unwrap_err(), FeeRouterError::TimelockNotElapsed); + r.apply_weights("gov", 100).unwrap(); + assert_eq!(r.sinks().len(), 1); + } + + #[test] + fn rejected_transfer_keeps_share_claimable() { + let mut r = FeeRouter::new("gov", 0, sinks()).unwrap(); + r.on_fee_received("usd", 1_000); + r.distribute("usd").unwrap(); + let before = r.claimable("treasury", "usd"); + assert_eq!(r.claim("treasury", "usd", |_, _, _| false).unwrap_err(), FeeRouterError::TransferFailed); + assert_eq!(r.claimable("treasury", "usd"), before); + assert_eq!(r.claim("treasury", "usd", |_, _, _| true).unwrap(), before); + } +} diff --git a/treasury_streams/Cargo.toml b/treasury_streams/Cargo.toml new file mode 100644 index 0000000..e45dc5f --- /dev/null +++ b/treasury_streams/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "treasury_streams" +version = "0.1.0" +edition = "2021" +description = "Streaming-payments treasury contract for contributor grants (Drips/Sablier-style)" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] + +[dev-dependencies] +proptest = "1" diff --git a/treasury_streams/src/lib.rs b/treasury_streams/src/lib.rs new file mode 100644 index 0000000..fe24184 --- /dev/null +++ b/treasury_streams/src/lib.rs @@ -0,0 +1,222 @@ +#![no_std] + +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, token, Address, Env}; + +/// Storage keys for the treasury streams contract. +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + /// Address allowed to create/cancel streams (timelock or governor). + Governor, + /// Monotonic counter for stream ids. + NextId, + /// Per-stream record. + Stream(u64), +} + +/// A single payment stream. +#[contracttype] +#[derive(Clone)] +pub struct Stream { + pub sender: Address, + pub recipient: Address, + pub token: Address, + pub total: i128, + pub start: u64, + pub cliff: u64, + pub end: u64, + pub withdrawn: i128, + pub cancelled: bool, +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + NotInitialized = 1, + AlreadyInitialized = 2, + Unauthorized = 3, + StreamNotFound = 4, + InvalidSchedule = 5, + InvalidAmount = 6, + NothingToWithdraw = 7, + AlreadyCancelled = 8, +} + +#[contract] +pub struct TreasuryStreams; + +#[contractimpl] +impl TreasuryStreams { + /// One-time initialization setting the governor (timelock/governor) address. + pub fn initialize(env: Env, governor: Address) -> Result<(), Error> { + if env.storage().instance().has(&DataKey::Governor) { + return Err(Error::AlreadyInitialized); + } + env.storage().instance().set(&DataKey::Governor, &governor); + env.storage().instance().set(&DataKey::NextId, &0u64); + Ok(()) + } + + /// Creates a new stream funded by `sender`. Only the governor may call this. + pub fn create_stream( + env: Env, + sender: Address, + recipient: Address, + token: Address, + total: i128, + start: u64, + cliff: u64, + end: u64, + ) -> Result { + let governor: Address = env + .storage() + .instance() + .get(&DataKey::Governor) + .ok_or(Error::NotInitialized)?; + governor.require_auth(); + + if total <= 0 { + return Err(Error::InvalidAmount); + } + if end <= start || cliff < start || cliff > end { + return Err(Error::InvalidSchedule); + } + + // Escrow the full deposit into the contract up front. + token::Client::new(&env, &token).transfer(&sender, &env.current_contract_address(), &total); + + let id: u64 = env.storage().instance().get(&DataKey::NextId).unwrap_or(0); + let stream = Stream { + sender: sender.clone(), + recipient: recipient.clone(), + token: token.clone(), + total, + start, + cliff, + end, + withdrawn: 0, + cancelled: false, + }; + env.storage().persistent().set(&DataKey::Stream(id), &stream); + env.storage().instance().set(&DataKey::NextId, &(id + 1)); + + env.events().publish( + (soroban_sdk::symbol_short!("create"), id), + (sender, recipient, token, total, start, cliff, end), + ); + Ok(id) + } + + /// Pull-based withdrawal of all currently vested funds for a stream. + pub fn withdraw(env: Env, stream_id: u64) -> Result { + let mut stream: Stream = env + .storage() + .persistent() + .get(&DataKey::Stream(stream_id)) + .ok_or(Error::StreamNotFound)?; + + stream.recipient.require_auth(); + + let vested = Self::vested_amount(&stream, env.ledger().timestamp()); + let claimable = vested - stream.withdrawn; + if claimable <= 0 { + return Err(Error::NothingToWithdraw); + } + + stream.withdrawn = vested; + env.storage().persistent().set(&DataKey::Stream(stream_id), &stream); + + token::Client::new(&env, &stream.token).transfer( + &env.current_contract_address(), + &stream.recipient, + &claimable, + ); + + env.events().publish( + (soroban_sdk::symbol_short!("withdraw"), stream_id), + (stream.recipient.clone(), claimable), + ); + Ok(claimable) + } + + /// Cancels a stream. Unvested funds return to the sender; vested funds stay + /// withdrawable by the recipient. Only the governor may call this. + pub fn cancel(env: Env, stream_id: u64) -> Result<(), Error> { + let governor: Address = env + .storage() + .instance() + .get(&DataKey::Governor) + .ok_or(Error::NotInitialized)?; + governor.require_auth(); + + let mut stream: Stream = env + .storage() + .persistent() + .get(&DataKey::Stream(stream_id)) + .ok_or(Error::StreamNotFound)?; + if stream.cancelled { + return Err(Error::AlreadyCancelled); + } + + let vested = Self::vested_amount(&stream, env.ledger().timestamp()); + let unvested = stream.total - vested; + + stream.cancelled = true; + // Cap the stream at what has vested so far; the recipient keeps the + // right to withdraw the vested remainder. + stream.total = vested; + env.storage().persistent().set(&DataKey::Stream(stream_id), &stream); + + if unvested > 0 { + token::Client::new(&env, &stream.token).transfer( + &env.current_contract_address(), + &stream.sender, + &unvested, + ); + } + + env.events().publish( + (soroban_sdk::symbol_short!("cancel"), stream_id), + (stream.sender.clone(), unvested), + ); + Ok(()) + } + + /// Returns the currently withdrawable (vested but not yet withdrawn) balance. + pub fn balance_of(env: Env, stream_id: u64) -> Result { + let stream: Stream = env + .storage() + .persistent() + .get(&DataKey::Stream(stream_id)) + .ok_or(Error::StreamNotFound)?; + let vested = Self::vested_amount(&stream, env.ledger().timestamp()); + Ok(vested - stream.withdrawn) + } + + /// Returns the full stream record. + pub fn get_stream(env: Env, stream_id: u64) -> Result { + env.storage() + .persistent() + .get(&DataKey::Stream(stream_id)) + .ok_or(Error::StreamNotFound) + } + + /// Precision-safe vested amount: `total * elapsed / duration` using integer + /// math. The final withdrawal sweeps any rounding dust because once + /// `now >= end` the full `total` is considered vested. + fn vested_amount(stream: &Stream, now: u64) -> i128 { + if now < stream.cliff { + return 0; + } + if now >= stream.end { + return stream.total; + } + let duration = (stream.end - stream.start) as i128; + let elapsed = (now - stream.start) as i128; + stream.total * elapsed / duration + } +} + +#[cfg(test)] +mod test;