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
76 changes: 46 additions & 30 deletions contracts/tholos/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,7 @@ impl Tholos {
// transfer can't be allocated the same not-yet-incremented id.
let id: u64 = Self::get(&env, &DataKey::NextId)?;
env.storage().instance().set(&DataKey::NextId, &(id + 1));
let assertion = Assertion {
let mut assertion = Assertion {
asserter: asserter.clone(),
final_outcome: None,
outcome,
Expand All @@ -659,11 +659,19 @@ impl Tholos {
Self::set_assertion(&env, id, &assertion);

let token_id: Address = Self::get(&env, &DataKey::Token)?;
token::Client::new(&env, &token_id).transfer(
&asserter,
env.current_contract_address(),
&bond_amount,
);
let token_client = token::Client::new(&env, &token_id);
let contract_address = env.current_contract_address();
let balance_before = token_client.balance(&contract_address);
token_client.transfer(&asserter, &contract_address, &bond_amount);
let balance_after = token_client.balance(&contract_address);
let received = balance_after.saturating_sub(balance_before);
if received <= 0 {
return Err(Error::InvalidBondAmount);
}
if received != bond_amount {
assertion.bond = received;
Self::set_assertion(&env, id, &assertion);
}

Asserted {
id,
Expand Down Expand Up @@ -714,11 +722,15 @@ impl Tholos {
Self::set_assertion(&env, id, &assertion);

let token_id: Address = Self::get(&env, &DataKey::Token)?;
token::Client::new(&env, &token_id).transfer(
&disputer,
env.current_contract_address(),
&assertion.bond,
);
let token_client = token::Client::new(&env, &token_id);
let contract_address = env.current_contract_address();
let balance_before = token_client.balance(&contract_address);
token_client.transfer(&disputer, &contract_address, &assertion.bond);
let balance_after = token_client.balance(&contract_address);
let received = balance_after.saturating_sub(balance_before);
if received <= 0 {
return Err(Error::InvalidBondAmount);
}

Disputed { id, disputer }.publish(&env);

Expand Down Expand Up @@ -759,13 +771,6 @@ impl Tholos {
return Err(Error::ChallengeWindowOpen);
}

let reward_bps: u32 = Self::get(&env, &DataKey::FinalizeRewardBps)?;
let reward = if reward_bps > 0 {
assertion.bond * (reward_bps as i128) / 10_000
} else {
0
};

// State is written before the external token transfers below so that
// a reentrant call from a non-standard token sees this assertion as
// already resolved, rather than still `Pending`.
Expand All @@ -774,8 +779,17 @@ impl Tholos {
assertion.finalizer = Some(caller.clone());
Self::set_assertion(&env, id, &assertion);

let reward_bps: u32 = Self::get(&env, &DataKey::FinalizeRewardBps)?;
let token_id: Address = Self::get(&env, &DataKey::Token)?;
let token_client = token::Client::new(&env, &token_id);
let contract_balance = token_client.balance(&env.current_contract_address());
let total_payout = assertion.bond.min(contract_balance);

let reward = if reward_bps > 0 {
total_payout * (reward_bps as i128) / 10_000
} else {
0
};

if reward > 0 {
// Pay the caller their reward first, then pay the asserter the
Expand All @@ -784,12 +798,14 @@ impl Tholos {
token_client.transfer(&env.current_contract_address(), &caller, &reward);
}

let asserter_payout = assertion.bond - reward;
token_client.transfer(
&env.current_contract_address(),
&assertion.asserter,
&asserter_payout,
);
let asserter_payout = total_payout - reward;
if asserter_payout > 0 {
token_client.transfer(
&env.current_contract_address(),
&assertion.asserter,
&asserter_payout,
);
}

Finalized {
id,
Expand Down Expand Up @@ -850,7 +866,6 @@ impl Tholos {
return Ok(None);
};

let payout = assertion.bond * 2;
let winner = if winner_is_asserter {
assertion.asserter.clone()
} else {
Expand All @@ -874,11 +889,12 @@ impl Tholos {
Self::set_assertion(&env, id, &assertion);

let token_id: Address = Self::get(&env, &DataKey::Token)?;
token::Client::new(&env, &token_id).transfer(
&env.current_contract_address(),
&winner,
&payout,
);
let token_client = token::Client::new(&env, &token_id);
let contract_balance = token_client.balance(&env.current_contract_address());
let payout = (assertion.bond.saturating_mul(2)).min(contract_balance);
if payout > 0 {
token_client.transfer(&env.current_contract_address(), &winner, &payout);
}
Resolved {
id,
outcome: final_outcome,
Expand Down
186 changes: 186 additions & 0 deletions contracts/tholos/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1821,6 +1821,192 @@ fn test_finalize_is_not_reentrant() {
assert_eq!(evil_token.balance(&asserter), 1_000);
}

// ---------------------------------------------------------------------------
// Fee-on-transfer token tests
// ---------------------------------------------------------------------------

mod fee_token {
use super::*;
use soroban_sdk::Map;

#[contracttype]
pub enum DataKey {
Balances,
FeeBps,
}

#[contract]
pub struct FeeToken;

#[contractimpl]
impl FeeToken {
pub fn set_fee_bps(env: Env, bps: u32) {
env.storage().instance().set(&DataKey::FeeBps, &bps);
}

pub fn credit(env: Env, addr: Address, amount: i128) {
let mut balances = Self::balances(&env);
let current = balances.get(addr.clone()).unwrap_or(0);
balances.set(addr, current + amount);
env.storage().instance().set(&DataKey::Balances, &balances);
}

pub fn balance(env: Env, addr: Address) -> i128 {
Self::balances(&env).get(addr).unwrap_or(0)
}

pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {
let fee_bps: u32 = env
.storage()
.instance()
.get(&DataKey::FeeBps)
.unwrap_or(1_000);

let fee = amount * (fee_bps as i128) / 10_000;
let received = amount - fee;

let mut balances = Self::balances(&env);
let from_bal = balances.get(from.clone()).unwrap_or(0);
assert!(from_bal >= amount, "insufficient balance");
let to_bal = balances.get(to.clone()).unwrap_or(0);

balances.set(from, from_bal - amount);
balances.set(to, to_bal + received);
env.storage().instance().set(&DataKey::Balances, &balances);
}

fn balances(env: &Env) -> Map<Address, i128> {
env.storage()
.instance()
.get(&DataKey::Balances)
.unwrap_or(Map::new(env))
}
}
}

fn fee_fixture(
env: &Env,
fee_bps: u32,
) -> (
fee_token::FeeTokenClient<'static>,
TholosClient<'static>,
Address,
Vec<Address>,
) {
use fee_token::FeeToken;

let fee_token_id = env.register(FeeToken, ());
let fee_token = fee_token::FeeTokenClient::new(env, &fee_token_id);
fee_token.set_fee_bps(&fee_bps);

let resolvers = Vec::from_array(
env,
[
Address::generate(env),
Address::generate(env),
Address::generate(env),
],
);
let contract_id = env.register(Tholos, ());
let client = TholosClient::new(env, &contract_id);

let admin = Address::generate(env);
client.initialize(
&admin,
&fee_token_id,
&DEFAULT_BOND,
&DEFAULT_WINDOW,
&resolvers,
&0u32,
);

(fee_token, client, contract_id, resolvers)
}

#[test]
fn test_fee_on_transfer_token_dispute_resolves_without_deadlock() {
let env = Env::default();
env.mock_all_auths();
let (fee_token, client, contract_id, resolvers) = fee_fixture(&env, 1_000); // 10% fee

let asserter = Address::generate(&env);
let disputer = Address::generate(&env);
fee_token.credit(&asserter, &1_000);
fee_token.credit(&disputer, &1_000);

// assert_outcome requests DEFAULT_BOND (100).
// With 10% fee, 90 arrives in the contract.
let id = client.assert_outcome(&asserter, &true);
let assertion = client.get_assertion_state(&id);
assert_eq!(assertion.bond, 90);
assert_eq!(fee_token.balance(&contract_id), 90);
assert_eq!(fee_token.balance(&asserter), 900);

// dispute requests assertion.bond (90).
// With 10% fee, 81 arrives in the contract.
// Total in contract is now 90 + 81 = 171.
client.dispute(&disputer, &id);
assert_eq!(fee_token.balance(&contract_id), 171);
assert_eq!(fee_token.balance(&disputer), 910);

// Resolvers vote to reach strict majority (2 out of 3).
client.resolve(&resolvers.get(0).unwrap(), &id, &false);
let outcome = client.resolve(&resolvers.get(1).unwrap(), &id, &false);
assert_eq!(outcome, Some(false));

// Payout was capped at available balance (171), not nominal 90 * 2 = 180,
// so it did not deadlock or panic.
// Disputer (winner) receives 171 - 10% fee = 171 - 17 = 154 tokens.
assert_eq!(fee_token.balance(&contract_id), 0);
assert_eq!(fee_token.balance(&disputer), 910 + 154);

let state = client.get_assertion_state(&id);
assert_eq!(state.status, Status::Resolved);
assert_eq!(state.final_outcome, Some(false));
}

#[test]
fn test_fee_on_transfer_token_finalize_resolves_without_deadlock() {
let env = Env::default();
env.mock_all_auths();
let (fee_token, client, contract_id, _resolvers) = fee_fixture(&env, 1_000); // 10% fee

let asserter = Address::generate(&env);
let caller = Address::generate(&env);
fee_token.credit(&asserter, &1_000);

let id = client.assert_outcome(&asserter, &true);
let assertion = client.get_assertion_state(&id);
assert_eq!(assertion.bond, 90);
assert_eq!(fee_token.balance(&contract_id), 90);

env.ledger().with_mut(|l| l.timestamp += DEFAULT_WINDOW + 1);

let outcome = client.finalize(&caller, &id);
assert!(outcome);

assert_eq!(fee_token.balance(&contract_id), 0);
// Asserter receives 90 - 10% fee = 81
assert_eq!(fee_token.balance(&asserter), 900 + 81);

let state = client.get_assertion_state(&id);
assert_eq!(state.status, Status::Resolved);
}

#[test]
fn test_zero_received_transfer_rejected() {
let env = Env::default();
env.mock_all_auths();
// 100% fee token: delivers 0 tokens on transfer
let (fee_token, client, _contract_id, _resolvers) = fee_fixture(&env, 10_000);

let asserter = Address::generate(&env);
fee_token.credit(&asserter, &1_000);

let result = client.try_assert_outcome(&asserter, &true);
assert_eq!(result, Err(Ok(Error::InvalidBondAmount)));
}

// ---------------------------------------------------------------------------
// Property-based tests for resolver vote counting and majority logic
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading