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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions