npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 tests

typecheck: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.mjs

The 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. recoveryKey accepts { 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:

  • pauseAuthority and abortAuthority are non-zero and different addresses
  • resumeMembers is non-empty, with no zero address and no duplicates
  • 0 < resumeThreshold <= resumeMembers.length
  • no resume member is the pause or abort authority
  • timelockSeconds and pauseCeilingSeconds are 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 has

A 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 epoch

stateOf(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 });                 // free

Recovery 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. alarm is "unknown" — never "none" — whenever the watch is not healthy, and status() 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 vectors

packages/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.ts

The 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.