reference · evm ↔ native

The seam where EVM calls meet Hedera services.

Hedera exposes native token and consensus services through EVM precompiles. That seam is where almost every build breaks, and it breaks in three specific ways: the wrong address form, a gas limit copied from Ethereum, and a revert that carries no data at all. Each rule below cost us a failed transaction on testnet first.

01

Two address shapes, one account

Every Hedera account can be addressed two ways from the EVM, and both resolve to the same 0.0.x account. They are not interchangeable when you send value.

  • long-zero
    0x + hex(account num), left-padded to 40 chars — e.g. 0.0.9822626 → 0x000000000000000000000000000000000095ec22
    sourcethe `account` field of /accounts/{idOrAddress}, derived from 0.0.x
    usesafe as a *fallback* recipient only when the account has no alias; always accepted when verifying
  • ECDSA alias
    0x + keccak-derived 20-byte address of the account's ECDSA key
    sourcethe `evm_address` field of /accounts/{idOrAddress}
    usethe ONLY form you may pay for an HTS transfer when the account has one

the sending rule

An HTS transfer(address,uint256) sent to the long-zero form of an account that owns an alias reverts inside the token precompile with empty revert data. Always resolve the alias and pay that; fall back to long-zero only for accounts with no evm_address.

// resolve, then pay — never inline a payee EVM address
const a = await fetch(`${MIRROR}/accounts/${"0.0.9822626"}`).then(r => r.json());
const payee = a.evm_address ?? a.account;   // alias wins
await sendCall({ to: USDC_EVM, data: transfer(payee, amount), gas: 900_000n });

the verifying rule

The same record mixes both shapes: tx.from / tx.to use one, event topics the other. Comparing a single shape produces false invalid_payload rejections on payments that were actually fine. Collect both forms and match against the set, and un-pad 32-byte topics.

const forms = new Set([a.account, a.evm_address].filter(Boolean).map(s => s.toLowerCase()));
const topicAddr = `0x${topic.slice(-40)}`.toLowerCase();   // un-pad
const ok = forms.has(topicAddr);

Never inline a payee EVM address in config or in a cart/quote payload. Resolve it server-side where the cart is built (cache per account id), and re-resolve on the client from the 0.0.x id before signing, so a stale cart cannot reintroduce the bad form.

02

Realistic gas for relay calls

Ethereum intuition is wrong here. A plain HBAR transfer through the JSON-RPC relay is not a 21,000-gas operation, because the relay translates it into a native transaction.

HBAR transfer through the JSON-RPC relay~900,000
120k reverts on-chain with INSUFFICIENT_GAS while the client reports success
HTS transfer(address,uint256)~900,000
the token precompile delegates to 0.0.359; budget for it
Contract write (setMandate, registerCart, settle)~900,000
same relay overhead applies to every state-changing call
View / pure reads (eth_call)negligible
no gas limit tuning needed

The trap is silent: at 120,000 gas the client-side call returns a hash and your flow log says success, while the transaction failed on-chain. Only the explorer and the mirror node tell the truth. Treat a returned hash as "submitted", never as "settled" — poll /contracts/results/{hash} until you see a status, and link every hash to HashScan so the user can check you.

03

Never ship an empty revert

The worst failure has a recognisable fingerprint: empty error_message (0x), roughly 39,000 gas burned, and a DELEGATECALL to the HTS system contract 0.0.359. That is not a funding problem. It is address form, or a missing token association.

preflight, before anything is signed

  • → Is the recipient (and the payer) associated with the token? If not, render an Associate step.
  • → Does the payer hold the exact amount? Read the balance, don't assume.
  • → Has the payee alias been resolved from the mirror node in this session?
  • → Is the gas limit ~900,000?
  • → If any check fails, disable the button and print the reason inline. A refused click is cheaper and clearer than a reverted transaction.

decode the reverts that do carry data

Read revert_reason ?? error_message from /contracts/results/{hash}. If it starts with the ABI Error(string) selector 08c379a0, decode offset + length + UTF-8 body. Otherwise hex-decode it and keep the printable run — that is where Hedera puts its status strings. Then map each one to a single actionable sentence:

  • TOKEN_NOT_ASSOCIATED_TO_ACCOUNTAssociate USDC on this account before paying — HTS receivers must opt in.
  • INSUFFICIENT_TOKEN_BALANCETop up USDC from the Circle faucet using the 0.0.x Account ID, not the EVM address.
  • INSUFFICIENT_PAYER_BALANCETop up HBAR from the Hedera portal faucet — the payer cannot cover fees.
  • INSUFFICIENT_GASRaise the gas limit to ~900,000 and retry; nothing was charged to the recipient.
  • SPENDER_DOES_NOT_HAVE_ALLOWANCEApprove the spender for this token amount first.
  • INVALID_TOKEN_IDThe configured token id is wrong for this network — check the testnet token.

Never surface a bare "the transfer reverted". It is a dead end for the user, and on Hedera it is almost always one of six fixable states.

Symptom → cause → fix

Empty revert data (0x), ~39k gas, DELEGATECALL to 0.0.359
causePaid the long-zero form of an alias-bearing account, or the token is not associated
fixResolve evm_address and pay the alias; gate on association state first
Client logs success, HashScan shows the transaction failed
causeGas limit too low (e.g. 120k) on a relay transfer or contract write
fixSet ~900k gas and read the mirror-node result before declaring success
invalid_payload: declared payer is not the sender
causeCompared only one address form against the record
fixCollect both `account` and `evm_address` and match against the set
Verification says amount 0 for a USDC payment
causeRead the parent consensus record, whose token_transfers is empty
fixSum the Transfer logs on /contracts/results/{hash} instead

Where these rules are proven