Trustless name resolution: forward Ethereum state proofs (sync-committee + Merkle proofs) for client-side verification
Nobody has claimed this yet.
Assessment
- Difficulty
- 5/5
- Estimated time
- Over a week
- Newbie friendliness
- 25/100
- Issue type
- Feature
- Clarity
- Needs clarification
- Activity status
- Quiet
- Domain
- api, blockchain, security
Research direction
Start with scripts/resolver/snrc-resolve.py and the proposed proof response extension, then review the client implementation notes and open questions. No client files or tests are named; first settle the verification tier, implementation location, envelope, freshness policy, and v1 record scope. Done requires an agreed, implementable phase with defined proof and verification boundaries.
Written by the indexing model from the issue text.
Description
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 addressresolver.text(node, "simplex.contact" | "simplex.channel" | …)andaddr(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 compromisedsimplex.contactlink 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 verifiedstate_root; - storage proof for slot
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_getProof on the resolver:
- account proof → resolver
storageHash; - storage proof for
recordVersions[node](the versionv) — required: text records are stored asversionable_texts[recordVersions[node]][node][key], so the
slot is version-indirected; assumingv = 0is wrong after anyclearRecords/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.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 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_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:
- 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 thatstateRoot. 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 thestateRoot, 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;
blstbindings exist. simplexmq is Haskell — ablst
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
simplexmqcore (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/websitetoo, or scope v1 tosimplex.contact/simplex.channelonly? - 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
proofassembly tosnrc-resolve.py(beaconlight_client/*+eth_getProof) behind a?proof=1flag; 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.
- Dominant language
- Haskell
- Stars
- 847
- Forks
- 111
- Avg merge
- 3d 11h
- Merged PRs (30d)
- 9
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from simplex-chat/simplexmq
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
simplex-chat/simplexmq#1853 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
simplex-chat/simplexmq#1781 · 1 comment ·
-
Difficulty 5/5 Over a week Newbie friendliness 30/100
simplex-chat/simplexmq#1864 ·
-
Difficulty 5/5 Over a week Newbie friendliness 20/100
simplex-chat/simplexmq#1857 ·
-
Difficulty 3/5 1-2 days Newbie friendliness 35/100
simplex-chat/simplexmq#1855 ·
All issues in simplex-chat/simplexmq
Similar issues
-
time-manager-0.4.0 Openfailure: bounds
Difficulty 1/5 Under an hour Newbie friendliness 72/100
commercialhaskell/stackage#8122 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
simplex-chat/simplex-chat#7547 ·
-
chore
Difficulty 1/5 Under an hour Newbie friendliness 90/100
alunduil/alunduil-chezmoi#775 ·
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
objectionary/phino#1350 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100