In-Depth Analysis: The Truebit Incident

In-Depth Analysis: The Truebit Incident

On January 8, 2026, the Truebit Protocol on Ethereum was exploited, resulting in approximately $26 million in losses [1]. The root cause was an integer overflow in the TRU purchase pricing logic. Because the contract was compiled with Solidity v0.6.10, which does not enforce overflow checks by default, a large intermediate value in the purchase-cost computation wrapped around to a much smaller number. As a result, an attacker could purchase a very large amount of TRU for little or even zero ETH, then immediately sell the acquired TRU back to the contract for ETH at a favorable rate, draining protocol reserves.

0x0 Background

Truebit provides computation services for Ethereum via off-chain computation and interactive verification [2]. The protocol uses a native token, TRU, and exposes two public trading functions:

  • buyTRU() executes TRU purchases. The required ETH cost is computed by an internal pricing function that is also used by getPurchasePrice(), so getPurchasePrice() reflects the exact on-chain pricing logic applied during purchase execution.

  • sellTRU() executes TRU sales (redemptions). The expected ETH payout can be queried via getRetirePrice().

A key design aspect is pricing asymmetry:

  • Purchases use a convex bonding curve (marginal price increases as supply increases).
  • Sales use a linear redemption rule (proportional to reserves).

Because the contract source code is not public, the following analysis is based on decompiled bytecode.

Buy logic

The buyTRU() function (and the getPurchasePrice() function) delegates pricing to an internal function _getPurchasePrice() that calculates the ETH requried to purchase amount TRU.

