@waaskey/sdk 0.0.1 → 0.2.0

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/README.md CHANGED
@@ -4,40 +4,271 @@ Official TypeScript SDK for [Waaskey](https://waaskey.com) — embedded, non-cus
4
4
  **MPC wallets** for your app. Create wallets and sign transactions where the private
5
5
  key is never assembled in one place (2-of-3 threshold ECDSA, no seed phrase).
6
6
 
7
- > **Early access (v0.0.x).** The public API is taking shape and may change before
7
+ > **Early access (v0.2.x).** The public API is taking shape and may change before
8
8
  > `1.0.0`. Pin an exact version.
9
9
 
10
10
  ## Install
11
11
 
12
12
  ```bash
13
- pnpm add @waaskey/sdk
13
+ pnpm add @waaskey/sdk @waaskey/client-wasm
14
14
  ```
15
15
 
16
+ `@waaskey/client-wasm` is the WASM MPC engine `WasmMpcCore` loads at runtime — it is
17
+ an **exact-pinned optional peer** (the SDK refuses a mismatched version), so install
18
+ it alongside the SDK for any app that creates wallets or signs. Passkey / step-up
19
+ flows additionally need the optional peer `@simplewebauthn/browser`.
20
+
16
21
  ## Quickstart
17
22
 
18
23
  ```ts
19
- import { Waaskey } from '@waaskey/sdk';
24
+ import { Waaskey, WasmMpcCore, EncryptedShareStore, loadClientWasm } from '@waaskey/sdk';
25
+
26
+ // A secret YOU derive per user (session token, passkey PRF output, device secret) —
27
+ // it keys the AES-256-GCM sealing of the local key share; never hardcode it.
28
+ const sessionSecret = await deriveUserSessionSecret();
29
+
30
+ const waaskey = new Waaskey({
31
+ apiKey: process.env.WAASKEY_PUBLISHABLE_KEY!,
32
+ // Non-custodial: the device runs its half of the ceremony and seals its key share.
33
+ mpc: new WasmMpcCore(loadClientWasm), // dynamic-imports @waaskey/client-wasm
34
+ shareStore: EncryptedShareStore.browser(sessionSecret), // sealed in IndexedDB
35
+ });
36
+
37
+ // Create an MPC wallet — runs the device keygen, seals the share, waits until active.
38
+ const wallet = await waaskey.wallets.create({ chain: 'ethereum' });
39
+ // → wallet.id, wallet.address, wallet.status === 'active'
40
+
41
+ // Sign a 32-byte digest (hex; 0x optional)
42
+ const signature = await wallet.sign(digestHex);
43
+ ```
44
+
45
+ **Reusing an existing wallet:** persist `wallet.id` (`wlt_…`) next to your user record
46
+ and load it later with `waaskey.wallets.get(id)` — the sealed device share is already
47
+ in the `shareStore` under that id, so `sign`/`send` work as soon as the sealing secret
48
+ is available again. Forgot the ids? `waaskey.wallets.list()` pages through the
49
+ tenant's wallets. On a brand-new device the share is restored via _Multi-factor
50
+ recovery_ (below), not re-created.
51
+
52
+ ### Node.js (no browser)
53
+
54
+ The same flow works server-side on Node ≥ 22 (global `fetch`, `WebSocket` and WebCrypto
55
+ are built in). Two things differ from the browser: the wasm engine is loaded from disk
56
+ (Node's `fetch` can't read file paths), and there is no IndexedDB — pass
57
+ `MemoryKeyValueStore` (or your own `KeyValueStore`, e.g. DB-backed). Generate the
58
+ Paillier primes **before** the ceremony with a `PrimePool` — inline generation takes
59
+ minutes of single-threaded CPU and can outlive the server party's ceremony timeout:
60
+
61
+ ```ts
62
+ import { readFile } from 'node:fs/promises';
63
+ import { createRequire } from 'node:module';
64
+ import { Waaskey, WasmMpcCore, EncryptedShareStore, MemoryKeyValueStore, PrimePool, generateRecoveryCode } from '@waaskey/sdk';
65
+
66
+ const require = createRequire(import.meta.url);
67
+
68
+ // Node wasm loader: read the bytes from the installed package and initialize with them.
69
+ async function loadClientWasmNode() {
70
+ const mod = await import('@waaskey/client-wasm');
71
+ const bytes = await readFile(require.resolve('@waaskey/client-wasm/client_wasm_bg.wasm'));
72
+ await mod.default(await WebAssembly.compile(bytes));
73
+ return mod;
74
+ }
75
+
76
+ const mpc = new WasmMpcCore(loadClientWasmNode);
77
+ const waaskey = new Waaskey({
78
+ apiKey: process.env.WAASKEY_API_KEY!,
79
+ mpc,
80
+ shareStore: new EncryptedShareStore(new MemoryKeyValueStore(), process.env.SHARE_SECRET!),
81
+ primePool: new PrimePool(mpc),
82
+ });
83
+
84
+ // Off the hot path (boot/idle): pre-generate the primes so create() takes seconds.
85
+ await waaskey.wallets.prewarm('ethereum');
86
+
87
+ const recoveryCode = generateRecoveryCode(); // show it to the user once
88
+ const wallet = await waaskey.wallets.create({ chain: 'ethereum' }, { backup: { recoveryCode, totpSecret, email } });
89
+ const signature = await wallet.sign(digestHex);
90
+ ```
91
+
92
+ > A wallet created this way is non-custodial with the _server you run this on_ acting
93
+ > as the "device" — its share lives in your `shareStore`. Guard that store accordingly.
94
+
95
+ ### Send a transaction — WaaS signs, **you** broadcast
96
+
97
+ WaaS is a **signing service, not a broadcaster** (non-custodial: it never owns the
98
+ mempool, nonce, or status-tracking). `wallet.send(...)` builds and MPC-signs the
99
+ transaction and returns the **signed raw tx** — you submit it from your own
100
+ node/provider:
101
+
102
+ ```ts
103
+ const { signedTx, txHash } = await wallet.send({ chainId: 'evm:1', to, value });
104
+ // signedTx → the signed raw tx to broadcast; txHash → a deterministic offline id (not a confirmation)
105
+
106
+ // Broadcast from YOUR node — either the optional helper…
107
+ const { txHash: broadcastHash } = await waaskey.broadcast(signedTx, { rpcUrl: 'https://your-rpc' });
108
+ // …or your own submitter / provider (recommended for production — you own tracking & retries).
109
+ ```
110
+
111
+ The `broadcast` helper is best-effort by design — one `eth_sendRawTransaction`
112
+ call, **no status polling and no retries**. Tracking the tx to confirmation is the
113
+ integrator's concern.
114
+
115
+ ### Custody policy — choose the threshold topology (advanced)
116
+
117
+ `wallets.create({ chain })` defaults to the **non-custodial 2-of-3** topology
118
+ `[device, server, user_backup]` — the platform holds 1 share < t=2, so it can never
119
+ sign alone (`custodyType: 'shared'`, `isNonCustodial: true`). To change the `(t, n)`
120
+ threshold or the custody guarantee, pass a custody policy; the SDK validates it
121
+ client-side and the backend enforces the attested invariant `platformShares < t ⟺
122
+ non-custodial`:
123
+
124
+ ```ts
125
+ const wallet = await waaskey.wallets.create({
126
+ chain: 'ethereum',
127
+ threshold: 2, // t
128
+ parties: ['device', 'user_backup', 'server'], // n = 3 well-known party roles
129
+ custodyKinds: ['user_device', 'user_backup', 'platform_signer'], // parallel to parties
130
+ custodyType: 'shared', // requested posture — the server refuses (400) on a mismatch
131
+ });
132
+
133
+ // The returned wallet surfaces the attestation so you can display the guarantee:
134
+ wallet.threshold; // 2
135
+ wallet.parties; // ['device','user_backup','server']
136
+ wallet.custodyKinds; // ['user_device','user_backup','platform_signer']
137
+ wallet.platformShareCount; // 1
138
+ wallet.custodyType; // 'shared'
139
+ wallet.isNonCustodial; // true — platform's 1 share alone can't reach t=2
140
+ ```
141
+
142
+ `CustodyKind` ∈ `user_device | user_backup | platform_signer | platform_recovery |
143
+ external_party`; `CustodyType` ∈ `embedded | shared | self_custody`. The standalone
144
+ `isNonCustodial(wallet)` helper is also exported.
20
145
 
21
- const waaskey = new Waaskey({ apiKey: process.env.WAASKEY_PUBLISHABLE_KEY! });
146
+ > The device runs whatever `t`-of-`n` the create ceremony describes — keygen is not
147
+ > pinned to 2-of-3. Coordinating an **interactive** `t`-of-`n` _sign_ across more than
148
+ > one user-held party (each running its half over the relay) is a separate follow-up;
149
+ > today's `wallet.sign(...)` covers the device+server quorum.
22
150
 
23
- // Create an MPC wallet
24
- const wallet = await waaskey.wallets.create({ userId: user.id, chain: 'ethereum' });
25
- // → { id: 'wlt_…', address: '0xabc…' }
151
+ Read the wallet's signing activity (raw signs + send/sweep signed txs, newest first):
26
152
 
27
- // Sign a message (interactive threshold-MPC, one awaited call)
28
- const signature = await wallet.signMessage('Hello Waaskey');
153
+ ```ts
154
+ const { items } = await wallet.signatures({ page: 1, limit: 20 });
29
155
  ```
30
156
 
31
157
  ## API
32
158
 
33
- | Method | Description |
34
- | -------------------------------------------- | ----------------------------------------- |
35
- | `new Waaskey({ apiKey, baseUrl?, fetch? })` | Create a client. |
36
- | `waaskey.wallets.create({ chain, userId? })` | Create a new wallet. Returns a `Wallet`. |
37
- | `waaskey.wallets.get(id)` | Load an existing wallet. |
38
- | `wallet.signMessage(message)` | Sign a message with the wallet's MPC key. |
159
+ | Method | Description |
160
+ | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
161
+ | `new Waaskey({ apiKey, mpc, shareStore, baseUrl?, fetch? })` | Create a client. `mpc` + `shareStore` are required to create wallets. |
162
+ | `waaskey.wallets.create({ chain, label?, threshold?, parties?, custodyKinds?, custodyType? }, options?)` | Create (keygen + sealed share). Optional custody policy (default: non-custodial 2-of-3 `[device, server, user_backup]`). Returns a `Wallet`. |
163
+ | `waaskey.wallets.list(query?, options?)` | List the tenant's wallets, newest first (paginated `WalletData` rows). |
164
+ | `waaskey.wallets.get(id, options?)` | Load an existing wallet. |
165
+ | `wallet.sign(digestHex, options?)` | Sign a 32-byte digest with the wallet's MPC key. |
166
+ | `wallet.send(params, options?)` | Build + MPC-sign a tx. Returns the **signed raw tx** (you broadcast). |
167
+ | `wallet.signatures(query?, options?)` | The wallet's signing activity (paginated). |
168
+ | `waaskey.broadcast(signedTx, { rpcUrl })` | **Optional** best-effort submit of a signed tx from your own node. |
169
+
170
+ `create` / `sign` accept `{ signal }` for cancellation; `create` also takes
171
+ `{ waitForActive?, activationTimeoutMs?, pollIntervalMs? }`.
172
+
173
+ Errors are thrown as `WaaskeyError` with a typed `code` (e.g. `unauthorized`,
174
+ `forbidden`, `validation`, `device_core_required`, `keygen_failed`, `aborted`) plus
175
+ `status?` and `details?` — branch on `error.code`, never on the message text.
176
+
177
+ ### Secure share storage
178
+
179
+ The device key share is the user's half of the key. `EncryptedShareStore` seals it
180
+ with AES-256-GCM (key derived from a secret **you** supply — the user's session,
181
+ a passkey, or a device secret — never embedded) and persists the ciphertext in
182
+ IndexedDB (`MemoryKeyValueStore` for tests/SSR). Wipe everything on logout:
183
+
184
+ ```ts
185
+ await shareStore.clear();
186
+ ```
187
+
188
+ #### Passkey-derived secret (recommended)
189
+
190
+ The strongest source for the sealing secret is a **passkey with the WebAuthn PRF
191
+ extension** (`PasskeyPrfSecretProvider`): the 32-byte secret only materializes after
192
+ the user touches their authenticator (or passes biometrics) and never sits on disk —
193
+ malware that steals the IndexedDB ciphertext cannot decrypt it in the background.
194
+ Needs the optional peer `@simplewebauthn/browser`.
195
+
196
+ ```ts
197
+ import { PasskeyPrfSecretProvider, isPrfSupported } from '@waaskey/sdk';
198
+
199
+ const prf = new PasskeyPrfSecretProvider();
200
+
201
+ // Onboarding — register a PRF-capable passkey (one authenticator touch).
202
+ // Persist credentialId + salt anywhere (not secret); keep `secret` in memory only.
203
+ const { credentialId, salt, secret } = await prf.enroll();
204
+
205
+ // Every later session — unlock with the same passkey (another touch).
206
+ const { secret: sessionSecret } = await prf.unlock(credentialId, { salt });
207
+
208
+ const shareStore = EncryptedShareStore.browser(sessionSecret);
209
+ ```
210
+
211
+ Feature-detect with `isPasskeySupported()` / `isPrfSupported()` and fall back to a
212
+ session-derived secret where PRF is unavailable.
213
+
214
+ ### Multi-factor recovery
215
+
216
+ Back up the device share so a user can recover after losing their device. The share
217
+ is encrypted **client-side** with a recovery code (the server only ever stores the
218
+ opaque ciphertext), and its release is gated behind 3 factors: recovery code, TOTP,
219
+ and email OTP.
220
+
221
+ ```ts
222
+ // Enrol — show the returned recoveryCode to the user once (it's the only key).
223
+ const { recoveryCode } = await waaskey.recovery.register(wallet.id, {
224
+ share: (await shareStore.get(wallet.id))!,
225
+ totpSecret, // base32 authenticator secret
226
+ email,
227
+ });
228
+
229
+ // Later, on a new device — verify factors, re-key, and restore the share locally.
230
+ const { challengeId, requiredFactors } = await waaskey.recovery.challenge(wallet.id);
231
+ await waaskey.recovery.recover(wallet.id, {
232
+ challengeId,
233
+ recoveryCode,
234
+ verifications: [
235
+ { type: 'recovery_code', token: recoveryCode },
236
+ { type: 'totp', token: otpFromAuthenticator },
237
+ { type: 'email_otp', token: otpFromEmail },
238
+ ],
239
+ });
240
+ ```
241
+
242
+ | Method | Description |
243
+ | ------------------------------------------ | -------------------------------------------------------------- |
244
+ | `recovery.register(walletId, params)` | Encrypt the share + enrol factors. Returns the `recoveryCode`. |
245
+ | `recovery.getInfo(walletId)` | The wallet's registered factors. |
246
+ | `recovery.challenge(walletId)` | Start a recovery session (`challengeId` + required factors). |
247
+ | `recovery.recover(walletId, params)` | Verify, re-key, decrypt, and restore the share to the store. |
248
+ | `recovery.retrieveShare(walletId, params)` | Verify + decrypt the share without re-keying (read-only). |
249
+
250
+ ### Balances (client-side, no backend)
251
+
252
+ WaaS is non-custodial and the backend does not index chain state — balances are read
253
+ **directly from a chain provider**, dApp-style. EVM chains work out of the box via a
254
+ default public RPC; override per chain (or plug a custom provider for non-EVM):
255
+
256
+ ```ts
257
+ const waaskey = new Waaskey({
258
+ apiKey,
259
+ chains: { ethereum: { rpcUrl: 'https://your-rpc' } }, // optional; EVM has defaults
260
+ });
261
+
262
+ const eth = await waaskey.balances.getBalance('ethereum', wallet.address!);
263
+ // → { raw: 1000000000000000000n, decimals: 18, symbol: 'ETH', formatted: '1' }
264
+ // (`symbol` is optional in the type — present for built-in EVM chains, may be
265
+ // undefined for a custom provider that doesn't supply one)
266
+
267
+ const usdc = await waaskey.balances.getTokenBalance('ethereum', usdcAddress, wallet.address!, { symbol: 'USDC' });
268
+ ```
39
269
 
40
- Errors are thrown as `WaaskeyError` (`{ message, status, code }`).
270
+ Balances are exact `bigint` base units plus a `formatted` decimal string (no float).
271
+ `formatUnits(raw, decimals)` is exported for rendering.
41
272
 
42
273
  ## Development
43
274
 
@@ -51,4 +282,4 @@ pnpm build # tsup → dist (esm + cjs + d.ts)
51
282
 
52
283
  ## License
53
284
 
54
- [MIT](./LICENSE) © Waaskey
285
+ [MIT](./LICENSE) © WAASKey