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
128 changes: 109 additions & 19 deletions contracts/tholos/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ pub enum DataKey {
ChallengeWindow,
Resolvers,
Assertion(u64),
AssertionEscrow(u64),
NextId,
Paused,
/// Basis points (0–1000) of the bond paid to whoever calls `finalize` as
Expand Down Expand Up @@ -259,6 +260,8 @@ pub enum Error {
/// assertion's asserter or disputer. A party voting on their own case
/// biases (and, on a size-1 committee, determines) the outcome.
SelfVote = 27,
/// The token transfer did not result in the expected balance change.
TokenTransferMismatch = 28,
}

const DAY_IN_LEDGERS: u32 = 17280;
Expand Down Expand Up @@ -970,6 +973,29 @@ impl Tholos {
Ok(())
}

fn transfer_in(
env: &Env,
token_client: &token::Client,
from: &Address,
amount: i128,
) -> Result<i128, Error> {
let contract = env.current_contract_address();
let balance_before = token_client.balance(&contract);

token_client.transfer(from, &contract, &amount);

let balance_after = token_client.balance(&contract);
let received = balance_after
.checked_sub(balance_before)
.ok_or(Error::TokenTransferMismatch)?;

if received <= 0 {
return Err(Error::TokenTransferMismatch);
}

Ok(received)
}

/// Posts a bonded claim about an outcome. Returns the new assertion id.
pub fn assert_outcome(env: Env, asserter: Address, outcome: bool) -> Result<u64, Error> {
Self::require_not_paused(&env)?;
Expand Down Expand Up @@ -999,14 +1025,35 @@ impl Tholos {
finalizer: None,
};
Self::set_assertion(&env, id, &assertion);
// Initialize escrow before the external token transfer.
// This ensures a reentrant dispute accounts only for the amount
// actually received, rather than falling back to the nominal bond.
Self::set_assertion_escrow(&env, id, 0);

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 received = Self::transfer_in(&env, &token_client, &asserter, bond_amount)?;

// The token transfer is an external call and may re-enter Tholos.
// Reload the assertion so a reentrant dispute cannot be overwritten
// by the stale pre-transfer snapshot.
let current_assertion = Self::get_assertion(&env, id)?;

if current_assertion.status == Status::Pending
|| current_assertion.status == Status::Disputed
{
let mut updated_assertion = current_assertion.clone();
updated_assertion.bond = received;
Self::set_assertion(&env, id, &updated_assertion);
}

// Add this deposit to whatever escrow was already recorded.
// In a reentrant dispute, the disputer's deposit is already there.
let escrow = Self::get_assertion_escrow(&env, id, &current_assertion)
.checked_add(received)
.ok_or(Error::TokenTransferMismatch)?;

Self::set_assertion_escrow(&env, id, escrow);
Asserted {
id,
asserter,
Expand Down Expand Up @@ -1070,11 +1117,14 @@ 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 received = Self::transfer_in(&env, &token_client, &disputer, assertion.bond)?;

let escrow = Self::get_assertion_escrow(&env, id, &assertion)
.checked_add(received)
.ok_or(Error::TokenTransferMismatch)?;

Self::set_assertion_escrow(&env, id, escrow);

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

Expand Down Expand Up @@ -1115,9 +1165,14 @@ impl Tholos {
return Err(Error::ChallengeWindowOpen);
}

let escrow = Self::get_assertion_escrow(&env, id, &assertion);

let reward_bps: u32 = Self::get(&env, &DataKey::FinalizeRewardBps)?;
let reward = if reward_bps > 0 {
assertion.bond * (reward_bps as i128) / 10_000
escrow
.checked_mul(reward_bps as i128)
.ok_or(Error::TokenTransferMismatch)?
/ 10_000
} else {
0
};
Expand All @@ -1132,6 +1187,11 @@ impl Tholos {

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());

if escrow > contract_balance {
return Err(Error::TokenTransferMismatch);
}

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

let asserter_payout = assertion.bond - reward;
let asserter_payout = escrow - reward;
token_client.transfer(
&env.current_contract_address(),
&assertion.asserter,
Expand Down Expand Up @@ -1220,7 +1280,19 @@ impl Tholos {
return Ok(None);
};

let payout = assertion.bond * 2;
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 escrow = Self::get_assertion_escrow(&env, id, &assertion);

let payout = assertion
.bond
.checked_mul(2)
.ok_or(Error::TokenTransferMismatch)?
.min(escrow)
.min(contract_balance);

let winner = if winner_is_asserter {
assertion.asserter.clone()
} else {
Expand All @@ -1243,12 +1315,7 @@ impl Tholos {
assertion.final_outcome = Some(final_outcome);
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,
);
token_client.transfer(&env.current_contract_address(), &winner, &payout);
Resolved {
id,
outcome: final_outcome,
Expand Down Expand Up @@ -1283,6 +1350,29 @@ impl Tholos {
);
}

fn get_assertion_escrow(env: &Env, id: u64, assertion: &Assertion) -> i128 {
env.storage()
.persistent()
.get(&DataKey::AssertionEscrow(id))
.unwrap_or_else(|| {
if assertion.status == Status::Disputed {
assertion.bond.checked_mul(2).unwrap_or(assertion.bond)
} else {
assertion.bond
}
})
}

fn set_assertion_escrow(env: &Env, id: u64, amount: i128) {
let key = DataKey::AssertionEscrow(id);
env.storage().persistent().set(&key, &amount);
env.storage().persistent().extend_ttl(
&key,
ASSERTION_LIFETIME_THRESHOLD,
ASSERTION_BUMP_AMOUNT,
);
}

/// Renews instance storage TTL. Called from every state-changing
/// entrypoint besides `initialize` (which already extends TTL on the
/// same write that creates the instance entries), mirroring how
Expand Down
178 changes: 178 additions & 0 deletions contracts/tholos/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2122,6 +2122,184 @@ fn test_finalize_is_not_reentrant() {
assert_eq!(evil_token.balance(&asserter), 1_000);
}

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

// ---------------------------------------------------------------------------
// 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(0);

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

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

let to_balance = balances.get(to.clone()).unwrap_or(0);

balances.set(from, from_balance - amount);
balances.set(to, to_balance + 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>,
) {
let fee_token_id = env.register(fee_token::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 admin = Address::generate(env);
let contract_id = env.register(Tholos, (admin,));
let client = TholosClient::new(env, &contract_id);

client.initialize(
&fee_token_id,
&DEFAULT_BOND,
&DEFAULT_WINDOW,
&resolvers,
&0u32,
);

(fee_token, client, contract_id, resolvers)
}

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

let (fee_token, client, contract_id, _resolvers) = fee_fixture(&env, 1_000);

let asserter = 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);

// 100 bond requested -> 90 received after the 10% fee.
assert_eq!(assertion.bond, 90);
assert_eq!(fee_token.balance(&contract_id), 90);
assert_eq!(fee_token.balance(&asserter), 900);
}
#[test]
fn test_fee_on_transfer_dispute_uses_actual_deposit() {
let env = Env::default();
env.mock_all_auths();

let (fee_token, client, contract_id, _resolvers) = fee_fixture(&env, 1_000);

let asserter = Address::generate(&env);
let disputer = Address::generate(&env);

fee_token.credit(&asserter, &1_000);
fee_token.credit(&disputer, &1_000);

let id = client.assert_outcome(&asserter, &true);

assert_eq!(fee_token.balance(&contract_id), 90);

client.dispute(&disputer, &id);
// 100 bond -> 90 received.
// 90 dispute deposit -> 81 received.
// Total = 171.
assert_eq!(fee_token.balance(&contract_id), 171);
assert_eq!(fee_token.balance(&disputer), 910);
}
#[test]
fn test_fee_on_transfer_resolve_uses_available_balance() {
let env = Env::default();
env.mock_all_auths();

let (fee_token, client, contract_id, resolvers) = fee_fixture(&env, 1_000);

let asserter = Address::generate(&env);
let disputer = Address::generate(&env);

fee_token.credit(&asserter, &1_000);
fee_token.credit(&disputer, &1_000);

let id = client.assert_outcome(&asserter, &true);
client.dispute(&disputer, &id);

// The contract has 171 tokens:
// 100 -> 90 received
// 90 -> 81 received
assert_eq!(fee_token.balance(&contract_id), 171);

// Cast enough resolver votes to reach the majority.
client.resolve(&resolvers.get(0).unwrap(), &id, &true);
client.resolve(&resolvers.get(1).unwrap(), &id, &true);

// The payout is capped by the actual escrow (171), not the nominal
// 2 x bond (200): the contract sends its entire escrow to the winning
// asserter and is drained to exactly zero. The fee token also charges
// its fee on the outgoing transfer, so the asserter receives
// 171 - 17 = 154 on top of the 900 left after posting the bond.
assert_eq!(fee_token.balance(&contract_id), 0);
assert_eq!(fee_token.balance(&asserter), 900 + 154);
}
// ---------------------------------------------------------------------------
// Property-based tests for resolver vote counting and majority logic
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading