Skip to content

Commit 51cb273

Browse files
author
minimalmod
committed
docs: submit vulnerability report for dispute_sink_balance lock (Issue #5)
1 parent e051444 commit 51cb273

1 file changed

Lines changed: 68 additions & 0 deletions

File tree

VULNERABILITY_REPORT.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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

Comments
 (0)