Skip to content
Closed
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
1 change: 1 addition & 0 deletions contracts/asserter-consumer/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#![no_std]
#![allow(clippy::too_many_arguments)]

//! Second integration example: this contract's own address as the asserter,
//! demonstrating the "Your contract's own address as asserter" pattern from
Expand Down
11 changes: 10 additions & 1 deletion contracts/asserter-consumer/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,21 @@ fn test_asserter_consumer_can_assert_as_itself_through_tholos() {
3600u64,
resolvers.clone(),
0u32,
86400u64,
)
.into_val(&env),
sub_invokes: &[],
},
}]);
tholos_client.initialize(&admin, &token_id, &bond_amount, &3600, &resolvers, &0u32);
tholos_client.initialize(
&admin,
&token_id,
&bond_amount,
&3600,
&resolvers,
&0u32,
&86400u64,
);

let consumer_id = env.register(AsserterConsumer, ());
let consumer_client = AsserterConsumerClient::new(&env, &consumer_id);
Expand Down

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions contracts/demo-consumer/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#![no_std]
#![allow(clippy::too_many_arguments)]

//! Minimal example of a contract that calls into Tholos rather than building its
//! own dispute resolution logic. Exists to validate the pattern documented in
Expand Down
2 changes: 1 addition & 1 deletion contracts/demo-consumer/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ fn test_demo_consumer_can_assert_and_read_status_through_tholos() {
Address::generate(&env),
],
);
tholos_client.initialize(&admin, &token_id, &100, &3600, &resolvers, &0u32);
tholos_client.initialize(&admin, &token_id, &100, &3600, &resolvers, &0u32, &86400u64);

let consumer_id = env.register(DemoConsumer, ());
let consumer_client = DemoConsumerClient::new(&env, &consumer_id);
Expand Down

Large diffs are not rendered by default.

126 changes: 125 additions & 1 deletion contracts/tholos/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#![no_std]
#![allow(clippy::too_many_arguments)]

