HusseinAdeiza/deathclock

Verifiable inheritance on Solana — an estate releases on a zero-knowledge proof of absence (RISC Zero Groth16)

★ 0Forks 0TypeScriptGitHub ↗Compare

README

DeathClock

Crypto World's Fair submission: SUBMISSION.md — organised against the six judging criteria, with reproducible commands. Third-party code and licenses: THIRD-PARTY-NOTICES.md. Roadmap: docs/ROADMAP.md.

Your will, on-chain. DeathClock is a trustless inheritance protocol for Solana. An owner deposits SOL into a PDA vault, proves they are alive with a zero-knowledge heartbeat, and names the beneficiaries who receive the estate after a 48-hour challenge window.

The heartbeat is a real RISC Zero proof, verified on-chain by Solana. Not a mock, not a signature, not an oracle assertion.

DeathClock ──CPI──▶ verifier_router ──selector 73c457ba──▶ groth_16_verifier
                                                                   │
                                            BN254 pairing check on the SNARK
  • Verified heartbeat transaction 5ngg4ghZrVjemXhr2fZ6GJ2i31w5nm3ow7S1CHaAy9n4YKDADdDwhABB5QYSGsfXFwzopZZZc585KnqgdeSMSkqN, confirmed against a local validator running Solana 1.18.26.

  • The proof path is live on public devnet. Heartbeat transaction 3KuQVp5k… carries a genuine RISC Zero Groth16 proof and runs the whole chain on-chain:

    DeathClock::heartbeat -> verifier_router::verify -> groth_16_verifier::verify
    

    183,194 of 200,000 compute units, err: None. The BN254 pairing check runs inside the Solana VM, not off-chain. Reproduce with npm run heartbeat:devnet.

  • Tamper case: the same receipt with a modified journal is rejected.

  • Live on devnet: vault GdqwHKfJ7wgNSGJ53J7mrA986Tg1UefUK9Y3btzX7Btt — 0.4 SOL deposited, two heirs at 60/40, created and funded by npm run e2e:devnet. Reproduce with npm run verify:live-vault, which decodes it through the same path the deployed site uses.

The problem

Roughly $140 billion in Bitcoin is unreachable — lost seed phrases, deceased holders with no executor, hardware wallets nobody can open. Crypto made value transferable but did nothing for succession. Every existing path needs a lawyer, a court, or a trusted intermediary, and none of them let you pre-authorize a release policy while you are still well.

DeathClock turns that policy into a small state machine:

flowchart LR
  A[Active] -->|heartbeat expires| B[Missed]
  B --> C[Challenged]
  C -->|owner proves alive / recover| A
  C -->|48h elapsed, death confirmed| D[Release]
  D --> E[Released]
Loading

The false-alarm case is the design constraint that matters most. Anyone can stop sending heartbeats to trigger a payout, so a heartbeat must be a proof of life, not an absence of activity, and a release must survive the owner showing up late.

How the heartbeat works

Every 30 days the owner must produce a Groth16 proof that a guest program executed correctly. The on-chain program accepts it only if:

  1. The receipt's image ID matches the pinned guest image (83a26d8b…c94ed).
  2. The journal is self-consistent: commitment == SHA-256(owner[32] || timestamp_le[8] || nonce[24]).
  3. The timestamp is within 300 seconds of chain time — this is what makes a replayed receipt worthless.
  4. The router's groth_16_verifier accepts the BN254 pairing check.

The public input is SHA-256(journal_outputs). The journal is 64 bytes: a SHA-256 commitment over owner || timestamp_le || nonce, followed by the raw 8-byte timestamp and 24-byte nonce.

What the proof does and does not hide. The ZK proof here is attestation, not secrecy, and it is worth being precise about which is which.

The guest commits the owner key, timestamp, and nonce in the clear:

env::commit_slice(&commitment);   // SHA-256(owner || timestamp || nonce)
env::commit_slice(&input[32..40]); // timestamp, raw
env::commit_slice(&input[40..64]); // nonce, raw

journal_outputs is a transaction argument, so it is public on-chain, and HeartbeatEvent additionally emits the owner and timestamp. A heartbeat does not hide that a particular vault owner proved they were alive at a given time. Anyone watching the cluster can link heartbeats to a vault address and build a liveness history for it.

What the proof guarantees is narrower and still non-trivial: only the committed guest running on the pinned image can produce a journal the verifier accepts, and the on-chain check binds it to the vault owner and the live cluster clock. A stale or replayed receipt is worthless.

Hiding liveness would need the guest to commit only a commitment over the whole input, with the timestamp checked inside the guest rather than published — a real design change, not a tweak. Tracked as a known limitation rather than claimed as a feature it is not.

Architecture

