@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 +248 -17
- package/dist/index.cjs +2605 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2642 -43
- package/dist/index.d.ts +2642 -43
- package/dist/index.js +2574 -40
- package/dist/index.js.map +1 -1
- package/package.json +29 -22
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
const
|
|
153
|
+
```ts
|
|
154
|
+
const { items } = await wallet.signatures({ page: 1, limit: 20 });
|
|
29
155
|
```
|
|
30
156
|
|
|
31
157
|
## API
|
|
32
158
|
|
|
33
|
-
| Method
|
|
34
|
-
|
|
|
35
|
-
| `new Waaskey({ apiKey, baseUrl?, fetch? })`
|
|
36
|
-
| `waaskey.wallets.create({ chain,
|
|
37
|
-
| `waaskey.wallets.
|
|
38
|
-
| `
|
|
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
|
-
|
|
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) ©
|
|
285
|
+
[MIT](./LICENSE) © WAASKey
|