function buyTRU(uint256 amount) public payable {
    require(msg.data.length - 4 >= 32);
    v0 = _getPurchasePrice(amount); // get the purchase price
    require(msg.value == v0, Error('ETH payment does not match TRU order'));
    v1 = 0x18ef(100 - _setParameters, msg.value);
    v2 = _SafeDiv(100, v1);
    v3 = _SafeAdd(v2, _reserve);
    _reserve = v3;
    require(bool(stor_97_0_19.code.size));
    v4 = stor_97_0_19.mint(msg.sender, amount).gas(msg.gas);
    require(bool(v4), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
    return msg.value;
}

function getPurchasePrice(uint256 amount) public nonPayable {
    require(msg.data.length - 4 >= 32);
    v0 = _getPurchasePrice(amount); // get the purchase price
    return v0;
}

function _getPurchasePrice(uint256 amount) private { 
    require(bool(stor_97_0_19.code.size));
    v0, /* uint256 */ v1 = stor_97_0_19.totalSupply().gas(msg.gas);
    require(bool(v0), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
    require(RETURNDATASIZE() >= 32);
    v2 = 0x18ef(v1, v1)
    v3 = 0x18ef(_setParameters, v2);
    v4 = 0x18ef(v1, v1);
    v5 = 0x18ef(100, v4);
    v6 = _SafeSub(v3, v5);// denominator = 100 * totalSupply**2 - _setParameters * totalSupply**2 
    v7 = 0x18ef(amount, _reserve);
    v8 = 0x18ef(v1, v7);
    v9 = 0x18ef(200, v8);// numerator_2 = 200 * totalSupply * amount * _reserve
    v10 = 0x18ef(amount, _reserve);
    v11 = 0x18ef(amount, v10);
    v12 = 0x18ef(100, v11);// numerator_1 = 100 * amount**2 * _reserve
    v13 = _SafeDiv(v6, v12 + v9); // purchasePrice = (numerator_1 + numerator_2) / denominator
    return v13;
}

From the decompiled logic, the purchase price can be expressed as a bonding-curve style function of:

Where,

  • amount: TRU to be purchased
  • reserve (_reserve): the contract's Ether reserves
  • totalSupply: the total supply of TRU
  • θ (_setParameters): a coefficient, fixed at 75

This curve is intended to make large purchases increasingly expensive (convex cost growth), discouraging speculation and reducing immediate buy-side manipulation.

Sell logic

The sellTRU() function (and the getRetirePrice() function) utilizes internal function _getRetirePrice() to calculate the ETH paid out when redeeming TRU.

function sellTRU(uint256 amount) public nonPayable {
    require(msg.data.length - 4 >= 32);
    require(bool(stor_97_0_19.code.size));
    v0, /* uint256 */ v1 = stor_97_0_19.allowance(msg.sender, address(this)).gas(msg.gas);
    require(bool(v0), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
    require(RETURNDATASIZE() >= 32);
    require(v1 >= amount, Error('Insufficient TRU allowance'));
    v2 = _getRetirePrice(amount); // get the retire price
    v3 = _SafeSub(v2, _reserve);
    _reserve = v3;
    require(bool(stor_97_0_19.code.size));
    v4, /* uint256 */ v5 = stor_97_0_19.transferFrom(msg.sender, address(this), amount).gas(msg.gas);
    require(bool(v4), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
    require(RETURNDATASIZE() >= 32);
    require(bool(stor_97_0_19.code.size));
    v6 = stor_97_0_19.burn(amount).gas(msg.gas);
    require(bool(v6), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
    v7 = msg.sender.call().value(v2).gas(!v2 * 2300);
    require(bool(v7), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
    return v2;
}

function getRetirePrice(uint256 amount) public nonPayable {
    require(msg.data.length - 4 >= 32);
    v0 = _getRetirePrice(amount); // get the retire price
    return v0;
}

function _getRetirePrice(uint256 amount) private { 
    require(bool(stor_97_0_19.code.size));
    v0, /* uint256 */ v1 = stor_97_0_19.totalSupply().gas(msg.gas);
    require(bool(v0), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
    require(RETURNDATASIZE() >= 32);
    v1 = v2.length;
    v3 = v2.data;
    v4 = 0x18ef(_reserve, amount);// numerator = _reserve * amount
    if (v1 > 0) {
        assert(v1);
        return v4 / v1;// retirePrice = numerator / totalSupply
    } else {
    // ...
}

The redemption rule is linear:

The retire price is proportional to the fraction of total supply being redeemed (i.e., amount / totalSupply) times the reserve.

This deliberate asymmetry creates a wide spread: buying is convex (expensive at scale), while selling is linear (redeems only a proportional share of reserves). Under normal conditions, that spread makes immediate buy→sell arbitrage unattractive.

0x1 Vulnerability Analysis

Despite the intended large buys are expensive design, _getPurchasePrice() contains an integer overflow in its arithmetic. Because the contract was compiled with Solidity 0.6.10, arithmetic operations on uint256 can silently overflow and wrap modulo 2^256 unless explicitly protected (e.g., via SafeMath).

function _getPurchasePrice(uint256 amount) private { 
    require(bool(stor_97_0_19.code.size));
    v0, /* uint256 */ v1 = stor_97_0_19.totalSupply().gas(msg.gas);
    require(bool(v0), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
    require(RETURNDATASIZE() >= 32);
    v2 = 0x18ef(v1, v1)
    v3 = 0x18ef(_setParameters, v2);
    v4 = 0x18ef(v1, v1);
    v5 = 0x18ef(100, v4);
    v6 = _SafeSub(v3, v5);// denominator = 100 * totalSupply**2 - _setParameters * totalSupply**2 
    v7 = 0x18ef(amount, _reserve);
    v8 = 0x18ef(v1, v7);
    v9 = 0x18ef(200, v8);// numerator_2 = 200 * totalSupply * amount * _reserve
    v10 = 0x18ef(amount, _reserve);
    v11 = 0x18ef(amount, v10);
    v12 = 0x18ef(100, v11);// numerator_1 = 100 * amount**2 * _reserve
    v13 = _SafeDiv(v6, v12 + v9); // purchasePrice = (numerator_1 + numerator_2) / denominator
    return v13;
}

In _getPurchasePrice(), a sufficiently large amount triggers an overflow during the addition of two large numerator terms (v12 + v9 in the decompiled snippet). When this overflow occurs, the numerator wraps to a small value, which causes the final division to return an artificially low purchase price, potentially zero.

Crucially, the overflow affects only the buy-side pricing. The sell-side function remains linear and behaves as intended, so an attacker can:

  • buy a large amount of TRU at an underpriced (or zero) cost, then
  • redeem it for ETH via sellTRU() at a much higher effective rate.

0x2 Attack Analysis

The attacker performed multiple rounds of arbitrage within a single transaction [3], repeating: getPurchasePrice() -> buyTRU() -> sellTRU()

First round: zero-cost purchase, then sell for profit

By supplying a carefully chosen purchase amount (240,442,509.453,545,333,947,284,131), the attacker triggered an overflow in _getPurchasePrice(), reducing the computed purchase price to 0 ETH and allowing acquisition of ~240 million TRU at no cost.

The below python code check illustrates that the numerator exceeds 2^256, and after wrapping, the computed purchase price becomes a tiny fractional value that truncates to zero when cast to an integer.

>>> _reserve = 0x1ceec1aef842e54d9ee
>>> totalSupply = 161753242367424992669183203
>>> amount = 240442509453545333947284131
>>> numerator = int(100 * amount * _reserve * (amount + 2 * totalSupply))
>>> numerator > 2**256
True
>>> denominator = (100 - 75) * totalSupply**2
>>> purchasePrice = (numerator - 2**256) / denominator
>>> purchasePrice
0.00025775798757211426
>>> int(purchasePrice)
0

The attacker then immediately called sellTRU(), redeeming the TRU for 5,105 ETH from protocol reserves.

Subsequent rounds: low-cost purchases, then sell for profit

The attacker repeated the cycle multiple times. Later purchases were not always strictly zero-cost, but overflow continued to keep purchase prices far below the corresponding sell returns.

Across these rounds, the attacker extracted substantial ETH, and our investigation suggests that additional zero-cost buys may still have been possible after the first round, though the reason the attacker opted for some non-zero-cost rounds is unclear.

Overall, the attacker drained 8,535 ETH from Truebit's reserves.

0x3 Summary

This incident was ultimately caused by an unchecked integer overflow in Truebit's buy-side pricing logic. Although the protocol's asymmetric buy/sell pricing model was intended to resist speculation, compiling with an older Solidity version (pre-0.8) without systematic overflow protection undermined the design and enabled reserve draining.

For any production contract still using Solidity versions below 0.8, developers should:

  • Apply overflow-safe arithmetic (e.g., SafeMath or equivalent checks) to every relevant operation, or
  • Preferably migrate to Solidity 0.8+ to benefit from default overflow checks.

Reference

[1] https://x.com/Truebitprotocol/status/2009328032813850839

[2] https://docs.truebit.io/v1docs

[3] Attack transasction

About BlockSec

BlockSec is a full-stack blockchain security and crypto compliance provider. We build products and services that help customers to perform code audit (including smart contracts, blockchain and wallets), intercept attacks in real time, analyze incidents, trace illicit funds, and meet AML/CFT obligations, across the full lifecycle of protocols and platforms.

BlockSec has published multiple blockchain security papers in prestigious conferences, reported several zero-day attacks of DeFi applications, blocked multiple hacks to rescue more than 20 million dollars, and secured billions of cryptocurrencies.

Sign up for the latest updates