Skip to content

Commit a5a2c30

Browse files
Fridayddev1nonlypiececlaude
authored
feat(contracts): restrict dispute_milestone to active states and rese… (#1759)
- Restrict dispute_milestone to vaults in Active status (Error::NotActive) - Reject disputing while vault is paused (Error::Paused) - Reject disputing milestones already released (Error::MilestoneAlreadyReleased) - Require check-in record for dispute; reject missing records with Error::MilestoneNotDisputable - Clear DataKey::MilestoneApprovals(index) and DataKey::CheckIn(index) on dispute to reset M-of-N round - Emit milestone_disputed event with milestone index and cleared approvals count - Bundle destinations into Destinations struct to adhere to Soroban 10-parameter limit - Add comprehensive 2-of-3 threshold unit tests covering dispute and re-approval - Document dispute invariants, re-approval workflow, and dispute capping decision in README.md Co-authored-by: 1nonlypiece <1nonlypiece@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent fcd6869 commit a5a2c30

4 files changed

Lines changed: 362 additions & 14 deletions

File tree

‎contracts/README.md‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Implements the vault lifecycle that the backend models off-chain in
2828
| `stake` | Creator transfers the SEP-41 token into the contract; `Draft` -> `Active`. |
2929
| `stake_from` | Pull the creator's SEP-41 token using an authorized spender's allowance; `Draft` -> `Active`. |
3030
| `check_in` | Designated verifier confirms a milestone before its `due_date`. |
31-
| `dispute_milestone` | Creator disputes a verified milestone within the vault's configured `dispute_window` (`verified_at.checked_add(vault.dispute_window)`), reverting `verified` to `false`. Requires an existing `CheckIn` record. |
31+
| `dispute_milestone` | Creator disputes a mistaken milestone verification within the vault's configured `dispute_window` (`verified_at.checked_add(vault.dispute_window)`); reverts `verified` to `false` and clears approvals and the check-in record for a fresh M-of-N round. Requires an existing `CheckIn` record. Blocked if paused, not `Active`, or already released. |
3232
| `slash_on_miss` | After the deadline with unverified milestones, slash funds to `failure_destination`; `Active` -> `Failed`. Bounded pause/dispute stops blocking once expired. |
3333
| `claim` | When all milestones are verified, release funds to `success_destination`; `Active` -> `Completed`. Blocked if paused. |
3434
| `claim_milestone` | Release funds for an individual verified milestone; blocked if paused, enforces CEI (state persisted before external transfer). |
@@ -45,6 +45,29 @@ Implements the vault lifecycle that the backend models off-chain in
4545
3. **Time-Bounded Pause & Dispute**: Emergency pauses (`emergency_pause`) and administrative disputes (`admin_dispute`) are strictly bounded to at most 14 days (`MAX_PAUSE_DURATION = 14 * 86400`). When ledger time advances past the pause or dispute expiry, `slash_on_miss` and claims proceed normally without being blocked indefinitely.
4646
4. **Emergency Pause Enforcement**: When a vault is paused, milestone payouts (`claim_milestone`) as well as full claims (`claim`), slashes (`slash_on_miss`), and refunds (`withdraw`) are strictly blocked (`Error::Paused`), ensuring payouts cannot be drained during an active emergency.
4747
5. **Bounded Active Refunds**: `withdraw` is only available through the exact 24-hour boundary after staking and before `end_timestamp`. At or after the deadline it returns `Error::DeadlinePassed`, preserving the full stake for `slash_on_miss`; after the grace period it returns `Error::WithdrawWindowClosed`. Any verified milestone also blocks a refund.
48+
6. **Milestone Dispute Guards & State Reset**: `dispute_milestone` is strictly restricted to `Active` and unpaused vaults, rejecting already-released milestones (`MilestoneAlreadyReleased`) and milestones lacking check-in history (`MilestoneNotDisputable`). Disputing clears the per-milestone approvals list and check-in timestamp so verifiers can conduct a clean M-of-N re-review without encountering `AlreadyApproved`.
49+
50+
### Milestone Disputes & Re-approval Flow
51+
52+
The creator may dispute a verified milestone using `dispute_milestone` to correct mistaken verifications before funds are released. This reopens verification review rather than permanently locking the milestone or forcing an unfair slash.
53+
54+
- **Preconditions**:
55+
- Vault must be in `Active` status (calls on `Draft`, `Completed`, `Failed`, `Cancelled`, or `Disputed` return `Error::NotActive`).
56+
- Vault must not be paused (returns `Error::Paused`).
57+
- Milestone must be currently verified (`milestone.verified == true`), otherwise returns `Error::MilestonesIncomplete`.
58+
- Milestone must not have already been released via `claim_milestone` (returns `Error::MilestoneAlreadyReleased`).
59+
- Milestone must have an existing check-in record in storage (`CheckIn(vault_id, index)`); pre-verified milestones lacking a check-in record cannot be disputed and return `Error::MilestoneNotDisputable`.
60+
- Ledger timestamp must be within the dispute window: `now <= verified_at + vault.dispute_window` (inclusive window rule; timestamps strictly greater than `verified_at + vault.dispute_window` return `Error::DeadlinePassed`).
61+
- **Reset & Re-approval**:
62+
- The per-milestone approval list (`MilestoneApprovals(vault_id, index)`) and check-in record (`CheckIn(vault_id, index)`) are deleted from storage.
63+
- `milestone.verified` is set back to `false`.
64+
- Verifiers can then re-submit approvals via `check_in`. Because previous approvals were cleared, verifiers will not be blocked by `Error::AlreadyApproved`.
65+
- Once the M-of-N threshold is satisfied again, `milestone.verified` transitions back to `true`, and milestone payout can proceed through `claim_milestone`.
66+
- Emits a `milestone_disputed` event with topics `(Symbol, vault_id)` and data `(index, cleared_approvals_count)`.
67+
- **Dispute Capping Decision**:
68+
- Adding a dispute counter to `Milestone` was evaluated to cap repeated disputes per milestone. However, altering the `Milestone` struct changes on-chain storage serialization and breaks existing vault records. Furthermore, disputing does not extend milestone `due_date` or vault `end_timestamp`: if a creator repeatedly disputes, time continues elapsing towards the deadline and the vault will be slashed if not reverified in time. Capping disputes per milestone via a counter is therefore deferred to a future storage-migration release.
69+
- **Storage Scoping**:
70+
- Approvals and check-in records are keyed per vault (`MilestoneApprovals(vault_id, index)`, `CheckIn(vault_id, index)`) in persistent storage; the reset removes only the disputed vault's keys.
4871

4972
### Token allowlist policy
5073

‎contracts/accountability_vault/fuzz/fuzz_targets/create_vault.rs‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ use soroban_sdk::{
1717
token, vec, Address, Env, String,
1818
};
1919

20-
use accountability_vault::{AccountabilityVault, AccountabilityVaultClient, Milestone, VerifierSet};
20+
use accountability_vault::{
21+
AccountabilityVault, AccountabilityVaultClient, Destinations, Milestone, VaultOptions,
22+
VerifierSet,
23+
};
2124

2225
/// Compact fuzzer-controlled input that covers all `create_vault` invariant checks.
2326
#[derive(Debug, arbitrary::Arbitrary)]
@@ -103,17 +106,24 @@ fuzz_target!(|input: FuzzInput| {
103106

104107
// The only assertion: the call must not panic. Either Ok(()) or a typed Error
105108
// is acceptable. Panics surface as fuzzer crashes.
109+
let destinations = Destinations {
110+
success,
111+
failure,
112+
};
113+
106114
let _ = client.try_create_vault(
107115
&vault_id,
108116
&creator,
109117
&verifier_set,
110-
&None,
111118
&token_addr,
112119
&input.amount,
113-
&success,
114-
&failure,
120+
&destinations,
115121
&end_timestamp,
116122
&milestones,
117-
&guardian,
123+
&VaultOptions {
124+
oracle: None,
125+
guardian,
126+
dispute_window: 86_400,
127+
},
118128
);
119129
});

‎contracts/accountability_vault/src/lib.rs‎

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,8 @@ pub enum Error {
292292
WithdrawBlockedByVerifiedMilestone = 33,
293293
/// Dispute window is outside the `[MIN_DISPUTE_WINDOW, MAX_DISPUTE_WINDOW]` bounds.
294294
InvalidDisputeWindow = 34,
295+
/// Milestone has no check-in record and cannot be disputed.
296+
MilestoneNotDisputable = 35,
295297
}
296298

297299
/// Accountability vault contract entry point.
@@ -1192,11 +1194,12 @@ impl AccountabilityVault {
11921194
}
11931195

11941196

1195-
/// Disputes a verified milestone within the vault's configured `dispute_window`.
1197+
/// Disputes a previously verified milestone within the vault's configured `dispute_window`.
11961198
///
1197-
/// Only the vault creator may dispute a milestone. Requires a recorded `CheckIn`
1198-
/// timestamp for the milestone and rejects the call with `Error::DeadlinePassed`
1199-
/// once `verified_at + vault.dispute_window` has elapsed.
1199+
/// Allows the vault creator to dispute a verified milestone on an `Active`,
1200+
/// unpaused vault before it is released, resetting approvals and the check-in
1201+
/// record so verifiers can conduct a fresh verification round. Rejects the call
1202+
/// with `Error::DeadlinePassed` once `verified_at + vault.dispute_window` has elapsed.
12001203
pub fn dispute_milestone(
12011204
env: Env,
12021205
vault_id: String,
@@ -1205,10 +1208,17 @@ impl AccountabilityVault {
12051208
) -> Result<(), Error> {
12061209
creator.require_auth();
12071210
let mut vault: Vault = Self::load(&env, &vault_id)?;
1211+
let now = env.ledger().timestamp();
12081212

12091213
if vault.creator != creator {
12101214
return Err(Error::Unauthorized);
12111215
}
1216+
if vault.status != VaultStatus::Active {
1217+
return Err(Error::NotActive);
1218+
}
1219+
if Self::is_paused(&vault, now) {
1220+
return Err(Error::Paused);
1221+
}
12121222
if index >= vault.milestones.len() {
12131223
return Err(Error::MilestoneIndexOutOfRange);
12141224
}
@@ -1217,6 +1227,9 @@ impl AccountabilityVault {
12171227
.milestones
12181228
.get(index)
12191229
.ok_or(Error::MilestoneIndexOutOfRange)?;
1230+
if milestone.released {
1231+
return Err(Error::MilestoneAlreadyReleased);
1232+
}
12201233
if !milestone.verified {
12211234
return Err(Error::MilestonesIncomplete);
12221235
}
@@ -1225,12 +1238,12 @@ impl AccountabilityVault {
12251238
.storage()
12261239
.persistent()
12271240
.get(&DataKey::CheckIn(vault_id.clone(), index))
1228-
.ok_or(Error::MilestonesIncomplete)?;
1241+
.ok_or(Error::MilestoneNotDisputable)?;
12291242

12301243
let window_end = verified_at
12311244
.checked_add(vault.dispute_window)
12321245
.ok_or(Error::InvalidDeadline)?;
1233-
if env.ledger().timestamp() > window_end {
1246+
if now > window_end {
12341247
return Err(Error::DeadlinePassed);
12351248
}
12361249

@@ -1242,9 +1255,22 @@ impl AccountabilityVault {
12421255
env.storage().persistent().set(&key, &vault);
12431256
Self::extend_ttl(&env, &key);
12441257

1258+
// Reset per-milestone approvals and check-in record so verifiers can re-approve.
1259+
let approvals_key = DataKey::MilestoneApprovals(vault_id.clone(), index);
1260+
let check_in_key = DataKey::CheckIn(vault_id.clone(), index);
1261+
let cleared_count: u32 = env
1262+
.storage()
1263+
.persistent()
1264+
.get::<DataKey, Vec<Address>>(&approvals_key)
1265+
.map(|a| a.len())
1266+
.unwrap_or(0);
1267+
1268+
env.storage().persistent().remove(&approvals_key);
1269+
env.storage().persistent().remove(&check_in_key);
1270+
12451271
env.events().publish(
12461272
(Symbol::new(&env, "milestone_disputed"), vault_id),
1247-
index,
1273+
(index, cleared_count),
12481274
);
12491275

12501276
Ok(())

0 commit comments

Comments
 (0)