fix(tholos): verify balance deltas for incoming token transfers (#164) - #180
fix(tholos): verify balance deltas for incoming token transfers (#164)#180xtep103 wants to merge 9 commits into
Conversation
629f37c to
b0b0fab
Compare
collinsezedike
left a comment
There was a problem hiding this comment.
Worth a separate follow-up issue, not blocking this one: contracts/tholos-v2 has no equivalent balance-delta check at all, so it remains exposed to the same fee-on-transfer deadlock this PR fixes only in v1.
| ); | ||
| 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); |
There was a problem hiding this comment.
This caps payout against token_client.balance(&env.current_contract_address()), the whole contract's pooled balance across every pending assertion, not a per-assertion tracked amount. If a fee-on-transfer token shorts one assertion's transfer, that assertion's payout can still pay out its full nominal amount by drawing on a completely separate, unrelated assertion's escrowed bond, and when that second assertion later resolves or finalizes, it comes up short by exactly that amount. Capping against the total contract balance only prevents the contract from ever attempting to pay out more than it holds in aggregate, it doesn't stop one assertion's shortfall from being silently subsidized by another's escrow. Consider tracking and capping against this specific assertion's own received balance instead of the contract-wide total.
| ); | ||
| let token_client = token::Client::new(&env, &token_id); | ||
| let contract_address = env.current_contract_address(); | ||
| let balance_before = token_client.balance(&contract_address); |
There was a problem hiding this comment.
This derives the received amount from a balance snapshot taken before the external transfer, but v1 has no contract-wide reentrancy mutex, unlike tholos-v2's ReentrancyGuard. A token whose transfer reenters a different entrypoint for a different assertion id before returning (both callers can be attacker-controlled via Soroban's sub-invocation auth tree, no third-party collusion needed) inflates the contract balance before this call reads balance_after, so received here counts tokens that actually belong to the nested assertion's escrow, corrupting both assertions' bookkeeping. Consider adding the same reentrancy guard v2 already has.
| ); | ||
| let token_client = token::Client::new(&env, &token_id); | ||
| let contract_address = env.current_contract_address(); | ||
| let balance_before = token_client.balance(&contract_address); |
There was a problem hiding this comment.
This balance-before/transfer/balance-after/zero-check block is duplicated near-verbatim from assert_outcome above, differing only in the sender address and what happens with the result. A future fix to this pattern, handling a negative received differently, or adding a minimum-received threshold, has to be applied in two places. Consider extracting a shared helper.
| .bond | ||
| .saturating_mul(2) | ||
| .min(escrow) | ||
| .min(contract_balance); |
There was a problem hiding this comment.
This capped-payout computation is duplicated between finalize (above, around line 877) and here, with only the bond multiplier differing. A change to the capping rule has to be made twice, and the two copies have already drifted slightly. Consider a shared helper.
| .ok_or(Error::AssertionNotFound) | ||
| } | ||
|
|
||
| fn get_assertion_escrow(env: &Env, id: u64, assertion: &Assertion) -> i128 { |
There was a problem hiding this comment.
This fallback assumes both bonds are already in the contract, but dispute calls it before the disputer's bond has been added. For any assertion missing an AssertionEscrow entry (pre-upgrade data with no such record yet), this returns bond2 as if the disputer already paid, then dispute's checked_add adds the disputer's actual payment on top, storing an escrow of bond2 + received instead of bond + received. Only the separate contract_balance cap elsewhere currently prevents this from being paid out, so the bug is masked rather than fixed.
|
CI is failing on the sdk job: packages/tholos-sdk/src is out of date with the new Error variant this PR adds. Regenerate the bindings (see packages/tholos-sdk/README.md) and commit the result. |
|
This PR now has a merge conflict with main. Please rebase and resolve it. The SDK bindings fix from the last commit looks correct, this is purely about the conflict. |
collinsezedike
left a comment
There was a problem hiding this comment.
This PR adds an AssertionEscrow tracking mechanism used by finalize and resolve, but two paths this diff touches don't consistently apply it, both inline. Separately, reclaim_stalled_dispute (not touched by this diff, pre-existing from #184) has the same class of gap, filing that as its own issue rather than blocking this PR on it.
| return Err(Error::InvalidBondAmount); | ||
| } | ||
| if received != bond_amount { | ||
| assertion.bond = received; |
There was a problem hiding this comment.
This correction write uses the original pre-transfer in-memory assertion struct, so it silently discards any state a reentrant call made during the transfer. With a token that's both fee-on-transfer and reentrant, a reentrant callback can call dispute(id) on this still-Pending assertion before the outer call resumes. dispute() succeeds and moves status to Disputed. When assert_outcome resumes and finds received != bond_amount, assertion.bond = received; Self::set_assertion(...) runs against the stale pre-transfer local (status: Pending, disputer: None), clobbering the reentrant dispute back to Pending while the disputer's tokens stay transferred in with no dispute record, stuck and unaccounted for. The existing evil_token reentrancy tests don't catch this because EvilToken's transfer always delivers the exact requested amount, so received != bond_amount is never true there.
| .ok_or(Error::AssertionNotFound) | ||
| } | ||
|
|
||
| fn get_assertion_escrow(env: &Env, id: u64, assertion: &Assertion) -> i128 { |
There was a problem hiding this comment.
This fallback for an assertion with no stored AssertionEscrow assumes a full 2x bond was already escrowed. At the point dispute() calls this, only the asserter's single deposit has actually happened, so for a pre-migration assertion this overstates true escrow by a full bond. Concretely: a pre-upgrade Pending assertion (no AssertionEscrow(id) entry) gets disputed post-upgrade with a 10% fee-on-transfer token. dispute() computes new escrow as this fallback (bond2, e.g. 200) plus the disputer's actually-received amount (e.g. 90), storing escrow=290, when the contract's real funds tied to this id are only about 180. resolve()'s min(bond2, escrow, contract_balance) can then reach 200 if other assertions' pooled balance covers it, paying this assertion 20 more than it actually escrowed out of other assertions' funds, the exact cross-assertion drain AssertionEscrow was added to prevent.
|
@xtep103 Checking in, any progress on the two open findings? Let us know if you're still working it or need to hand it off. |
I'm still working on it. |
Overview
Verifies balance deltas for incoming token transfers during assertion creation (
assert_outcome) and disputes (dispute) incontracts/tholos, recording actual received tokens and protectingresolveandfinalizepayouts against deadlocks caused by fee-on-transfer tokens.Related Issue
Closes #164
Changes
contracts/tholos
contracts/tholos/src/lib.rsassert_outcomeanddispute.assertion.bondin persistent storage. Rejects zero/negative received amounts withError::InvalidBondAmount.finalize(assertion.bond.min(contract_balance)) andresolve((assertion.bond.saturating_mul(2)).min(contract_balance)) to prevent transfer failure panics when contract balances are constrained by transfer deductions.contracts/tholos/src/test.rsFeeTokentest fixture simulating configurable fee-on-transfer mechanics.test_fee_on_transfer_token_dispute_resolves_without_deadlockconfirming assertions with fee tokens can be disputed and resolved without balance underflow.test_fee_on_transfer_token_finalize_resolves_without_deadlockconfirming undisputed assertions with fee tokens finalize successfully.test_zero_received_transfer_rejectedensuring 100% fee transfers that deliver 0 tokens are rejected.Verification Results
assert_outcome&disputeError::InvalidBondAmountresolve/finalizeclamped to available balance to prevent deadlock