Skip to content

Commit 59dfcbf

Browse files
feat: add admin-controlled pause for assert, dispute, and resolve
1 parent 961b312 commit 59dfcbf

22 files changed

Lines changed: 1523 additions & 23 deletions

CONTRACT.md

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ State of an assertion: `Pending`, `Disputed`, or `Resolved`.
5959
| `ChallengeWindowOpen` | Tried to finalize before the challenge window elapsed |
6060
| `NotAResolver` | Caller isn't in the current resolver committee |
6161
| `AlreadyVoted` | Resolver already voted on this assertion |
62+
| `Paused` | Called `assert_outcome`, `dispute`, or `resolve` while paused |
6263

6364
## Functions
6465

@@ -76,17 +77,26 @@ odd-length requirement as `initialize`. Emits `ResolversUpdated`. Does not affec
7677
longer cast further votes; a resolver added mid-dispute can vote on assertions that
7778
were already disputed before they joined.
7879

80+
### `set_paused(paused)`
81+
82+
Pauses or unpauses `assert_outcome`, `dispute`, and `resolve`. Requires the stored
83+
admin's signature. `finalize` is deliberately exempt: assertions already `Pending`
84+
before a pause can still be finalized while paused, so an uncontested claim isn't
85+
stuck waiting on an unpause. `update_resolvers` is also exempt, so a compromised
86+
committee can be replaced without unpausing first. Emits `PauseUpdated`.
87+
7988
### `assert_outcome(asserter, outcome) -> u64`
8089

8190
Posts a bonded claim. Transfers `bond_amount` from `asserter` to the contract.
82-
Requires `asserter`'s signature. Returns the new assertion id. Emits `Asserted`.
91+
Requires `asserter`'s signature. Fails with `Paused` if paused. Returns the new
92+
assertion id. Emits `Asserted`.
8393

8494
### `dispute(disputer, id)`
8595

8696
Disputes a `Pending` assertion within the challenge window, matching its bond.
87-
Requires `disputer`'s signature. Fails with `NotPending` if the assertion isn't
88-
pending (including if it's already disputed), or `ChallengeWindowClosed` if the
89-
window has elapsed. Emits `Disputed`.
97+
Requires `disputer`'s signature. Fails with `Paused` if paused, `NotPending` if the
98+
assertion isn't pending (including if it's already disputed), or
99+
`ChallengeWindowClosed` if the window has elapsed. Emits `Disputed`.
90100

91101
### `finalize(id) -> bool`
92102

@@ -97,8 +107,8 @@ dispute. Returns the asserter's bond and returns the asserted outcome. Fails wit
97107
### `resolve(resolver, id, agrees_with_asserter) -> Option<bool>`
98108

99109
Casts one resolver's vote on a `Disputed` assertion. Requires `resolver`'s signature
100-
and that they're in the current committee. Fails with `NotAResolver`,
101-
`NotDisputed`, or `AlreadyVoted` as appropriate.
110+
and that they're in the current committee. Fails with `Paused` if paused,
111+
`NotAResolver`, `NotDisputed`, or `AlreadyVoted` as appropriate.
102112

103113
Returns `None` if no side has reached a strict majority yet. Once a majority agrees,
104114
the winning side (asserter if the majority agreed with them, disputer otherwise)
@@ -134,6 +144,7 @@ history without polling `get_assertion_state`:
134144
| `Finalized` | `finalize` | `id`, `outcome` |
135145
| `Resolved` | `resolve`, once a majority is reached | `id`, `outcome` |
136146
| `ResolversUpdated` | `update_resolvers` | `resolvers` (the new committee) |
147+
| `PauseUpdated` | `set_paused` | `paused` |
137148

138149
## Example: calling it with the Stellar CLI
139150

@@ -164,7 +175,6 @@ resolve.
164175
- No fee/reward mechanism for uncontested finalizes: the original design called for
165176
a small reward funded by market fees, but no fee-generating market layer exists
166177
yet, so `finalize` just returns the bond as-is.
167-
- No pause or emergency-stop.
168-
- `update_resolvers` is a single-admin-key operation, which is a bigger centralization
178+
- `set_paused` and `update_resolvers` are both single-admin-key operations, which is a bigger centralization
169179
point than the resolver committee itself. A resolver self-rotation scheme (the
170180
committee votes to replace one of its own) was considered but not built for v1.

