nihilium-recovery-sdk 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitmodules +3 -0
- package/README.md +441 -0
- package/examples/quorum-recovery.mjs +187 -0
- package/onchain/README.md +18 -0
- package/onchain/evm/.env.example +67 -0
- package/onchain/evm/README.md +155 -0
- package/onchain/evm/bindings/abi.generated.ts +797 -0
- package/onchain/evm/bindings/generate-abi.mjs +49 -0
- package/onchain/evm/bindings/index.ts +83 -0
- package/onchain/evm/deployments/11155111.json +8 -0
- package/onchain/evm/deployments/42161.json +8 -0
- package/onchain/evm/deployments/README.md +46 -0
- package/onchain/evm/foundry.toml +61 -0
- package/onchain/evm/package-lock.json +7674 -0
- package/onchain/evm/package.json +53 -0
- package/onchain/evm/remappings.txt +13 -0
- package/onchain/evm/script/DeployRecoveryModule.s.sol +196 -0
- package/onchain/evm/src/GradualVeto.sol +232 -0
- package/onchain/evm/src/RecoveryModule.sol +381 -0
- package/onchain/evm/test/GradualVeto.t.sol +319 -0
- package/onchain/evm/test/GradualVetoHarness.sol +60 -0
- package/onchain/evm/test/MockERC7579Account.sol +78 -0
- package/onchain/evm/test/RecoveryModule.t.sol +457 -0
- package/onchain/evm/test/VetoDifferential.t.sol +113 -0
- package/onchain/evm/test/fixtures/veto-traces.json +3152 -0
- package/onchain/evm/tsconfig.json +28 -0
- package/package.json +36 -0
- package/packages/adapters/condition-quorum/README.md +43 -0
- package/packages/adapters/condition-quorum/package.json +25 -0
- package/packages/adapters/condition-quorum/src/adapter.ts +514 -0
- package/packages/adapters/condition-quorum/src/errors.ts +46 -0
- package/packages/adapters/condition-quorum/src/format.ts +207 -0
- package/packages/adapters/condition-quorum/src/index.ts +27 -0
- package/packages/adapters/condition-quorum/src/resolver.ts +48 -0
- package/packages/adapters/condition-quorum/test/adapter.test.ts +482 -0
- package/packages/adapters/condition-quorum/test/barrel.test.ts +33 -0
- package/packages/adapters/condition-quorum/test/fake-condition.ts +71 -0
- package/packages/adapters/condition-quorum/test/integration.test.ts +206 -0
- package/packages/adapters/condition-quorum/tsconfig.json +9 -0
- package/packages/adapters/condition-zkemail/.env.example +20 -0
- package/packages/adapters/condition-zkemail/package.json +17 -0
- package/packages/adapters/condition-zkemail/src/adapter.ts +275 -0
- package/packages/adapters/condition-zkemail/src/email-hash.ts +57 -0
- package/packages/adapters/condition-zkemail/src/index.ts +25 -0
- package/packages/adapters/condition-zkemail/src/single_email_collection.json +72 -0
- package/packages/adapters/condition-zkemail/src/unsealing.ts +307 -0
- package/packages/adapters/condition-zkemail/test/adapter.test.ts +159 -0
- package/packages/adapters/condition-zkemail/test/barrel.test.ts +36 -0
- package/packages/adapters/condition-zkemail/test/email-hash.test.ts +60 -0
- package/packages/adapters/condition-zkemail/test/live-config.ts +61 -0
- package/packages/adapters/condition-zkemail/test/live.test.ts +225 -0
- package/packages/adapters/condition-zkemail/tsconfig.json +22 -0
- package/packages/adapters/key-evm/package.json +25 -0
- package/packages/adapters/key-evm/src/index.ts +96 -0
- package/packages/adapters/key-evm/tsconfig.json +9 -0
- package/packages/adapters/key-solana/package.json +24 -0
- package/packages/adapters/key-solana/src/index.ts +74 -0
- package/packages/adapters/key-solana/tsconfig.json +9 -0
- package/packages/adapters/resolver-dkim/package.json +13 -0
- package/packages/adapters/resolver-dkim/src/index.ts +193 -0
- package/packages/adapters/resolver-dkim/test/resolver.test.ts +148 -0
- package/packages/adapters/resolver-dkim/tsconfig.json +9 -0
- package/packages/adapters/storage-local/package.json +23 -0
- package/packages/adapters/storage-local/src/index.ts +177 -0
- package/packages/adapters/storage-local/test/storage-local.test.ts +127 -0
- package/packages/adapters/storage-local/tsconfig.json +9 -0
- package/packages/core/package.json +28 -0
- package/packages/core/src/adapters/condition.ts +85 -0
- package/packages/core/src/adapters/index.ts +10 -0
- package/packages/core/src/adapters/key.ts +43 -0
- package/packages/core/src/adapters/settlement.ts +43 -0
- package/packages/core/src/adapters/storage.ts +85 -0
- package/packages/core/src/authority.ts +61 -0
- package/packages/core/src/envelope.ts +264 -0
- package/packages/core/src/errors.ts +50 -0
- package/packages/core/src/index.ts +46 -0
- package/packages/core/src/kdf.ts +176 -0
- package/packages/core/src/sdk.ts +230 -0
- package/packages/core/src/testing/index.ts +255 -0
- package/packages/core/src/types.ts +196 -0
- package/packages/core/src/veto-types.ts +105 -0
- package/packages/core/test/authority.test.ts +141 -0
- package/packages/core/test/boundaries.test.ts +177 -0
- package/packages/core/test/envelope-v2.test.ts +214 -0
- package/packages/core/test/envelope.test.ts +93 -0
- package/packages/core/test/kdf.test.ts +166 -0
- package/packages/core/test/sdk.test.ts +318 -0
- package/packages/core/tsconfig.json +8 -0
- package/packages/nihilium/package.json +27 -0
- package/packages/nihilium/src/index.ts +64 -0
- package/packages/nihilium/src/protocol.ts +69 -0
- package/packages/nihilium/src/scenario/sealing.ts +316 -0
- package/packages/nihilium/src/scenario/unsealing.ts +104 -0
- package/packages/nihilium/tsconfig.json +9 -0
- package/packages/shamir/README.md +30 -0
- package/packages/shamir/package.json +23 -0
- package/packages/shamir/src/derive.ts +78 -0
- package/packages/shamir/src/errors.ts +11 -0
- package/packages/shamir/src/gf256.ts +77 -0
- package/packages/shamir/src/index.ts +15 -0
- package/packages/shamir/src/shamir.ts +211 -0
- package/packages/shamir/test/gf256.test.ts +104 -0
- package/packages/shamir/test/shamir.test.ts +238 -0
- package/packages/shamir/test/vectors.test.ts +96 -0
- package/packages/shamir/tsconfig.json +8 -0
- package/packages/veto/package.json +23 -0
- package/packages/veto/scripts/generate-veto-traces.mjs +134 -0
- package/packages/veto/src/index.ts +18 -0
- package/packages/veto/src/machine.ts +174 -0
- package/packages/veto/src/validate.ts +190 -0
- package/packages/veto/test/machine.test.ts +257 -0
- package/packages/veto/test/traces.test.ts +96 -0
- package/packages/veto/test/validate.test.ts +183 -0
- package/packages/veto/tsconfig.json +9 -0
- package/spec/nihilium-recovery-sdk.md +438 -0
- package/spec/vectors/generate-kdf-vectors.mjs +72 -0
- package/spec/vectors/generate-shamir-vectors.mjs +110 -0
- package/spec/vectors/kdf.json +160 -0
- package/spec/vectors/shamir.json +150 -0
- package/tsconfig.base.json +25 -0
- package/tsconfig.json +35 -0
- package/vitest.config.ts +44 -0
package/.gitmodules
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
# Nihilium Recovery SDK
|
|
2
|
+
|
|
3
|
+
Identity-gated, condition-based key recovery for self-custodial wallets, built on
|
|
4
|
+
[Nihilium](https://github.com/nihilium). A wallet integrates two calls: `seal()` places a recovery
|
|
5
|
+
key under a private identity condition; `recover()` returns signing authority once that condition is
|
|
6
|
+
satisfied.
|
|
7
|
+
|
|
8
|
+
The security comes from the settlement construction the recovered authority operates within, not from
|
|
9
|
+
the SDK hiding a secret. On smart-account chains that construction is a **graduated veto** — three
|
|
10
|
+
separated capabilities (pause / resume / abort), each individually harmless, held by up to three
|
|
11
|
+
different parties. No veto key can move funds; completing a recovery needs the identity gate *plus* a
|
|
12
|
+
matured timelock.
|
|
13
|
+
|
|
14
|
+
The full specification is [`spec/nihilium-recovery-sdk.md`](spec/nihilium-recovery-sdk.md); section
|
|
15
|
+
references throughout the code point at it.
|
|
16
|
+
|
|
17
|
+
> **Scope: recovery covers loss, not theft.** It restores access to an owner who lost it. It does not
|
|
18
|
+
> defend a wallet whose seed an attacker already holds.
|
|
19
|
+
|
|
20
|
+
## Layout
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
packages/
|
|
24
|
+
core/ chain- and protocol-agnostic: types, adapter contracts, RRS + key derivation
|
|
25
|
+
veto/ graduated-veto state machine and config validation (the on-chain oracle)
|
|
26
|
+
shamir/ Shamir secret sharing over GF(256) (no internal dependencies)
|
|
27
|
+
nihilium/ protocol port — the ONLY package that imports @nihilium/client-sdk
|
|
28
|
+
adapters/
|
|
29
|
+
key-evm/ secp256k1
|
|
30
|
+
key-solana/ ed25519
|
|
31
|
+
condition-zkemail/ zkEmail identity gate (forked scenario, see below)
|
|
32
|
+
condition-quorum/ composite gate: any k of n member conditions
|
|
33
|
+
resolver-dkim/ TrustAnchorResolver over the DKIM registry
|
|
34
|
+
storage-local/ filesystem SealStore + add-only VaultStore
|
|
35
|
+
sdk/ default wiring; the package wallets install
|
|
36
|
+
onchain/
|
|
37
|
+
evm/ foundry + ModuleKit; ERC-7579 executor + graduated veto
|
|
38
|
+
solana/ anchor program
|
|
39
|
+
spec/
|
|
40
|
+
vectors/ frozen conformance vectors
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`core` has no internal dependencies, and `@nihilium/client-sdk` is reachable from exactly one
|
|
44
|
+
package. Both rules are enforced by `packages/core/test/boundaries.test.ts`, not just documented.
|
|
45
|
+
|
|
46
|
+
`onchain/*` is deliberately **not** an npm workspace member: it installs its own dependency tree, so
|
|
47
|
+
the contract toolchain stays independent of the SDK's and `forge test` never needs the TypeScript
|
|
48
|
+
build.
|
|
49
|
+
|
|
50
|
+
## Getting started
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm install # TypeScript workspaces
|
|
54
|
+
npm run build
|
|
55
|
+
npm test # 220 tests
|
|
56
|
+
|
|
57
|
+
npm --prefix onchain/evm install # ModuleKit's Solidity deps (separate tree)
|
|
58
|
+
npm run test:evm # 58 tests
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Using it
|
|
62
|
+
|
|
63
|
+
> `packages/sdk` — the preconfigured wiring a wallet would install — is **not built yet**. Today you
|
|
64
|
+
> assemble the adapters yourself, which is what the examples below do. The shapes will not change
|
|
65
|
+
> when that package lands; it will just pick the defaults for you.
|
|
66
|
+
|
|
67
|
+
A recovery has four moving parts, and they happen at two very different times:
|
|
68
|
+
|
|
69
|
+
| | At setup | At recovery |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| **Off-chain** (this SDK) | `seal()` puts a root secret behind the identity gate | `recover()` returns signing authority |
|
|
72
|
+
| **On-chain** (the module) | install `RecoveryModule` with the veto config | initiate → timelock → execute, under veto |
|
|
73
|
+
|
|
74
|
+
The off-chain half proves *who you are*. The on-chain half decides *what that lets you do, and how
|
|
75
|
+
slowly*. Neither is sufficient alone, and that separation is the design.
|
|
76
|
+
|
|
77
|
+
### 1 · Seal (at wallet setup)
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { RecoverySDK } from "@nihilium-recovery/core";
|
|
81
|
+
import { EvmKeyAdapter, toEvmAddress } from "@nihilium-recovery/key-evm";
|
|
82
|
+
import { ZKEmailConditionAdapter } from "@nihilium-recovery/condition-zkemail";
|
|
83
|
+
import { LocalSealStore } from "@nihilium-recovery/storage-local";
|
|
84
|
+
import {
|
|
85
|
+
NihiliumPaymentProviderClientAPIKEY_DO_NOT_USE,
|
|
86
|
+
setApiEndpoint,
|
|
87
|
+
} from "@nihilium-recovery/nihilium";
|
|
88
|
+
|
|
89
|
+
setApiEndpoint("https://api.nihilium.io");
|
|
90
|
+
|
|
91
|
+
// Keep a reference to the adapter: `buildCondition` / `buildProof` are the adapter's, and the SDK
|
|
92
|
+
// deliberately does not re-export them — the ceremony's parameters are adapter-private.
|
|
93
|
+
const condition = new ZKEmailConditionAdapter({
|
|
94
|
+
emailServiceUrl: "https://zkemail.nihilium.io",
|
|
95
|
+
network: 11155111,
|
|
96
|
+
threshold: 2, // k — processors that must cooperate to recover
|
|
97
|
+
processorCount: 3, // n — processors sealed with
|
|
98
|
+
payment: new NihiliumPaymentProviderClientAPIKEY_DO_NOT_USE(
|
|
99
|
+
"https://api.nihilium.io",
|
|
100
|
+
process.env.NIHILIUM_API_KEY!,
|
|
101
|
+
),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const sdk = new RecoverySDK({
|
|
105
|
+
key: new EvmKeyAdapter(),
|
|
106
|
+
condition,
|
|
107
|
+
sealStore: new LocalSealStore({ directory: "./seals" }),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const chain = {
|
|
111
|
+
namespace: "eip155:11155111", // CAIP-2
|
|
112
|
+
tier: "smart-account" as const,
|
|
113
|
+
accountId: "0xYourSmartAccount",
|
|
114
|
+
vaultId: "wallet-1", // one seal + one processor cohort per vault
|
|
115
|
+
epoch: 0, // bumped by each completed recovery
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const { recoveryPubKey, sealBlob } = await sdk.seal({
|
|
119
|
+
condition: await condition.buildCondition({ email: "you@example.com" }),
|
|
120
|
+
chain,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const recoveryOwner = toEvmAddress(recoveryPubKey); // → the module's `recoveryOwner`
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Sealing is a paid operation** and runs a Groth16 proof per share, so it takes tens of seconds.
|
|
127
|
+
`sealBlob` is a **bearer artifact**: whoever holds it can attempt a recovery, subject to the gate and
|
|
128
|
+
the veto. Back it up; don't publish it.
|
|
129
|
+
|
|
130
|
+
Two deliberate constraints worth knowing before you design around them:
|
|
131
|
+
|
|
132
|
+
- **You cannot seal an existing private key.** `recoveryKey` accepts `{kind:"generate"}` or
|
|
133
|
+
`{kind:"rrs", rrs}` — a root secret, never a chain key. Every key is *derived* from that root plus
|
|
134
|
+
the epoch, which is what lets a completed recovery mint a fresh key without re-running (and
|
|
135
|
+
re-paying for) the ceremony. Accepting a chain key would silently turn epoch rotation back into a
|
|
136
|
+
full re-seal.
|
|
137
|
+
- **`epoch` is not stored in the seal.** It is a KDF input, so rotating it costs nothing.
|
|
138
|
+
|
|
139
|
+
### 2 · Install the module (once per account)
|
|
140
|
+
|
|
141
|
+
`recoveryOwner` above is the address the module checks intent signatures against. It goes in
|
|
142
|
+
alongside the veto config, as `onInstall` data:
|
|
143
|
+
|
|
144
|
+
```solidity
|
|
145
|
+
GradualVeto.Config memory veto = GradualVeto.Config({
|
|
146
|
+
pauseAuthority: 0x…, // may pause an in-flight recovery. That is all it can do.
|
|
147
|
+
abortAuthority: 0x…, // may kill one, irreversibly. Keep this key offline.
|
|
148
|
+
resumeMembers: [g1, g2, g3],
|
|
149
|
+
resumeThreshold: 2, // of resumeMembers, to lift a pause early
|
|
150
|
+
timelockSeconds: 7 days, // must elapse before the recovery is executable
|
|
151
|
+
pauseCeilingSeconds: 3 days // a pause auto-lifts after this, so nobody can stall forever
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
account.installModule(2 /* EXECUTOR */, RECOVERY_MODULE, abi.encode(recoveryOwner, veto));
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Hold the three veto roles separately — ideally by three different parties. Each is individually
|
|
158
|
+
harmless: **no veto key can move funds**, and none of them can complete a recovery either. The
|
|
159
|
+
protection comes from them being separate, so putting all three on one key throws it away while
|
|
160
|
+
leaving everything looking correctly configured.
|
|
161
|
+
|
|
162
|
+
`pauseCeilingSeconds` is the counterweight: a pause that never expires would be a denial-of-service on
|
|
163
|
+
the legitimate owner, so a pause buys investigation time rather than a veto.
|
|
164
|
+
|
|
165
|
+
### 3 · Recover (when the key is lost)
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
const authority = await sdk.recover({
|
|
169
|
+
proof: await condition.buildProof({ email: "you@example.com" }),
|
|
170
|
+
chain,
|
|
171
|
+
onProgress: (m) => console.log(m),
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
This is **long-running and human-in-the-loop**: the service sends an email, a human must reply, and
|
|
176
|
+
the proof is built from that reply. Expect minutes, not seconds.
|
|
177
|
+
|
|
178
|
+
By default you get a scoped capability rather than raw bytes:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { withCapability } from "@nihilium-recovery/core";
|
|
182
|
+
|
|
183
|
+
if (authority.kind === "capability") {
|
|
184
|
+
await withCapability(authority.capability, async (cap) => {
|
|
185
|
+
const sig = await cap.sign(intentHash); // EVM: a 32-byte digest, already hashed
|
|
186
|
+
}); // key wiped in `finally`, even on throw
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`{ options: { mode: "rawKey" } }` returns the bytes instead. That is a supported, first-class opt-out
|
|
191
|
+
(§13), not a back door — the confinement that matters is on-chain, and holding the raw key still only
|
|
192
|
+
buys you the committed rotation described below.
|
|
193
|
+
|
|
194
|
+
### 4 · Drive the on-chain recovery
|
|
195
|
+
|
|
196
|
+
The recovered key signs an EIP-712 `Intent` naming the account, epoch, nonce, replacement validator
|
|
197
|
+
and an expiry. Every field is bound, so an intent cannot be replayed onto another account, another
|
|
198
|
+
epoch, or after it goes stale.
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
initiateRecovery(intent, signature) anyone may submit — authority is the signature, not the sender
|
|
202
|
+
│ (so a relayer can pay gas for a user with no funded account)
|
|
203
|
+
├─ pause(account) pause authority only; INITIATED → PAUSED
|
|
204
|
+
│ └─ resume(account, …) a threshold of distinct quorum members, or the ceiling lapses
|
|
205
|
+
├─ abort(account) abort authority only; irreversible, needs no cooperation
|
|
206
|
+
▼
|
|
207
|
+
timelockSeconds elapse → EXECUTABLE
|
|
208
|
+
executeRecovery(intent) installs the validator, bumps the epoch
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Read the live state any time with `stateOf(account)` — it projects the clock forward, so a pause past
|
|
212
|
+
its ceiling reads as `INITIATED` whether or not anyone has poked the contract.
|
|
213
|
+
|
|
214
|
+
The only call this module can ever make is `installModule(TYPE_VALIDATOR, …)` on the recovering
|
|
215
|
+
account. The intent chooses *which* validator; it never chooses the call shape or the target. There
|
|
216
|
+
is no path through the module that moves a token. That is what makes "extracting the raw recovery key
|
|
217
|
+
still only yields the committed, vetoable rotation" a property rather than an aspiration.
|
|
218
|
+
|
|
219
|
+
It deliberately does **not** uninstall the superseded validator: removing a module from an ERC-7579
|
|
220
|
+
sentinel list needs a correct predecessor pointer and gets the list wrong if you miss. Recovery's
|
|
221
|
+
scope is loss, not theft — the old key is gone, not hostile — so that is a deliberate follow-up by
|
|
222
|
+
the recovered owner.
|
|
223
|
+
|
|
224
|
+
## Recovering behind k of n identities
|
|
225
|
+
|
|
226
|
+
`@nihilium-recovery/condition-quorum` is a `ConditionAdapter` that wraps *other* condition adapters.
|
|
227
|
+
It Shamir-splits the secret over GF(256) and places one share behind each member, so any k of n
|
|
228
|
+
recover it and k-1 reveal nothing — information-theoretically, not merely computationally.
|
|
229
|
+
|
|
230
|
+
Because it satisfies the ordinary `ConditionAdapter` contract, nothing else changes: `RecoverySDK`,
|
|
231
|
+
the envelope, the KDF, zeroization and `SealStore` all work exactly as they do for a single member,
|
|
232
|
+
and the whole quorum is **one file, stored per `vaultId`**.
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
import { QuorumConditionAdapter } from "@nihilium-recovery/condition-quorum";
|
|
236
|
+
|
|
237
|
+
const email = new ZKEmailConditionAdapter({ /* … as in step 1 … */ });
|
|
238
|
+
|
|
239
|
+
// One stateless zkEmail adapter can back every member.
|
|
240
|
+
const quorum = new QuorumConditionAdapter({ members: [email, email, email] });
|
|
241
|
+
|
|
242
|
+
const condition = await quorum.buildCondition({
|
|
243
|
+
threshold: 2, // any 2 of the 3
|
|
244
|
+
members: [ // positional: entry i goes to member i
|
|
245
|
+
{ email: "alice@example.com" },
|
|
246
|
+
{ email: "bob@example.net" },
|
|
247
|
+
{ email: "carol@example.org" },
|
|
248
|
+
],
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const sdk = new RecoverySDK({ key: new EvmKeyAdapter(), condition: quorum, sealStore });
|
|
252
|
+
const { recoveryPubKey } = await sdk.seal({ condition, chain }); // 3 ceremonies, 1 file
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Recovery names **exactly** the members to use, so nobody is contacted who is not needed. The named
|
|
256
|
+
members run concurrently, since all of them are required:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
const proof = await quorum.buildProof({
|
|
260
|
+
members: [{ index: 1 }, { index: 3 }], // Shamir indices, 1..n
|
|
261
|
+
onProgress: (m) => console.log(m), // prefixed "[member #1] …"
|
|
262
|
+
});
|
|
263
|
+
const authority = await sdk.recover({ proof, chain });
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
If a member fails, `QuorumIncompleteError` carries every member's outcome so you know who to retry
|
|
267
|
+
without. Shamir is unauthenticated, so the seal also records a commitment per share and one over the
|
|
268
|
+
secret: a corrupted share names its own member *before* anything is combined, rather than surfacing
|
|
269
|
+
much later as an unparseable envelope after everyone has already answered their email.
|
|
270
|
+
|
|
271
|
+
### Sealing costs money, so it resumes
|
|
272
|
+
|
|
273
|
+
Each member is a separate paid ceremony. `onMemberSealed` checkpoints as it goes, and `resumeFrom`
|
|
274
|
+
skips members already bought:
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
const checkpoint: QuorumMember[] = [];
|
|
278
|
+
const quorum = new QuorumConditionAdapter({ members, onMemberSealed: (m) => checkpoint.push(m) });
|
|
279
|
+
// … member 4 of 5 dies …
|
|
280
|
+
|
|
281
|
+
const resumed = new QuorumConditionAdapter({
|
|
282
|
+
members,
|
|
283
|
+
resumeFrom: { setId, members: checkpoint }, // setId from the interrupted run
|
|
284
|
+
});
|
|
285
|
+
await sdk.seal({ recoveryKey: { kind: "rrs", rrs }, condition, chain }); // buys only 4 and 5
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
The split is *derived* from `(secret, setId, k, n)` rather than sampled, which is what lets a resume
|
|
289
|
+
reproduce byte-identical shares without a plaintext share ever being written to disk. Supply the same
|
|
290
|
+
root secret and set id; each reused member's commitment is re-checked, so resuming with the wrong
|
|
291
|
+
secret fails immediately instead of writing a seal that can never reconstruct.
|
|
292
|
+
|
|
293
|
+
### Two thresholds, and they are not the same
|
|
294
|
+
|
|
295
|
+
`ZKEmailConditionOptions.threshold` is Nihilium's **processor** cohort — who runs one member's
|
|
296
|
+
ceremony. The quorum threshold is across **members** — which identities may recover. Keep them
|
|
297
|
+
distinct in anything user-facing.
|
|
298
|
+
|
|
299
|
+
## One seal, several chains
|
|
300
|
+
|
|
301
|
+
The KDF already separates keys by `(namespace, accountId, vaultId, epoch)`, so one root secret can
|
|
302
|
+
serve many chains; a v1 envelope simply had nowhere to record more than one context. `sealMultiChain`
|
|
303
|
+
records a closed set:
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
const { recoveryKeys } = await sdk.sealMultiChain({
|
|
307
|
+
condition,
|
|
308
|
+
contexts: [
|
|
309
|
+
{ chain: evmChain, key: new EvmKeyAdapter() },
|
|
310
|
+
{ chain: solanaChain, key: new SolanaKeyAdapter() },
|
|
311
|
+
],
|
|
312
|
+
});
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
This matters most with a quorum, because members are expensive and contexts are free: two chains
|
|
316
|
+
behind a 3-of-5 quorum costs **5** ceremonies, not 10. Adding a tenth chain still costs 5 — so seal a
|
|
317
|
+
generous context set up front, since the set is fixed at seal time and adding one later means
|
|
318
|
+
re-sealing.
|
|
319
|
+
|
|
320
|
+
`recover()` is unchanged and still returns authority for exactly one chain: `parseEnvelope` selects
|
|
321
|
+
the matching context and reports its algorithm, so recovering through the wrong `KeyAdapter` fails on
|
|
322
|
+
the curve check rather than deriving an unrelated key. Sealing every chain at once is a convenience;
|
|
323
|
+
assembling every chain's authority at once is not.
|
|
324
|
+
|
|
325
|
+
## Deployments
|
|
326
|
+
|
|
327
|
+
The contracts (`RecoveryModule`, `GradualVeto`), their Foundry tests, deployment script, and
|
|
328
|
+
deployment records now live in
|
|
329
|
+
[nihilium/recovery-guardian-contracts](https://github.com/nihilium/recovery-guardian-contracts) —
|
|
330
|
+
pulled in here as a git submodule at `onchain/`, pinned to a specific reviewed commit — so they
|
|
331
|
+
can be publicly audited independently of the rest of the SDK. That repo is organised per chain
|
|
332
|
+
(`evm/`, with room for e.g. `solana/` later), which is why the EVM contracts land at `onchain/evm/`
|
|
333
|
+
here. See that repo's README for the current live addresses on Sepolia and Arbitrum One, the
|
|
334
|
+
veto-clock and settlement/proof-network
|
|
335
|
+
notes, and the deployment guide.
|
|
336
|
+
|
|
337
|
+
## The forked zkEmail scenario
|
|
338
|
+
|
|
339
|
+
`@nihilium/client-sdk`'s `ZKEmailSealingClient` hardcodes its collection JSON, its datastream binding
|
|
340
|
+
and its email-hash user input inside `create()` and `buildTemplate()`, so the machinery cannot be
|
|
341
|
+
reused for a different condition graph. Rather than change nihilium-core — which backs the public
|
|
342
|
+
recovery demo — the pattern is **forked** into this repo with those three lifted into parameters:
|
|
343
|
+
|
|
344
|
+
| Here | Forked from | Change |
|
|
345
|
+
|---|---|---|
|
|
346
|
+
| `nihilium/ScenarioSealingClient` | `ZKEmailSealingClient` | collection, datastream binding and user inputs are parameters |
|
|
347
|
+
| `nihilium/ScenarioUnsealingClient` | `ZKEmailUnsealingClient` | abstract base; endpoint resolution + vault decryption pulled up |
|
|
348
|
+
| `condition-zkemail/ZKEmailUnsealingClient` | same | resolvers keyed per **node id**, so two-domain email (§12) works |
|
|
349
|
+
| `condition-zkemail/hashEmailAddress` | `hashEmailAddress` | verbatim; pinned against upstream by a test |
|
|
350
|
+
|
|
351
|
+
The upstream scenario is untouched. `condition-zkemail/test/email-hash.test.ts` compares the fork
|
|
352
|
+
against `@nihilium/client-sdk` directly on every call — a drifted hash does not fail at seal time, it
|
|
353
|
+
fails at recovery time as an unverifiable proof, so it is worth pinning hard.
|
|
354
|
+
|
|
355
|
+
### Node compatibility
|
|
356
|
+
|
|
357
|
+
`@nihilium/client-sdk` used to ship a browser-only bundle that threw on import under Node, and this
|
|
358
|
+
repo carried a shim for it. As of client-sdk **0.7.1** the package ships a `node` export condition,
|
|
359
|
+
the shim is gone, and `packages/nihilium/` imports it like any other dependency.
|
|
360
|
+
|
|
361
|
+
Two things were wrong and both were fixed upstream rather than worked around here: the build
|
|
362
|
+
resolved `snarkjs` to its browser entry (whose prover builds a worker from a `blob:` URL Node cannot
|
|
363
|
+
load), and several dependencies had ESM/CJS interop that breaks under Node's resolver. Proving under
|
|
364
|
+
Node is now also ~3x faster than it was through the shim, because snarkjs runs its own native path.
|
|
365
|
+
|
|
366
|
+
## Testing against the live deployment
|
|
367
|
+
|
|
368
|
+
Parameters mirror `../forgot-my-password-ui/.env`; see
|
|
369
|
+
[`packages/adapters/condition-zkemail/.env.example`](packages/adapters/condition-zkemail/.env.example).
|
|
370
|
+
Three opt-in tiers, because the costs differ sharply — `npm test` sets none of them, so the default
|
|
371
|
+
suite stays hermetic and offline.
|
|
372
|
+
|
|
373
|
+
| Tier | Gate | Cost |
|
|
374
|
+
|---|---|---|
|
|
375
|
+
| Registry + DKIM reads | `NIHILIUM_LIVE=1` | free |
|
|
376
|
+
| A real k-of-n seal | `+ NIHILIUM_API_KEY` | **paid**, ~10s of proving per share |
|
|
377
|
+
| A real recovery | `+ NIHILIUM_LIVE_EMAIL` | paid, and blocks on a human replying to an email |
|
|
378
|
+
|
|
379
|
+
```bash
|
|
380
|
+
NIHILIUM_LIVE=1 npx vitest run packages/adapters/condition-zkemail/test/live.test.ts
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
> **The public registry lists one processor.** A k-of-n seal against it exercises the threshold
|
|
384
|
+
> *mechanics* — each slot still gets its own reveal value and key — but carries none of the
|
|
385
|
+
> independence the threshold exists for, because one operator holds every share. The live registry
|
|
386
|
+
> test warns when this is the case, so the day a second processor appears, it says so.
|
|
387
|
+
|
|
388
|
+
## Two things worth knowing before you rely on this
|
|
389
|
+
|
|
390
|
+
**`recover()` returns a scoped, zeroizing key — not never-assemble threshold signing.** Spec §3
|
|
391
|
+
describes `SigningCapability` as threshold-signing without ever assembling the key. Nihilium's
|
|
392
|
+
threshold protects a BabyJubJub *vault* scalar which is recovered whole, client-side; there is no
|
|
393
|
+
threshold signer over secp256k1 or ed25519 anywhere in the protocol. What is delivered is a real but
|
|
394
|
+
smaller guarantee: the key is assembled only during a recovery event, held in one closure, and wiped
|
|
395
|
+
by `zeroize()`. `KeyAdapter.thresholdSign()` exists as the reserved seam and throws, naming the
|
|
396
|
+
backend it needs. **User-facing copy must not claim the key is never assembled.**
|
|
397
|
+
|
|
398
|
+
**A quorum seal names every guardian.** It is a bearer artifact, and each member seal carries its
|
|
399
|
+
own address in the clear (the zkEmail adapter records it so a recovery needs nothing but the file).
|
|
400
|
+
One stolen quorum file therefore discloses the whole recovery social graph, not just one address.
|
|
401
|
+
The `Condition` summaries stay domain-only, but that is not the same as the file being private.
|
|
402
|
+
|
|
403
|
+
**A multi-chain seal widens the blast radius on purpose.** Under envelope v2 a completed recovery
|
|
404
|
+
yields the envelope and therefore *every* key in the context set, so §11's compartment moves from
|
|
405
|
+
`(chain, account)` out to `vaultId`. It is opt-in and the context set is closed — there is no
|
|
406
|
+
wildcard form — but reach for separate vaults when the chains should fail independently.
|
|
407
|
+
|
|
408
|
+
**The timelock is on-chain only.** The Nihilium condition is a pure identity gate. This keeps
|
|
409
|
+
pause/resume meaningful — an off-chain delay could be neither paused nor aborted — and keeps the
|
|
410
|
+
state machine single-sourced.
|
|
411
|
+
|
|
412
|
+
## Conformance
|
|
413
|
+
|
|
414
|
+
Two frozen artefacts pin behaviour that must never drift:
|
|
415
|
+
|
|
416
|
+
- **`spec/vectors/kdf.json`** — byte-exact recovery-key derivation (§11) across both curves,
|
|
417
|
+
including the length-prefix collision case that naive concatenation would get wrong. Do not
|
|
418
|
+
regenerate; a changed derivation silently strands every seal already in the wild.
|
|
419
|
+
- **`spec/vectors/shamir.json`** — the GF(256) split, byte-exact, with fixed coefficients plus the
|
|
420
|
+
derived-coefficient path a resumed seal depends on. Do not regenerate; a changed split leaves the
|
|
421
|
+
shares already sitting behind each member unable to reconstruct, and nothing reveals it until
|
|
422
|
+
someone actually needs their recovery.
|
|
423
|
+
- **`onchain/evm/test/fixtures/veto-traces.json`** — 200 traces generated from the TypeScript veto
|
|
424
|
+
machine and replayed against the Solidity library. The TypeScript machine is the oracle; the
|
|
425
|
+
fixture is what makes that an enforced fact rather than a comment.
|
|
426
|
+
|
|
427
|
+
## Status
|
|
428
|
+
|
|
429
|
+
| Phase | State |
|
|
430
|
+
|---|---|
|
|
431
|
+
| 0 · Scaffold + boundaries | done |
|
|
432
|
+
| 1 · Core types, KDF, capability | done |
|
|
433
|
+
| 2 · Storage contracts + local impl | done |
|
|
434
|
+
| 3 · Veto machine + validation | done |
|
|
435
|
+
| 4 · EVM contracts (ModuleKit) | done — deployed and verified on Sepolia |
|
|
436
|
+
| 5 · zkEmail condition adapter | done — verified by a real 2-of-2 seal on the live set |
|
|
437
|
+
| 6 · `seal()` / `recover()` over Nihilium | `seal()` verified live; `recover()` needs a human email reply to close out |
|
|
438
|
+
| 7 · EVM hardened mode | partly — intent-binding and epoch invalidation already in |
|
|
439
|
+
| 12 · k-of-n quorum condition | done — hermetic; not yet exercised against live ceremonies |
|
|
440
|
+
| 13 · Multi-chain envelope (v2) | done — `sealMultiChain()`, v1 seals unaffected |
|
|
441
|
+
| 8–11 · Solana, provider storage, demo, script tier | not started |
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runnable tour of the k-of-n quorum and the multi-chain envelope.
|
|
3
|
+
*
|
|
4
|
+
* npm run build && node examples/quorum-recovery.mjs
|
|
5
|
+
*
|
|
6
|
+
* Everything here is the real SDK — real key adapters, real KDF, real filesystem SealStore. The one
|
|
7
|
+
* stand-in is `DemoCondition`, which replaces a live identity ceremony so this runs offline and for
|
|
8
|
+
* free. Swap it for `ZKEmailConditionAdapter` and the rest is unchanged; see `realWiring()` below.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { RecoverySDK, withCapability } from "@nihilium-recovery/core";
|
|
14
|
+
import { EvmKeyAdapter } from "@nihilium-recovery/key-evm";
|
|
15
|
+
import { SolanaKeyAdapter } from "@nihilium-recovery/key-solana";
|
|
16
|
+
import { LocalSealStore } from "@nihilium-recovery/storage-local";
|
|
17
|
+
import {
|
|
18
|
+
QuorumConditionAdapter,
|
|
19
|
+
QuorumIncompleteError,
|
|
20
|
+
parseQuorumSeal,
|
|
21
|
+
} from "@nihilium-recovery/condition-quorum";
|
|
22
|
+
|
|
23
|
+
const hex = (b) => Buffer.from(b).toString("hex");
|
|
24
|
+
const log = (...a) => console.log(...a);
|
|
25
|
+
|
|
26
|
+
/** Stands in for a real identity ceremony. Holds the share; hands it back on demand. */
|
|
27
|
+
class DemoCondition {
|
|
28
|
+
conditionType = "zkemail";
|
|
29
|
+
resolver = { rejectWeakSelectors: true, revocationFreshnessBlocks: 100,
|
|
30
|
+
async resolve() { return { status: "valid", reason: "demo", revoked: "no", strength: "ok" }; } };
|
|
31
|
+
unreachable = false;
|
|
32
|
+
async buildCondition({ email }) {
|
|
33
|
+
return { conditionType: "zkemail", descriptor: { email },
|
|
34
|
+
summary: `Control of an email address at ${email.split("@").pop()}` };
|
|
35
|
+
}
|
|
36
|
+
async buildProof(params = {}) { return { conditionType: "zkemail", descriptor: params }; }
|
|
37
|
+
async sealSecret({ secret }) { return { format: "demo-seal-v1", payload: hex(secret) }; }
|
|
38
|
+
async openSecret({ seal, onProgress }) {
|
|
39
|
+
if (this.unreachable) throw new Error("no reply from this mailbox");
|
|
40
|
+
onProgress?.("email answered, producing proof");
|
|
41
|
+
return Uint8Array.from(seal.payload.match(/../g).map((b) => parseInt(b, 16)));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const dir = mkdtempSync(join(tmpdir(), "quorum-example-"));
|
|
46
|
+
const sealStore = new LocalSealStore({ directory: dir });
|
|
47
|
+
|
|
48
|
+
const evmChain = {
|
|
49
|
+
namespace: "eip155:11155111", tier: "smart-account",
|
|
50
|
+
accountId: "0xAcc0", vaultId: "my-wallet", epoch: 0,
|
|
51
|
+
};
|
|
52
|
+
const solanaChain = {
|
|
53
|
+
namespace: "solana:mainnet", tier: "script",
|
|
54
|
+
accountId: "9xQeWvG…", vaultId: "my-wallet", epoch: 0,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const guardians = [
|
|
58
|
+
{ email: "alice@example.com" },
|
|
59
|
+
{ email: "bob@example.net" },
|
|
60
|
+
{ email: "carol@example.org" },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
// 1 · Seal 2-of-3
|
|
65
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
const members = guardians.map(() => new DemoCondition());
|
|
67
|
+
const quorum = new QuorumConditionAdapter({ members });
|
|
68
|
+
|
|
69
|
+
const condition = await quorum.buildCondition({ threshold: 2, members: guardians });
|
|
70
|
+
log("condition:", condition.summary, "\n");
|
|
71
|
+
|
|
72
|
+
const key = new EvmKeyAdapter();
|
|
73
|
+
const sdk = new RecoverySDK({ key, condition: quorum, sealStore });
|
|
74
|
+
|
|
75
|
+
const { recoveryPubKey } = await sdk.seal({ condition, chain: evmChain });
|
|
76
|
+
log("1 · sealed. register this on-chain:", hex(recoveryPubKey.bytes).slice(0, 24) + "…");
|
|
77
|
+
|
|
78
|
+
const files = readdirSync(join(dir, "seals"));
|
|
79
|
+
const payload = parseQuorumSeal(JSON.parse(readFileSync(join(dir, "seals", files[0]), "utf8")).blob);
|
|
80
|
+
log(` ${files.length} file on disk, holding ${payload.members.length} member seals:`);
|
|
81
|
+
for (const m of payload.members) log(` #${m.index} ${m.summary}`);
|
|
82
|
+
|
|
83
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
// 2 · Recover with any 2. Only the members you name are contacted.
|
|
85
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
86
|
+
const proof = await quorum.buildProof({
|
|
87
|
+
members: [{ index: 1 }, { index: 3 }],
|
|
88
|
+
onProgress: (m) => log(" " + m),
|
|
89
|
+
});
|
|
90
|
+
log("\n2 · recovering via members #1 and #3:");
|
|
91
|
+
|
|
92
|
+
const authority = await sdk.recover({ proof, chain: evmChain });
|
|
93
|
+
await withCapability(authority.capability, async (cap) => {
|
|
94
|
+
const sig = await cap.sign(new Uint8Array(32).fill(1));
|
|
95
|
+
log(` recovered ${cap.algorithm}, signed ${sig.bytes.length} bytes`);
|
|
96
|
+
log(` matches what seal() registered:`,
|
|
97
|
+
hex(cap.publicKey.bytes) === hex(recoveryPubKey.bytes));
|
|
98
|
+
});
|
|
99
|
+
log(" capability zeroized:", authority.capability.zeroized);
|
|
100
|
+
|
|
101
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
102
|
+
// 3 · A member that cannot be reached
|
|
103
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
members[1].unreachable = true;
|
|
105
|
+
log("\n3 · trying members #1 and #2, where #2 is unreachable:");
|
|
106
|
+
try {
|
|
107
|
+
await sdk.recover({
|
|
108
|
+
proof: await quorum.buildProof({ members: [{ index: 1 }, { index: 2 }] }),
|
|
109
|
+
chain: evmChain,
|
|
110
|
+
});
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (!(error instanceof QuorumIncompleteError)) throw error;
|
|
113
|
+
for (const o of error.outcomes) log(` #${o.index} ${o.status}${o.reason ? ` — ${o.reason}` : ""}`);
|
|
114
|
+
log(" → retry with a different pair; #1's work is not wasted, unsealing is free");
|
|
115
|
+
}
|
|
116
|
+
members[1].unreachable = false;
|
|
117
|
+
|
|
118
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
119
|
+
// 4 · One seal covering two chains (envelope v2)
|
|
120
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
const multiDir = mkdtempSync(join(tmpdir(), "quorum-multi-"));
|
|
122
|
+
const multiStore = new LocalSealStore({ directory: multiDir });
|
|
123
|
+
const multiMembers = guardians.map(() => new DemoCondition());
|
|
124
|
+
const multiQuorum = new QuorumConditionAdapter({ members: multiMembers });
|
|
125
|
+
const multiCondition = await multiQuorum.buildCondition({ threshold: 2, members: guardians });
|
|
126
|
+
|
|
127
|
+
const evmKey = new EvmKeyAdapter();
|
|
128
|
+
const solanaKey = new SolanaKeyAdapter();
|
|
129
|
+
const evmSdk = new RecoverySDK({ key: evmKey, condition: multiQuorum, sealStore: multiStore });
|
|
130
|
+
const solanaSdk = new RecoverySDK({ key: solanaKey, condition: multiQuorum, sealStore: multiStore });
|
|
131
|
+
|
|
132
|
+
const { recoveryKeys } = await evmSdk.sealMultiChain({
|
|
133
|
+
condition: multiCondition,
|
|
134
|
+
contexts: [{ chain: evmChain, key: evmKey }, { chain: solanaChain, key: solanaKey }],
|
|
135
|
+
});
|
|
136
|
+
log("\n4 · one seal, two chains:");
|
|
137
|
+
for (const { chain, publicKey } of recoveryKeys) {
|
|
138
|
+
log(` ${chain.namespace.padEnd(18)} ${publicKey.algorithm.padEnd(10)} ${hex(publicKey.bytes).slice(0, 20)}…`);
|
|
139
|
+
}
|
|
140
|
+
log(` ${readdirSync(join(multiDir, "seals")).length} file, 3 ceremonies — not 6.`);
|
|
141
|
+
|
|
142
|
+
// Each chain recovers on its own curve, through whichever guardians are available.
|
|
143
|
+
const evmAuth = await evmSdk.recover({
|
|
144
|
+
proof: await multiQuorum.buildProof({ members: [{ index: 1 }, { index: 2 }] }),
|
|
145
|
+
chain: evmChain,
|
|
146
|
+
});
|
|
147
|
+
const solAuth = await solanaSdk.recover({
|
|
148
|
+
proof: await multiQuorum.buildProof({ members: [{ index: 2 }, { index: 3 }] }),
|
|
149
|
+
chain: solanaChain,
|
|
150
|
+
});
|
|
151
|
+
log(" EVM via #1,#2 →", evmAuth.capability.algorithm,
|
|
152
|
+
hex(evmAuth.capability.publicKey.bytes) === hex(recoveryKeys[0].publicKey.bytes) ? "✓" : "✗");
|
|
153
|
+
log(" Solana via #2,#3 →", solAuth.capability.algorithm,
|
|
154
|
+
hex(solAuth.capability.publicKey.bytes) === hex(recoveryKeys[1].publicKey.bytes) ? "✓" : "✗");
|
|
155
|
+
evmAuth.capability.zeroize();
|
|
156
|
+
solAuth.capability.zeroize();
|
|
157
|
+
|
|
158
|
+
// Recovering a chain the seal never listed fails loudly rather than deriving a stranger's key.
|
|
159
|
+
try {
|
|
160
|
+
await evmSdk.recover({
|
|
161
|
+
proof: await multiQuorum.buildProof({ members: [{ index: 1 }, { index: 2 }] }),
|
|
162
|
+
chain: { ...evmChain, namespace: "eip155:137" },
|
|
163
|
+
});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
log(" polygon (unlisted) →", error.constructor.name);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
rmSync(dir, { recursive: true, force: true });
|
|
169
|
+
rmSync(multiDir, { recursive: true, force: true });
|
|
170
|
+
|
|
171
|
+
/** The only difference in production: a real condition adapter, and it costs money. */
|
|
172
|
+
function realWiring() {
|
|
173
|
+
return `
|
|
174
|
+
import { ZKEmailConditionAdapter } from "@nihilium-recovery/condition-zkemail";
|
|
175
|
+
|
|
176
|
+
const email = new ZKEmailConditionAdapter({
|
|
177
|
+
emailServiceUrl: "https://zkemail.nihilium.io",
|
|
178
|
+
network: 11155111,
|
|
179
|
+
threshold: 2, // Nihilium PROCESSOR cohort — not the quorum threshold
|
|
180
|
+
processorCount: 3,
|
|
181
|
+
payment,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// One stateless adapter can back every member.
|
|
185
|
+
const quorum = new QuorumConditionAdapter({ members: [email, email, email] });
|
|
186
|
+
`;
|
|
187
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# recovery-guardian-contracts
|
|
2
|
+
|
|
3
|
+
On-chain contracts for [nihilium-recovery-sdk](https://github.com/nihilium/recovery-sdk)'s
|
|
4
|
+
recovery guardian — identity-gated, condition-based key recovery for self-custodial wallets. Kept
|
|
5
|
+
in its own repo, independent of the SDK's TypeScript codebase, so the on-chain code can be
|
|
6
|
+
publicly audited on its own.
|
|
7
|
+
|
|
8
|
+
Recovery guardian is a per-chain concept: each chain gets its own recovery module and its own
|
|
9
|
+
implementation, one directory per chain.
|
|
10
|
+
|
|
11
|
+
| Directory | Chain | Status |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| [`evm/`](evm/) | Ethereum and other EVM chains (Foundry) | Live — deployed on Sepolia and Arbitrum One, see [`evm/README.md`](evm/README.md) |
|
|
14
|
+
| `solana/` | Solana (Anchor) | Not started |
|
|
15
|
+
|
|
16
|
+
`nihilium-recovery-sdk` pulls this whole repo in as a single git submodule mounted at `onchain/`,
|
|
17
|
+
pinned to a specific reviewed commit rather than tracking `main`, so `onchain/evm/` here lines up
|
|
18
|
+
with `onchain/evm/` there.
|