
On-chain inheritance with ENSv2 subnames & World ID. Owner aren't alive, heirs gain access.
THE PROBLEM: When a crypto holder dies or becomes incapacitated, their assets are often permanently locked on-chain without a documented plan. No bank, no password reset, no next-of-kin override. THE SOLUTION: Herit, a crypto inheritance platform, uses ENS subnames as heir slots and a role-based permission system to enforce the inheritance lifecycle without any off-chain coordination. Herit combines Solidity contracts on Ethereum Sepolia, a World ID verification backend, and a browser frontend to let someone pass their crypto to their next of kin without ever handing over a seed phrase. Herit is a dead-man's switch, but it replaces the two parts of that pattern that are usually fake: proof that the owner is still alive, and the heir's identity. Ordinary dead-man's-switch contracts treat a timestamp as proof of life. Any transaction refreshes it, which means a bot on a schedule refreshes it, and so does the thief who stole the key. The contract concludes the owner is fine while the estate drains. Herit instead requires a World ID Selfie Check to check in, which is a live biometric liveness and uniqueness proof. World ID returns a nullifier that is stable for one human and different for everyone else, and the backend salts that into a commitment and signs an EIP-712 attestation that a LivenessAttestor contract on Sepolia verifies. The first check-in binds the estate to that commitment permanently, and every subsequent CONTINUITY check-in must match. A stolen key can sign anything, but it cannot pass a liveness check, so the thief produces a different nullifier, and the transaction reverts. Selfie Check is used because an inheritance switch needs to know the owner is alive today, not merely that they are a unique person. The second replacement is the heir. Most inheritance contracts store heirs as addresses in an array, which have no verifiable link to a real person and nothing stopping one human from occupying several heir slots. In Herit, each heir is given a genuine ENSv2 subname, such as son.alice.herit.eth, inside a registry deployed for that one estate through the Verifiable Factory. The subname carries public text records of the relationship and the share in basis points, so an heir can see their inheritance and read its terms years before they can act on it. What keeps it dormant is a withheld permission rather than a flag in our own database. Herit claims a free slot in ENSv2's Enhanced Access Control role bitmap as a claim role and deliberately does not grant it when the subname is created, so canClaim reads false. Unlocking the estate is a grantRoles call on the real ENS registry, and the claim gate is a hasRoles call, which means anyone can read an heir's permission directly from ENS without touching Herit at all. Using it works as follows. - The grantor opens an estate under herit, which is free and avoids making every user buy a .eth name first - The grantor registers their heirs with shares, and deposits ETH or ERC-20 tokens into an opt-in vault rather than exposing their whole wallet. - Then, they pass a Selfie Check to start the clock. Each interval, a continuity Selfie Check resets it. If the interval lapses, anyone at all can poke the contract, and the estate enters a grace period. - The grace period is a real second stage and not a formality, because a single Selfie Check inside grace returns the estate to active. Someone who was simply traveling with no signal can undo the whole thing by proving they are alive. - If grace also lapses, another poke unlocks the estate, and in one transaction the vault freezes its balances so a later deposit cannot change what a share means. - Afterward, the estate name is renewed so no permission lands on an expired name, and each heir's subname is granted the claim role. - Each heir then passes their own Selfie Check, proving they are a distinct living human, and withdraws their share. - One human gets one claim per estate; the grantor cannot claim from their own estate, and the same person cannot claim twice under two different heir names. Everything described is deployed and verified on Ethereum Sepolia against the frozen ENSv2 hackathon deployment: AccessControlGate, HeritRegistry, HeritVault, ClaimManager, and LivenessAttestor, with herit.eth registered and a grantor registry attached beneath it. Because Herit targets that frozen hackathon set rather than the regular Sepolia beta set, the names don't appear in the official ENS app and must be read directly from the registry or the dedicated hackathon ENS Explorer(https://hackathon-deployment-portal-app.ens-cf.workers.dev/). Lastly, in line with Security-minded development, there are 45 passing tests, including fork tests that run against the live ENSv2 registry rather than mocks. The frontend is a Next.js app using IDKit, wagmi and viem, and the full path runs end to end on Sepolia: open an estate, fund it, check in, miss the window, unlock, and claim. The Graph makes Herit's history visible. Herit runs a subgraph over all five of its Sepolia contracts, indexing fourteen events across the gate that opens estates and mints heir subnames, the registry that holds the check-in clock, the vault, the claim manager, and the attestor that verifies each Selfie Check. The subgraph exists because there is no "list every estate" getter on-chain, because an unbounded array in contract storage is gas-intensive. The subgraph builds that list from the EstateOpened event instead: every estate anyone has ever opened, with its label, its grantor, the ENS registry deployed for it, and when it was opened, newest first. The Graph replays everything that has happened to one estate: configured, checked in, entered grace, unlocked, deposited, withdrawn, claimed, settled, and every Selfie Check attested along the way.
The contracts are Solidity 0.8.30, built with Foundry and deployed to Ethereum Sepolia. The app is a Next.js 16.3.4 frontend on React 19 using wagmi 3, viem 2, ensjs, and Tailwind 4, with the World ID integration through IDKit 4.2.3. There are five contracts: - AccessControlGate deploys a per-estate ENS registry and is the only thing allowed to touch ENS roles. - HeritRegistry is the estate state machine holding the check-in clock and the heir list. - HeritVault is the escrow, ClaimManager is the heir-facing payout path, and LivenessAttestor verifies the backbackend's-712 attestation before anything else can run. Every dependency between them is immutable with no setter, so a compromised or mistaken address cannot be repointed later. That decision created the most interesting problem in the build. The five contracts reference each other in a ring, so none of them can be deployed first, and CREATE2 does not solve it because a CREATE2 address depends on the creation code, which carries the constructor arguments, so predicting the vault would need the registry's address and predicting the registry would need the vault's. However, plain CREATE does solve it, because a contract address is only the hash of the deployer and its nonce, and the constructor arguments aren't part of it. So the deploy script computes all five addresses from the deployer's before deploying anything, passes the predicted addresses into the constructors, and asserts after each deployment that the real address matches the prediction. It then walks the finished ring and checks that every contract agrees with every other one, which is the test itself. The ENS integration took the most reading. ENSvENSv2'sanced Access Control packs 64 roles into a uint256 as nybbles, where a role at nybble N sits at bit 4N and its admin counterpart 128 bits higher. ENS's RegistryRolesLib occupies nybbles 0 to 9 and 30 to 31, so Herit claims nybbles 10 and 11 for its claim role and its registration marker, sitting alongside ENS's rather than in a parallel system. That is what makes unlock a literal grantRoles call on the real registry and the claim gate a hasRoles call. Because hasRoles requires the whole bitmap, checking both bits at once asks "has this estate unlocked for you" and "did Herit create this subname at all" in a single call, and the second bit stops a name that acquired the claim role by some other route. We had to find three ENSv2 behaviors the hard way and handle them in code. A role granted on an expired name writes to a resource ID that nothing reads, so the grant appears to succeed and silently does nothing; unlock has to renew the heirheir'sname before granting anything. Every grant and revoke burns and re-mints the subname token, so any cached token ID is a bug waiting to fire, and names are addressed by labelhash throughout. And renewing an already-expired name takes a revival path that needs a root-level renew role on that specific registry, which the deploy has to grant explicitly. On top of that, the deployed ENSv2 hackathon contracts are newer than the pinned submodule, and one signature differs exactly where it matters: the deployed user registry takes initialize as an array of account-and-role pairs. In contrast, the submodule declares it as a single pair. I Importing the submsubmodule'serface would have compiled, deployed, and reverted on Sepolia with no error to explain why, so every function Herit calls was checked selector by selector against the deployed bytecode, and a minimal local interface was declared instead. The World ID side has one design decision that quietly decides whether the whole thing is secure. A World ID action string is the namespace that scopes the nullifier, so the same human verifying against two different action strings produces two unrelated nullifiers. - The first design used a per-heir claim action, which reads correctly and is wrong: one person registered as two heirs of the same estate would have produced two different nullifiers, two different commitments, and been paid twice, with every individual proof valid. - Collapsing check-in and claim into one action per estate and moving the heir label in proof's final fixed it, and it also turned on a second check that had been dead code — because a grantor's check-in and a claim on the same estate now share a namespace, the contract can compare the two commitments and refuse a grantor trying to claim from their own estate. - Selfie Check is a World ID 3.0 credential, so legacy proofs have to be explicitly allowed and results can arrive in the older shape. The nullifier has to be read from the IDKit result rather than the Cloud Verify response, whose success shape isn't documented anywhere. The backend is two Next.js route handlers. - One signs the relying-party request server-side, since that key proves a proof request came from Herit. It can never reach the browser, and it has to remap four of the five fields the signing helper returns into the names the widget expects, across a JSON boundary where nothing type-checks it. - The other verifies the prooWorld'srld'sud Verify, salts the nullifier into a commitment, and signs the EIP-712 attestation the contract will check. Before signing, it runs read-only preflight calls against Sepolia that mirror each revert the transaction would hit, so it refuses a doomed attestation with a readable sentence instead of costing the user gas; those checks fail open if the RPC is unreachable because the contract enforces every rule regardless. The frontend never hand-writes an ABI; a script generates it from Foundry's output, and it caught a stale copy missing the two main write functions. The tests run against a Sepolia fork with the real ENS registry rather than mocks, including the two that matter most: proving a stolen key cannot check in, and the same human cannot claim twice. The subgraph uses graph-cli 0.98.1 and graph-ts 0.38.2 to index five Sepolia contracts from their deployment blocks. Fourteen handlers cover estate creation and heir subname minting, check-ins, vault activity, claims, and World ID attestations. ABIs are generated directly from Foundry artifacts, preventing contract changes from leaving the indexer with outdated interfaces. The schema has two entities: a mutable Estate, keyed by grantors'aby grantors', and an immutable ActivityEvent, keyed by transaction hash and log index. Shared mapping logic populates common fields and rejects events for estates the gate never opened, avoiding dangling relationships. Events include a sequence number calculated as block number × 1,000,000 + log index, enabling accurate chain-order sorting in GraphQL's single orderBy field. Data remains typed rather than pre-rendered, leaving token formatting and heir-label display to the application. The frontend sends GraphQL requests with fetch and uses TanStack Query for caching and retries. To stay within StudStudio's00-query daily limit, it polls every 30 seconds and pauses in hidden tabs. After a user transaction confirms, it stores its block number outside TanStack Query. The feed compares that block with the subgraph's block, polls every four seconds while the indexer catches up, and stops after two minutes to avoid hammering a stalled indexer. The chain remains the source of truth for live state. The subgraph records what happened, while the interface reads countdowns, statuses, and balances directly from Sepolia so it never shows stale time-sensitive data.