INTEGRATION.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,5 +138,9 @@ event carries, not `get_assertion_state(id).outcome`.
138138
mechanism, so integrators who want to incentivize keepers to call `finalize`
139139
promptly need to handle that themselves (e.g. your own contract pays a small
140140
bounty to whoever triggers your callback).
141-
- No pause: if something goes wrong, there's no way to freeze in-flight assertions
142-
short of `update_resolvers` locking out the compromised committee.
141+
- The admin can pause `assert_outcome`, `dispute`, and `resolve` at any time via
142+
`set_paused`. Your integration should treat a `Paused` error as a distinct,
143+
expected failure mode (surface it to the user as "resolution temporarily
144+
unavailable") rather than an unexpected error. `finalize` and
145+
`update_resolvers` stay callable while paused, so assertions already `Pending`
146+
before a pause can still resolve uncontested.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ The assertion and dispute contract (`contracts/tholos`) is implemented, tested,
88

99
- Core propose/dispute/resolve flow: done
1010
- Admin-controlled resolver committee updates: done
11+
- Pause / emergency-stop: done
12+
- Reentrancy hardening (state written before external token transfers): done
1113
- CI (fmt, clippy, tests, wasm build): done
1214
- Fee-funded reward for uncontested finalizes: not yet (no fee-generating market layer exists to fund it)
13-
- Pause / emergency-stop: not yet
1415

1516
See [CONTRACT.md](CONTRACT.md) for the full interface and known gaps, or
1617
[INTEGRATION.md](INTEGRATION.md) if you're building a contract that wants to call

contracts/demo-consumer/test_snapshots/test/test_demo_consumer_can_assert_and_read_status_through_tholos.1.json

Lines changed: 23 additions & 11 deletions
Large diffs are not rendered by default.

contracts/tholos/src/lib.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ pub struct ResolversUpdated {
3838
pub resolvers: Vec<Address>,
3939
}
4040

41+
#[contractevent]
42+
pub struct PauseUpdated {
43+
pub paused: bool,
44+
}
45+
4146
#[contracttype]
4247
#[derive(Clone, Debug, Eq, PartialEq)]
4348
pub enum Status {
@@ -69,6 +74,7 @@ pub enum DataKey {
6974
Resolvers,
7075
Assertion(u64),
7176
NextId,
77+
Paused,
7278
}
7379

7480
#[contracterror]
@@ -84,6 +90,7 @@ pub enum Error {
8490
ChallengeWindowOpen = 8,
8591
NotAResolver = 9,
8692
AlreadyVoted = 10,
93+
Paused = 11,
8794
}
8895

8996
const DAY_IN_LEDGERS: u32 = 17280;
@@ -126,6 +133,7 @@ impl Tholos {
126133
.instance()
127134
.set(&DataKey::Resolvers, &resolvers);
128135
env.storage().instance().set(&DataKey::NextId, &0u64);
136+
env.storage().instance().set(&DataKey::Paused, &false);
129137
env.storage()
130138
.instance()
131139
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
@@ -135,7 +143,8 @@ impl Tholos {
135143

136144
/// Replaces the resolver committee. Only callable by the admin set at
137145
/// initialization. `new_resolvers` must have an odd length so a simple
138-
/// majority vote can never tie.
146+
/// majority vote can never tie. Callable even while paused, so a
147+
/// compromised committee can be replaced without waiting to unpause.
139148
pub fn update_resolvers(env: Env, new_resolvers: Vec<Address>) -> Result<(), Error> {
140149
let admin: Address = env
141150
.storage()
@@ -159,8 +168,39 @@ impl Tholos {
159168
Ok(())
160169
}
161170

171+
/// Pauses or unpauses new assertions, disputes, and resolver votes.
172+
/// Assertions already `Pending` can still be `finalize`d while paused,
173+
/// so existing uncontested claims aren't stuck waiting on an unpause.
174+
/// Only callable by the admin set at initialization.
175+
pub fn set_paused(env: Env, paused: bool) -> Result<(), Error> {
176+
let admin: Address = env
177+
.storage()
178+
.instance()
179+
.get(&DataKey::Admin)
180+
.ok_or(Error::NotInitialized)?;
181+
admin.require_auth();
182+
183+
env.storage().instance().set(&DataKey::Paused, &paused);
184+
PauseUpdated { paused }.publish(&env);
185+
186+
Ok(())
187+
}
188+
189+
fn require_not_paused(env: &Env) -> Result<(), Error> {
190+
let paused: bool = env
191+
.storage()
192+
.instance()
193+
.get(&DataKey::Paused)
194+
.ok_or(Error::NotInitialized)?;
195+
if paused {
196+
return Err(Error::Paused);
197+
}
198+
Ok(())
199+
}
200+
162201
/// Posts a bonded claim about an outcome. Returns the new assertion id.
163202
pub fn assert_outcome(env: Env, asserter: Address, outcome: bool) -> Result<u64, Error> {
203+
Self::require_not_paused(&env)?;
164204
asserter.require_auth();
165205

166206
let bond_amount: i128 = Self::get(&env, &DataKey::BondAmount);
@@ -204,6 +244,7 @@ impl Tholos {
204244

205245
/// Disputes a pending assertion within the challenge window by matching its bond.
206246
pub fn dispute(env: Env, disputer: Address, id: u64) -> Result<(), Error> {
247+
Self::require_not_paused(&env)?;
207248
disputer.require_auth();
208249

209250
let mut assertion = Self::get_assertion(&env, id)?;
@@ -284,6 +325,7 @@ impl Tholos {
284325
id: u64,
285326
agrees_with_asserter: bool,
286327
) -> Result<Option<bool>, Error> {
328+
Self::require_not_paused(&env)?;
287329
resolver.require_auth();
288330

289331
let resolvers: Vec<Address> = Self::get(&env, &DataKey::Resolvers);

contracts/tholos/src/test.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,62 @@ fn test_admin_can_update_resolvers() {
323323
assert_eq!(token.balance(&disputer), 1_100);
324324
}
325325

326+
#[test]
327+
fn test_paused_blocks_assert_dispute_and_resolve_but_not_finalize() {
328+
let env = Env::default();
329+
env.mock_all_auths();
330+
331+
let (_token_admin, token_id, token, resolvers) = setup(&env);
332+
let token_asset_client = token::StellarAssetClient::new(&env, &token_id);
333+
let contract_id = env.register(Tholos, ());
334+
let client = TholosClient::new(&env, &contract_id);
335+
336+
let admin = Address::generate(&env);
337+
let asserter = Address::generate(&env);
338+
let disputer = Address::generate(&env);
339+
token_asset_client.mint(&asserter, &1_000);
340+
token_asset_client.mint(&disputer, &1_000);
341+
client.initialize(&admin, &token_id, &100, &3600, &resolvers);
342+
343+
// An assertion posted before the pause can still finalize normally.
344+
let pending_id = client.assert_outcome(&asserter, &true);
345+
346+
client.set_paused(&true);
347+
348+
assert_eq!(
349+
client.try_assert_outcome(&asserter, &true),
350+
Err(Ok(Error::Paused))
351+
);
352+
assert_eq!(
353+
client.try_dispute(&disputer, &pending_id),
354+
Err(Ok(Error::Paused))
355+
);
356+
357+
env.ledger().with_mut(|l| l.timestamp += 3601);
358+
let outcome = client.finalize(&pending_id);
359+
assert!(outcome);
360+
assert_eq!(token.balance(&asserter), 1_000);
361+
362+
client.set_paused(&false);
363+
let id = client.assert_outcome(&asserter, &true);
364+
client.dispute(&disputer, &id);
365+
assert_eq!(
366+
client.try_resolve(&resolvers.get(0).unwrap(), &id, &true),
367+
Ok(Ok(None))
368+
);
369+
}
370+
371+
#[test]
372+
fn test_cannot_pause_before_initialization() {
373+
let env = Env::default();
374+
env.mock_all_auths();
375+
376+
let contract_id = env.register(Tholos, ());
377+
let client = TholosClient::new(&env, &contract_id);
378+
379+
assert_eq!(client.try_set_paused(&true), Err(Ok(Error::NotInitialized)));
380+
}
381+
326382
#[test]
327383
fn test_cannot_update_resolvers_to_even_count() {
328384
let env = Env::default();

contracts/tholos/test_snapshots/test/test_admin_can_update_resolvers.1.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,18 @@
544544
"u64": "1"
545545
}
546546
},
547+
{
548+
"key": {
549+
"vec": [
550+
{
551+
"symbol": "Paused"
552+
}
553+
]
554+
},
555+
"val": {
556+
"bool": false
557+
}
558+
},
547559
{
548560
"key": {
549561
"vec": [

contracts/tholos/test_snapshots/test/test_cannot_dispute_after_window_closes.1.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,18 @@
412412
"u64": "1"
413413
}
414414
},
415+
{
416+
"key": {
417+
"vec": [
418+
{
419+
"symbol": "Paused"
420+
}
421+
]
422+
},
423+
"val": {
424+
"bool": false
425+
}
426+
},
415427
{
416428
"key": {
417429
"vec": [

contracts/tholos/test_snapshots/test/test_cannot_dispute_an_already_disputed_assertion.1.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,18 @@
499499
"u64": "1"
500500
}
501501
},
502+
{
503+
"key": {
504+
"vec": [
505+
{
506+
"symbol": "Paused"
507+
}
508+
]
509+
},
510+
"val": {
511+
"bool": false
512+
}
513+
},
502514
{
503515
"key": {
504516
"vec": [

contracts/tholos/test_snapshots/test/test_cannot_finalize_before_window_closes.1.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,18 @@
370370
"u64": "1"
371371
}
372372
},
373+
{
374+
"key": {
375+
"vec": [
376+
{
377+
"symbol": "Paused"
378+
}
379+
]
380+
},
381+
"val": {
382+
"bool": false
383+
}
384+
},
373385
{
374386
"key": {
375387
"vec": [

0 commit comments

Comments
 (0)