Hedera's EVM speaks tinybar, its RPC speaks weibar, and both mistakes return SUCCESS
DEV Community

Hedera's EVM speaks tinybar, its RPC speaks weibar, and both mistakes return SUCCESS

I lost most of a day to a bug that never once failed. I was building a contract that pays for its own future: an access subscription that re-arms itself on-chain through the Hedera Schedule Service, so an agent pays once and the window keeps renewing with nobody awake. The scheduling turned out to be the easy half. The hard half was that no test I owned could tell me whether the contract was moving the right amount of money.

Code: github.com/edycutjong/retainer ยท live: retainer.edycu.dev

The symptom that made no sense

I deployed the contract with 8 HBAR of constructor value. HashScan showed the contract holding 8 HBAR. The contract's own gasReserve read zero. Money plainly there, booked nowhere. A payable constructor that receives funds and records none of them is not, on its face, a units bug. It was the first thread.

Two possible answers, both silent

Hedera has two denominations for the same money:

  • tinybar - 1 HBAR = 1e8
  • weibar - 1 HBAR = 1e18, the shape Ethereum tooling expects

The question is where the boundary sits, and it mattered symmetrically:

  • If msg.value is weibar and I store tinybar, every payout needs a 1e10 multiply.
  • If msg.value is already tinybar, that same multiply overpays by ten orders of magnitude.

Both mistakes produce a SUCCESS receipt. Neither reverts. The receipts look identical. And nothing local can separate them, which is the part worth internalising: a Hardhat node uses Ethereum's units, so a green suite there is evidence about Ethereum, not about Hedera.

I picked the wrong one and wrote a docblock defending it

Here is what I committed, natspec and all:

/// @title Units - the sharpest edge on Hedera
/// @notice Hedera's EVM denominates `msg.value` and `address(this).balance` in **weibar**,
/// while the network's own accounting is in **tinybar**, at 1 tinybar = 1e10 weibar.
/// This contract stores every amount in tinybar and converts only at the EVM boundary:
/// `_toTinybar` on the way in, `_send` on the way out.

/// 1 tinybar = 1e10 weibar. The only place this ratio is written down.
uint256 private constant WEIBAR_PER_TINYBAR = 1e10;

function _toTinybar(uint256 weibar) private pure returns (uint256) {
    return weibar / WEIBAR_PER_TINYBAR;
}

/// @dev Outbound transfers must be denominated back in weibar, or they underpay by 1e10.
function _send(address to, uint256 tinybar) private {
    (bool ok, ) = payable(to).call{value: tinybar * WEIBAR_PER_TINYBAR}("");
    if (!ok) revert TransferFailed();
}

Confident, specific, wrong. The test suite passed. Of course it did.

The tell I wrote myself and didn't read

In the same commit, this:

uint256 amount = _toTinybar(weibarValue);
if (weibarValue == 0) revert NothingToFund();
// Below 1 tinybar the network cannot represent the value; crediting it would either
// mint balance from nothing or silently keep the dust.
if (amount == 0) revert DustAmount();

I invented an error for the case where a genuine deposit divides down to nothing. Read that back cold: a guard whose entire job is catching real money rounding to zero. That is not a safety rail, it is a bug leaving a footprint in the source. I shipped the footprint, documented it, and moved on. DustAmount does not exist in the contract any more. It never described a real condition.

Thirty lines that settled it

You cannot argue your way out of this. Both answers are internally coherent and both produce successful transactions, so the only move is to deploy something whose sole job is to report what it was actually handed:

contract UnitProbe {
    uint256 public constructorValue;
    uint256 public lastCallValue;

    constructor() payable {
        constructorValue = msg.value;
    }

    function ping() external payable {
        lastCallValue = msg.value;
    }

    function selfBalance() external view returns (uint256) {
        return address(this).balance;
    }

    /// @return ok whether a raw `call{value:}` of `amount` succeeds against this balance
    function trySend(address payable to, uint256 amount) external returns (bool ok) {
        (ok, ) = to.call{value: amount}("");
    }
}

Deployed to Hedera testnet, sent 2 HBAR - which is 2e18 on the wire:

msg.value == 200000000  // 2 HBAR in tinybar
address(this).balance == 200000000  // tinybar, not weibar
call{value: 1e8} moved exactly 1 HBAR

The relay converts at the edge. Inside the EVM, everything is already tinybar. So a contract on Hedera should do no conversion at all - it receives tinybar and it sends tinybar:

function _send(address to, uint256 tinybar) private {
    (bool ok, ) = payable(to).call{value: tinybar}("");
    if (!ok) revert TransferFailed();
}

The one-line rule I'd tattoo on the next project

On Hedera, convert once, in JavaScript, at the moment a value goes on the wire. The contract converts nothing.

The only 1e10 left anywhere is the TypeScript that puts a value on the wire - and its inverse refuses to guess:

