The on-chain layer of Cryple's digital inheritance protocol: an impartial clock and an integrity
notary. Implements .docs/onchain-architecture.md
in the API repository, which is the specification and wins over this code where they disagree.
The chain is the impartial clock and the integrity notary. It authorizes release and proves the data was not altered. It never holds, moves, or decrypts any user data — and it cannot.
Target chain: Arbitrum Sepolia for the MVP, Arbitrum One for production. Arbitrum is required rather than incidental: the RIP-7212 P-256 precompile is what lets a user's existing seed-derived key sign transactions without a wallet, a browser extension, or a second curve.
| Contract | Purpose |
|---|---|
DeadManSwitch |
One record per account: timers, status, guardian commitment. Singleton keyed by account address. |
ProofRegistry |
One Merkle root per (account, epoch), anchoring vault-item hashes. Singleton. |
The account layer — the ERC-4337 smart account and its P-256 validator — lives in
p256-account, a separate MIT-licensed repository with no dependency on
Cryple. That separation is deliberate: the account and its social-recovery module are generic
infrastructure, and nothing in them is inheritance-specific.
Repeated here because the inverse is the most common misreading, and an auditor will check:
- It stores no user data. No plaintext, no ciphertext. Only hashes and commitments.
- It holds no keys and cannot decrypt anything. Release is a state flag, not a key handover.
- It does not enforce the confidentiality window. A contract cannot stop an off-chain party that already holds ciphertext and key material from using it early. This is limitation L1 in the architecture document and it is the most important honest caveat about the whole design.
- It does not verify identity or death. It measures silence, not mortality.
Unconfigured ──configure()──▶ Active ──trigger()──▶ Contest ──finalize()──▶ Released
▲ │ │
└───┴───checkIn()───────┘
| Function | Caller | Effect |
|---|---|---|
configure(...) |
owner account only | Sets periods and the guardian commitment; refreshes the clock. During Contest this also revokes |
checkIn() |
owner account only | Refreshes the clock. During Contest this is the revocation |
trigger(owner) |
anyone | Only after lastCheckIn + inactivityPeriod |
finalize(owner) |
anyone | Only after triggeredAt + contestPeriod |
Two access decisions carry the trust model, and both are tested:
Check-in is owner-only. It must be a userOp signed by the user's P-256 key on the user's own
device. Nothing server-side may ever check in on a user's behalf — if Cryple's backend could, it
could keep a dead user's switch alive forever and the "impartial judge" claim would be false.
test_OnlyTheOwnerCanCheckIn pins it.
Trigger and finalize are permissionless. Anyone may advance a record whose deadline has genuinely passed: an heir, a guardian, a keeper bot, or Cryple's relayer. If only Cryple could advance the state, Cryple's disappearance would freeze every switch, which is precisely the failure mode the on-chain layer exists to remove. A premature call reverts and the caller pays the gas.
Released is terminal. There is no undo, because an undo function would be an attack surface aimed
at the exact moment the owner can no longer defend themselves. The contest period is the protection
instead.
checkIn() is the documented revocation, but configure() resets a record from any status except
Released, so an owner who reconfigures during Contest also cancels the pending release. Both
paths emit Revoked, so an indexer can key off that one event and be complete — it does not
need to treat Configured as a possible revocation or reconcile against statusOf().
When configure() revokes, Revoked is emitted before Configured and CheckedIn, so a
consumer replaying the log in order can never write a countdown state after the cancellation that
ended it. test_RevokedPrecedesConfiguredAndCheckedIn pins the order; two further tests pin that
no Revoked is emitted when there was no pending release to cancel.
minInactivityPeriod and minContestPeriod are constructor arguments, so a testnet deployment can
exercise the whole lifecycle in minutes while mainnet enforces production floors (for example 30
days and 7 days). The values in force must be recorded in the deployment record — they are the
difference between a switch that protects a user on holiday and one that fires on them.
anchor(uint64 epoch, bytes32 root) // owner account only
mapping(account => mapping(epoch => root)) // epoch = unixSeconds / 86400
- Leaf =
SHA-256(encrypted blob bytes)— the ciphertext as stored, exactly what an heir downloads. - Tree = SHA-256 throughout, including internal nodes. Proofs are verified only in the heir's browser, never on-chain, so there is no gas argument for keccak256, and one hash function everywhere removes a class of client bugs.
Anchoring is signed by the user, not the backend. A relayer authorised to anchor could anchor the root of tampered data, and the heir's verification would then pass against the tampered blob — silently destroying the exact guarantee the registry exists to provide. The relayer may only relay.
Once an epoch ends, the root recorded for it can never be restated — anchor reverts with
EpochAlreadyAnchored on any write to a past epoch that already holds a root, including a write of
the identical root. This is what makes an inclusion proof durable: an heir holding a valid proof
against the epoch-N root cannot have it invalidated afterwards. test_RewritingAnOccupiedPastEpochIsRejected
and testFuzz_AClosedEpochNeverChanges pin it.
Two writes stay legal, and neither rewrites history:
- Re-anchoring within the current epoch, which is expected — the vault changes during the day.
Today's root is mutable and freezes at midnight (
test_TodaysRootFreezesWhenItsEpochCloses). - Backfilling a past epoch that was never used, which is not an overwrite. The client depends on it: an anchor submitted at 23:59 may be mined after midnight, and rejecting it would burn a sponsored userOp for a clock race.
Only the owner's own account can anchor, so this was never reachable by a compromised backend. It was reachable by anyone holding the owner's key, and the guarantee as documented is unconditional — so the code now enforces it rather than the document describing an intention.
Client consequence. If an anchor for epoch N is mined after epoch N closed and N already holds
a root, the transaction reverts. The client must treat EpochAlreadyAnchored as "re-anchor at the
current epoch", not as an error to surface.
Normative for every contract and every event here:
- Never on-chain: guardian addresses, heir addresses, identities, item names or counts, email
addresses,
user_address,username, or any relationship between two accounts. - Allowed: the account address, timers and status, the guardian Merkle root, vault roots, epochs.
- Events are as public as storage.
test_EventsCarryNoGuardianOrHeirAddressasserts that no guardian or heir address appears in any topic or data word across a full lifecycle.
The residual leak is accepted honestly: an observer learns that an address uses an inheritance protocol, its configured periods, and its check-in cadence.
forge build
forge testTests cover the full state machine, both permission boundaries, the revocation path, minimum-period enforcement, the privacy rule above, and a fuzz over period configuration asserting the switch never releases early.
A user's smart account is not deployed at sign-up. Its address is derived, quoted, and only becomes
a contract when the first UserOperation carries initCode. That path had never been exercised:
every test called factory.createAccount directly and passed initCode: "", so no bundler had ever
judged the factory.
Two things now cover it, and they answer different questions.
test/CounterfactualDeployment.t.sol proves the encoding and the state transition against a
local EntryPoint: one operation deploys the account and configures the switch, the deployed address
is the one derived beforehand, the switch records the account rather than the relayer as caller, and
a foreign key leaves nothing deployed. It cannot answer the bundler question, because ERC-7562's
rules for factories are enforced off-chain by the bundler.
script/bundler_userop.py answers that one, against the live Arbitrum Sepolia deployment.
python3 script/bundler_userop.py estimate # bundler validation; spends nothing
python3 script/bundler_userop.py simulate # eth_call of handleOps; spends nothing
python3 script/bundler_userop.py send # broadcast; needs the sender funded
python3 script/bundler_userop.py receipt <opHash> # poll and read back chain stateEvery mode prints the sender's balance next to the prefund the declared gas limits require, so the one failure this harness kept producing is visible before it is paid for.
It requires cast on the path and Python's cryptography package, and reads
ARBITRUM_SEPOLIA_RPC_URL, BUNDLER_URL and OWNER_P256_KEY from the environment — all three have
defaults, and the default key is a fixed test scalar that must never hold anything.
It reads the repository's .env itself, since only forge gets that for free, with real
environment variables taking precedence over the file.
Three details worth knowing before changing it:
- The userOpHash comes from the chain, not from this repository. EntryPoint v0.8 hashes a
UserOperation with EIP-712, so the script calls
entryPoint.getUserOpHashrather than reimplementing it. A local reimplementation that drifted would produce signatures that fail only in production. simulateuses aneth_callstate override to give the counterfactual sender a balance. That covers the prefund without a transaction, so the whole path — factory, initialisation, P-256 verification through RIP-7212, andconfigure()— is exercised for free against the deployed contracts. A wrong key reverts, so a clean result is meaningful rather than vacuous.estimateis signature-blind. Bundlers accept a dummy signature when estimating, so a clean estimate says the factory was accepted and says nothing about the signature. Usesimulatefor the signature.sendrefuses to broadcast an underfunded operation, and does not trust its own success. It aborts locally when the balance is below the required prefund rather than paying a bundler to returnAA21; after the operation mines it re-readseth_getCode(sender)andDeadManSwitch.recordOf(sender), because a bundler accepting an operation and the operation having done its job are two different claims.receiptruns that same read-back on its own, for when the poll window expires while the op is still pending.
The stake question this settles is recorded in
p256-account/README.md § The factory needs no EntryPoint stake,
along with the result of the real send on 2026-08-17.
With no paymaster the account pays its own prefund, so ETH has to reach the counterfactual address before the deploying operation. Re-measured on EntryPoint v0.8, 2026-08-19, deploy-plus-configure burns 654,529 gas and cost 0.0000786 ETH — the 660,006 figure recorded on 2026-08-17 was measured on v0.9 against the old implementation and is superseded. The version change was worth 0.8%; the declared limits below were worth far more.
Size the transfer against the declared gas limits, not against that cost. The EntryPoint demands
(verificationGasLimit + callGasLimit + preVerificationGas) × maxFeePerGas in hand before it starts.
Fund for the cost and the send fails with AA21 while holding more than enough ETH to have paid for
itself.
The harness used to declare a flat 3,300,000 gas, which demanded 0.00066 ETH of prefund for an
operation that spends a tenth of it — and made the paymaster price every sponsored operation off that
figure. measure_gas_limits now calls eth_estimateUserOperationGas before every send and declares
the result plus headroom.
| Operation | declared before | declared now | prefund at 0.2 gwei |
|---|---|---|---|
deploy + configure() |
3,300,000 | ~613,000 | 0.00066 → 0.000123 ETH |
checkIn() |
3,300,000 | ~265,000 | 0.00066 → 0.000053 ETH |
The headroom differs by field because the refund rules do. Unused verificationGasLimit and
callGasLimit are refunded, so headroom there costs only a larger prefund requirement — they carry
EXECUTION_GAS_HEADROOM, 1.25. preVerificationGas is charged in full as declared, used or not,
so every unit of headroom is spent; it carries PRE_VERIFICATION_GAS_HEADROOM, 1.15. Declaring
300,000 there against a real requirement near 127,000 is where most of the old waste sat: it is why
the on-chain operation was billed 654,529 gas for roughly 373,000 gas of measured work.
preVerificationGas cannot be hardcoded lower on Arbitrum. It embeds the L1 data-availability
fee, so it moves with the L1 base fee — observed between 51,802 and 147,188 for the same operation.
A constant tuned to a quiet L1 turns into a rejected operation on a busy one, which is why this is
measured per-send rather than lowered. PROBE_* are the ceilings used for the estimate call itself,
and remain the declared values when the bundler cannot be reached.
Nothing is lost to the gap: the unspent prefund lands in the account's EntryPoint deposit rather
than its balance, where it is withdrawable with withdrawTo and spendable on later checkIn()
operations. The 2026-08-17 run left 0.00058 ETH there; under measured limits the same gap is about
0.00004 ETH, so the deposit no longer quietly absorbs most of what a user transferred.
The cost figure is the least durable number here. The EntryPoint charges
min(maxFeePerGas, baseFee + maxPriorityFeePerGas), and the base fee at execution was 0.02002 gwei
against a 0.2 gwei ceiling — so the same operation at the ceiling costs about 0.000132 ETH.
Budget from the gas number.
With sponsorship (Task 50) the funding step disappears entirely, which is why that task and this one share a dependency.
The account is an ERC-4337 account bound to EntryPoint v0.8 at
0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108, set by an entryPoint() override in P256Account
that returns ERC4337Utils.ENTRYPOINT_V08 instead of OpenZeppelin's Account default of v0.9.
Bundler support does not imply paymaster support, and assuming it cost a round-trip. Pimlico's
bundler for chain 421614 lists v0.9 in eth_supportedEntryPoints, and this section previously
concluded from that alone that no account change was needed for sponsorship. It was wrong: bundling
and sponsoring are separate services, and a verifying paymaster is deployed per EntryPoint version.
Against a live key and policy, pm_getPaymasterStubData answers v0.7 and v0.8 with stub data and
rejects v0.9 with "Paymaster is not enabled for this EntryPoint version". The version is
therefore chosen by what the paymaster supports, not the bundler.
Nothing in this protocol used a v0.9-only feature. PackedUserOperation is byte-identical
between the two versions, EIP-7702 support exists in both, and RIP-7212 is a precompile that owes
nothing to the EntryPoint. What v0.9 adds and this codebase never referenced: the optional
paymasterSignature appended to paymasterAndData, getCurrentUserOpHash(), the Stakeable
helper, and a handful of finer-grained errors and events. The one real cost of v0.8 is thinner
revert data on some paymaster and beneficiary failure paths.
test/SponsoredCheckIn.t.sol proves the mechanism against the live
chain rather than a local EntryPoint: an account holding zero wei, with no deposit of its own,
configures and checks in while a paymaster pays. It also asserts the account's balance and deposit
are still zero afterwards, so a passing test cannot mean "it quietly paid for itself".
forge test --match-contract SponsoredCheckIn # needs ARBITRUM_SEPOLIA_RPC_URLThe test skips itself when that variable is unset, so forge test stays green on a machine or CI
job without an endpoint.
The paymaster in that test is TestPaymasterAcceptAll, deployed and funded inside the fork. It
proves the EntryPoint accounting — that a zero-balance account can be paid for — and it cannot
prove that a hosted sponsor will agree to pay, because that decision is a policy on someone
else's server.
Probed against Pimlico's public endpoint on 2026-08-17, the pm_* methods are routed but
unsponsored:
pm_getPaymasterStubData -> "Sponsorship policy ID is required for this API key"
That error is the whole remaining gap, and it is an account signup rather than any code change. Two environment variables turn it on:
| Variable | Meaning |
|---|---|
PIMLICO_API_KEY |
project key; the harness routes pm_* to api.pimlico.io when set |
PIMLICO_SPONSORSHIP_POLICY_ID |
the policy that decides what gets paid for |
python3 script/bundler_userop.py sponsor # ERC-7677 stub -> estimate -> final -> sendScope the policy to contract addresses if the provider allows it, and to a spend cap regardless.
A policy that sponsors any target turns the paymaster into a public faucet for arbitrary calldata,
since anyone may deploy a P256Account through the same factory. Pimlico's documented policy schema
covers chain ids, date bounds and spend limits; target-address scoping is a dashboard/webhook
feature whose exact form should be read off the dashboard rather than assumed from here. On testnet
the spend cap is sufficient; on Arbitrum One target scoping is not optional. The three targets
this protocol needs are:
| Address | Why |
|---|---|
0x6951a65CDc706A2D23E1015d35B8353F18A569a9 |
DeadManSwitch — configure, checkIn |
0xd344197975C4D47f97dDB1d26b91a96be6e83930 |
ProofRegistry — anchor |
0xa2Cd247C12f087450f4991c92e6FBc7cE015a527 |
P256AccountFactory — first-operation deployment |
The factory entry is what makes a fresh account sponsorable. Without it the very first operation — the one the user has no ETH for and no way to fund — is the one that gets refused, and sponsorship helps only users who already needed no help.
sponsor adapts to the account's state: an undeployed sender gets initCode plus configure(),
an already-configured one gets checkIn(). Point it at a fresh OWNER_P256_KEY to exercise the
zero-ETH path from nothing. It verifies sponsorship by arithmetic rather than by trust — the
sender's balance and EntryPoint deposit must both be unchanged and zero afterwards, so an operation
that quietly paid for itself is reported as a failure, not a success.
Sponsorship is not only a convenience here — its failure points the wrong way. A user who cannot
check in looks exactly like a user who has died: after inactivityPeriod anyone may trigger(),
and the switch fires on a living owner. So the unsponsored path is a safety requirement, not a
fallback of last resort. The EntryPoint charges the account directly when paymasterAndData is
empty, which needs no extra contract, and
test_AnUnsponsoredAccountCanStillCheckInFromItsOwnDeposit pins that it works. This is limitation
L7 in the architecture document.
Configuration lives in two places: the network aliases and verification keys in
foundry.toml, and the secrets they expand, which are listed in
.env.example. Copy it to .env and fill it in; forge loads .env from the
repository root automatically.
| Variable | Used by | Notes |
|---|---|---|
ARBITRUM_SEPOLIA_RPC_URL |
--rpc-url arbitrum_sepolia |
Public endpoint works; a keyed provider is steadier for --verify |
ARBITRUM_ONE_RPC_URL |
--rpc-url arbitrum_one |
Production only |
ARBISCAN_API_KEY |
--verify |
An Etherscan V2 key from etherscan.io covers Arbiscan on both chains |
DEPLOYER_ACCOUNT |
--account |
Keystore alias created by cast wallet import <name> --interactive |
DEPLOYER_PRIVATE_KEY |
--private-key |
Alternative to the keystore; throwaway testnet keys only |
PRODUCTION |
Deploy.s.sol |
true selects the mainnet floors and rejects anything below them |
MIN_INACTIVITY_SECONDS |
Deploy.s.sol |
Optional override, seconds |
MIN_CONTEST_SECONDS |
Deploy.s.sol |
Optional override, seconds |
The deployer is a plain EOA that signs the two CREATE transactions and nothing else. It is
unrelated to any user's P-256 key and holds no authority over DeadManSwitch or ProofRegistry
once they exist — neither contract has an owner, an admin, or an upgrade path.
forge script script/Deploy.s.sol \
--rpc-url arbitrum_sepolia \
--account cryple-deployer \
--sender 0xYourDeployerAddress \
--broadcastFor a dry run, drop --broadcast and pass --sender alone — no password is needed, and the script
still prints the addresses it would create, the gas estimate, and the minimum periods in force. Note
that a dry run computes addresses from the sender's current nonce, so they will not match the real
deployment if anything else is broadcast in between.
Verify afterwards, explicitly — --verify cannot be trusted here. On the 2026-08-16 deployment
it reported "We haven't found any matching bytecode" and then "All (0) contracts were verified!",
exiting zero having submitted nothing. It has to infer which source produced each address by matching
bytecode against build artifacts, and a failed match is not treated as an error.
forge verify-contract <DeadManSwitch> src/DeadManSwitch.sol:DeadManSwitch --chain 421614 --watch \
--constructor-args $(cast abi-encode "constructor(uint32,uint32)" 300 120)
forge verify-contract <ProofRegistry> src/ProofRegistry.sol:ProofRegistry --chain 421614 --watchDeadManSwitch needs its constructor arguments re-encoded with the values actually used at
broadcast; ProofRegistry takes none. Neither command needs the deployer key — verification is an
HTTPS POST of source code, not a transaction. Confirm the result on Arbiscan rather than trusting the
CLI: an unverified contract returns an empty SourceCode field from
module=contract&action=getsourcecode.
The account factory in p256-account is deployed separately and has an additional
wrinkle — its implementation is created inside the factory constructor and needs its own submission.
See its README.
Unaudited. Deployed to Arbitrum Sepolia on 2026-08-16 from commit db9582f, compiler v0.8.30,
optimizer enabled at 200 runs. Both contracts are verified on Arbiscan:
| Contract | Address | Constructor |
|---|---|---|
DeadManSwitch |
0x6951a65CDc706A2D23E1015d35B8353F18A569a9 |
minInactivity = 300, minContest = 120 |
ProofRegistry |
0xd344197975C4D47f97dDB1d26b91a96be6e83930 |
none |
This deployment is for demonstration and testing only. Its minimum periods are 300 and 120
seconds, so a full configure → trigger → contest → finalize cycle completes in about seven minutes.
They are constructor arguments with no upgrade path, so Arbitrum One requires a fresh deployment at
the production floors — see Minimum periods are a deployment
constant.
The full deployment record lives in
.docs/onchain-architecture.md
and must capture, per deployment: commit hash, compiler version and settings, constructor
arguments, the minimum-period constants in force, and the Arbiscan verification link.