| name | lovable-hedera |
| description | Build Hedera testnet demos on Lovable — Magic email wallets, x402 paid endpoints settled in HBAR or HTS USDC, mirror-node verification, HCS timestamping, and bespoke Solidity (mandate vault) with A2A/AP2/UCP agent commerce and RFC 9421 signing. |
Lovable · Hedera
Ship end-to-end Hedera testnet demos: email sign-in mints an embedded wallet,
the user pays 0.01 HBAR or 0.01 HTS USDC to unlock a metered endpoint, and a
server-side facilitator verifies real consensus on the mirror node. Native
HTS/HCS services cover that — no smart contract deploy is required. When the
demo needs on-chain state no native service models (spending mandates, cart
authorisation), deploy a small bespoke contract through the relay; see
"Bespoke contracts" below.
Network facts (Hedera testnet)
| value |
|---|
| CAIP-2 | hedera:testnet |
| EVM chain id | 296 (0x128) |
| JSON-RPC relay | https://testnet.hashio.io/api |
| Mirror node | https://testnet.mirrornode.hedera.com/api/v1 |
| Explorer | https://hashscan.io/testnet |
| HBAR faucet | https://portal.hedera.com/faucet |
| USDC faucet | https://faucet.circle.com/ (choose Hedera Testnet) |
| USDC | token id 0.0.429274 · EVM 0x0000000000000000000000000000000000068cda · 6 decimals |
| HTS system contract | 0.0.359 (every token precompile call delegates here) |
| HBAR decimals | 8 (tinybars); the relay speaks weibars (18) |
Keep all of it in src/data/hedera.json and src/data/x402.json. Never inline
a relay URL, token id, or payee address in a component.
Non-obvious rules (each one cost real debugging)
- Magic's Content Security Policy gates every RPC host. Any RPC URL not
added under Magic Dashboard → Settings → Content Security Policy is blocked
inside the wallet iframe and surfaces as
Magic RPC Error: [-32603] Failed to fetch. No code change bypasses it — add https://testnet.hashio.io plus
your site origins. Corollary: pin the transport to the absolute Hashio
URL. A same-origin proxy path (/api/public/hedera-rpc) is unreachable from
inside the iframe even though it works for app-side reads.
- Rebuild the Magic instance when the RPC URL changes. A cached SDK
instance keeps its original transport, so a corrected URL never takes effect
and you keep seeing the stale
Failed to fetch. Rebuild the Magic client and
the viem client together, keyed on the URL.
- HBAR transfers and contract writes via the relay need ~900k gas. 120k
reverts on-chain with
INSUFFICIENT_GAS while the client-side call reports
success — the flow log lies and only HashScan shows the truth.
- Verify ERC-20 USDC from the
Transfer log, not consensus transfers.
/contracts/results/{hash} has no transaction_id, and the parent
consensus record's token_transfers is empty — the HTS movement lands on a
child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash. The
authoritative signal ships with the EVM record:
logs[i].address = USDC EVM address
topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
topics[1] = payer (32-byte padded)
topics[2] = payee (32-byte padded)
data = amount (atomic, 6 decimals — 0x2710 = 10000 = 0.01 USDC)
Un-pad topics with 0x${topic.slice(-40)} and sum BigInt(log.data).
- Every account has two address shapes, and the shape decides success.
The long-zero form (
0x + hex account num, left-padded to 40) and the ECDSA
evm_address alias.
- Verifying:
tx.from/tx.to use one shape, event topics the other —
comparing a single shape produces false invalid_payload rejections.
Collect both from /accounts/{idOrAddress} (account → long-zero,
evm_address → alias) and match against the set.
Never say "reverted" — decode it
A bare "the transfer reverted" is a dead end for the user. Read
/contracts/results/{hash} and decode revert_reason ?? error_message:
- ABI
Error(string) selector 08c379a0 → offset+length+UTF-8 body;
- otherwise hex-decode to ASCII and keep the printable run — Hedera puts status
strings there (
TOKEN_NOT_ASSOCIATED_TO_ACCOUNT, INSUFFICIENT_TOKEN_BALANCE,
INSUFFICIENT_GAS, SPENDER_DOES_NOT_HAVE_ALLOWANCE, INVALID_TOKEN_ID,
INSUFFICIENT_PAYER_BALANCE);
- map each to one actionable sentence (associate USDC, top up from this faucet
with this Account ID, retry — gas raised);
- empty revert data + ~39k gas + a
DELEGATECALL to 0.0.359 is not a
funding problem: it is address form (rule 5) or a missing association. Say so,
and print the payee address you actually resolved.
Preflight before signing. Check association state and the balance for the
exact amount, disable the pay button, and render the reason inline. A refused
click is cheaper and clearer than a reverted transaction.
x402 profile on Hedera
Hedera has no public x402 facilitator, so self-host both halves in one server
route: src/routes/api/public/x402-paid-content.ts returns the challenge and
also verifies settlement.
Challenge (HTTP 402):
{ "x402Version": 2,
"accepts": [
{ "scheme": "exact", "network": "hedera:testnet",
"asset": "HBAR", "amount": "1000000", "payTo": "0.0.9822626" },
{ "scheme": "exact", "network": "hedera:testnet",
"asset": "0.0.429274", "amount": "10000", "payTo": "0.0.9822626" }
] }
HBAR amounts are tinybars (8 decimals); USDC amounts are atomic (6).
Headers: send PAYMENT-SIGNATURE (base64 envelope naming the chosen
accepts[] entry plus the settlement tx hash / transaction id), read
PAYMENT-RESPONSE back with the HashScan-linkable hash.
Order matters — settle, await consensus, then retry. Submit the transfer,
poll the mirror node until result === "SUCCESS", and only then retry the
resource with PAYMENT-SIGNATURE. Retrying immediately produces
settlement_not_found even though the payment is fine.
Verification checklist, per asset:
- record exists (with retries) and
result/status is success (SUCCESS / 0x1)
- consensus timestamp inside the validity window (guard both directions)
- HBAR:
tx.to ∈ payee forms, tx.from ∈ payer forms, tx.amount ≥ tinybars
- USDC:
tx.to is the USDC contract, then sum matching Transfer logs (rule 4)
- return descriptive reasons (
invalid_payload: expected … saw …) — the flow log
is the only diagnostic the user has
Bespoke contracts (the mandate vault pattern)
When agents spend on a human's behalf, the authorisation itself is on-chain
state: a small Solidity vault holds per-agent mandates (per-tx cap, daily cap,
expiry, revocation) and binds settled carts.
setMandate / revoke / registerCart(mandateId, cartHash, amount) /
settle(cartId, settlementRef) + views (mandateOf, cartOf, isSettled,
spentToday). No dependencies, no proxy.
- Authorise before money moves.
registerCart reverts when over cap,
expired, or revoked — so the vault says no before any transfer is signed.
Only after a mirror-node-verified payment does the server call settle(),
binding the tx hash to the cart.
- Browser writes go through the Magic wallet at ~900k gas (rule 3), with the
absolute Hashio relay URL pinned (rule 1).
- The seller's
settle() is signed server-side by the deployer key and never
touches the browser.
HEDERA_EVM_PRIVATE_KEY must be ECDSA hex, funded with testnet HBAR — an
ED25519 portal key cannot sign EVM transactions.
- Decode custom contract errors into human reasons the same way as HTS reverts
("the mandate's daily cap is exhausted", not "execution reverted").
- Boot without the address: with no deployed vault, run a clearly labelled
simulated mode instead of throwing.
Verifying source on HashScan
HashScan reads Sourcify, and server-verify.hashscan.io now redirects there;
Sourcify's v1 API is in a scheduled brownout, so talk to v2:
GET /v2/contract/296/{address} for status, POST /v2/verify/296/{address}
with { stdJsonInput, compilerVersion, contractIdentifier }, then poll
GET /v2/verify/{verificationId} until isJobCompleted.
- You must reproduce the exact compile: pin the solc version and optimizer
runs and diff against the deployed bytecode (brute-force a small matrix of
runs if the settings weren't recorded). Editing the source at all — even a
comment — changes the metadata hash and kills an exact match.
- Aim for
exact_match; then link the Source tab from the demo UI.
A2A / AP2 / UCP on Hedera
- A2A 0.3 is the transport: JSON-RPC
message/send and tasks/get, agent
cards at /.well-known/agent-card.json, task states
submitted → working → input-required → completed | rejected | failed.
- AP2 is the payload:
IntentMandate (buyer), CartMandate (seller-signed
line items), PaymentMandate (buyer, carries the settlement hash), sent as
typed DataParts with application/vnd.ap2.mandate.*+json MIME types.
- UCP is the fixed-price collapse:
/api/public/ucp/discovery +
/api/public/ucp/checkout over the same catalogue, one signed round trip.
Both paths must produce identical on-chain artifacts.
- x402 stays the settlement rail — reuse the facilitator and its
Transfer-log USDC verification unchanged.
RFC 9421 signing on UCP (both directions):
- Sign every response with a server-side Ed25519 key (
UCP_SIGNING_KEY),
emitting Signature-Input + Signature; cover @method, @path,
@authority, content-digest, ucp-version, with created, expires,
nonce, keyid. Emit Content-Digest (sha-256) on every body.
- Publish public keys as a
signing_keys[] JWK list at
/api/public/ucp/signing-keys; the client caches by keyid.
- Reject, don't warn: bad signature, stale
created, expired window, or a
replayed nonce. Verify inbound payment-recording requests the same way, and
demo a deliberate tamper case failing.
- Keys are static per deployment. No rotation service.
Agent brain. The buyer agent's accept/reject is a real LLM step with typed
tools (get_hbar_balance, get_token_balance, transfer_hbar,
transfer_token, submit_topic_message, plus read_mandate / register_cart),
following the Agent Kit pattern — implemented directly against the relay and
mirror node, because the Agent Kit package is Node-only and won't bundle for the
edge runtime. Its reasoning renders in the flow log next to the on-chain proofs.
When the LLM key is absent, fall back to a deterministic cap check so the demo
still runs.
File layout that works
src/
data/
hedera.json network, relay, mirror, faucets, USDC, payTo, topicId, Magic key
x402.json challenge amounts + asset config for both assets
ap2.json vault address + ABI, mandate defaults, MIME types, agent cards
lib/
magic.ts Magic SDK + viem client, rebuilt on RPC change
x402.ts client helpers (payee resolution, awaitSettlement, revert decode)
settlement-verify.server.ts mirror-node HBAR/USDC verification
hedera-hcs.server.ts HCS topic submit (WebClient)
hedera-evm.server.ts mirror-node reads / EVM helpers
ap2.ts mandate shapes, cart hashing, DataPart helpers
mandate-vault.server.ts viem reads/writes + revert decoding for the vault
ucp-merchant.server.ts cart build, payee alias resolution, register + settle
http-signature.ts/.server.ts RFC 9421 sign + verify
routes/
api/public/
hedera-rpc.ts same-origin relay proxy (app-side reads only)
x402-paid-content.ts challenge + facilitator verify
a2a-seller.ts agent card, message/send, tasks/get
a2a-buyer.ts buyer agent loop with the Hedera tools
ucp/discovery.ts ucp/checkout.ts ucp/signing-keys.ts
contracts/AP2MandateVault.sol
scripts/compile-vault.mjs scripts/deploy-mandate-vault.ts scripts/verify-vault.mjs
Onboarding UX
- Decouple sign-in from wallet provisioning. Account creation and indexing take
seconds; let the user into the demo with a "needs funding" state instead of a
blocking "provisioning wallet…" spinner.
- Always show, while signed in and outside any conditional funding block:
copyable Account ID and EVM address, HBAR and USDC balances, both
faucet links (with the "Circle wants the 0.0.x id" note), an Associate
USDC button while unassociated, and a Refresh balance button. Balance
arrival is not push — poll on click.
- Render the payment as a step-by-step flow log (challenge → authorise →
transfer → retry → verified) and print facilitator error strings verbatim.
- Link every hash to
https://hashscan.io/testnet/transaction/{hash}, every
contract to /contract/{id}, and every HCS message to its topic by consensus
timestamp.
Failure modes
| Symptom | Cause | Fix |
|---|
[-32603] Failed to fetch in the wallet iframe | RPC host missing from Magic CSP, or a same-origin proxy path | Allow-list the host in the Magic Dashboard; pin the absolute Hashio URL |
| Corrected RPC URL still fails | cached Magic instance kept the old transport | Rebuild Magic + viem clients when the URL changes |
| Client says success, HashScan says failed | 120k gas on an HBAR relay transfer or contract write | Raise the gas limit to ~900k |
Empty revert, ~39k gas, DELEGATECALL to 0.0.359 | paid to the long-zero form of an alias-bearing account (or token not associated) | Resolve evm_address and pay the alias; associate first |
settlement_not_found: …no consensus transaction yet on USDC | tried to hop to a consensus record from the EVM hash | Verify from the Transfer log on /contracts/results/{hash} |
expected 10000 atomic units … saw 0 | read the parent record's empty token_transfers | same as above |
declared payer … is not the sender | compared only long-zero or only alias | match against both forms from /accounts/{id} |
| USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first |
No such module "node:process" | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser |
settlement_not_found right after submit | read the mirror node once | poll with retries before verifying |
Sourcify says partial_match / nothing | wrong solc version or optimizer runs, or the source was edited after deploy | brute-force the settings matrix against deployed bytecode; never touch the source |
| Deploy signs but Hedera rejects it | ED25519 portal key used for an EVM tx |
Secrets
HEDERA_OPERATOR_ID and HEDERA_OPERATOR_KEY are server-only (HTS association,
HCS topic creation and submits). HEDERA_EVM_PRIVATE_KEY (ECDSA) deploys the
vault and signs settle(). UCP_SIGNING_KEY is the Ed25519 RFC 9421 merchant
key. The agent-brain LLM key (e.g. AISA_API_KEY for the OpenAI-compatible
https://api.aisa.one/v1, bare model ids) is read inside the handler; handle
402 (top up) and 429 explicitly and surface them in the flow log. The Magic
publishable key (pk_live_…) is public — put it in src/data/hedera.json,
not in secrets.
References