/**
 * Read a weibar wire amount back as the tinybar the EVM will see.
 *
 * Throws rather than truncating on a value that is not a whole number of tinybar.
 * Silently rounding here would be the same class of bug as the 1e10 overpay:
 * wrong, and invisible.
 */
export function weibarToTinybar(weibar: bigint): bigint {
    if (weibar < 0n) throw new Error("weibar amount cannot be negative");
    if (weibar % WEIBAR_PER_TINYBAR !== 0n) {
        throw new Error(`${weibar} weibar is not a whole number of tinybar`);
    }
    return weibar / WEIBAR_PER_TINYBAR;
}

And the missing 8 HBAR? A second Hedera rule

That one wasn't units at all. Hedera credits a contract-create's initial balance at the HAPI level, outside the EVM frame, so a payable constructor genuinely sees msg.value == 0 even when the deploy carried value.

Two changes: the deploy script funds the gas reserve with an ordinary call, and the contract can adopt balance it holds but never booked:

function syncReserve() external onlyBeneficiary {
    uint256 booked = _owed + revenue + gasReserve;
    uint256 held = address(this).balance;
    if (held <= booked) revert NothingToFund();
    uint256 unbooked = held - booked;
    gasReserve += unbooked;
    emit GasReserveFunded(msg.sender, unbooked, gasReserve);
}

How do you test for a bug that looks fine?

Examples can't catch an off-by-1e10 that returns SUCCESS, because you'd have to already suspect it to pick the example. So the conversion is verified across a range instead:

  • 202,059 distinct amounts, three invariants each - 606,177 assertions
Band Amounts
Exhaustive, 0 โ€ฆ 100,001 exhaustive
Across the 1 HBAR seam 99,999,000 โ€ฆ 100,001,000
2,001 exhaustive, every decade edge 10^k โˆ’ 1, 10^k, 10^k + 1 for k โ‰ค 18
57 randomised over the full uint64 range (fast-check)
100,000 -

The invariants:

  1. The wire round trip tinybar โ†’ weibar โ†’ tinybar is the exact identity.
  2. The display round trip tinybar โ†’ "H.BBBBBBBB" โ†’ tinybar is the exact identity.
  3. No formatted amount ever carries more than 8 decimal places.

The two display functions are deliberately written as independent code paths - padStart + trim one way, split + padEnd the other - so their agreement is evidence rather than tautology.

And the regression tests are named for the defect, not the function, so the suite reads as a list of things that actually went wrong:

it("defect: the 1e10 conversion was applied inside the contract as well as on the wire, overpaying by ten orders of magnitude", ...)
it("defect: a weibar value that is not a whole number of tinybar was silently truncated instead of refused", ...)
it("defect: sub-tinybar input was accepted and rounded, inventing precision the chain cannot hold", ...)

258 tests across 14 files, 4.7 seconds.

What it was protecting, and what that costs

The thing underneath all of this: the contract schedules a call to renew() on itself through HIP-1215, the Schedule Service at system contract 0x16b. No server, no cron, no keeper bot.

Across 30 of 30 renewals it had armed, the network executed a median 64 ms after the exact second requested - p95 151 ms, never early, never later than 248 ms.

Then the number I did not expect. A renewal that also re-arms the next one charged 1.60263036 โ„. The final renewal, which does the same work but re-arms nothing, charged 0.05432076 โ„. That is a 29.5ร— gap, reproduced across three separate runs at two different period lengths: 96.6% of every unattended renewal is the contract buying its own next wakeup, not doing the work.

The reason is another rule that inverts EVM instinct. Hedera refunds at most 20% of an unused gas limit, so the bill is effectively max(gas used, 80% of the limit). The gas limit is not a ceiling you might touch - it is the floor of what you pay, which makes it a pricing decision rather than a safety margin. At 1 HBAR per period, the seller loses money on every unattended renewal. I measured that; I have not solved it.

Limitations, plainly

  • Testnet only. Mainnet fees and scheduling behaviour may differ; not measured.
  • Operator-seeded traffic, zero external users. Every renewal in that sample came from subscriptions I funded myself.
  • The latency figures are one location, one hour, strictly sequential - a latency measurement, not a load test. At n=30 a p95 sits near the second-largest sample: treat 151 ms as "the tail observed", not a guarantee.
  • One scheduled execution reverted on the contract's own solvency guard. It is counted in the drift sample, because dropping a call that showed up on time would flatter the number.

The transferable part

A bug with two failure modes that both return SUCCESS cannot be reasoned about from another chain's semantics, no matter how confident the docblock. The fix wasn't cleverness, it was thirty lines deployed to the real network to ask a question I'd been answering from memory.

Repo: github.com/edycutjong/retainer ยท live gate: retainer.edycu.dev.

The measurements are in DEMO.md, the units write-up in docs/hedera-units.md, and the gas arithmetic in docs/gas-economics.md.

If you're porting Solidity to Hedera and reading this before you hit it - deploy the probe.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.