use soroban_sdk::{
contract, contracterror, contractevent, contractimpl, contracttype, token, Address, Env, Vec,
Expand Down Expand Up @@ -50,6 +51,20 @@ pub struct PauseUpdated {
pub paused: bool,
}

#[contractevent]
pub struct StalledDisputeReclaimed {
#[topic]
pub id: u64,
pub asserter: Address,
pub disputer: Address,
pub bond: i128,
}

#[contractevent]
pub struct StalledDisputeTimeoutUpdated {
pub stalled_dispute_timeout_secs: u64,
}

#[contractevent]
pub struct RotationProposed {
pub old_resolver: Address,
Expand Down Expand Up @@ -121,6 +136,9 @@ pub struct Assertion {
/// `Some` after `finalize` completes — the caller must authorize the call
/// unconditionally, so this is always a verified address.
pub finalizer: Option<Address>,
/// The ledger timestamp when this assertion was disputed via `dispute`.
/// `None` until disputed.
pub disputed_at: Option<u64>,
}

#[contracttype]
Expand All @@ -138,6 +156,7 @@ pub enum DataKey {
/// full bond is returned to the asserter (original behavior).
FinalizeRewardBps,
RotationProposal,
StalledDisputeTimeout,
}

#[contracterror]
Expand Down Expand Up @@ -171,6 +190,10 @@ pub enum Error {
/// slot without any economic risk (they receive both bonds back regardless
/// of the resolver vote), nullifying the bond-forfeiture deterrent.
SelfDispute = 22,
/// `stalled_dispute_timeout_secs` was 0 or exceeded `MAX_STALLED_DISPUTE_TIMEOUT_SECS`.
InvalidStalledDisputeTimeout = 23,
/// `reclaim_stalled_dispute` was called before the dispute timeout elapsed.
DisputeTimeoutNotElapsed = 24,
}

const DAY_IN_LEDGERS: u32 = 17280;
Expand All @@ -185,6 +208,7 @@ const INSTANCE_LIFETIME_THRESHOLD: u32 = INSTANCE_BUMP_AMOUNT - DAY_IN_LEDGERS;
const ASSERTION_BUMP_AMOUNT: u32 = 30 * DAY_IN_LEDGERS;
const ASSERTION_LIFETIME_THRESHOLD: u32 = ASSERTION_BUMP_AMOUNT - DAY_IN_LEDGERS;
const MAX_CHALLENGE_WINDOW_SECS: u64 = 7 * 24 * 60 * 60;
pub const MAX_STALLED_DISPUTE_TIMEOUT_SECS: u64 = 21 * 24 * 60 * 60;

/// A resolver committee larger than this gets copied in full onto every
/// disputed assertion (see `Assertion.resolvers`), so an unbounded size
Expand Down Expand Up @@ -243,7 +267,8 @@ impl Tholos {
/// fraction of the bond (in basis points, 0–1000) paid to whoever calls
/// `finalize` as an incentive for prompt finalization; 0 disables the
/// reward entirely and preserves the original behavior where the full
/// bond is returned to the asserter.
/// bond is returned to the asserter. `stalled_dispute_timeout_secs` sets the
/// duration after which a stalled dispute can be reclaimed neutrally.
pub fn initialize(
env: Env,
admin: Address,
Expand All @@ -252,6 +277,7 @@ impl Tholos {
challenge_window_secs: u64,
resolvers: Vec<Address>,
finalize_reward_bps: u32,
stalled_dispute_timeout_secs: u64,
) -> Result<(), Error> {
if env.storage().instance().has(&DataKey::Admin) {
return Err(Error::AlreadyInitialized);
Expand All @@ -272,6 +298,11 @@ impl Tholos {
if finalize_reward_bps > MAX_FINALIZE_REWARD_BPS {
return Err(Error::InvalidFinalizeReward);
}
if stalled_dispute_timeout_secs == 0
|| stalled_dispute_timeout_secs > MAX_STALLED_DISPUTE_TIMEOUT_SECS
{
return Err(Error::InvalidStalledDisputeTimeout);
}

admin.require_auth();

Expand All @@ -291,6 +322,10 @@ impl Tholos {
env.storage()
.instance()
.set(&DataKey::FinalizeRewardBps, &finalize_reward_bps);
env.storage().instance().set(
&DataKey::StalledDisputeTimeout,
&stalled_dispute_timeout_secs,
);
Self::touch_instance_ttl(&env);

Ok(())
Expand Down Expand Up @@ -565,6 +600,43 @@ impl Tholos {
Ok(())
}

/// Updates the stalled dispute timeout duration. Only callable by the
/// admin set at initialization.
pub fn set_stalled_dispute_timeout(
env: Env,
stalled_dispute_timeout_secs: u64,
) -> Result<(), Error> {
let admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.ok_or(Error::NotInitialized)?;
admin.require_auth();
Self::touch_instance_ttl(&env);

if stalled_dispute_timeout_secs == 0
|| stalled_dispute_timeout_secs > MAX_STALLED_DISPUTE_TIMEOUT_SECS
{
return Err(Error::InvalidStalledDisputeTimeout);
}

env.storage().instance().set(
&DataKey::StalledDisputeTimeout,
&stalled_dispute_timeout_secs,
);
StalledDisputeTimeoutUpdated {
stalled_dispute_timeout_secs,
}
.publish(&env);

Ok(())
}

/// Returns the currently configured stalled dispute timeout duration in seconds.
pub fn get_stalled_dispute_timeout(env: Env) -> Result<u64, Error> {
Self::get(&env, &DataKey::StalledDisputeTimeout)
}

fn require_not_paused(env: &Env) -> Result<(), Error> {
let paused: bool = env
.storage()
Expand Down Expand Up @@ -603,6 +675,7 @@ impl Tholos {
voted: Vec::new(&env),
resolvers: Vec::new(&env),
finalizer: None,
disputed_at: None,
};
Self::set_assertion(&env, id, &assertion);

Expand Down Expand Up @@ -659,6 +732,7 @@ impl Tholos {
// already disputed, rather than still `Pending`.
assertion.disputer = Some(disputer.clone());
assertion.status = Status::Disputed;
assertion.disputed_at = Some(env.ledger().timestamp());
Self::set_assertion(&env, id, &assertion);

let token_id: Address = Self::get(&env, &DataKey::Token)?;
Expand Down Expand Up @@ -836,6 +910,56 @@ impl Tholos {
Ok(Some(final_outcome))
}

/// Reclaims bonds for a stalled dispute once its timeout has elapsed
/// without reaching a resolver majority. Permissionless: callable by
/// anyone. Returns both the asserter's and disputer's bonds to their
/// respective depositors and marks the assertion `Status::Resolved` with
/// `final_outcome` set to `None`.
pub fn reclaim_stalled_dispute(env: Env, id: u64) -> Result<(), Error> {
Self::require_not_paused(&env)?;
Self::touch_instance_ttl(&env);

let mut assertion = Self::get_assertion(&env, id)?;
if assertion.status != Status::Disputed {
return Err(Error::NotDisputed);
}

let timeout: u64 = Self::get(&env, &DataKey::StalledDisputeTimeout)?;
let disputed_at = assertion.disputed_at.ok_or(Error::NotDisputed)?;
if env.ledger().timestamp() <= disputed_at + timeout {
return Err(Error::DisputeTimeoutNotElapsed);
}

let disputer = assertion
.disputer
.clone()
.expect("a Disputed assertion always has a disputer set by dispute()");

assertion.status = Status::Resolved;
assertion.final_outcome = None;
Self::set_assertion(&env, id, &assertion);

let token_id: Address = Self::get(&env, &DataKey::Token)?;
let token_client = token::Client::new(&env, &token_id);

token_client.transfer(
&env.current_contract_address(),
&assertion.asserter,
&assertion.bond,
);
token_client.transfer(&env.current_contract_address(), &disputer, &assertion.bond);

StalledDisputeReclaimed {
id,
asserter: assertion.asserter,
disputer,
bond: assertion.bond,
}
.publish(&env);

Ok(())
}

pub fn get_assertion_state(env: Env, id: u64) -> Result<Assertion, Error> {
Self::get_assertion(&env, id)
}
Expand Down
Loading