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:
node = namehash(name)
registry.resolver(node) → resolver address
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):
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
- Where does verification live — in
simplexmq core (shared Haskell, all clients inherit it) or per-client?
- Bundle encoding: JSON (debuggable) vs SSZ/CBOR (compact) for the on-wire SMP message.
- Tier A vs B as the v1 floor — is client-side BLS acceptable on all target platforms?
- Envelope + cadence for head attestations on SMP responses; size budget per response.
- Make the head attestation a generic SMP transport feature (benefits message timestamps broadly) vs name-resolution-specific?
- Default freshness window
W, with a tighter per-name override for high-value names?
- Re-bootstrap UX after long offline gaps (committee staleness).
- Do we prove
addr/multicoin + nickname/website too, or scope v1 to simplex.contact/simplex.channel only?
- Checkpoint distribution & rotation policy for the weak-subjectivity root.
.simplex (when deployed) vs .testing: one committee, two registries — bundle carries both registry/resolver proofs.
Suggested phasing
- Spec the bundle + head-attestation envelope + slot derivations (this issue).
- 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.
- Verifier: reference implementation (Python alongside the resolver, then Haskell in simplexmq core) — Tier B first, then Tier A.
- Client: ingest head attestations from normal traffic, enforce the freshness window, verify on resolve; surface verified/unverified + staleness in the UI.
Summary
Today the SNRC resolver (
scripts/resolver/snrc-resolve.py) returns name → SimpleX-link data that the client must trust: it's the result ofeth_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:node = namehash(name)registry.resolver(node)→ resolver addressresolver.text(node, "simplex.contact" | "simplex.channel" | …)andaddr(node, coinType)Every step is an
eth_callwhose result is asserted by the relay. A malicious relay can return any links it likes (e.g. swapsimplex.contactfor anattacker-controlled SMP address → MitM the contact request).
Goal & threat model
Ethereum light client makes). Everything after is verified, not trusted.
Attacks in scope:
owner rotates a compromised
simplex.contactlink or transfers the name, it keeps replaying the pre-rotation records to hold victims on attacker-controlledinfrastructure. 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
SyncAggregateis a 96-byte aggregatesignature + a 512-bit participation bitfield. The client:
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
LightClientHeadercarries theExecutionPayloadHeader+ anexecution_branchMerkle proof binding it tothe 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_getProofon ENSRegistry:storageHash, rooted at the verifiedstate_root;keccak256(node ‖ uint256(0)) + 1— theresolverfield ofrecords[node](recordsis 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_getProofon the resolver:storageHash;recordVersions[node](the versionv) — required: text records are stored asversionable_texts[recordVersions[node]][node][key], so theslot is version-indirected; assuming
v = 0is wrong after anyclearRecords/transfer;keccak256(key ‖ keccak256(node ‖ keccak256(v ‖ S_texts)))— including the data slots for long strings (a multi-URLsimplex.contactCSV spills past 31 bytes intokeccak256(slot)+i). Same shape foraddr/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 parsedfields. If anything mismatches → reject.
This works identically for subnames (
bar.alice.testing): resolution only readsregistry.records[subnode].resolver+ resolver text slots — theSubnameRegistrar / soulbound logic isn't on the resolution path, so the proof shape is unchanged.
Proposed response extension
Augment the resolver JSON with a
proofobject (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:
/eth/v1/beacon/light_client/finality_update(+bootstrap/updates);eth_getProofat 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:
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.
(blockHash, number, timestamp, stateRoot); the client takes the freshest consistent headacross its diverse servers as a recency floor and verifies the resolution's MPT proof against that
stateRoot. Trust shifts from "the resolving relay" to "notall 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
Wof the freshest head the client holds.Wis 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_getProofMPT proofs (~1–3 KB each); the committee / head tracking is carried by thepassively-ingested head attestations, not per query.
Residual trust & finality
first run. One-time setup, not per-query trust in the relay.
optimisticmode (attested header only, ~12 s lag, reorg risk) could be offered for freshness.
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
blstbindings exist. simplexmq is Haskell — ablstFFI is the main new dependency on the verifier side. Tier B needs no BLS.
Open questions
simplexmqcore (shared Haskell, all clients inherit it) or per-client?W, with a tighter per-name override for high-value names?addr/multicoin +nickname/websitetoo, or scope v1 tosimplex.contact/simplex.channelonly?.simplex(when deployed) vs.testing: one committee, two registries — bundle carries both registry/resolver proofs.Suggested phasing
proofassembly tosnrc-resolve.py(beaconlight_client/*+eth_getProof) behind a?proof=1flag; add the head-attestation envelope toSMP responses.