flowchart TB
  UI[Next.js + Phantom] --> RPC[Solana RPC]
  UI --> PROVER[Proving pipeline]
  PROVER --> SEAL[Groth16 receipt]
  SEAL --> PROGRAM[DeathClock Anchor program]
  PROGRAM --> VALIDATE[Journal + freshness checks]
  VALIDATE -->|CPI| ROUTER[verifier_router]
  ROUTER -->|73c457ba| GROTH[groth_16_verifier]
  GROTH -->|pairing result| ROUTER
  PROGRAM --> VAULT[Vault PDA]
  PROGRAM --> TREASURY[Treasury PDA]
  PROGRAM --> HEIRS[Named heir wallets]
Loading

The on-chain program is the source of truth for ownership, shares, state transitions, and lamport movement. The UI never claims a release is complete until the chain confirms it.

Verifier source is vendored under vendor/risc0-solana/ so the exact binary that runs on-chain is reproducible from this repository. DeathClock has no direct on-chain risc0-zkvm dependency — it only CPIs the router.

Business model

The protocol takes 0.5% of the distributable vault balance at release, paid to the treasury PDA. It is charged only on a successful inheritance, never on deposits, heartbeats, or a recovered false alarm. The owner pays nothing while alive.

The rationale is that the fee is collected exactly once, at the moment value actually moves and the heirs would otherwise need an executor.

Protocol rules

Rule Value
Heartbeat interval 30 days (2,592,000 s)
Challenge period 48 hours (172,800 s)
Proof freshness window 300 s (PROOF_MAX_AGE_SECONDS)
Proof clock skew allowance 60 s (PROOF_MAX_FUTURE_SECONDS)
Protocol fee 0.5% (5 / 1000) of distributable balance
Maximum heirs 5; shares non-zero, totalling 100%
Vault PDA ["vault", owner]
Treasury PDA ["treasury"], canonical bump

Repository layout

  • programs/deathclock/ — Anchor program, generated IDL, and the real-receipt E2E test.
  • zk/ — RISC Zero guest, host crate, and the two-phase proving pipeline.
  • vendor/risc0-solana/ — vendored verifier_router and groth_16_verifier.
  • app/ — Next.js 14 frontend with Phantom connection.
  • scripts/ — proving, E2E, deployment, and VPS tunnel scripts. drain-devnet-keypairs.sh moves lamports out of deploy-time keypairs only, because Solana will not let you deploy to an address that has ever held an account; see docs/DEPLOYMENT.md.
  • docs/ARCHITECTURE.md — component and sequence diagrams.
  • docs/POSTMORTEM.md — the failures behind this build, and what caused them.
  • docs/DEMO.md — judge-facing demo script.
  • docs/ROADMAP.md — what blocks this from being a product someone can safely use.

Reproducing the verified heartbeat

The proving pipeline needs Docker. Phase 1 (STARK) and phase 2 (seal assembly) run in the toolchain image; the Groth16 wrap runs the prover image directly from the host, because a nested container cannot see the build container's filesystem.

# Terminal 1 — a validator with all three programs at genesis.
bash scripts/localnet-e2e.sh          # or point DEATHCLOCK_RPC at a remote validator

With a validator on a separate host, start the tunnel first and skip local validator startup:

bash scripts/vps-tunnel.sh            # self-healing, forwards RPC and WebSocket
DEATHCLOCK_RPC=http://127.0.0.1:8899 DEATHCLOCK_RPC_EXTERNAL=1 \
  bash scripts/localnet-e2e.sh

Expected output:

✔ opens and funds the vault before the proof is used
✔ accepts a journal whose commitment is self-consistent
✔ submits the receipt and the router verifies it on-chain
✔ rejects the same proof once the journal is tampered with

4 passing

Run the ZK unit tests — including the two regressions for the BN254 modulus bug described in the postmortem — with:

docker run --rm -v "$PWD:/workspace" \
  -v sanitova_solana_cargo:/root/.cargo \
  -v sanitova_solana_zk_target:/build-target \
  -w /workspace -e CARGO_TARGET_DIR=/build-target \
  sanitova-solana bash -lc 'cargo test -p deathclock-zk'

Devnet deployment

All three programs are live on public devnet, each verified by reading the account back from the cluster (executable: true, owned by BPFLoaderUpgradeab1) rather than by trusting a deploy signature.

Program Devnet address
deathclock C8unxtjoDZWy2GmwHUPuSve1BHT5TtRKpNaDofbMS5Vh
verifier_router 5n8zx79RUHafwSSB4vRU5ao9atHzJQHTdJR9ty8YrVte
groth_16_verifier 2iPoTWMXWJ6inLnBeGEZyiKkwEzaQvCX24Cp82UcWm8K

Router state 7NHg6MZbtSaxJ7DQzdFCXLd1ZCJgHiYA2epxbGYYcPpQ is initialized and owned by the router program.

The proof path is wired on devnet. add_verifier requires the router PDA to hold the verifier's upgrade authority, and LoaderV3 forbids SetAuthority as a CPI -- but solana program set-upgrade-authority --skip-new-upgrade-authority-signer-check hands it over in a top-level transaction, since a PDA cannot co-sign the default checked form. See docs/POSTMORTEM.md §6 for how this was established, including the two dead ends on the way.

Verifier entry HFAWG7uYosXrfWHho4Q7Q3BqxcqqEAA8UsRUhjskHycF
Selector 73c457ba
estopped false
Registered by 2n96CPsM…

