Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions contracts/tholos/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ pub struct PauseUpdated {
pub paused: bool,
}

#[contractevent]
pub struct BondAmountUpdated {
pub bond_amount: i128,
}

#[contractevent]
pub struct RotationProposed {
pub old_resolver: Address,
Expand Down Expand Up @@ -103,6 +108,12 @@ pub struct Assertion {
/// the assertion is still pending or disputed.
pub final_outcome: Option<bool>,
pub outcome: bool,
/// The bond amount required to dispute this assertion and the amount
/// paid out to the winning side. Pinned to the live `DataKey::BondAmount`
/// at the moment `assert_outcome` created this assertion; a later
/// `set_bond_amount` call never changes it retroactively. Every payout
/// path (`dispute`, `finalize`, `resolve`) reads this field, never the
/// live `DataKey::BondAmount`, so this guarantee holds structurally.
pub bond: i128,
pub opened_at: u64,
pub status: Status,
Expand Down Expand Up @@ -565,6 +576,47 @@ impl Tholos {
Ok(())
}

/// Updates the bond amount required for assertions created from this
/// point on. Only callable by the admin set at initialization, validated
/// against the same bounds `initialize` already enforces
/// (`new_bond_amount > 0`, `new_bond_amount <= MAX_BOND_AMOUNT`).
/// Pause-exempt, like `update_resolvers` and `set_paused`.
///
/// This only affects assertions created after the change: `Assertion.bond`
/// pins the bond amount at the moment `assert_outcome` creates the
/// assertion, and every payout path (`dispute`, `finalize`, `resolve`)
/// reads `assertion.bond`, never the live `DataKey::BondAmount`. An
/// already-open assertion's payout is therefore unaffected by a later
/// `set_bond_amount` call.
///
/// Fails with `NotInitialized` if called before `initialize`, or
/// `InvalidBondAmount` if `new_bond_amount` is zero, negative, or greater
/// than `MAX_BOND_AMOUNT`.
pub fn set_bond_amount(env: Env, new_bond_amount: i128) -> Result<(), Error> {
let admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.ok_or(Error::NotInitialized)?;
admin.require_auth();
Self::touch_instance_ttl(&env);

if new_bond_amount <= 0 || new_bond_amount > MAX_BOND_AMOUNT {
return Err(Error::InvalidBondAmount);
}

env.storage()
.instance()
.set(&DataKey::BondAmount, &new_bond_amount);

BondAmountUpdated {
bond_amount: new_bond_amount,
}
.publish(&env);

Ok(())
}

fn require_not_paused(env: &Env) -> Result<(), Error> {
let paused: bool = env
.storage()
Expand Down
99 changes: 99 additions & 0 deletions contracts/tholos/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,105 @@ fn test_resolvers_updated_mid_dispute_do_not_affect_it() {
f.client.resolve(&f.resolvers.get(1).unwrap(), &id, &false);
assert_eq!(f.token.balance(&disputer), 1_100);
}
// ---------------------------------------------------------------------------
// set_bond_amount tests
// ---------------------------------------------------------------------------

/// Covers both the happy path and the regression the issue calls for: a bond
/// change never retroactively affects an assertion opened before it.
#[test]
fn test_admin_can_set_bond_amount_and_it_only_affects_future_assertions() {
let f = Fixture::new();
let asserter_a = f.funded_address();
let caller = f.generate();

// Assertion A opens under the original bond (100).
let id_a = f.client.assert_outcome(&asserter_a, &true);
assert_eq!(f.client.get_assertion_state(&id_a).bond, DEFAULT_BOND);

f.client.set_bond_amount(&200);

// Assertion B, opened after the change, uses the new bond.
let asserter_b = f.funded_address(); // funded with 1_000, plenty for 200
let id_b = f.client.assert_outcome(&asserter_b, &true);
assert_eq!(f.client.get_assertion_state(&id_b).bond, 200);

// Assertion A is untouched: still pinned at 100, and its payout reflects
// that, not the live (now 200) bond amount.
assert_eq!(f.client.get_assertion_state(&id_a).bond, DEFAULT_BOND);
f.advance_past_window();
f.client.finalize(&caller, &id_a);
assert_eq!(f.token.balance(&asserter_a), 1_000); // 900 + 100, not + 200
}

#[test]
fn test_set_bond_amount_requires_admin_auth() {
let env = Env::default();
env.mock_all_auths();

let (token_id, resolvers) = setup(&env);
let contract_id = env.register(Tholos, ());
let client = TholosClient::new(&env, &contract_id);
let admin = Address::generate(&env);
client.initialize(
&admin,
&token_id,
&DEFAULT_BOND,
&DEFAULT_WINDOW,
&resolvers,
&0u32,
);

client.set_bond_amount(&200);

// env.auths() returns every require_auth invocation from the last call;
// the admin's must appear, proving set_bond_amount is admin-gated.
let auths = env.auths();
let admin_was_authed = auths.iter().any(|(addr, _)| *addr == admin);
assert!(
admin_was_authed,
"admin's require_auth was not invoked during set_bond_amount"
);
}

#[test]
fn test_cannot_set_bond_amount_to_zero() {
let f = Fixture::new();
let result = f.client.try_set_bond_amount(&0);
assert_eq!(result, Err(Ok(Error::InvalidBondAmount)));
}

#[test]
fn test_cannot_set_bond_amount_negative() {
let f = Fixture::new();
let result = f.client.try_set_bond_amount(&-1);
assert_eq!(result, Err(Ok(Error::InvalidBondAmount)));
}

#[test]
fn test_cannot_set_bond_amount_above_max() {
let f = Fixture::new();
let result = f.client.try_set_bond_amount(&(MAX_BOND_AMOUNT + 1));
assert_eq!(result, Err(Ok(Error::InvalidBondAmount)));
}

#[test]
fn test_can_set_bond_amount_exactly_at_max() {
let f = Fixture::new();
let result = f.client.try_set_bond_amount(&MAX_BOND_AMOUNT);
assert_eq!(result, Ok(Ok(())));
}

#[test]
fn test_cannot_set_bond_amount_before_initialization() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(Tholos, ());
let client = TholosClient::new(&env, &contract_id);

let result = client.try_set_bond_amount(&DEFAULT_BOND);
assert_eq!(result, Err(Ok(Error::NotInitialized)));
}

#[test]
fn test_paused_blocks_assert_dispute_and_finalize() {
Expand Down
16 changes: 15 additions & 1 deletion docs/src/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ State of an assertion: `Pending`, `Disputed`, or `Resolved`.
| `asserter` | `Address` | Who posted the claim |
| `outcome` | `bool` | The claimed outcome |
| `final_outcome` | `Option<bool>` | The authoritative resolved outcome; `None` until the assertion reaches `Resolved` |
| `bond` | `i128` | Bond amount posted (in the configured token) |
| `bond` | `i128` | Bond amount posted (in the configured token), pinned at the moment `assert_outcome` created the assertion; a later `set_bond_amount` call never changes it retroactively |
| `opened_at` | `u64` | Ledger timestamp the assertion was posted |
| `status` | `Status` | Current state |
| `disputer` | `Option<Address>` | Who disputed it, if disputed |
Expand Down Expand Up @@ -98,6 +98,19 @@ present), so a committee-driven rotation can never execute against a committee i
wasn't built for. Day-to-day committee changes go through `propose_rotation` /
`vote_rotation` instead.

### `set_bond_amount(new_bond_amount)`

Updates the bond amount required for assertions created *after* this call. Requires
the stored admin's signature. Same bounds as `initialize`: `new_bond_amount` must be
positive and no greater than `MAX_BOND_AMOUNT`. Pause-exempt, like `update_resolvers`
and `set_paused`. Emits `BondAmountUpdated`.

Has no effect on assertions already open: `Assertion.bond` pins the bond amount at
the moment `assert_outcome` created the assertion, and every payout path (`dispute`,
`finalize`, `resolve`) reads that field, never the live bond amount. Fails with
`InvalidBondAmount` if `new_bond_amount` is zero, negative, or above
`MAX_BOND_AMOUNT`, or `NotInitialized` if called before `initialize`.

### `propose_rotation(resolver, old_resolver, new_resolver)`

Proposes a single-slot committee rotation: remove `old_resolver` (must be a current
Expand Down Expand Up @@ -246,6 +259,7 @@ history without polling `get_assertion_state`:
| `Resolved` | `resolve`, once a majority is reached | `id`, `outcome` |
| `ResolversUpdated` | `update_resolvers`, `vote_rotation` (on execution) | `resolvers` (the new committee) |
| `PauseUpdated` | `set_paused` | `paused` |
| `BondAmountUpdated` | `set_bond_amount` | `bond_amount` (the new value) |
| `RotationProposed` | `propose_rotation` | `old_resolver`, `new_resolver`, `proposed_by` |
| `RotationExecuted` | `vote_rotation`, once a majority is reached | `old_resolver`, `new_resolver` |
| `RotationCancelled` | `vote_rotation` (deadlock auto-cancel), `cancel_rotation`, `update_resolvers` (admin override) | `old_resolver`, `new_resolver` |
Expand Down
Loading
Loading