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
35 changes: 27 additions & 8 deletions contracts/tholos/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ pub struct StalledDisputeReclaimed {
pub asserter: Address,
/// The disputer whose bond was returned.
pub disputer: Address,
/// The per-side bond amount refunded to each party.
/// The amount refunded to the asserter. With a fee-on-transfer token
/// the disputer's refund differs (the remainder of the escrow); with
/// standard tokens both sides receive the full bond.
pub refunded: i128,
/// Who called `reclaim_stalled_dispute`. Always a verified address —
/// the call requires the caller's auth unconditionally.
Expand Down Expand Up @@ -938,22 +940,39 @@ impl Tholos {

let token_id: Address = Self::get(&env, &DataKey::Token)?;
let token_client = token::Client::new(&env, &token_id);
// Each side receives exactly the bond they posted, in the same
// transfer shape every other path uses. Two separate transfers,
// not one combined: the two recipients are unrelated parties and
// neither is owed the other's half.
// Each side receives exactly what they actually deposited, not the
// nominal bond. With a fee-on-transfer token the contract received
// less than the nominal amount for each deposit, so paying the raw
// `assertion.bond` to each side would either trap on insufficient
// balance (bricking this liveness fallback for exactly the disputes
// most in need of it) or overpay from other assertions' pooled
// funds (#207). Post-#164 accounting, `assertion.bond` records the
// asserter's actually-received amount and the per-assertion escrow
// holds both deposits, so the disputer's deposit is the remainder.
// Two separate transfers, not one combined: the two recipients are
// unrelated parties and neither is owed the other's half.
let escrow = Self::get_assertion_escrow(&env, id, &assertion);
let asserter_payout = assertion.bond.min(escrow);
let disputer_payout = (escrow - asserter_payout).max(0);
// Mirror `resolve` and `finalize`: never pay out more than the
// contract's live token balance, so a shortfall fails clean or
// pays what is actually available rather than trapping.
let mut available = token_client.balance(&env.current_contract_address());
let asserter_payout = asserter_payout.min(available);
available -= asserter_payout;
let disputer_payout = disputer_payout.min(available);
token_client.transfer(
&env.current_contract_address(),
&assertion.asserter,
&assertion.bond,
&asserter_payout,
);
token_client.transfer(&env.current_contract_address(), &disputer, &assertion.bond);
token_client.transfer(&env.current_contract_address(), &disputer, &disputer_payout);

StalledDisputeReclaimed {
id,
asserter: assertion.asserter.clone(),
disputer,
refunded: assertion.bond,
refunded: asserter_payout,
caller,
}
.publish(&env);
Expand Down
43 changes: 43 additions & 0 deletions contracts/tholos/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2821,4 +2821,47 @@ mod stalled_dispute {
client.reclaim_stalled_dispute(&trigger, &id);
assert_eq!(token.balance(&client.address), 0);
}

#[test]
fn test_reclaim_stalled_dispute_fee_on_transfer() {
// #207: with a fee-on-transfer token, reclaim must refund each side
// what they actually deposited (the escrow), not the nominal bond,
// and must not trap on insufficient balance.
let env = Env::default();
env.mock_all_auths();
env.ledger().with_mut(|l| l.timestamp = 1_000_000);

let (fee_token, client, contract_id, _resolvers) = super::fee_fixture(&env, 1_000);
client.set_stall_timeout(&3600);

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

// 100 bond -> 90 received; dispute transfers the recorded 90 -> 81
// received. Escrow = 171, less than the nominal 2 x 100.
assert_eq!(fee_token.balance(&contract_id), 171);

// Let it stall and reclaim.
env.ledger().with_mut(|l| l.timestamp += 3600);
let trigger = Address::generate(&env);
client.reclaim_stalled_dispute(&trigger, &id);

// Each side gets their actual deposit back, minus the fee on the
// outgoing transfer: asserter 90 - 9, disputer 81 - 8. The
// contract is drained to exactly zero rather than trapping on the
// nominal 200 it never held.
assert_eq!(fee_token.balance(&contract_id), 0);
assert_eq!(fee_token.balance(&asserter), 1_000 - 100 + 81);
assert_eq!(fee_token.balance(&disputer), 1_000 - 90 + 73);

// Terminal state, same as the standard-token path.
let state = client.get_assertion_state(&id);
assert_eq!(state.status, Status::Resolved);
assert_eq!(state.final_outcome, None);
}
}
Loading
Loading