One contract. SureXOffchainResolver is an ERC-3668 (CCIP-Read) resolver that makes every SureX
registry entry readable as an ENS name, with no transaction per entry and no bytes on chain.
sxf1-<first 40 hex of the fingerprint>.<parent>.eth
Set it once as the resolver on one parent name and every entry resolves — the 51 that exist today, and every one written after. That is ENSIP-10 wildcard resolution, and it is the whole reason this is one contract rather than 51 records.
Any mainnet RPC. surex.eth is live and every entry resolves as a subname.
mkdir /tmp/surex && cd /tmp/surex && npm i viemimport { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
const c = createPublicClient({ chain: mainnet, transport: http() });
await c.getEnsText({
name: 'sxf1-09dcb0601b4d2f1fdebba5d2dfe629f3421274bc.surex.eth',
key: 'surex:state',
});
// → 'flagged'ethers is identical, and verified:
const r = await provider.getResolver('sxf1-09dcb…surex.eth');
await r.getText('surex:state'); // → 'flagged'Keys: surex:state · surex:severity · surex:tier · surex:reviewed · surex:fingerprint · url.
That subname was never registered and never will be — ENSIP-10 wildcard resolution answers for every
entry in the registry, and the answer is a CCIP-Read response signed against the key this contract
pins.
From this repo, hop by hop:
cd probes && pnpm install --ignore-workspace
node ens-resolve.mjs live --name sxf1-09dcb0601b4d2f1fdebba5d2dfe629f3421274bc.surex.ethapp.ens.domains/sxf1-….surex.eth confirms the name resolves and
shows parent: surex.eth, but its Records tab is empty. An offchain wildcard resolver cannot
enumerate its records, so a client has to ask for specific keys, and surex:* is not in the app's
list. Anyone sent there concludes it is broken. Same reason wallets and the Safe UI will not render
these records either. (FRICTION-LOG E9)
cast cannot do it. Foundry has no CCIP-Read support, so cast call stops at the
OffchainLookup revert and cannot follow it.
The working set is: anything holding a viem, ethers, wagmi or ensjs client — which is the population this was built for, and the reason the pitch says "already holding an Ethereum client" rather than "anyone".
That the response came from the holder of the key the resolver pins. Nothing else.
It does not prove the registry is right, and it does not make the SureX Gate stronger. The Gate
is the Claude Code PreToolUse hook in packages/plugin; it reads the HTTP API and does not read
this. PRD risk #10 — the Gate acting on unsigned responses — is not closed by this work and is
still listed as Accepted in docs/surex-prd.md. Written here because "signed" is a word people
finish the sentence of themselves, and they finish it wrong.
src/SureXOffchainResolver.sol the resolver — resolve(), resolveWithProof(), rotation
test/SureXOffchainResolver.t.sol the Foundry suite, including the cross-language digest vector
script/Deploy.s.sol deploys, and refuses a gateway URL missing its placeholders
No dependencies beyond forge-std. The ENS offchain-resolver reference pulls OpenZeppelin and the
ens-contracts tree for what is about sixty lines of logic here.
forge install foundry-rs/forge-std --no-git # first time only
forge test -vvvIf you do not have Foundry, the resolver can still be compiled and executed:
cd ../probes && pnpm install --ignore-workspace
node ens-resolve.mjs contractThat mode compiles src/SureXOffchainResolver.sol with solc-js and runs it on an in-process EVM. It
covers the digest, the interface IDs, and the six resolveWithProof acceptance and rejection paths.
It exists because Foundry could not be installed in the environment this was written in — see
FRICTION-LOG.md E4. forge test is the canonical suite and covers more; this is the one that runs
anywhere Node does.
keccak256(abi.encodePacked(hex"1900", address(this), expires, keccak256(extraData), keccak256(result)))0x1900 is EIP-191 version 0x00, "data with intended validator" — the validator being the resolver
address, so a signature made for one resolver cannot be replayed against another. Unchanged from the
ENS reference, so any standard CCIP-Read client verifies a response without knowing anything about
SureX.
The signature is over the raw digest. In JavaScript that is
privateKeyToAccount(key).sign({ hash }) and never signMessage(), which would add a second EIP-191
prefix and make ecrecover return an address nobody holds. This is the single most likely way to
break the whole thing, which is why the same four inputs and the same expected digest are pinned in
three places — here, in apps/web/lib/ens.ts, and asserted across both in
apps/web/test/ens.test.mjs.
| name | surex.eth, expires 2027-07-25, not wrapped |
| resolver | 0x2BEaeC431bB22Fd1160319d0ebDAE886Ef593a8B |
| pinned signer | 0x9D80524581a242a8F67c5333418B6b8b3a8a6D01 |
name owner (setResolver) |
0xFE388539e3fffeA23ba4C5aa4c750cb90f369b2E |
resolver owner (setSigner, setUrls) |
0xC19a460767CcD13c63e0a2470Ee10c75804c3dB4 |
| deploy cost | 0.000072 ETH — 1,067,648 gas at 0.067 gwei |
Verified live, end to end. A stock viem client reads a verdict off a subname that was never registered:
cd ../probes && node ens-resolve.mjs live --name sxf1-<40 hex>.surex.eth
# ✓ the full path resolved in one call surex:state = flaggedThat walks wildcard resolution → OffchainLookup → gateway fetch → resolveWithProof → ecrecover,
driving the DEPLOYED contract rather than a constructed request. Use this mode, not getEnsText, to
check a deployment: getEnsText returns null on a failed CCIP fetch rather than throwing, so a
broken seam is indistinguishable from an empty record.
0xCb140fF30c449c3782D96Bfa356cDDE8E33b2559 was the first deployment and is superseded. It forwarded
data instead of msg.data, dropping the name the gateway needs — see FRICTION-LOG.md E8. Nothing
should point at it.
Why mainnet and not a testnet: .eth registration on Sepolia has been broken network-wide since
early June 2026 — see FRICTION-LOG.md E5 and E6. It was not a preference.
Nothing below is in this repo and nothing below should be. Secrets live in the deployment
environment (AGENTS.md §4).
At app.ens.domains. Do not reach for Sepolia — registration there is broken (E5). A 5+ character name is ~0.0027 ETH/year and gas is the smaller half.
cd contracts
SUREX_ENS_SIGNER=0x… # address whose key the gateway signs with — NOT its key
SUREX_ENS_GATEWAY_URL='https://arkiv-surex.vercel.app/api/ens/{sender}/{data}.json' \
forge script script/Deploy.s.sol \
--rpc-url https://ethereum-rpc.publicnode.com \
--sender <deployer address> --interactive --broadcast--sender is required even with --interactive; without it forge falls back to its default sender
and refuses to broadcast. --interactive needs a real TTY — it fails with Device not configured
inside a non-interactive shell, so run it in a terminal.
The {sender} and {data} placeholders are literal and required; the script refuses to deploy
without them, because a URL missing one deploys fine and then fails every lookup with an opaque
gateway error.
This is the step that turns wildcard resolution on. Until it runs, nothing resolves.
cast send 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e \
"setResolver(bytes32,address)" \
$(cast namehash <parent>.eth) <deployed resolver address> \
--rpc-url https://ethereum-rpc.publicnode.com --from <name owner> --interactive0x0000…2e1e is the ENS registry, the same address on Sepolia as on mainnet. If the parent is
wrapped in the Name Wrapper, call setResolver on the wrapper instead. The ENS app's More →
Resolver → Edit → Custom resolver does the same thing and is easier; expect a warning that the
address is not a recognised resolver, which is correct — ours implements IExtendedResolver, not
the usual profile interface.
text() for subnames, not
addr() for the parent. That is intended for a registry-as-a-name and surprising otherwise.
On the web deployment (Vercel project apps/web):
| Variable | What |
|---|---|
SUREX_ENS_SIGNING_KEY |
0x-prefixed 32-byte private key whose address is SUREX_ENS_SIGNER |
SUREX_ENS_RESOLVER_ADDRESS |
the address from step 2 — the gateway signs for this resolver and refuses every other |
NEXT_PUBLIC_SUREX_ENS_PARENT |
surex.eth. Until it is set, the evidence page shows no ENS row at all |
SUREX_ENS_TTL_SECONDS |
optional, default 300 |
SUREX_ENS_CHAIN |
optional — only picks the explorer host for the UI link. Set mainnet for this deployment |
For the live deployment those are SUREX_ENS_RESOLVER_ADDRESS=0x2BEaeC431bB22Fd1160319d0ebDAE886Ef593a8B,
NEXT_PUBLIC_SUREX_ENS_PARENT=surex.eth, SUREX_ENS_CHAIN=mainnet, and SUREX_ENS_SIGNING_KEY is the
key for 0x9D80524581a242a8F67c5333418B6b8b3a8a6D01 — kept in ~/.secrets/surex-ens.env and never
in this repo.
With SUREX_ENS_SIGNING_KEY or SUREX_ENS_RESOLVER_ADDRESS unset the gateway answers 503 and
names what is missing. It never manufactures a signature.
cd ../probes
node ens-resolve.mjs sepolia --name sxf1-<40 hex>.surex.eth --rpc https://ethereum-rpc.publicnode.comThat walks the whole path with a real client: eth_call → OffchainLookup revert → gateway fetch →
resolveWithProof → ecrecover. (The mode is still called sepolia; pass --rpc for any chain.)
Until the gateway is deployed this stops at the fetch, which is the current state — resolution
reaches the contract, and there is nothing on the other end to answer. A status table does not get a
✅ on an assertion, so AGENTS.md §2 says gateway pending until this prints green.
cast send 0x2BEaeC431bB22Fd1160319d0ebDAE886Ef593a8B "setSigner(address)" <new signer> \
--rpc-url https://ethereum-rpc.publicnode.com \
--from 0xC19a460767CcD13c63e0a2470Ee10c75804c3dB4 --interactiveThen update SUREX_ENS_SIGNING_KEY. Rotation invalidates every signature already in flight, which is
the intended behaviour. A key that cannot be rotated is a liability the first time it is exposed, and
that is the only reason owner exists on this contract.