nihilium-recovery-sdk
v0.9.0
Published
Identity-gated, condition-based key recovery for self-custodial wallets, built on Nihilium.
Readme
Nihilium Recovery SDK
Identity-gated key recovery for self-custodial wallets, built on Nihilium.
A recovery has two halves. Off-chain, this SDK puts a recovery key behind a private identity condition — an email inbox, a passport, or k of n of them — and returns signing authority once that condition is satisfied. On-chain, a settlement contract decides what that authority may do and how slowly: a rotation to a committed new owner, after a timelock, under a three-way veto.
Neither half is sufficient alone. The identity gate proves who you are; the contract bounds what that permits. No veto key can move funds, and no recovery completes without both the gate and a matured timelock.
Recovery covers loss, not theft. It restores access to an owner who lost it. It does not defend a wallet whose seed an attacker already holds.
Specification: spec/nihilium-recovery-sdk.md. Section references
in the code point at it.
Contents
- Install
- The interface
- Creating a vault
- Settlement
- Recovering
- Identity gates
- Storage
- Watchtower
- Packages
- Reference
Install
npm install # workspaces; postinstall inits the onchain/ submodule
npm run build
npm test # 980 tests
npm run typecheck # tsc -b --force, over every package's src/
npm run typecheck:tests # the test files, which the project build does not cover
npm --prefix onchain/evm install # Solidity deps, a separate tree
npm run test:evm # 118 teststypecheck:tests is separate because every package tsconfig.json is include: ["src/**/*"], so
tsc -b never sees test/ and vitest strips types without checking them. Run both.
Every package here is released in lockstep, so one version spans 18 manifests plus every internal
dependency range. npm run version:set 0.9.0 writes them together, and
packages/core/test/version.test.ts fails if they ever disagree — a range left behind stops
matching its sibling, which makes npm resolve an old copy from the registry instead of linking the
workspace.
Version 0.8, unpublished — consume it with file: links against this checkout, under the
@nihilium/recovery-* names used throughout. @nihilium/client-sdk resolves from npm at ^0.8.0.
For a runnable tour — real key adapters, real KDF, real filesystem stores, with only the identity ceremony faked so it runs offline and for free:
npm run build && node examples/quorum-recovery.mjsThe interface
RecoverySDK is constructed from four adapters and exposes eight methods.
new RecoverySDK({
key, // KeyAdapter — the curve (secp256k1, ed25519)
condition, // ConditionAdapter — the identity ceremony
sealStore, // optional — where the seal is kept
dataStore, // optional — where encrypted records are kept
recordHosts, // optional — hosts to declare in seals
followSealDeclaredHosts, // optional RecordHostPolicy — see Storage
})| Method | Cost | Returns |
|---|---|---|
| createVault({ condition, vaultId, recordHosts?, onProgress? }) | paid, online, minutes | { sealBlob, publicComponent, recordId } |
| addChain({ publicComponent, chain, key?, recoveryKey? }) | free, offline, ~2 ms | { recoveryPubKey, publicComponent, entry } |
| addChainWithSigner({ publicComponent, chain, key?, use }) | free, offline | the same, plus whatever use(signer) returned — for a construction the incoming key must sign for |
| recover({ proof, chain, seal?, entries?, options?, onProgress? }) | free, long-running | { authority, spent } |
| recoverAll({ proof, chains, seal?, entries?, options?, onProgress? }) | free, long-running, one ceremony | { results, spent, wipe } — one row per chain, a failure as a row |
| discardSpentSeal(vaultId) | free | void |
| watchTargetsFor(publicComponent) | free, pure | WatchTarget[] |
| buildWatchRegistration({ publicComponent, additional?, label? }) | free, pure | WatchRegistration |
createVault() and addChain() are separate calls because only the first is paid. A vault with no
chains is a normal state: recovery can be configured before the user holds anything, and each chain
added when value first arrives on it.
ChainContext identifies what a record protects. Four of its fields are KDF inputs — namespace,
accountId, vaultId and epoch — and three of those are checked against the envelope at recovery.
epoch is not, so a wallet records what it sealed under and never recomputes it from the chain;
tier is a label, not a KDF input, and changing it affects nothing derived.
const chain = {
namespace: "eip155:11155111", // CAIP-2. Solana uses the genesis-hash form.
tier: "smart-account" as const, // "smart-account" | "delegated-eoa" | "script"
accountId: "0xYourSmartAccount",
vaultId: "wallet-1", // the compartment: one seal, one processor cohort
epoch: 0, // bumped by each completed recovery
};A vault spanning chains recovers in one ceremony: recoverAll opens the records once and derives
each chain's key on that chain's own curve, checking each against the expectedRecoveryPubKey the
wallet recorded at addChain() — the only guard against a wrong epoch.
namespace cannot be corrected after the fact. Changing it derives different keys from the same
root, so every seal made before the change opens an envelope whose namespace no longer matches.
There is no migration. Mainnet and testnet are different namespaces and therefore different keys.
Creating a vault
import { RecoverySDK } from "@nihilium/recovery-core";
import { EvmKeyAdapter, toEvmAddress } from "@nihilium/recovery-key-evm";
import { ZKEmailConditionAdapter } from "@nihilium/recovery-condition-zkemail";
import { LocalSealStore, LocalSealedDataStore } from "@nihilium/recovery-storage-local";
import {
NihiliumPaymentProviderClientAPIKEY_DO_NOT_USE,
setApiEndpoint,
} from "@nihilium/recovery-nihilium";
setApiEndpoint("https://api.nihilium.io");
const condition = new ZKEmailConditionAdapter({
emailServiceUrl: "https://zkemail.nihilium.io",
network: 11155111,
threshold: 2, // k — processors that must cooperate to recover
processorCount: 3, // n — processors sealed with
payment: new NihiliumPaymentProviderClientAPIKEY_DO_NOT_USE(
"https://api.nihilium.io",
process.env.NIHILIUM_API_KEY!,
),
});
const sdk = new RecoverySDK({
key: new EvmKeyAdapter(),
condition,
sealStore: new LocalSealStore({ directory: "./seals" }),
dataStore: new LocalSealedDataStore({ directory: "./seals" }),
});
const { sealBlob, publicComponent, recordId } = await sdk.createVault({
condition: await condition.buildCondition({ email: "[email protected]" }),
vaultId: chain.vaultId,
});
const { recoveryPubKey, entry } = await sdk.addChain({ publicComponent, chain });
const recoveryOwner = toEvmAddress(recoveryPubKey);Hold a reference to the adapter: buildCondition and buildProof belong to it, and the SDK does
not re-export them — a ceremony's parameters are adapter-private.
Sealing runs a Groth16 proof per share, so expect tens of seconds. The four outputs want four different homes:
| Output | What it is | Where it goes |
|---|---|---|
| sealBlob | bearer artifact — whoever holds it can attempt a recovery | the user's device or cloud, not the identity factor's domain (§12) |
| entry | this chain's encrypted record — inert without the seal | anywhere, and ideally several anywheres |
| publicComponent | the non-bearer half: endpoints, reveal values, append key | alongside the seal; it re-derives if lost |
| recordId | a lookup handle, e.g. K7M2-9QX4-3FRB-8TDN-P51W-6YC0 | safe to email to the user |
recoveryPubKey goes on-chain — see Settlement. A recovery key nothing has
registered protects nothing.
Constraints
- An existing private key cannot be sealed.
recoveryKeyaccepts{ kind: "generate" }or{ kind: "rrs", rrs }— a root secret, never a chain key. The KDF is what buys agreement without coordination: seal time and recovery time derive the same key from the root plus the context alone. A supplied chain key has no context to reproduce from. - Losing the records loses the recovery, seal or no seal. A record is inert without the seal, so the mitigation is the inverse of the seal's placement rule: keep copies everywhere.
- One recovery opens every record in the vault. Each chain gets its own root, so a root exposed
for one chain derives nothing for another — but every record is encrypted to the same vault key.
The compartment is
vaultId, not(chain, account). Use separate vaults for chains that must fail independently.
Adding chains later
addChain() takes no SealBlob — the parameter list is the guarantee. It needs only the vault's
published public key, so it mints a fresh root, encrypts one record and appends it, offline.
const first = await sdk.addChain({ publicComponent, chain: evmChain });
const second = await solanaSdk.addChain({ publicComponent, chain: solanaChain });| Per added chain | Cost | |---|---| | Nihilium ceremony | none | | off-chain work | ~2 ms, local | | settlement transaction | one, per account |
Two chains behind a 3-of-5 quorum cost 5 ceremonies; so do ten chains added over five years.
Settlement
The on-chain half is an extension point with three tiers, and a wallet picks the one it needs:
| Tier | Who | What it uses |
|---|---|---|
| 0 — just the key | a wallet with its own account implementation | recover() / recoverAll() above, and nothing else here |
| 1 — a construction | a wallet that wants one of the shipped ones, or has a module of its own | a SettlementAdapter: @nihilium/recovery-settlement-evm, @nihilium/recovery-settlement-solana, or yours |
| 2 — the standard flow | a wallet that wants the ledger, protect, handovers and watch done | @nihilium/recovery-sdk, which drives any Tier-1 adapter |
A SettlementAdapter is twelve methods: read the account's state, two preflights, register, build
an intent, initiate, execute, abort, optional pause and resume, the server side of a relay, and an
optional watch target. SettlementAdapterBase in core supplies the defaults, and
settlementAdapterConformance in @nihilium/recovery-core/testing runs against any implementation,
so a custom module is held to the same contract as the shipped ones. Who pays is a separate axis
from who authorises: initiate and execute carry their authority inside the transaction, so they
go through a Submitter — HttpRelaySubmitter in a browser, a direct submitter behind
@nihilium/recovery-relayer on a server — while abort and pause check the sender and take an
AccountExecutor. examples/settlement-evm.mjs and examples/settlement-solana.mjs drive one
adapter each, headlessly.
Three shipped constructions, the two EVM ones sharing the GradualVeto library — which is inlined
into each and never deployed on its own.
| Contract | Protects | Installed by | Adapter |
|---|---|---|---|
| RecoveryModule | an ERC-7579 smart account | installModule(EXECUTOR, …) | Erc7579RecoveryModuleSettlement |
| Eip7702RecoveryAccount | a plain EOA that already exists | a one-time EIP-7702 delegation | Eip7702RecoveryAccountSettlement |
| the recovery vault program | a program-owned vault PDA on Solana | create_vault, then register signed by the incoming key | SolanaRecoveryVaultSettlement |
The 7702 path has its own walkthrough: docs/arbitrum-7702.md. Its delegation can only be signed while the EOA's own key is live, which constrains when a wallet can offer it.
Installing the module
recoveryOwner and the veto config are the onInstall data, ABI-encoded as
(address, GradualVeto.Config):
GradualVeto.Config memory veto = GradualVeto.Config({
pauseAuthority: 0x…, // may pause an in-flight recovery; that is all
abortAuthority: 0x…, // may kill one, irreversibly. Keep offline.
resumeMembers: [g1, g2, g3],
resumeThreshold: 2, // of resumeMembers, to lift a pause early
timelockSeconds: 7 days,
pauseCeilingSeconds: 3 days
});
account.installModule(2 /* EXECUTOR */, RECOVERY_MODULE, abi.encode(recoveryOwner, veto));GradualVeto.validate rejects a config unless all of the following hold, so separation is enforced
on-chain rather than trusted from the client:
pauseAuthorityandabortAuthorityare non-zero and different addressesresumeMembersis non-empty, with no zero address and no duplicates0 < resumeThreshold <= resumeMembers.length- no resume member is the pause or abort authority
timelockSecondsandpauseCeilingSecondsare both non-zero
Three capabilities therefore need three distinct parties. Each is individually harmless — none can move funds, and none can complete a recovery — so the protection is in their separation. A wallet provider can hold the pause key without holding custody.
Check the known issues for the version you are on
The contracts carry no upgrade path, so a fix only reaches an account that installs the fixed version — and the versions currently deployed are not the fixed ones. The address book ships the audit findings per version so this is checkable rather than folklore:
import {
recoveryModuleKnownIssues, // version -> KnownIssue[]
eip7702AccountKnownIssues,
recoveryModuleVersion,
} from "@nihilium/recovery-onchain-evm";
recoveryModuleKnownIssues[recoveryModuleVersion(421614)]; // what the live module still hasA KnownIssue carries an id, a severity and a summary. An empty list means none known:
RecoveryModule 4.0.0 and Eip7702RecoveryAccount 2.0.0 are both empty. Every deployed version
is not.
The ones that shape how you configure the veto:
| Id | Affects | What it means |
|---|---|---|
| H-1 | module ≤ 3.0.0, account 1.0.0 | a resume signature stays valid for the whole attempt, so one endorsement can be replayed to undo every later pause — leaving only abort |
| H-2 | module ≤ 2.0.0 | a resume signature is not bound to the attempt, so an endorsement also lifts a pause on a later attempt with the same intent |
| M-1 | module ≤ 3.0.0, account 1.0.0 | abort does not spend the nonce, so an aborted intent can be resubmitted and reopened until it expires |
| M-2 | module ≤ 3.0.0, account 1.0.0 | pauseCeilingSeconds bounds each pause rather than the total, so the pause authority can re-pause after every ceiling and hold an attempt indefinitely unless the resume quorum acts |
| L-3 | module ≤ 3.0.0, account 1.0.0 | the recovery key is not checked against the veto roles, so it may also hold one |
Until the fixed versions are deployed, configure as if a pause could be held indefinitely and a
resume could be replayed: the resume quorum is the only counterweight to a stalling pause authority,
and abort remains the only irreversible capability. Check recoveryModuleKnownIssues on a live
account's installed module and prompt a migration rather than assuming the latest source is what the
account runs.
Recovery lifecycle
The recovered key signs an EIP-712 Intent over the account, epoch, nonce, replacement validator
and expiry. Every field is bound, so an intent cannot be replayed onto another account, another
epoch, or after it goes stale.
initiateRecovery(intent, signature) permissionless to submit — the authority is the signature,
│ not the sender, so a relayer can pay for a user with no funds
├─ pause(account) pause authority only; INITIATED → PAUSED
│ └─ resume(account, …) a threshold of distinct members, or the ceiling lapses
├─ abort(account) abort authority only; irreversible
▼
timelockSeconds elapse → EXECUTABLE
executeRecovery(intent) installs the validator, bumps the epochstateOf(account) projects the clock forward, so a pause past its ceiling reads as INITIATED
whether or not anyone has poked the contract.
Confinement. The only call the module can make is installModule(VALIDATOR, …) on the
recovering account. The intent chooses which validator; it never chooses the call shape or the
target. There is no path through the module that moves a token — which is what makes a leaked raw
recovery key still worth only the committed, vetoable rotation.
The module does not uninstall the superseded validator: removing a module from an ERC-7579 sentinel list needs a correct predecessor pointer. That is a follow-up for the recovered owner.
Addresses
Read them from the package, never from a constant in your own code. The accessors throw for a chain with no deployment rather than returning a guess.
import {
recoveryModuleAddress, // chainId -> address, or throws
recoveryModuleVersion, // chainId -> "2.0.0" | "3.0.0" …
eip7702AccountAddress, // chainId -> address, or throws
legacyRecoveryModuleAddresses, // superseded deployments, still on-chain
recoveryModuleKnownIssues, // version -> KnownIssue[]
eip7702AccountKnownIssues,
recoveryModuleAbi,
eip7702RecoveryAccountAbi,
VetoStateOrdinal,
ModuleType,
} from "@nihilium/recovery-onchain-evm";A module is a singleton with no upgrade path, so a new version is a different contract at a
different address, and chains move to it independently — hence recoveryModuleVersion, and hence
legacyRecoveryModuleAddresses for the superseded deployments that are still on-chain and always
will be. Which version a chain runs decides which audit findings apply to it: see
the known issues.
Never compute an intent or resume digest locally. The domain separator is built from
version(), so a locally derived hash is a signature over a message the deployed contract has never
heard of — and it fails at initiateRecovery looking like a key problem. Call hashIntent,
hashRegister, hashExecute and resumeDigest on the contract.
Contracts, tests, deploy scripts and deployment records live in
nihilium/recovery-guardian-contracts,
pulled in as a submodule at onchain/ pinned to a reviewed commit so it can be audited
independently. Live addresses are in onchain/evm/deployments/.
Recovering
const { authority, spent } = await sdk.recover({
proof: await condition.buildProof({ email: "[email protected]" }),
chain,
onProgress: (m) => console.log(m),
});Long-running and human-in-the-loop: the service sends an email, a human replies, and the proof is built from that reply. Expect minutes.
authority is a scoped capability by default rather than raw bytes:
import { withCapability } from "@nihilium/recovery-core";
if (authority.kind === "capability") {
await withCapability(authority.capability, async (cap) => {
const sig = await cap.sign(intentHash); // EVM: a 32-byte digest, already hashed
}); // key wiped in `finally`, even on throw
}{ options: { mode: "rawKey" } } returns the bytes instead. That is a supported opt-out (§13), not
a back door: the confinement is on-chain, so the raw key still buys only the committed rotation.
This is a scoped, zeroizing key — not never-assemble threshold signing. Spec §3 describes
SigningCapability as signing without ever assembling the key. Nihilium's threshold protects a
BabyJubJub vault scalar, recovered whole, client-side; there is no threshold signer over secp256k1
or ed25519 in the protocol. The guarantee is smaller and real: the key is assembled only during a
recovery, held in one closure, and wiped by zeroize(). KeyAdapter.thresholdSign() is the
reserved seam and throws, naming the backend it needs. User-facing copy must not claim the key is
never assembled.
A recovery spends the vault
There is no key rotation. A recovery assembles the envelope client-side, exposing the root secret of
every chain in the vault to whoever ran it. Bumping epoch derives a different key from that same
exposed root — a different key, not a recovered one, and derivable by the party who just ran the
recovery.
recover() therefore returns the fact as a field a caller has to destructure past:
const { authority, spent } = await sdk.recover({ proof, chain });
// spent.reason: "Recovering vault "wallet-1" decrypted all 3 of its records, so the root secret
// for every chain it protects has now been exposed …"The answer to a spent vault is a new ceremony. Call discardSpentSeal(vaultId) once the replacement
exists and its on-chain rotation has landed — recover() does not delete the seal itself, because
that rotation can fail and a user holding neither authority is worse off than one holding a spent
seal.
epoch remains a KDF input and the module's intent-replay counter.
Re-protecting an account is an uninstall/reinstall. recoveryOwner is only writable at install:
onInstall sets it, onUninstall clears it, and nothing else writes it. executeRecovery bumps
config.epoch and leaves the owner alone, so a key derived at a new epoch has an address the module
will not accept until it is reinstalled. onUninstall does not reset config.epoch — zeroing it
would revive every intent the account ever signed — so replay protection survives the cycle, pinned
by test_uninstallDoesNotResetTheEpoch.
Identity gates
A ConditionAdapter owns one ceremony. RecoverySDK, the envelope, the KDF and zeroization are
identical whichever one is installed.
| Adapter | Gate |
|---|---|
| condition-zkemail | a zkEmail inbox proof |
| condition-zkpassport | an exact date of birth and a name from a passport |
| condition-zkemail-zkpassport | both, in one ceremony, bound to one hash tie |
| condition-quorum | any k of n of the above |
k of n identities
QuorumConditionAdapter wraps other adapters. Each member runs its own ceremony and gets its own
vault; every record written afterwards is Shamir-split over GF(256) with one share encrypted into
each member's vault. Any k recover it, and k-1 reveal nothing — information-theoretically, not
computationally.
The split happens at record time, not ceremony time. sealVault creates n empty vaults; splitting
is local and free, so adding a chain to a quorum costs no ceremonies. It also means appending needs
every member's public component, not k of them.
import { QuorumConditionAdapter } from "@nihilium/recovery-condition-quorum";
const email = new ZKEmailConditionAdapter({ /* … */ });
const quorum = new QuorumConditionAdapter({ members: [email, email, email] });
const condition = await quorum.buildCondition({
threshold: 2, // any 2 of the 3
members: [ // positional: entry i goes to member i
{ email: "[email protected]" },
{ email: "[email protected]" },
{ email: "[email protected]" },
],
});
const sdk = new RecoverySDK({ key: new EvmKeyAdapter(), condition: quorum, sealStore, dataStore });
await sdk.createVault({ condition, vaultId: chain.vaultId }); // 3 ceremonies
await sdk.addChain({ publicComponent, chain }); // freeRecovery names exactly the members to use, so nobody unnecessary is contacted. The named members run concurrently, since all of them are required:
const proof = await quorum.buildProof({
members: [{ index: 1 }, { index: 3 }], // Shamir indices, 1..n
onProgress: (m) => console.log(m), // prefixed "[member #1] …"
});One ceremony per named member covers every record in the vault: five chains behind a 3-of-5 quorum cost three member recoveries, not fifteen.
QuorumIncompleteError carries every member's outcome, so a retry can skip whoever succeeded.
Shamir is unauthenticated, so each record carries a commitment per share and one over the whole
record — a corrupted share names its own member before anything is combined, rather than surfacing
later as an unparseable envelope. Each record also carries a splitId on top of the vault's
setId, without which share 1 of one record and share 2 of another would agree on every field the
manifest checks and combine into garbage.
Resuming a part-paid run. Each member is a separate paid ceremony. onMemberSealed checkpoints
as it goes; resumeFrom skips members already bought:
const checkpoint: QuorumMember[] = [];
const quorum = new QuorumConditionAdapter({ members, onMemberSealed: (m) => checkpoint.push(m) });
const resumed = new QuorumConditionAdapter({
members,
resumeFrom: { setId, members: checkpoint }, // setId from the interrupted run
});A resumed run mints a fresh vault keypair and re-splits it across every member, which is free and offline, so the checkpoint only records that a member's paid ceremony happened. It carries no share, no commitment and no root secret.
Two thresholds, which are not the same. ZKEmailConditionOptions.threshold is Nihilium's
processor cohort — who runs one member's ceremony. The quorum threshold is across members —
which identities may recover. Keep them distinct in anything user-facing.
Seal privacy
A quorum seal is a bearer artifact that names every guardian: each member seal carries its own
address in the clear, so a recovery needs nothing but the file. One stolen quorum seal discloses the
whole recovery social graph. Condition summaries stay domain-only, which is not the same as the
file being private.
Storage
Two contracts, holding opposite things. Both default to the user's own disk via
@nihilium/recovery-storage-local, which also ships LocalVaultStore and LocalWatchStore.
| | SealStore | SealedDataStore |
|---|---|---|
| holds | the seal — bearer material | encrypted records — inert without the seal |
| keyed by | vaultId | recordId |
| mutable | yes; a re-seal replaces | no, add-only |
| may be held by | the user | the user and a provider |
| placement rule | never the identity factor's domain | keep copies everywhere |
The split is what lets a provider running @nihilium/recovery-service hold a
replica of the records and learn almost nothing: an opaque record id, opaque ciphertext, and how
many entries a vault holds. Not which chains, not which accounts, not whether a given id exists. The
entry count is a genuine small leak — it says how many chains a user protects. This is §12 value
decomposition, and it is what lets a wallet provider hold operational controls, the pause key
included, without holding custody.
Record hosts
A seal locates records by recordId and can also declare where they are: record_hosts in its
hints, projected onto the public component as recordHosts, and read back by RemoteSealedDataStore
— a SealedDataStore over the record service's two routes. Reads union across hosts and dedupe on
entryId, so one dead provider is not a lost vault. An unreachable host throws rather than
reporting an empty list, because "no records" is advice a user may act on destructively.
Fetching from a seal-declared host is opt-in, via RecoverySDKOptions.followSealDeclaredHosts.
A seal is not an authenticated document — its hints are attached client-side, committed to nothing,
and stored as plain JSON with no MAC — so a URL inside one is attacker-controlled input and dialling
it unasked is SSRF. Without a policy, recover() still names the hosts in its error so a human can
act on them. The policy screens schemes, userinfo, redirects and IP literals; allowHostnames is
the shape for a wallet that knows its own provider.
Sealing with recordHosts: [] opts out, which keeps a stolen seal from also being a map to the
ciphertext.
Watchtower
The pause capability is worth nothing to a user who first hears about an attempt when the rotation
lands. @nihilium/recovery-watchtower is an optional backend that polls for
two signals and answers one question on demand.
| Signal | Fires when | Tells you | |---|---|---| | Nihilium reveal values | an unseal begins, before any proof or processor call | someone is opening your seal | | EVM module state | a recovery is initiated, paused, matures or executes | how long you have to pause it |
Registration is a separate call after the install, which the user opts into:
import { WatchtowerClient } from "@nihilium/recovery-core";
import { evmRecoveryTarget } from "@nihilium/recovery-watchtower-evm";
const watchtower = new WatchtowerClient({ baseUrl: "https://watch.example.com" });
const registration = sdk.buildWatchRegistration({
publicComponent, // -> the reveal-value targets
additional: [evmRecoveryTarget({ // -> the on-chain target
namespace: "eip155:11155111",
accountId: account,
// REQUIRED, never defaulted: an eth_call to an address with no code returns zeros, which
// decode as NONE, which reads as "nothing has happened".
module: recoveryModuleAddress(11155111),
expectedRecoveryOwner: toEvmAddress(recoveryPubKey),
expectedEpoch: chain.epoch,
})],
});
const { watchId, manageCredential } = await watchtower.register(registration, ENROLMENT_CREDENTIAL);
const { alarm, protection, actBefore } = await watchtower.status(watchId);WatchtowerClient lives in core, not in the watchtower package, so a browser can call status() —
the watchtower package itself is Node-only.
watchTargetsFor(publicComponent) returns just the off-chain targets, for showing a user what would
be watched before they opt in, or for driving a different registration format. For a quorum it is
the union over every member, so the alarm fires when the first guardian is opened.
Store the two results apart. watchId is a read capability on an alarm and belongs with the
public component; manageCredential is a write capability, returned once, and belongs with the
wallet's config. Neither goes with the seal. watchId is minted fresh and is never the vault's
recordId — if it were, the record host could query the alarm, and an id designed to be emailed
would reveal whether a recovery is in flight.
RecoverySDKOptions has no watchtower field, so sealing cannot auto-register. Registration is
necessarily later anyway: expectedRecoveryOwner and expectedEpoch are only knowable after the
install.
Three properties to design around:
- A broken watcher never looks like a quiet one.
alarmis"unknown"— never"none"— whenever the watch is not healthy, andstatus()throws rather than returning a reassuring shape when the watchtower cannot be reached. - Opting in has a privacy price that belongs in onboarding copy. The operator learns that a vault exists, which processor cohort protects it, and which account. One watch spanning both planes ties the last two together, a link neither discloses alone.
- It notices; it cannot act. A deployment holds no keys and has no write path, so issuing the pause is still the pause authority's to do. On the off-chain plane there is nothing to pause at all: the reveal signal is early warning for a ceremony this SDK has no lever over.
Routes, auth model and the mechanisms keeping "broken" distinguishable from "quiet":
packages/watchtower/README.md.
Packages
packages/
core/ types, adapter contracts, RRS + key derivation, orchestration
veto/ graduated-veto state machine and config validation
shamir/ Shamir secret sharing over GF(256)
nihilium/ protocol port — the ONLY package importing @nihilium/client-sdk
service/ Node-only host for a vault's encrypted records (§12)
watchtower/ Node-only host polling for evidence of a recovery attempt (§6, §15)
relayer/ Node-only host that pays for submissions it cannot alter (§6, §8, §9)
sdk/ Tier-2 orchestration: ledger, protect, one-ceremony recovery, handovers, watch
adapters/
key-evm/ secp256k1
key-solana/ ed25519
condition-zkemail/ zkEmail identity gate
condition-zkpassport/ passport: an exact date of birth and a name
condition-zkemail-zkpassport/ both, in one ceremony
condition-quorum/ any k of n member conditions
resolver-dkim/ TrustAnchorResolver over the DKIM registry
storage-local/ filesystem SealStore, SealedDataStore, VaultStore, WatchStore
storage-indexeddb/ the same, plus the sdk's ledger stores, over IndexedDB
settlement-evm/ ERC-7579 RecoveryModule and Eip7702RecoveryAccount as settlement adapters
settlement-solana/ the Solana recovery vault program as a settlement adapter
passport-zkpassport/ the ZKPassport scan, as a plain promise over @zkpassport/sdk
watchtower-nihilium/ watch probe: Nihilium reveal values
watchtower-evm/ watch probe: the EVM recovery module
watchtower-solana/ watch probe: the Solana program
onchain/
evm/ ERC-7579 executor, EIP-7702 account, graduated veto
solana/ anchor program, and its TypeScript bindings
spec/
vectors/ frozen conformance vectorspackages/core/test/boundaries.test.ts carries every package's dependency rules; sdk is the one
package allowed to import any internal package, and it never imports a settlement package, a chain
library, a prover or a framework — adapters arrive as instances. The settlement packages depend on
the on-chain bindings through file: links until @nihilium/recovery-onchain-* publish.
core has no internal dependencies, and @nihilium/client-sdk is reachable from exactly one
package. Both rules are enforced by that same test. onchain/* is deliberately not a workspace
member, so the contract toolchain stays independent and forge test never needs the TypeScript
build.
The zkEmail scenario machinery is forked from @nihilium/client-sdk rather than changed upstream,
with the collection, datastream binding and user inputs lifted into parameters.
packages/nihilium/README.md has the fork table; condition-zkemail/test/email-hash.test.ts
compares the fork against upstream on every run, because a drifted hash fails at recovery time as an
unverifiable proof rather than at seal time.
Reference
Frozen artefacts
Three fixtures pin behaviour that must not drift. None should be regenerated to make a test pass.
| Artefact | Pins | Cost of a change |
|---|---|---|
| spec/vectors/kdf.json | byte-exact key derivation (§11) over both curves, including the length-prefix collision naive concatenation gets wrong | strands every seal in the wild |
| spec/vectors/shamir.json | the GF(256) split, byte-exact, fixed and derived coefficients | shares behind each member stop reconstructing, discovered only at recovery |
| onchain/evm/test/fixtures/veto-traces.json | 200 traces from the TypeScript veto machine, replayed against the Solidity library | the machine stops being the oracle |
deriveCoefficients sits off the quorum's hot path — records are split with sampled coefficients —
but stays exported and pinned.
Shared conformance suites
@nihilium/recovery-core/testing exports seven suites as framework-agnostic case objects, so an
implementation written outside this repo runs the same cases rather than its own paraphrase.
| Suite | Pins |
|---|---|
| sealStoreConformance | round-trip, and that a re-put replaces |
| vaultStoreConformance | the §12 one-way property: a committed entry is never overwritten |
| sealedDataStoreConformance | add-only, and that appending a chain leaves the earlier one byte-identical |
| watchStoreConformance | that a tripped latch is never cleared or moved by a later poll |
| sealPublicComponentConformance | that the public half carries none of the seal's secrets, at any depth |
| watchTargetConformance | that a watch target carries no recordId — the join that must not exist |
| watchProbeConformance | one observation per target, and that a failed transport is unknown, never clear |
The last two are whole-object substring scans rather than field checks: a field check only tests the fields someone thought of.
Testing against the live deployment
npm test sets none of these, so the default suite is hermetic and offline. Parameters are in
packages/adapters/condition-zkemail/.env.example.
| Tier | Gate | Cost |
|---|---|---|
| Registry + DKIM reads | NIHILIUM_LIVE=1 | free |
| A real k-of-n seal | + NIHILIUM_API_KEY | paid, ~10s of proving per share |
| A real recovery | + NIHILIUM_LIVE_EMAIL | paid, and blocks on a human replying |
NIHILIUM_LIVE=1 npx vitest run packages/adapters/condition-zkemail/test/live.test.tsThe public registry lists one processor, so a k-of-n seal against it exercises the threshold mechanics — each slot gets its own reveal value and key — while carrying none of the independence a threshold is for, because one operator holds every share. The live registry test says so when it is the case.
The timelock is on-chain only
The Nihilium condition is a pure identity gate with no delay of its own. This keeps pause and resume meaningful, since an off-chain delay could be neither paused nor aborted, and keeps the state machine single-sourced.
