---
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)

1. **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.
2. **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.
3. **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.
4. **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:
   ```text
   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)`.
5. **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.
   - *Sending*: an HTS `transfer(address,uint256)` to the **long-zero** form of
     an account that owns an alias **reverts inside the token precompile with
     empty revert data** (~39k gas burned, `error_message: 0x`). Only the alias
     works. Always resolve `evm_address` from `/accounts/{id}` and pay to that;
     fall back to long-zero only for accounts with no alias.
   - Never inline a payee EVM address in config or 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 can't reintroduce the bad form.
6. **HTS tokens must be associated before an account can receive them.** Gate
   the USDC path on an association step (HIP-719 / `associate`) and tell users
   the Circle faucet wants the **Account ID** `0.0.x`, not the EVM address —
   funding the EVM address silently yields a 0 balance.
7. **Hedera SDKs drag Node built-ins into the browser bundle.** `No such module
   "node:process"` means you imported the Node build — use the SDK's web/browser
   entry and `WebClient`, and alias `pino` to its browser build in
   `vite.config.ts`.
8. **The mirror node lags consensus by seconds.** Never read once after submit;
   poll with a bounded retry loop (8–10 attempts, ~1.5s apart) for both
   `/transactions/{id}` and `/contracts/results/{hash}`.
9. **Native services beat contracts.** Token transfers → HTS. Append-only logs
   and timestamping → HCS topics. Only reach for Solidity when the logic itself
   is on-chain state no native service models.

## 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):**
```json
{ "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 | use an ECDSA `HEDERA_EVM_PRIVATE_KEY` |

## 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

- Hedera docs: <https://docs.hedera.com/llms-full.txt>
- x402 spec: <https://docs.x402.org/llms-full.txt>
- Hedera x402 bounty: <https://hedera.com/x402-bounty/>
- Reference impls: `matevszm/x402-hedera-example`,
  `hedera-dev/scaffold-hbar` (branch `templates/x402-pay-per-use`)
- Magic on Hedera: <https://docs.magic.link/embedded-wallets/blockchains/evm/hedera>
- Agent Kit: <https://docs.hedera.com/solutions/ai/agent-kit>
- Sourcify v2 API: <https://sourcify.dev/server/v2>