The deployer cannot upgrade the verifier afterwards -- authority belongs to the router PDA, so the revocation property add_verifier protects is intact.

Re-running the setup on a fresh cluster:

npx tsx scripts/claim-verifier-authority.ts   # once, moves the authority
npx tsx scripts/setup-router.ts               # initializes + registers

Redeploying after a program-ID change:

docker run --rm -v "$PWD:/workspace" \
  -v sanitova_solana_cargo:/root/.cargo \
  -v sanitova_solana_cache:/root/.cache/solana \
  -v "$HOME/.config/solana:/root/.config/solana" \
  -w /workspace -e DEATHCLOCK_AUTHORITY=<pubkey> \
  sanitova-solana bash -lc 'bash scripts/deploy-devnet.sh'

Two rules that script encodes, both learned the hard way: a program ID lives in Anchor.toml as well as declare_id! and Anchor.toml wins, and a program keypair must never be funded before deploying, because an account that has ever received lamports can never become a program account.

A vulnerability found and fixed

release_inheritance credited ctx.remaining_accounts[index] without ever comparing it to vault.heirs[index], and ReleaseInheritance carries no Signer. The caller chose both the timing and the recipients, so any account could be named as a "heir" and take the entire payout. The InheritancePayment event reported the registered heir, so the logs would show a payment to the correct beneficiary that never happened.

The state machine makes this reachable by design: once a heartbeat lapses and the challenge period elapses, the vault sits in Release and anyone can call the instruction.

Fixed in lib.rs by binding each payout account to its recorded heir, and by refusing program-owned accounts as recipients -- crediting lamports to a token account looks like a payment and pays nothing, because the balance lives in its data.

The fix is deployed. Transaction 4JXMzpPraVV8gRG3TauJEue3K6ezGVb27dW6PnehXqTN77t4JmFRJPvgf2rgHvnrNCj8gXp6jBVGLnHyGo59sTRe (err: None) upgraded the program, and the loader's last_deployed_slot is 505959380, matching the finalize transaction's slot -- so the program is running the guarded code rather than a deploy that merely reported success. npm run verify:deploy prints that state.

Getting there took funding the deploy authority from a second machine: the devnet faucet allows two airdrops per hour per IP, and the same IP was refused even for a freshly generated address, so no amount of retrying or keypair rotation would have helped.

Honest limitations

  1. Not audited. The security model is reasoned, not third-party reviewed.

  2. Proving takes 4-5 minutes, and the freshness window is 300 seconds. The program accepts a receipt up to 300s old and 60s in the future, so the seal is proven for a forward-shifted timestamp and the two must be issued as one flow. npm run heartbeat:devnet does this, deriving the offset from measured proving times. In production this is what a proving service exists to absorb -- the browser cannot do it, which is limitation 3.

  3. The browser cannot produce proofs; a service does. A Groth16 proof needs the RISC Zero prover, a multi-GB Docker pipeline that takes 4-5 minutes. scripts/prover-service.ts is that service: it takes an owner, proves, and returns a genuine seal. Verified on devnet -- a seal it produced is accepted on-chain:

    POST /prove {"owner":"<64 hex>"}   ->  202 { jobId }
    GET  /prove/:jobId                 ->  { status: "ready", seal: {...} }
    

    The frontend is wired to it: set NEXT_PUBLIC_PROVER_URL and the panel's "Request a proof" button asks for a seal, polls, and submits it on arrival. With the variable unset the panel says so and falls back to pasting, rather than showing a button that cannot work.

    Two honest limits remain. The service runs on one machine with a local Docker pipeline -- it is not a hosted, multi-tenant prover -- and a request occupies it for 4-5 minutes, so it serves one proof at a time. Neither is a problem for a demo; both matter for production.

  4. The oracle design is experimental. Death confirmation is currently a function of the challenge period elapsing unchallenged, not an independent death attestation.

  5. Release moves lamports directly, not from a PDA-owned token account. Native SOL works; a tokenised estate would need rework.

  6. The freshness window is tight by design. 300 seconds is the space between proving and submitting. It is a real operational constraint, not a formality.

Demo narrative

David, 45, deposits 10 SOL. Wife and two children are named 60/20/20. He sends a heartbeat with a real proof. Time passes without one; the vault goes Missed, then Challenged. If he submits a fresh heartbeat inside 48 hours, the vault returns to Active and nothing moves — that recovery path is what makes the protocol safe to use. If nobody objects, the estate releases to the heirs and 0.5% goes to the treasury.

The proof is the part worth showing live: generate one, watch the router dispatch to the Groth16 verifier, and show the tampered variant being rejected. Full runbook in docs/DEMO.md.

Roadmap

  • Deploy all three programs to devnet — done; see the devnet section below.
  • Stand up a proving service so the frontend can request a receipt.
  • Move release logic from lamport mutation to a PDA-owned token account.
  • Commission an external audit.

License

MIT

Contributors

HusseinAdeiza

Issues