|
| 1 | +# Vulnerability Report: Permanent Lock of Dispute Sink USDC (No Withdrawal Mechanism) |
| 2 | + |
| 3 | +## Summary |
| 4 | + |
| 5 | +In `market_app/contract.py`, the `dispute_sink_balance` state variable accumulates USDC from slashed dispute bonds but **has no withdrawal method**. Combined with the absence of a `DeleteApplication` handler, this permanently locks funds in the contract with no recovery path. |
| 6 | + |
| 7 | +## Vulnerability Detail |
| 8 | + |
| 9 | +When a dispute is resolved, a portion of the losing party's bond is routed to `dispute_sink_balance`: |
| 10 | + |
| 11 | +```python |
| 12 | +# contract.py line 726 (_settle_confirmed_dispute — loser is challenger) |
| 13 | +self.dispute_sink_balance.value = self.dispute_sink_balance.value + (losing_bond - winner_bonus) |
| 14 | + |
| 15 | +# contract.py line 736 (_settle_overturned_dispute — loser is proposer) |
| 16 | +self.dispute_sink_balance.value = self.dispute_sink_balance.value + (losing_bond - winner_bonus) |
| 17 | + |
| 18 | +# contract.py line 745 (_settle_cancelled_dispute — proposer bond fully slashed) |
| 19 | +self.dispute_sink_balance.value = self.dispute_sink_balance.value + self.proposer_bond_held.value |
| 20 | +``` |
| 21 | + |
| 22 | +The variable is initialized to 0 in `create()` (line 864) and only ever incremented. A comprehensive search of the entire codebase confirms: |
| 23 | + |
| 24 | +1. **No `withdraw_dispute_sink()` method exists** — there is no ABI method that decrements `dispute_sink_balance` or sends the corresponding USDC to any address (treasury, admin, or otherwise). |
| 25 | +2. **No `DeleteApplication` handler exists** — the contract has no `@arc4.baremethod(allow_actions=["DeleteApplication"])`, so the AVM rejects any delete call. Even if deletion were possible, ASA (USDC) balances require explicit inner transactions to transfer before deletion — passive ALGO close-out does not apply to ASAs. |
| 26 | +3. **No admin override exists** — neither `market_admin`, `resolution_authority`, nor `creator` have any path to extract these funds. |
| 27 | + |
| 28 | +### Comparison with other balance types |
| 29 | + |
| 30 | +Every other tracked USDC balance has an explicit withdrawal path: |
| 31 | + |
| 32 | +| Balance Variable | Withdrawal Method | Access Control | |
| 33 | +|---|---|---| |
| 34 | +| `pool_balance` | `claim()`, `refund()`, `claim_lp_residual()` | Users, LPs | |
| 35 | +| `lp_fee_balance` | `withdraw_lp_fees()` | LPs | |
| 36 | +| `protocol_fee_balance` | `withdraw_protocol_fees()` | Anyone (→treasury) | |
| 37 | +| `resolution_budget_balance` | `reclaim_resolution_budget()` | Creator | |
| 38 | +| `proposer_bond_held` | `_credit_pending_payout()` → `withdraw_pending_payouts()` | Winner | |
| 39 | +| `challenger_bond_held` | `_credit_pending_payout()` → `withdraw_pending_payouts()` | Winner | |
| 40 | +| **`dispute_sink_balance`** | **❌ NONE** | **N/A** | |
| 41 | + |
| 42 | +## Impact |
| 43 | + |
| 44 | +**MEDIUM — Permanent fund lock.** USDC accumulates in the contract with no recovery. |
| 45 | + |
| 46 | +With `DEFAULT_DISPUTE_SINK_SHARE_BPS = 5000` (50%), half of every losing bond is permanently locked. For a market with a 1,000 USDC challenge bond and an overturned dispute in which the proposer had posted a 1,000 USDC bond: |
| 47 | + |
| 48 | +- `winner_bonus = floor(1000 * 5000 / 10000) = 500 USDC` → winner |
| 49 | +- `dispute_sink = 1000 - 500 = 500 USDC` → **permanently locked** |
| 50 | + |
| 51 | +Across many resolved markets, the cumulative locked amount grows without bound. |
| 52 | + |
| 53 | +## Recommendation |
| 54 | + |
| 55 | +Add a `withdraw_dispute_sink` method gated to an authorized role (e.g., `protocol_treasury` or `market_admin`): |
| 56 | + |
| 57 | +```python |
| 58 | +@arc4.abimethod() |
| 59 | +def withdraw_dispute_sink(self) -> None: |
| 60 | + self._require_status_any2(UInt64(STATUS_RESOLVED), UInt64(STATUS_CANCELLED)) |
| 61 | + amount = self.dispute_sink_balance.value |
| 62 | + self._require(amount > UInt64(0)) |
| 63 | + self.dispute_sink_balance.value = UInt64(0) |
| 64 | + self._send_currency(Account(self.protocol_treasury.value), amount) |
| 65 | + arc4.emit("WithdrawDisputeSink(uint64)", arc4.UInt64(amount)) |
| 66 | +``` |
| 67 | + |
| 68 | +Alternatively, fold the sink share into `protocol_fee_balance` during settlement so it is withdrawn via the existing `withdraw_protocol_fees()` path. |
0 commit comments