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.
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-zero0x + hex(account num), left-padded to 40 chars — e.g. 0.0.9822626 → 0x000000000000000000000000000000000095ec22sourcethe `account` field of /accounts/{idOrAddress}, derived from 0.0.xusesafe as a *fallback* recipient only when the account has no alias; always accepted when verifying
- ECDSA alias0x + keccak-derived 20-byte address of the account's ECDSA keysourcethe `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.
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.
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.
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
Where these rules are proven
- → x402 verification — which consensus records and logs prove a 402 unlock
- → x402 paywall — dual HBAR / USDC settlement, verified from Transfer logs
- → Mandate Vault — alias resolution, preflight guards and decoded reverts end to end
- → AP2MandateVault 0.0.9864287 ↗ — source-verified on HashScan
- → mirror node /accounts/0.0.9822626 ↗ — see both address forms on one account
- → HTS system contract 0.0.359 ↗ — every token precompile call delegates here