Skip to content

Trustless name resolution: forward Ethereum state proofs (sync-committee + Merkle proofs) for client-side verification #1816

Description

@brenzi

Summary

Today the SNRC resolver (scripts/resolver/snrc-resolve.py) returns name → SimpleX-link data that the client must trust: it's the result of eth_calls run by whichever relay the user queried. The only defense is "ask several relays run by different operators and hope they agree." This issue proposes dropping that trust entirely: have the resolver forward the raw cryptographic material so the SimpleX Chat client can verify, against Ethereum mainnet consensus, that the resolution is exactly what is committed on-chain — with no trust in the relay or its RPC node.

The reduction is the standard Ethereum light-client one: sync-committee signature → execution state root → Merkle-Patricia account/storage proofs → recomputed record — plus a freshness anchor so a relay can't replay stale-but-once-valid data.

Current trust model (what we're removing)

resolve(name) does, over a relay-controlled JSON-RPC endpoint:

  1. node = namehash(name)
  2. registry.resolver(node) → resolver address
  3. resolver.text(node, "simplex.contact" | "simplex.channel" | …) and addr(node, coinType)

Every step is an eth_call whose result is asserted by the relay. A malicious relay can return any links it likes (e.g. swap simplex.contact for an
attacker-controlled SMP address → MitM the contact request).

Goal & threat model

  • Untrusted: the relay, its RPC/beacon nodes, the network path. They may lie or omit.
  • Trusted (one-time, out-of-band): a weak-subjectivity checkpoint — a recent finalized beacon block root the app ships with / pins (the assumption every
    Ethereum light client makes). Everything after is verified, not trusted.
  • Verified client-side: that the returned records are the values stored at the ENS registry + resolver contracts at a recent, finalized mainnet block.

Attacks in scope:

  • Forgery / substitution. Relay returns records that were never on-chain. Closed by the sync-committee + Merkle proof (authenticity).
  • Stale-but-once-valid replay. A relay can withhold the current proof and serve an older one that was cryptographically valid when produced — e.g. after the
    owner rotates a compromised simplex.contact link or transfers the name, it keeps replaying the pre-rotation records to hold victims on attacker-controlled
    infrastructure. The proof attests authenticity, not recency. Closed only by bounding proof age against an independently-sourced chain head (see Staying synced).

Verification chain (what the client checks)

Given the proof bundle, the client verifies, bottom-up:

(A) Consensus — sync committee. The sync committee's signature is already a single aggregated BLS12-381 signature. The SyncAggregate is a 96-byte aggregate
signature + a 512-bit participation bitfield. The client:

  • aggregates the participating sync-committee G1 pubkeys (≤512 point additions),
  • checks one pairing equation over the signed beacon header,
  • requires ≥ 2/3 participation (≥ 342 / 512).

The client tracks the current sync committee from the pinned checkpoint, rotated via committee-update proofs (delivered as below), so the relay can't forge it.

(B) Header → execution state root. Post-Capella the LightClientHeader carries the ExecutionPayloadHeader + an execution_branch Merkle proof binding it to
the beacon block. The client verifies the branch and extracts the execution-layer state_root, block_number, block_hash.

(C) State root → resolver address. An eth_getProof on ENSRegistry:

  • account proof: ENSRegistry account → storageHash, rooted at the verified state_root;
  • storage proof for slot keccak256(node ‖ uint256(0)) + 1 — the resolver field of records[node] (records is mapping slot 0; struct Record { address owner; address resolver; uint64 ttl; }, so resolver sits at base+1) → the resolver address.

(D) State root → record value. An eth_getProof on the resolver:

  • account proof → resolver storageHash;
  • storage proof for recordVersions[node] (the version v) — required: text records are stored as versionable_texts[recordVersions[node]][node][key], so the
    slot is version-indirected; assuming v = 0 is wrong after any clearRecords/transfer;
  • storage proofs for the text value at keccak256(key ‖ keccak256(node ‖ keccak256(v ‖ S_texts)))including the data slots for long strings (a multi-URL
    simplex.contact CSV spills past 31 bytes into keccak256(slot)+i). Same shape for addr/multicoin records.

(E) Recompute & compare. The client reassembles node, the resolver address, and each record from the proven slots and checks they equal the response's parsed
fields. If anything mismatches → reject.

This works identically for subnames (bar.alice.testing): resolution only reads registry.records[subnode].resolver + resolver text slots — the
SubnameRegistrar / soulbound logic isn't on the resolution path, so the proof shape is unchanged.

Proposed response extension

Augment the resolver JSON with a proof object (records stay where they are, so existing clients ignore it):

{
  "name": "alice.testing",
  "simplexContact": ["smp://…", "smp://…"],
  "simplexChannel": [],
  "proof": { 
    "chainId": 1,
    "block": { "number": 21000000, "hash": "0x…", "stateRoot": "0x…", "timestamp": 1700000000 },
    "consensus": {                      // Altair light-client objects, served verbatim from a beacon node
      "finalityUpdate": { "attested_header": {}, "finalized_header": {},
                          "finality_branch": [...], "sync_aggregate": {}, "signature_slot": "" },
      "executionBranch": [...]          // finalized_header → execution state_root
    },    
    "state": {                          // eth_getProof outputs, rooted at block.stateRoot
      "registry": { "address": "0x03f438…", "accountProof": [...], "storageProof": [ {slot,value,proof} ] },
      "resolver": { "address": "0x…",    "accountProof": [...], "storageProof": [ /* version + text data slots */ ] }
    },
    "derivation": {                     // so the client can recompute, not trust, the slots
      "node": "0x<namehash>",
      "registryResolverSlot": "0x…",
      "recordVersionSlot": "0x…",
      "textValueSlots": { "simplex.contact": ["0x…", "0x…"] }
    }
  }
}

The relay produces this from standard endpoints — no custom proving:

  • consensus: a beacon node's /eth/v1/beacon/light_client/finality_update (+ bootstrap/updates);
  • state: an execution node's eth_getProof at the finalized block.

Staying synced without the client tracking consensus

The client must not run a beacon light client or poll for sync-committee / chain-head state — directly, from relays, or out-of-band. Instead, SMP servers piggyback a compact, periodically-refreshed Ethereum head attestation on the message-delivery responses the client already fetches. The user's normal polling cadence keeps the client synced; no dedicated request, no separate beacon connection.

Two tiers over one envelope:

  • Tier A (fully trustless): the envelope carries light-client finality + committee-rotation updates; the client verifies the sync-committee BLS, advances its
    own committee, and holds a cryptographically-verified finalized head. Forging a recent head is impossible, so the staleness attack requires suppressing head-bearing
    traffic from every server the user touches. Cost: client-side BLS + committee state.
  • Tier B (no client BLS, diversity-trust): the envelope carries only (blockHash, number, timestamp, stateRoot); the client takes the freshest consistent head
    across its diverse servers as a recency floor and verifies the resolution's MPT proof against that stateRoot. Trust shifts from "the resolving relay" to "not
    all my SMP servers collude on a stale head" — the resolving relay stays fully untrusted, and it's still strictly better than today. Cost: just MPT verification,
    no BLS. (A bare block hash is insufficient — the client needs the stateRoot, or the header preimage that hashes to it, to check state proofs.)

Suggested: spec Tier B as the floor (mobile-friendly, no BLS) and Tier A as the trustless upgrade, sharing one head-attestation envelope that differs only
in whether it carries the verifiable signature.

Freshness check. Accept a resolution proof only if its block is within a window W of the freshest head the client holds. W is a security parameter — smaller
= faster revocation propagation when a link is rotated, more sensitive to head liveness. Complement with the on-chain record version (recordVersions[node]) /
SNRC reregister generation so the client can also detect an older version — though knowing the current version still requires recency, so the head anchor
remains primary.

Amortization. Steady-state per-resolution overhead is just the eth_getProof MPT proofs (~1–3 KB each); the committee / head tracking is carried by the
passively-ingested head attestations, not per query.

Residual trust & finality

  • Weak subjectivity: the only trust is the initial checkpoint. Ship it in the app, and/or let the client cross-check it against several independent sources on
    first run. One-time setup, not per-query trust in the relay.
  • Finalized vs optimistic: prove against the finalized header (no reorg risk; ~13 min lag). Names change rarely, so finality lag is fine; an optimistic
    mode (attested header only, ~12 s lag, reorg risk) could be offered for freshness.
  • Offline gaps: after a long offline period a Tier-A committee may be too stale to chain-update; the client re-bootstraps from a recent pinned checkpoint (app
    update or a one-time fetch). Tier B degrades gracefully to a stale floor.

Side benefit: a decentralized clock

A consensus-anchored head carries a slot-derived timestamp, giving the client a trustless recent lower bound ("originates no-earlier-than") on wall-clock time — independent of device and relay clocks. Beyond the resolution freshness check, this can sanity-check / anti-backdate SMP message timestamps generally, and anchor time-dependent logic. Caveat: lower bound only (≤ now); recency bounded by delivery cadence + finality lag. In Tier B the timestamp is diversity-trusted rather than verified.

Client implementation notes

  • BLS12-381 verification (Tier A) is one pairing + ≤512 G1 additions — single-digit to low-hundreds of ms; blst bindings exist. simplexmq is Haskell — a blst
    FFI is the main new dependency on the verifier side. Tier B needs no BLS.
  • MPT (keccak + RLP) and SSZ Merkle verification are light; SSZ generalized-index branches for the light-client objects are spec-defined.

Open questions

  1. Where does verification live — in simplexmq core (shared Haskell, all clients inherit it) or per-client?
  2. Bundle encoding: JSON (debuggable) vs SSZ/CBOR (compact) for the on-wire SMP message.
  3. Tier A vs B as the v1 floor — is client-side BLS acceptable on all target platforms?
  4. Envelope + cadence for head attestations on SMP responses; size budget per response.
  5. Make the head attestation a generic SMP transport feature (benefits message timestamps broadly) vs name-resolution-specific?
  6. Default freshness window W, with a tighter per-name override for high-value names?
  7. Re-bootstrap UX after long offline gaps (committee staleness).
  8. Do we prove addr/multicoin + nickname/website too, or scope v1 to simplex.contact/simplex.channel only?
  9. Checkpoint distribution & rotation policy for the weak-subjectivity root.
  10. .simplex (when deployed) vs .testing: one committee, two registries — bundle carries both registry/resolver proofs.

Suggested phasing

  1. Spec the bundle + head-attestation envelope + slot derivations (this issue).
  2. Relay: add proof assembly to snrc-resolve.py (beacon light_client/* + eth_getProof) behind a ?proof=1 flag; add the head-attestation envelope to
    SMP responses.
  3. Verifier: reference implementation (Python alongside the resolver, then Haskell in simplexmq core) — Tier B first, then Tier A.
  4. Client: ingest head attestations from normal traffic, enforce the freshness window, verify on resolve; surface verified/unverified + staleness in the UI.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions