@waaskey/sdk 0.4.1 → 0.6.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
@@ -80,14 +80,41 @@ const waaskey = new Waaskey({
80
80
  baseUrl: process.env.WAASKEY_API_URL!,
81
81
  mpc,
82
82
  shareStore: new EncryptedShareStore(new MemoryKeyValueStore(), process.env.SHARE_SECRET!),
83
- primePool: new PrimePool(mpc),
83
+ // `concurrency` only helps a WORKER-BACKED core: inline WASM generation shares the calling
84
+ // thread, so overlapping the searches finishes no sooner and delays the first usable prime.
85
+ primePool: new PrimePool(mpc, { concurrency: 4 }),
84
86
  });
85
87
 
86
88
  // Off the hot path (boot/idle): pre-generate the primes so create() takes seconds.
87
89
  await waaskey.wallets.prewarm('ethereum');
88
90
 
89
- const recoveryCode = generateRecoveryCode(); // show it to the user once
90
- const wallet = await waaskey.wallets.create({ chain: 'ethereum' }, { backup: { recoveryCode, totpSecret, email } });
91
+ const recoveryCode = generateRecoveryCode();
92
+ let mustShowTheCode = true;
93
+
94
+ const wallet = await waaskey.wallets.create(
95
+ { chain: 'ethereum' },
96
+ {
97
+ backup: {
98
+ recoveryCode,
99
+ totpSecret,
100
+ email,
101
+ // Enrols a passkey INSTEAD of the code where the device can produce a PRF, and the code
102
+ // where it cannot. Never both: the recovery gate is an AND of every enrolled factor, so a
103
+ // wallet holding both would be bricked by losing either one.
104
+ passkeyEnroller: () => new PasskeyPrfSecretProvider().enroll({ userName: email }),
105
+ },
106
+ // Which one actually happened — the app cannot infer it, because enrolment can fall back for
107
+ // reasons it cannot see.
108
+ onRecoveryEnrolled: ({ strongFactor }) => {
109
+ mustShowTheCode = strongFactor === 'recovery_code';
110
+ },
111
+ },
112
+ );
113
+
114
+ // Show the code ONLY if it is what guards the wallet. Telling a passkey user to write down a code
115
+ // nobody will ever ask them for is how a recovery screen teaches people to ignore it.
116
+ if (mustShowTheCode) showRecoveryCode(recoveryCode);
117
+
91
118
  const signature = await wallet.sign(digestHex);
92
119
  ```
93
120
 
@@ -168,6 +195,8 @@ const { items } = await wallet.signatures({ page: 1, limit: 20 });
168
195
  | `wallet.send(params, options?)` | Build + MPC-sign a tx. Returns the **signed raw tx** (you broadcast). |
169
196
  | `wallet.signatures(query?, options?)` | The wallet's signing activity (paginated). |
170
197
  | `waaskey.broadcast(signedTx, { rpcUrl })` | **Optional** best-effort submit of a signed tx from your own node. |
198
+ | `waaskey.sessionKeys.list(walletId?, signal?)` | The tenant's active session keys (delegated, scoped permissions). |
199
+ | `waaskey.sessionKeys.send(params, signal?)` | Sign a call with a session key and submit it as a UserOperation. |
171
200
 
172
201
  `create` / `sign` accept `{ signal }` for cancellation; `create` also takes
173
202
  `{ waitForActive?, activationTimeoutMs?, pollIntervalMs? }`.
@@ -176,6 +205,87 @@ Errors are thrown as `WaaskeyError` with a typed `code` (e.g. `unauthorized`,
176
205
  `forbidden`, `validation`, `device_core_required`, `keygen_failed`, `aborted`) plus
177
206
  `status?` and `details?` — branch on `error.code`, never on the message text.
178
207
 
208
+ ### Session keys — acting with a delegated permission
209
+
210
+ A session key is a scoped, time-limited signer a wallet granted: certain contracts, certain
211
+ selectors, up to a value, a bounded number of calls. `send` prepares the UserOperation server-side,
212
+ signs its hash with the key, and submits exactly those fields — the gas and paymaster values are
213
+ part of what the signature covers, so a locally-guessed op would produce a signature for an
214
+ operation the bundler never sees.
215
+
216
+ ```ts
217
+ const { userOpHash } = await waaskey.sessionKeys.send({
218
+ sessionKeyId,
219
+ privateKey, // the granted key's PRIVATE half — signs locally, never sent
220
+ sender, // the smart account it acts for
221
+ chainId: 'evm:1',
222
+ callData, // ABI-encoded execute calldata
223
+ contract, // checked against the key's allowlist
224
+ selector,
225
+ valueWei: '0x0', // checked against the key's cap
226
+ });
227
+ ```
228
+
229
+ ### Asking a user for a permission
230
+
231
+ An app that wants a permission asks for one and sends the user to their wallet to answer:
232
+
233
+ ```ts
234
+ const waaskey = new Waaskey({ apiKey, baseUrl, walletUrl: 'https://wallet.example.com' });
235
+
236
+ const req = await waaskey.sessionKeys.request({
237
+ walletId,
238
+ label: 'Dungeon Quest — one battle',
239
+ scope: { allowedContracts: [game], maxCalls: 50, periodSeconds: 3600, maxCallsPerPeriod: 10 },
240
+ expiresAt: new Date(Date.now() + 24 * 3600_000),
241
+ requesterId, // your registered app — the wallet shows the domain you proved you own
242
+ });
243
+
244
+ showQrCode(req.url); // or open it; this is where the user answers
245
+
246
+ const { status, key, narrowed } = await waaskey.sessionKeys.waitForDecision(req.sessionKeyId, {
247
+ asked: scope, // supply it to learn whether the user accepted less
248
+ });
249
+ ```
250
+
251
+ **The private key never travels.** `request` generates the keypair locally and registers only its
252
+ public half, so nothing secret is in the request, in the link, or in the wallet — `req.privateKey`
253
+ is yours to keep, and there is no way to fetch it later. That is also why the user is never asked to
254
+ copy a key out of a wallet UI.
255
+
256
+ **The user may narrow the request, never widen it.** They can shorten the duration or cut the
257
+ amounts; the server refuses anything wider than what was asked. `narrowed: true` says they accepted
258
+ less, so your app can adapt rather than failing opaquely on its first call outside the smaller scope.
259
+
260
+ `waitForDecision` polls rather than waiting for a redirect back — a user answering on their phone,
261
+ or closing the tab, would otherwise strand you with no answer. A refusal comes back as
262
+ `status: 'declined'` (an answer, not an error); a request nobody answers throws
263
+ `permission_request_timeout`.
264
+
265
+ **Two credentials, and they are not interchangeable.** The client's `apiKey` must carry the `SIGN`
266
+ scope — the session private key alone does not reach the API — while the private key is what
267
+ actually authorizes the operation on-chain. This surprises everyone once.
268
+
269
+ A refused call says WHY, because the fixes are opposite: `permission_expired`,
270
+ `permission_revoked`, `permission_scope`, `permission_value_exceeded`, `permission_exhausted`,
271
+ `permission_rate_limited`. The first three mean "re-request the permission", `permission_scope` and
272
+ `permission_value_exceeded` mean "change the call", and `permission_rate_limited` means "the same
273
+ call succeeds in the next period" — a grant may cap both how much it is worth in total and how fast
274
+ it may be spent, so a spent period is not a spent permission.
275
+
276
+ A listed permission carries the name it was granted under (`label`), the app that asked for it
277
+ (`requesterId`) and the policy paying its gas (`paymasterPolicyId`). Resolve the requester to show
278
+ who is asking:
279
+
280
+ ```ts
281
+ const app = await waaskey.sessionKeys.requester(key.requesterId!);
282
+ // app.origin is identity ONLY when app.status === 'verified' — that means the domain itself
283
+ // published a document naming the tenant. `name` and `iconUrl` are the app's own claims.
284
+ ```
285
+
286
+ The code comes from the server, not from its wording: it is read off the refusal's `code` field,
287
+ and matching the message is only a fallback for a server that predates it.
288
+
179
289
  ### Secure share storage
180
290
 
181
291
  The device key share is the user's half of the key. `EncryptedShareStore` seals it