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::verify183,194 of 200,000 compute units,
err: None. The BN254 pairing check runs inside the Solana VM, not off-chain. Reproduce withnpm 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 bynpm run e2e:devnet. Reproduce withnpm run verify:live-vault, which decodes it through the same path the deployed site uses.
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]
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.
Every 30 days the owner must produce a Groth16 proof that a guest program executed correctly. The on-chain program accepts it only if:
- The receipt's image ID matches the pinned guest image (
83a26d8b…c94ed). - The journal is self-consistent:
commitment == SHA-256(owner[32] || timestamp_le[8] || nonce[24]). - The timestamp is within 300 seconds of chain time — this is what makes a replayed receipt worthless.
- The router's
groth_16_verifieraccepts 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, rawjournal_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.
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]
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.
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.
| 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 |
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/— vendoredverifier_routerandgroth_16_verifier.app/— Next.js 14 frontend with Phantom connection.scripts/— proving, E2E, deployment, and VPS tunnel scripts.drain-devnet-keypairs.shmoves lamports out of deploy-time keypairs only, because Solana will not let you deploy to an address that has ever held an account; seedocs/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.
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 validatorWith 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.shExpected 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'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 + registersRedeploying 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.
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.
-
Not audited. The security model is reasoned, not third-party reviewed.
-
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:devnetdoes 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. -
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.tsis 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_URLand 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.
-
The oracle design is experimental. Death confirmation is currently a function of the challenge period elapsing unchallenged, not an independent death attestation.
-
Release moves lamports directly, not from a PDA-owned token account. Native SOL works; a tokenised estate would need rework.
-
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.
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.
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.
MIT