ckb-session-kit 1.0.0 → 2.1.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/CHANGELOG.md +331 -0
- package/README.md +797 -558
- package/dist/deployment.d.ts +21 -0
- package/dist/deployment.js +92 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -2
- package/dist/onchain.d.ts +356 -30
- package/dist/onchain.js +855 -86
- package/dist/paywall.d.ts +29 -0
- package/dist/paywall.js +84 -25
- package/dist/place.d.ts +18 -9
- package/dist/place.js +32 -28
- package/dist/session.d.ts +14 -1
- package/dist/session.js +4 -1
- package/dist/store.d.ts +2 -1
- package/dist/store.js +17 -6
- package/dist/subscription.d.ts +103 -7
- package/dist/subscription.js +456 -119
- package/package.json +15 -3
package/README.md
CHANGED
|
@@ -1,558 +1,797 @@
|
|
|
1
|
-
# ckb-session-kit
|
|
2
|
-
|
|
3
|
-
Browser-held self-custody sessions for CKB applications.
|
|
4
|
-
|
|
5
|
-
**Live demo:** [ckb-session-kit.vercel.app](https://ckb-session-kit.vercel.app/) — connect a wallet, sign once to open a session, pay with no popups while the network enforces the limits, sign once to close (CKB testnet).
|
|
6
|
-
|
|
7
|
-
**Status: v1.0, on CKB testnet (not audited, no mainnet deployment).** Built in the open during my CKBuilder programme.
|
|
8
|
-
|
|
9
|
-
| When | Milestone |
|
|
10
|
-
|---|---|
|
|
11
|
-
| w/c 5 Oct 2026 | ✅ Scaffold, tests, CI |
|
|
12
|
-
| w/c 12 Oct | ✅ Session key held in the browser signs a testnet transfer; live demo |
|
|
13
|
-
| w/c 19 Oct | ✅ Session lock script (Rust) — outflow limit, recipient, rate limit enforced on-chain; deployed to testnet |
|
|
14
|
-
| w/c 26 Oct | ✅ Delegate, act repeatedly with no wallet dialog, revoke — library + demo (v0.2) |
|
|
15
|
-
| w/c 2 Nov | ✅ Survives reload; ✅ device-loss recovery |
|
|
16
|
-
| w/c 23 Nov | v1.0 on npm |
|
|
17
|
-
|
|
18
|
-
## The problem
|
|
19
|
-
|
|
20
|
-
A CKB application that needs to act repeatedly on a user's behalf — paying per API
|
|
21
|
-
call, submitting a transaction per interaction, anything with a loop in it — runs
|
|
22
|
-
into the same wall: every action opens a wallet dialog. Approving a hundred
|
|
23
|
-
signatures to make a hundred calls is not a usable product.
|
|
24
|
-
|
|
25
|
-
The obvious fix is to hand a key to the server, and that destroys the point.
|
|
26
|
-
|
|
27
|
-
## What this is meant to be
|
|
28
|
-
|
|
29
|
-
A session layer where:
|
|
30
|
-
|
|
31
|
-
- The session key is **created and held in the browser**, and never leaves it.
|
|
32
|
-
- It can sign within a **declared scope, enforced on-chain** — and nothing outside it.
|
|
33
|
-
- The session **survives a page reload**, and expires on terms the user set.
|
|
34
|
-
- The owner can **revoke it on-chain** at any moment.
|
|
35
|
-
- Losing the device does not mean losing the funds.
|
|
36
|
-
|
|
37
|
-
Its first example application is pay-per-read: a reader unlocks articles for
|
|
38
|
-
1 CKB each with no wallet prompt, and the session can only ever pay the creator.
|
|
39
|
-
The problem is not specific to that application.
|
|
40
|
-
|
|
41
|
-
##
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
npm install
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
(
|
|
71
|
-
|
|
72
|
-
(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
[
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
[
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
[
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
→
|
|
292
|
-
→ [
|
|
293
|
-
|
|
294
|
-
→
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
→ [
|
|
306
|
-
→
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
session
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
|
462
|
-
|
|
463
|
-
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
1
|
+
# ckb-session-kit
|
|
2
|
+
|
|
3
|
+
Browser-held self-custody sessions for CKB applications.
|
|
4
|
+
|
|
5
|
+
**Live demo:** [ckb-session-kit.vercel.app](https://ckb-session-kit.vercel.app/) — connect a wallet, sign once to open a session, pay with no popups while the network enforces the limits, sign once to close (CKB testnet).
|
|
6
|
+
|
|
7
|
+
**Status: v1.0, on CKB testnet (not audited, no mainnet deployment).** Built in the open during my CKBuilder programme.
|
|
8
|
+
|
|
9
|
+
| When | Milestone |
|
|
10
|
+
|---|---|
|
|
11
|
+
| w/c 5 Oct 2026 | ✅ Scaffold, tests, CI |
|
|
12
|
+
| w/c 12 Oct | ✅ Session key held in the browser signs a testnet transfer; live demo |
|
|
13
|
+
| w/c 19 Oct | ✅ Session lock script (Rust) — outflow limit, recipient, rate limit enforced on-chain; deployed to testnet |
|
|
14
|
+
| w/c 26 Oct | ✅ Delegate, act repeatedly with no wallet dialog, revoke — library + demo (v0.2) |
|
|
15
|
+
| w/c 2 Nov | ✅ Survives reload; ✅ device-loss recovery |
|
|
16
|
+
| w/c 23 Nov | v1.0 on npm |
|
|
17
|
+
|
|
18
|
+
## The problem
|
|
19
|
+
|
|
20
|
+
A CKB application that needs to act repeatedly on a user's behalf — paying per API
|
|
21
|
+
call, submitting a transaction per interaction, anything with a loop in it — runs
|
|
22
|
+
into the same wall: every action opens a wallet dialog. Approving a hundred
|
|
23
|
+
signatures to make a hundred calls is not a usable product.
|
|
24
|
+
|
|
25
|
+
The obvious fix is to hand a key to the server, and that destroys the point.
|
|
26
|
+
|
|
27
|
+
## What this is meant to be
|
|
28
|
+
|
|
29
|
+
A session layer where:
|
|
30
|
+
|
|
31
|
+
- The session key is **created and held in the browser**, and never leaves it.
|
|
32
|
+
- It can sign within a **declared scope, enforced on-chain** — and nothing outside it.
|
|
33
|
+
- The session **survives a page reload**, and expires on terms the user set.
|
|
34
|
+
- The owner can **revoke it on-chain** at any moment.
|
|
35
|
+
- Losing the device does not mean losing the funds.
|
|
36
|
+
|
|
37
|
+
Its first example application is pay-per-read: a reader unlocks articles for
|
|
38
|
+
1 CKB each with no wallet prompt, and the session can only ever pay the creator.
|
|
39
|
+
The problem is not specific to that application.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install ckb-session-kit
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
[ckb-session-kit on npm](https://www.npmjs.com/package/ckb-session-kit) (v2.1.0, ESM, Node 20 or later
|
|
48
|
+
and browsers). The API is in [docs/API.md](docs/API.md); the walkthrough in
|
|
49
|
+
[docs/WALKTHROUGH.md](docs/WALKTHROUGH.md).
|
|
50
|
+
|
|
51
|
+
New in 2.0.0 (details and upgrade notes in [CHANGELOG.md](CHANGELOG.md)):
|
|
52
|
+
|
|
53
|
+
- `openSession(owner, session, deployment, { ..., onWarning })`: called with a
|
|
54
|
+
`SessionWarning` (`"no-rate-limit"`) before anything is signed; throw from it
|
|
55
|
+
to stop the opening. Without it the warning goes to `console.warn`.
|
|
56
|
+
- `SigningLockOptions` (`{ allowUnreviewedOwnerLock?: boolean }`), taken by
|
|
57
|
+
`assertSigningLock`, `openSession` and `openSubscription`: owner locks are
|
|
58
|
+
checked against a list of reviewed configurations (see
|
|
59
|
+
[Which wallets can own a session](#which-wallets-can-own-a-session)). Refusals
|
|
60
|
+
carry `reason: "owner-lock-unsafe"` (never allowed) or
|
|
61
|
+
`"owner-lock-unreviewed"` (allowed, with a warning, by this option).
|
|
62
|
+
- `RetryOptions` (`{ attempts?, delayMs? }`, defaults in `DEFAULT_RETRY`: 3
|
|
63
|
+
attempts from 1000 ms, doubling, with jitter), passed as `retry` to
|
|
64
|
+
`spendInSession` and `chargeSubscription`: a send the node refuses for a spent
|
|
65
|
+
input is built again with another payout cell (`poolConflict` tells an RBF
|
|
66
|
+
refusal apart). The retry loop itself is internal (not exported).
|
|
67
|
+
- `pickPayoutCell(client, lock, cells, avoid?)`: the payout cell a top-up uses,
|
|
68
|
+
confirmed and free in the node's pool (`MAX_POOL_CHECKS`, `MAX_PAYOUT_CELLS`).
|
|
69
|
+
When none is free, `SessionBalanceError` with reason `"payout-busy"`.
|
|
70
|
+
- `createPayoutCells(funder, payTo, { count, capacity?, token? })`: more
|
|
71
|
+
anyone-can-pay cells for a creator, so concurrent payments do not compete.
|
|
72
|
+
- `clearPlantedCells(subscriber, deployment, subscription)`: sweeps cells a third
|
|
73
|
+
party created under a subscription's lock, keeping the subscription's own.
|
|
74
|
+
- `assertCurrentDeployment(client, deployment)`, `MAX_SUBSCRIPTION_CELLS`, and the
|
|
75
|
+
`SessionPolicyReason` / `SessionBalanceReason` types for the errors' `reason`.
|
|
76
|
+
|
|
77
|
+
## Try it
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npm install
|
|
81
|
+
npm test # unit + CKB-VM tests, no network needed
|
|
82
|
+
npm run demo # the Next.js web demo at http://localhost:3000
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The demo (v0.2) connects a wallet through CCC (JoyID, MetaMask, OKX, UniSat, …).
|
|
86
|
+
The wallet signs **once** to open a session: it funds a session cell under the
|
|
87
|
+
on-chain session lock plus a small key cell that pays the session's fees. Payments
|
|
88
|
+
are then signed by a key held only in the browser, with **no wallet popup**, and the
|
|
89
|
+
network enforces the limits. The wallet signs **once more** to close the session and
|
|
90
|
+
take everything back.
|
|
91
|
+
|
|
92
|
+
New here? [`docs/WALKTHROUGH.md`](docs/WALKTHROUGH.md) tours every demo page on
|
|
93
|
+
testnet in about fifteen minutes, with one wallet.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { createSession, openSession, spendInSession, closeSession, TESTNET_DEPLOYMENT as deployment } from "ckb-session-kit";
|
|
97
|
+
|
|
98
|
+
const session = createSession({ expiresAt, scope: { maxPerTx: 100n * 10n ** 8n } });
|
|
99
|
+
const { binding } = await openSession(wallet, session, deployment, { budget: 400n * 10n ** 8n });
|
|
100
|
+
await spendInSession(session, client, deployment, binding, { to, amount }); // no wallet
|
|
101
|
+
await closeSession(wallet, session, deployment, binding); // one signature
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`npm run smoke:flow` runs exactly that against testnet with a key standing in for
|
|
105
|
+
the wallet. Run on 8 Oct 2026: [open](https://testnet.explorer.nervos.org/transaction/0x5835048d4226f0b74be01cd79664d7e4dce67ea8aa77bb9d3468e896d27bf2b8)
|
|
106
|
+
(owner signs) → [pay 100 CKB](https://testnet.explorer.nervos.org/transaction/0x9448e354252d71913d18fda2ba6d2460e1d78c2c0e4123eb8068482d7ec05c77)
|
|
107
|
+
and [pay 80 CKB](https://testnet.explorer.nervos.org/transaction/0x9391051dcc7f490ade402dbc2057de4382e005e9c9d21ad8d7f835328ddcd182)
|
|
108
|
+
(session key only) → 150 CKB refused → [key cell returned](https://testnet.explorer.nervos.org/transaction/0xe8eb8954674665aa1a4b990ecfd8ffea1ad728e8689a5f5aafdb9fc17c00b8d9)
|
|
109
|
+
and [session swept](https://testnet.explorer.nervos.org/transaction/0x0d2a05460bdbfd5693c2d91a55e9c96b2eaaa6fbb7e0b1e3237a1fe9c2d6f1e4) (owner signs).
|
|
110
|
+
|
|
111
|
+
### Lost the device? The wallet alone gets it back
|
|
112
|
+
|
|
113
|
+
The session key lives only in one browser. If that browser is gone, the owner's
|
|
114
|
+
wallet can still recover every session it ever opened:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
await recoverSessions(wallet, deployment); // finds them all, usually one signature
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Session-lock args begin with the owner's lock hash, so an indexer prefix search
|
|
121
|
+
finds the session cells without knowing the lost key, and owner mode sweeps them.
|
|
122
|
+
Cells whose args are malformed are skipped: the lock rejects them even in owner
|
|
123
|
+
mode, and anyone could plant one with the owner's hash as a prefix to block a
|
|
124
|
+
sweep. Anyone can also plant a *well-formed* cell with the owner's hash and their
|
|
125
|
+
own key, and spend it just before the sweep lands; if the single sweep fails,
|
|
126
|
+
each session is swept on its own (one signature each), so only the planted cell
|
|
127
|
+
is left out. What cannot come back is the key cell (the fee budget), which only
|
|
128
|
+
the session key can spend; keep it small. It must stay big enough to go back to
|
|
129
|
+
the owner after paying fees, so `openSession` sizes it from the owner's lock: 62
|
|
130
|
+
CKB by default for a secp256k1 owner, 64 for JoyID or Omnilock (MetaMask, UniSat,
|
|
131
|
+
OKX), whose cells need 63. The extra 1 CKB pays thousands of fees.
|
|
132
|
+
|
|
133
|
+
`npm run smoke:recover` runs it on testnet. Run on 9 Oct 2026: two sessions
|
|
134
|
+
[opened](https://testnet.explorer.nervos.org/transaction/0x289291b3026af3d1e1929308b75276bc72d0c8de151f1b74f6b7926f29bda74b)
|
|
135
|
+
([second](https://testnet.explorer.nervos.org/transaction/0xb4d91cdb0751c7fd9908ac18930c07c74d09c67f7338916f9893eb8190acd883)),
|
|
136
|
+
one [spent from](https://testnet.explorer.nervos.org/transaction/0xf173fd2b02b1b8e208bd493c9d31ead9d5052b5aae190119a2393554de0e8079),
|
|
137
|
+
a [decoy](https://testnet.explorer.nervos.org/transaction/0x40781ca16108dfde33328ded076721137c6be935d2417258c881dfe4266f000f)
|
|
138
|
+
planted, both keys dropped → [one owner transaction](https://testnet.explorer.nervos.org/transaction/0x4efd8b7875c8a0f2883ac2b21ed509ff86c72012e6f4a24816783aa25e02eeaf)
|
|
139
|
+
swept every session cell back, decoy skipped.
|
|
140
|
+
|
|
141
|
+
## Examples and API
|
|
142
|
+
|
|
143
|
+
- [`examples/node-quickstart.ts`](examples/node-quickstart.ts): open, pay twice
|
|
144
|
+
with no wallet, close, in one file (`OWNER_KEY=0x… npx tsx --tsconfig examples/tsconfig.json examples/node-quickstart.ts`).
|
|
145
|
+
Run on 13 Oct 2026: [open](https://testnet.explorer.nervos.org/transaction/0x8229533c8769bb30730d075abd5c6cf60b39ab9765a3fc24adeb6d6af649cf8c)
|
|
146
|
+
→ [pay 61](https://testnet.explorer.nervos.org/transaction/0x75e3673ce82d202dd76bd30903f199b83eddd0e4b2085fd45b2cd286cf27fea7)
|
|
147
|
+
→ [pay 65](https://testnet.explorer.nervos.org/transaction/0x821816ec1916c805e98b0de1ec5e3c606837950edb468a7a4d998dd7f8f9598a)
|
|
148
|
+
→ [sweep](https://testnet.explorer.nervos.org/transaction/0x36d47d37139d21affe8d0660395dba03aba2d3b247566f1e34684d85b9aac67d)
|
|
149
|
+
and [key cell back](https://testnet.explorer.nervos.org/transaction/0x41604d6d13fc7f7b0e7adc92316651a25507a7a2ffe8b2964887b3935498de75).
|
|
150
|
+
- [`examples/browser-session.ts`](examples/browser-session.ts): what an app does
|
|
151
|
+
around any CCC wallet: save before funding, one session per tab, restore on load.
|
|
152
|
+
- [`examples/paywall-server.ts`](examples/paywall-server.ts): a paywall with no
|
|
153
|
+
database, on `node:http`.
|
|
154
|
+
- [`docs/API.md`](docs/API.md): every export, grouped by task. CI typechecks the
|
|
155
|
+
examples and fails if an export is missing from the reference.
|
|
156
|
+
|
|
157
|
+
## Example: pay-per-read
|
|
158
|
+
|
|
159
|
+
A reader's wallet signs once to open a session scoped to one creator. Each article
|
|
160
|
+
unlock is then a 1 CKB payment signed by the session key alone. Two things make
|
|
161
|
+
this work on CKB:
|
|
162
|
+
|
|
163
|
+
- **Payments smaller than a cell.** A new cell needs at least 61 CKB, so a 1 CKB
|
|
164
|
+
payment cannot create one. Instead the creator keeps an
|
|
165
|
+
[anyone-can-pay](https://github.com/nervosnetwork/rfcs/blob/master/rfcs/0026-anyone-can-pay/0026-anyone-can-pay.md)
|
|
166
|
+
cell, and each payment tops it up: `spendInSession(…, { to, amount, topUp: true })`.
|
|
167
|
+
The anyone-can-pay lock accepts the top-up without the creator's signature.
|
|
168
|
+
- **A stolen key can only pay the creator.** The session's recipient is the
|
|
169
|
+
creator's anyone-can-pay address, so the session lock refuses any other output,
|
|
170
|
+
and `max_per_tx` caps each unlock. The fee is not an output: up to `max_per_tx`
|
|
171
|
+
per transaction can still go to the miner, so give a session with a recipient a
|
|
172
|
+
rate limit (`minInterval`) too.
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
const session = createSession({ expiresAt, scope: { maxPerTx: 5n * CKB, recipients: [creatorAcp] } });
|
|
176
|
+
const { binding } = await openSession(wallet, session, deployment, { budget: 200n * CKB });
|
|
177
|
+
await spendInSession(session, client, deployment, binding, { to: creatorAcp, amount: 1n * CKB, topUp: true });
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Server-verified unlocks, no database.** Each payment names its article in a
|
|
181
|
+
memo (`spendInSession(…, { memo })`), stored in the witness the session key signs,
|
|
182
|
+
so the payment commits to what it buys. To read, the browser signs a request with
|
|
183
|
+
the same key (`signAccess`), and the server checks the chain (`verifyAccess`):
|
|
184
|
+
committed, the creator gained at least the price, the memo matches, and the key
|
|
185
|
+
that signed the request is the key that paid. Anyone can see a payment on-chain;
|
|
186
|
+
only its payer can use it, and only for that article. The article text is never
|
|
187
|
+
in the page bundle.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// browser
|
|
191
|
+
const tx = await spendInSession(session, client, deployment, binding, { to: creatorAcp, amount: 1n * CKB, topUp: true, memo: paymentMemo("read:since") });
|
|
192
|
+
const proof = await signAccess(session, tx, paymentMemo("read:since"));
|
|
193
|
+
// server
|
|
194
|
+
const check = await verifyAccess(client, proof, { to: creatorLock, minAmount: 1n * CKB, memo: paymentMemo("read:since") });
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`npm run smoke:pay-per-read` runs it on testnet; with `ARTICLE_API` set it also
|
|
198
|
+
claims each article through the demo's `/api/article` route. Run on 9 Oct 2026
|
|
199
|
+
against the production build:
|
|
200
|
+
[session opened](https://testnet.explorer.nervos.org/transaction/0xbaa5657c27d44e37e9557b1dbb0ee38fb3cda95924098529892906857b7ae72d)
|
|
201
|
+
(reader signs once, recipient = creator) → reads
|
|
202
|
+
[#1](https://testnet.explorer.nervos.org/transaction/0x5aeeb65326efb1ff2d651ba22f5a685c224162e974b51ceb9638f6576d056942),
|
|
203
|
+
[#2](https://testnet.explorer.nervos.org/transaction/0x575becbaa7aa2b03d26406c974ca417b82f06d044b863919d07cf5d760e6910b),
|
|
204
|
+
[#3](https://testnet.explorer.nervos.org/transaction/0x3b54027483f7239c1b5af7c82a773263107493c8db88183d9aef740091680eb0)
|
|
205
|
+
at 1 CKB each, session key only, each released by the route to the payer, while
|
|
206
|
+
a claim by another key and a claim for another article were refused →
|
|
207
|
+
paying anyone else and paying 6 CKB (limit 5) refused before signing →
|
|
208
|
+
[key cell returned](https://testnet.explorer.nervos.org/transaction/0xf7d9f2cc05d0841d59fa82d66ae5e3152b69860155fd6c6b2ca82dba15fa9493)
|
|
209
|
+
and [session swept](https://testnet.explorer.nervos.org/transaction/0x978e186aceeadca6d963cf6f570066c36028d7ba0273e5f84bfb7c6e46c9e281).
|
|
210
|
+
The on-chain side of the refusals is covered by the CKB-VM tests below.
|
|
211
|
+
|
|
212
|
+
**Try it in the browser:** [ckb-session-kit.vercel.app/read](https://ckb-session-kit.vercel.app/read)
|
|
213
|
+
(the `/read` page of the demo): open a reading session with one wallet signature,
|
|
214
|
+
then unlock articles for 1 CKB each with no popup.
|
|
215
|
+
|
|
216
|
+
A session cell with a recipient occupies 153 CKB, so a 200 CKB session has 47 CKB
|
|
217
|
+
to spend; the rest comes back on close. Each payment consumes one of the creator's
|
|
218
|
+
anyone-can-pay cells, so readers paying at the same moment compete for them. A
|
|
219
|
+
top-up picks a confirmed cell that no transaction in the node's pool spends
|
|
220
|
+
(`pickPayoutCell`; at most 4 cells checked, 64 read). If the node still refuses
|
|
221
|
+
the payment for a spent cell, it is built again with another cell after a short
|
|
222
|
+
wait, up to 3 attempts by default (`retry: { attempts, delayMs }`). A send whose
|
|
223
|
+
outcome is unknown is never retried. When every cell is busy, nothing is sent
|
|
224
|
+
and the error says `"payout-busy"`. Give a creator more cells with
|
|
225
|
+
`createPayoutCells(funder, payTo, { count })`; `npm run setup:creator` gives the
|
|
226
|
+
demo's creator eight.
|
|
227
|
+
`npm run smoke:contention` checks it on testnet: four payers pay one creator at the same moment, every payment must commit, and a hand-built double top-up shows the node's own refusal.
|
|
228
|
+
`npm run smoke:pool-lag` measures how stale the pool view behind `pickPayoutCell` is: on one node, none; behind a load-balanced URL, up to about 0.5 s on the backend that did not take the send ([docs/SECURITY-EVIDENCE.md](docs/SECURITY-EVIDENCE.md#payout-contention-the-pool-view-after-a-send-testnet)).
|
|
229
|
+
|
|
230
|
+
Chained payments (topping up a cell another payment created while that payment
|
|
231
|
+
is still in the pool) are not used: if the first payment is dropped or slow to
|
|
232
|
+
propagate, every payment built on it is stuck or dropped too, and a reader's
|
|
233
|
+
session would wait on someone else's transaction. More cells and a pool-aware
|
|
234
|
+
pick avoid that.
|
|
235
|
+
|
|
236
|
+
## Example: CKB Place, a pixel board on testnet
|
|
237
|
+
|
|
238
|
+
[`/place`](https://ckb-session-kit.vercel.app/place): a shared 32×32 board. Open a
|
|
239
|
+
session once (one wallet signature; the session lock lets it pay only the board),
|
|
240
|
+
then every batch of up to 32 pixels is one 0.1 CKB-per-pixel payment signed by
|
|
241
|
+
the session key, with no wallet popup. The pixels travel in the payment's memo
|
|
242
|
+
(`"px1"` + `x, y, colour` per pixel); the board is rebuilt from the chain by
|
|
243
|
+
reading those memos in chain order, counting only payments that carry exactly one
|
|
244
|
+
memo, signed by the payer, for at least the price. No server, no database:
|
|
245
|
+
[`src/place.ts`](src/place.ts).
|
|
246
|
+
|
|
247
|
+
`npm run smoke:place` paints "CKB" on testnet. Run on 14 Oct 2026:
|
|
248
|
+
[board cells created](https://testnet.explorer.nervos.org/transaction/0x2dbcf33a2c8360a1ad2a7984f2f1331f345b0c9874511e7f5c7f828db24dea1f)
|
|
249
|
+
→ [session opened](https://testnet.explorer.nervos.org/transaction/0x91cfac448029eba2354e08a6a3f6fbeaf4227a6507ebcda267e5a4e0060d3715)
|
|
250
|
+
→ [29 pixels, 2.9 CKB, no wallet](https://testnet.explorer.nervos.org/transaction/0x91acae7b00a0210e3779b76ec974395964ba65e7797780dd3a9d815e4245d0f5)
|
|
251
|
+
→ [swept](https://testnet.explorer.nervos.org/transaction/0x3c3b734fdda064e108dbcf0d989d573625e5823a54f92233a66f0cfa0e08b1e3)
|
|
252
|
+
and [key cell back](https://testnet.explorer.nervos.org/transaction/0x3c369a5c81e66847003ff4019fd01773d97563378a07d05857a5c18d4096e006).
|
|
253
|
+
|
|
254
|
+
## Example: pay-per-minute
|
|
255
|
+
|
|
256
|
+
[`/listen`](https://ckb-session-kit.vercel.app/listen): a player for three
|
|
257
|
+
generative tracks (Web Audio, composed per minute on the server). Open a session
|
|
258
|
+
once; while it plays, the session key pays 0.5 CKB for the next minute 30 s
|
|
259
|
+
before it is needed, with memo `listen:<track>:<minute>`, and the server releases
|
|
260
|
+
that minute only to the key that paid for it (`verifyAccess`). Pause, and the
|
|
261
|
+
payments stop. No database.
|
|
262
|
+
|
|
263
|
+
`npm run smoke:listen` runs it on testnet with the server's own gating. Run on 14 Oct 2026:
|
|
264
|
+
[session opened](https://testnet.explorer.nervos.org/transaction/0x818094035373e3a62d34af39f98fab664f6dc089d3149d57b0a53ea7e3a30c1b)
|
|
265
|
+
→ [minute 0](https://testnet.explorer.nervos.org/transaction/0x10df4596aaad21201c33784fb28387d8f129d4bcf546c6a54b49ea8cb48c55c7)
|
|
266
|
+
→ [minute 1](https://testnet.explorer.nervos.org/transaction/0xb53479536932ff07a19d87d4d0d48fada4981e1bc66687a7a9ddd5f0fac1b6f1),
|
|
267
|
+
each released by the server; minute 2 claimed with minute 0's payment: refused
|
|
268
|
+
→ [swept](https://testnet.explorer.nervos.org/transaction/0x2f091ff6430b62d157743450cf18cc9d5f52d91852012cc348ff377f79dab8e0)
|
|
269
|
+
and [key cell back](https://testnet.explorer.nervos.org/transaction/0x5024c47cd6eacb822bc6c9392270bc6b4389110a353b0139475aafdbddb8cf2e).
|
|
270
|
+
|
|
271
|
+
## Token sessions (xUDT)
|
|
272
|
+
|
|
273
|
+
A session can hold a token as well as CKB: a stablecoin, game credits, a
|
|
274
|
+
creator's token. The owner signs once; the session key pays in the token with
|
|
275
|
+
no wallet, and the lock caps the units per spend the way it caps shannons, with
|
|
276
|
+
the same recipient and rate-limit rules. Payments top up the recipient's
|
|
277
|
+
anyone-can-pay token cell, so no CKB leaves the session. Closing or recovering
|
|
278
|
+
returns the tokens as tokens.
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
const { binding } = await openSession(wallet, session, TESTNET_DEPLOYMENT, {
|
|
282
|
+
budget: 300n * CKB,
|
|
283
|
+
token: { type: xudtType, amount: 100n, maxPerTx: 10n },
|
|
284
|
+
});
|
|
285
|
+
await spendInSession(session, client, TESTNET_DEPLOYMENT, binding, { to: creator, token: 3n, topUp: true });
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Design, threats and both reviews: [`docs/design/token-sessions.md`](docs/design/token-sessions.md).
|
|
289
|
+
`npm run smoke:token` runs it on testnet. Run on 14 Oct 2026, with a test xUDT:
|
|
290
|
+
[session opened, 100 units](https://testnet.explorer.nervos.org/transaction/0x7dac7740cf9be29cf900085e98f43cb3557e2c956b9946a2d20ddc3dec49105d)
|
|
291
|
+
→ [3 units](https://testnet.explorer.nervos.org/transaction/0xa8d1d98f8fd5b22cab9fd989b58a39995171e7099b1df5df46292c6ca8f94a88)
|
|
292
|
+
→ [3 units](https://testnet.explorer.nervos.org/transaction/0xba27bcc564c00cdf2b8b3d3badc30091a29f17fbc4ce71bbb84117af6cb7b19d),
|
|
293
|
+
no wallet; 11 units in a 10-unit session: rejected by the network (code 20)
|
|
294
|
+
→ [94 units swept back](https://testnet.explorer.nervos.org/transaction/0x798c65c8ab49de39eb5aeb084c386f6ec1e1803e12636129ba73ace24b710a5d).
|
|
295
|
+
|
|
296
|
+
**Try it in the browser:** [ckb-session-kit.vercel.app/tokens](https://ckb-session-kit.vercel.app/tokens):
|
|
297
|
+
pay-per-read again, 2 tokens per article. The demo token is free to mint from the
|
|
298
|
+
page: its issuer is an anyone-can-pay cell nobody holds the key to, and xUDT lets
|
|
299
|
+
whoever spends an issuer cell mint, so anyone can (it is worth nothing). The
|
|
300
|
+
server releases an article after `verifyAccess(…, { token })` finds, on-chain,
|
|
301
|
+
that this key paid 2 units of this token for it. `npm run setup:token` creates
|
|
302
|
+
the issuer cells; `npm run smoke:read-tokens` runs the page's flow on testnet.
|
|
303
|
+
Run on 14 Oct 2026:
|
|
304
|
+
[minted 20 units with only the minter's key](https://testnet.explorer.nervos.org/transaction/0x37e5005a74ee503a86813907c6aef1d02e930c9575c271751d6fafed9cdeef75)
|
|
305
|
+
→ [session opened](https://testnet.explorer.nervos.org/transaction/0x366d40dad9c8be024af28e93aa4b65e8f0a7ad9903ff923eb5c192d49c99c678)
|
|
306
|
+
→ [2 units for an article](https://testnet.explorer.nervos.org/transaction/0x9a16f6601309af1deec985c55a7537195047c28630d0cae719e8e8afc3d01dee),
|
|
307
|
+
released by the demo's server route; at a 3-unit price the same payment is refused
|
|
308
|
+
→ [18 units back](https://testnet.explorer.nervos.org/transaction/0x3688288b9c49c771b85a38a80806f9fefead39b936caea92ecb97e6d4a3aa2ee).
|
|
309
|
+
|
|
310
|
+
## Funding an app-built transaction
|
|
311
|
+
|
|
312
|
+
Some apps build their own transaction: a crowdfunding pledge spends the
|
|
313
|
+
campaign cell and creates a typed pledge cell and a receipt. The session can pay
|
|
314
|
+
for it, with no wallet prompt: `fundInSession` adds session cells, one change
|
|
315
|
+
cell and the key cell (which pays the fee) to the app's transaction, and
|
|
316
|
+
`sendWithSession` builds, funds, signs with the session key and sends.
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
// overhead: what the app's outputs hold beyond the amount; it depends on the app's deployment.
|
|
320
|
+
const session = createSession({ expiresAt, scope: { maxPerTx: amount + overhead } }); // no recipients
|
|
321
|
+
const { binding } = await openSession(wallet, session, TESTNET_DEPLOYMENT, { budget, minInterval: 0n });
|
|
322
|
+
|
|
323
|
+
const hash = await sendWithSession(session, client, TESTNET_DEPLOYMENT, binding, async () => buildPledgeTx(), {
|
|
324
|
+
feeRate: 2000n,
|
|
325
|
+
check: (tx) => assertPledgeShape(tx), // the app's own rules on the finished transaction
|
|
326
|
+
});
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
What it takes, and why:
|
|
330
|
+
|
|
331
|
+
- **A session without a recipient.** The lock lets a recipient-scoped session
|
|
332
|
+
pay only its recipient and the owner, in plain cells, so a typed output to
|
|
333
|
+
another lock is refused on chain (errors 13 and 18). `fundInSession` refuses
|
|
334
|
+
such a session before anything is signed (`reason: "session-has-recipient"`).
|
|
335
|
+
Without a recipient, a leaked key can pay anyone, typed cells included, up to
|
|
336
|
+
`maxPerTx` per transaction: keep the budget small and the expiry short, and
|
|
337
|
+
show a Close button (see [If a session key leaks](#if-a-session-key-leaks)).
|
|
338
|
+
- **`maxPerTx` covers everything the outputs hold beyond the app's inputs**,
|
|
339
|
+
not just the amount: the storage of every cell the app creates, too. That
|
|
340
|
+
overhead depends on the app and on the version of its scripts it deploys:
|
|
341
|
+
for a CrowdCell pledge it is the pledge cell's storage (252 CKB) plus the
|
|
342
|
+
receipt's, which is 300 CKB on testnet today and 262 CKB in CrowdCell's source
|
|
343
|
+
at 2fd13f6 (514 to 552 CKB in all, returned to the backer later). Compute it
|
|
344
|
+
from the app's own constants; the kit has none. The fee comes from the key
|
|
345
|
+
cell and never counts.
|
|
346
|
+
- **No wallet input.** A cell under the owner's lock would switch the lock to
|
|
347
|
+
owner mode and need the wallet's signature: refused
|
|
348
|
+
(`reason: "owner-lock-input"`).
|
|
349
|
+
- **Races.** If the node refuses the transaction for one of the session's own
|
|
350
|
+
cells (another tab spent it), it is rebuilt and sent again. If it refuses it
|
|
351
|
+
for the app's own cell (another backer's pledge took the campaign cell first),
|
|
352
|
+
the error is thrown unchanged (`deadInputs(e)` is true) so the app can wait
|
|
353
|
+
for its cell to move and build again. The fee rate is never raised on a
|
|
354
|
+
retry, so a retry cannot replace another user's pending transaction.
|
|
355
|
+
|
|
356
|
+
A CrowdCell pledge built this way passes CrowdCell's five live testnet scripts
|
|
357
|
+
and the session lock in the CKB-VM (`test/fund-pledge-vm.test.ts`, after
|
|
358
|
+
`npx tsx scripts/fetch-crowdcell.ts`). `npm run smoke:fund-pledge` runs it on
|
|
359
|
+
testnet, against CrowdCell's live scripts, on a campaign of its own. Run on 3 Nov 2026
|
|
360
|
+
([log](docs/evidence/2026-11-02-fund-pledge.log)):
|
|
361
|
+
[campaign created](https://testnet.explorer.nervos.org/transaction/0xafbced2f56328fc19db61e9751e1aeb4e3d6f702f3941277bd681fe6460d9fa4)
|
|
362
|
+
→ [session opened, no recipient, 773 CKB, 652 CKB per transaction](https://testnet.explorer.nervos.org/transaction/0x0dc8fdde564befa201a2c92a2ac2ee0fb3a3a660389dfb1a4205709dfbccbf76)
|
|
363
|
+
→ [100 CKB pledged, no wallet](https://testnet.explorer.nervos.org/transaction/0x282adf8e8a25bef1bde96d285856c96c1fd7d1f4ff7d28da3e67669879a12b67)
|
|
364
|
+
(a 101 CKB pledge refused before signing)
|
|
365
|
+
→ [session swept back](https://testnet.explorer.nervos.org/transaction/0x80121445ab4d2d9746afdcd0f0f32ccbc7f30254801d987d618d8824fd4ae381)
|
|
366
|
+
→ after the deadline, [campaign finalized as Failed](https://testnet.explorer.nervos.org/transaction/0xe248c5353d534b99b7b0ec8b0f127253d7e54ff9d6157b1ce216bb845b3836cb)
|
|
367
|
+
→ [pledge refunded, receipt reclaimed](https://testnet.explorer.nervos.org/transaction/0xa2e7acc7f95cf88c42f462ee470502e5d26934356c52fa30d37f444096c07d25).
|
|
368
|
+
|
|
369
|
+
## Subscriptions
|
|
370
|
+
|
|
371
|
+
The same lock with the roles moved: the subscriber owns the cell, the creator's
|
|
372
|
+
server holds the session key. The network lets the creator charge at most the
|
|
373
|
+
price, at most once per period, and only to the creator's address, while the
|
|
374
|
+
subscriber is offline; the subscriber cancels alone at any time. **No change to
|
|
375
|
+
the deployed lock**: the price is `max_per_tx`, the period is `min_interval`,
|
|
376
|
+
the payout address is the recipient.
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
// subscriber: one signature; the first period is paid at once
|
|
380
|
+
const { subscription } = await openSubscription(wallet, TESTNET_DEPLOYMENT, {
|
|
381
|
+
charger, payTo, price: 10n * CKB, periodBlocks: 10n, budget: 173n * CKB, account,
|
|
382
|
+
});
|
|
383
|
+
// creator's server, once per period, no subscriber involved
|
|
384
|
+
await chargeSubscription(chargerKey, TESTNET_DEPLOYMENT, subscription, { payTo, topUp: true });
|
|
385
|
+
// creator's server, on every request, with no database
|
|
386
|
+
const r = await subscriptionStatus(client, TESTNET_DEPLOYMENT, subscription, { openTx, account });
|
|
387
|
+
// subscriber, any time
|
|
388
|
+
await cancelSubscription(wallet, TESTNET_DEPLOYMENT, subscription);
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
The design was attacked on paper before any code
|
|
392
|
+
([`docs/design/subscriptions.md`](docs/design/subscriptions.md)). The
|
|
393
|
+
subscriber's money held; the creator's side did not, and the library now covers it:
|
|
394
|
+
- the opening pays the first period, since a new cell cannot be charged for a whole period;
|
|
395
|
+
- access follows payments, not the live cell, so re-creating the cell to reset its age gains nothing;
|
|
396
|
+
- the opening commits to an account id (hashed) under the subscriber's signature, so nobody can present someone else's subscription.
|
|
397
|
+
|
|
398
|
+
Several mature cells of one subscription can be merged in one charge, and
|
|
399
|
+
together they still pay at most one price. Each subscription is checked on its
|
|
400
|
+
own: two subscribers' cells charged in one transaction are refused (error 13),
|
|
401
|
+
since each sees the other's change as a third party, so a creator sends one
|
|
402
|
+
charge per subscriber. A subscriber's cancel sweeps every cell of the
|
|
403
|
+
subscription in one transaction.
|
|
404
|
+
|
|
405
|
+
A charge costs the session lock 20,999 cycles.
|
|
406
|
+
|
|
407
|
+
`npm run smoke:subscription` runs it on testnet. Run on 26 Oct 2026:
|
|
408
|
+
[opened, first period paid](https://testnet.explorer.nervos.org/transaction/0x383242a3bf89ae8007fc8fc4f7ec04420718c2e32590bdbbd445bcf4c4e2a1b9)
|
|
409
|
+
→ the creator's check accepts the terms, the first payment and the account, and refuses another account
|
|
410
|
+
→ a charge 1 block in: rejected by the network (code 14)
|
|
411
|
+
→ [charge 1](https://testnet.explorer.nervos.org/transaction/0xf98e36b2f5ef1bb86875fb0989e32f25f67807bc95e34aa273b497db5eebdb16)
|
|
412
|
+
→ [charge 2](https://testnet.explorer.nervos.org/transaction/0x930beb93436c884f679f691a901eec1db4742fd69cd28e8d63b4eb386de260e0), a period later, charger key only
|
|
413
|
+
→ a third charge refused: what is left cannot cover a price and a cell
|
|
414
|
+
→ [cancelled by the subscriber alone, 153 CKB back](https://testnet.explorer.nervos.org/transaction/0x235c9d2e4fc2205fa552c144890002f611c362402b62ceb1dc8540160c459c4f).
|
|
415
|
+
|
|
416
|
+
**Paid in a token.** The same flow in an xUDT (a stablecoin, say): pass
|
|
417
|
+
`token: { type, amount }` to `openSubscription`. The lock's per-payment CKB limit
|
|
418
|
+
is 0, so no CKB ever leaves the cell; the price is the per-payment token limit, and
|
|
419
|
+
charges top up the creator's anyone-can-pay token cell. `npm run smoke:subscription-token`
|
|
420
|
+
ran it on 27 Oct 2026 with the demo token:
|
|
421
|
+
[opened, first 2 units paid](https://testnet.explorer.nervos.org/transaction/0xc168cc4f0122a0c84add3449a261dd400defacffc59934d2c906da60d10353ba)
|
|
422
|
+
→ a charge 1 block in: rejected (code 14); a charge moving 1 shannon of CKB too: rejected (code 12)
|
|
423
|
+
→ [charge 1](https://testnet.explorer.nervos.org/transaction/0x7aa3872fec7e32e5b1f4e719d034a0d7310f717aa128642a1bfb2d252efceb6d)
|
|
424
|
+
→ [charge 2](https://testnet.explorer.nervos.org/transaction/0x20ee3154a4db0d375f3f444e48fe9d4e47587abb2df0111c7979c0d03823335c), each 2 units with no CKB out
|
|
425
|
+
→ a third refused: no tokens left
|
|
426
|
+
→ [cancelled by the subscriber alone](https://testnet.explorer.nervos.org/transaction/0x9121f5f3bcd7614a2c191ec99550bf57f56117733cdd670a3e2fb300f9c05f51).
|
|
427
|
+
|
|
428
|
+
**Try it in the browser:** [ckb-session-kit.vercel.app/subscribe](https://ckb-session-kit.vercel.app/subscribe):
|
|
429
|
+
2 CKB per 60 blocks; members' notes the server releases only while you are paid up.
|
|
430
|
+
|
|
431
|
+
**Deploying it.** The server that charges holds the charger key (`CHARGER_KEY` in
|
|
432
|
+
the deployment's environment, on Vercel for the demo). Make it a **key of its
|
|
433
|
+
own, never the creator's key** or the key behind the payout address: the lock
|
|
434
|
+
limits what the charger can take from subscriptions, but nothing limits what a
|
|
435
|
+
key can do with its own cells, so a leak of the server's environment would also
|
|
436
|
+
hand over all the creator's income. Give it only a few fee cells. `npm run
|
|
437
|
+
setup:charger` creates one in `.env.charger` (gitignored) and prints its address
|
|
438
|
+
and the next steps; nothing is sent. A status or a charge reads at most
|
|
439
|
+
`MAX_SUBSCRIPTION_CELLS` (16) cells under one subscription's lock and refuses
|
|
440
|
+
beyond that (`SessionPolicyError`, reason `"too-many-cells"`), so planted cells
|
|
441
|
+
cannot make one request cost thousands of RPC calls. Only cells of the
|
|
442
|
+
subscription's kind count (plain CKB, or exactly its token): the indexer leaves
|
|
443
|
+
other cells out, including look-alikes of the token with longer args. The
|
|
444
|
+
subscriber clears planted ones without cancelling:
|
|
445
|
+
`clearPlantedCells(wallet, deployment, subscription)` sweeps every cell not made
|
|
446
|
+
by the opening, a charge or the subscriber (and any look-alike of the token)
|
|
447
|
+
back to the subscriber, at most 30 per transaction, one signature each, and
|
|
448
|
+
leaves the subscription's own cells as they are. Only the subscriber's and the
|
|
449
|
+
charger's spends count toward the 64 recent transactions a status reads;
|
|
450
|
+
creations by anyone else are paged past, up to 1024 per period.
|
|
451
|
+
`npm run smoke:clear-planted` checks it on testnet: 16 planted cells block the status and charges, `clearPlantedCells` clears them, and the next charge stays on time.
|
|
452
|
+
|
|
453
|
+
## Which wallets can own a session
|
|
454
|
+
|
|
455
|
+
The owner signs to open, close and recover, so the owner's wallet must work. `npm
|
|
456
|
+
run matrix:wallets` runs the session and subscription flows on testnet with each
|
|
457
|
+
kind of owner lock the demo's wallet picker gives, signing the way that wallet
|
|
458
|
+
signs (EIP-191 for Ethereum wallets, Bitcoin message signing, and so on). Run on
|
|
459
|
+
26-27 Oct 2026:
|
|
460
|
+
|
|
461
|
+
| Owner wallet | session opened | paid 1 CKB | session swept | key cell back | subscribed | cancelled |
|
|
462
|
+
|---|---|---|---|---|---|---|
|
|
463
|
+
| CKB default lock (secp256k1): Neuron, CKB CLI, most libraries | [0x5e14b73f…](https://testnet.explorer.nervos.org/transaction/0x5e14b73f596c0938d228f117bfcae5788ae1c043f93ba6c97b73041febe2a7a4) | [0xd89e75ff…](https://testnet.explorer.nervos.org/transaction/0xd89e75ffc99be568127dda64b35f09966c0a6e9f6c3b805b2e2d85cc099260f9) | [0xd9336db9…](https://testnet.explorer.nervos.org/transaction/0xd9336db92d25b50f4f42cb715c8b1f84bca40c30d3cff9638108bd6021ad61f4) | [0x881ca37f…](https://testnet.explorer.nervos.org/transaction/0x881ca37f01bfe087da0e1eda74e712f2ac1e9d18b126867099c20a7a2fc42a87) | [0x2b0274df…](https://testnet.explorer.nervos.org/transaction/0x2b0274df681c435ab40a685110906dfe7e78658b5cb87eac885823da3329dad7) | [0x9aeff7dc…](https://testnet.explorer.nervos.org/transaction/0x9aeff7dc0518e8b4a12228cfe60768bd0a02481c474e38b5bb61733a1284f634) |
|
|
464
|
+
| Omnilock, Ethereum auth (0x12): MetaMask, OKX (EVM) | [0xae66203d…](https://testnet.explorer.nervos.org/transaction/0xae66203d64e7000d200992280e0bfbff7ffd08b83a18b22686fa01e7663230f1) | [0x3b0211b0…](https://testnet.explorer.nervos.org/transaction/0x3b0211b0c260c9cd0a009276271c9cb5007fcd4d566ab4eac161280a8e4c0206) | [0xe5bc64e9…](https://testnet.explorer.nervos.org/transaction/0xe5bc64e9980482c5eeeb832a204fa9142006b64badc97f6f845f328ecc0f1170) | [0xf795b033…](https://testnet.explorer.nervos.org/transaction/0xf795b0331fbee0e850f238c89f3728b4f7e22fcaacf66ee0e2cfc7344c9f83fd) | [0x6f5b5666…](https://testnet.explorer.nervos.org/transaction/0x6f5b5666593c7d4c63a69c0afcec90a1440dc1ad3c1570001cc8c059621c889c) | [0x9e963bbf…](https://testnet.explorer.nervos.org/transaction/0x9e963bbfdf38eebc238f7b4c77290fc9228e8f6fdaba70ee039c73a7821517e3) |
|
|
465
|
+
| Omnilock, Bitcoin auth: UniSat, OKX (BTC) | [0x9a1beefc…](https://testnet.explorer.nervos.org/transaction/0x9a1beefc1fbc7d6a3d0eee5ef14a4b9c37c60f26794ff9716ee8ea8da9235f3d) | [0xe5db9f8b…](https://testnet.explorer.nervos.org/transaction/0xe5db9f8bf28be2a1e5859037643ba2b84cb38150b143c8113479002e7ff21e11) | [0x5f7f4d69…](https://testnet.explorer.nervos.org/transaction/0x5f7f4d69404be44a09bc0d35e40890e85e1c16ab70fefb20926ae516c4f4056f) | [0x44a0e09d…](https://testnet.explorer.nervos.org/transaction/0x44a0e09da81c6df8377971c08e08131c88fd22e307a511f6077f63d02a9ae86e) | [0xf44697da…](https://testnet.explorer.nervos.org/transaction/0xf44697da2f0c19d62370113d89b5e767735338eadab5ec236c2946831842c2a3) | [0x38141883…](https://testnet.explorer.nervos.org/transaction/0x3814188336d6a345a525a0eb9805d35202072c2567c4f1bae3b50a2a1fdb0be9) |
|
|
466
|
+
| Omnilock, Dogecoin auth | [0xbd693312…](https://testnet.explorer.nervos.org/transaction/0xbd6933129c74bae2a9ed16467ea422c093e8869f901a871994cc41838667bbb5) | [0x1f7069e9…](https://testnet.explorer.nervos.org/transaction/0x1f7069e9fb0e078dccaa291be51c24bc86d6245b2925446a917d09b68f18dbf2) | [0x356fb5f1…](https://testnet.explorer.nervos.org/transaction/0x356fb5f179bb2aee0d396b1933840eb6c96b693907d68deaf78970934a2cd260) | [0x093d5331…](https://testnet.explorer.nervos.org/transaction/0x093d533118f9f671928f87a710eb0079275246ad552ab39c8e131e60c5df9c1d) | [0x6d8c67d7…](https://testnet.explorer.nervos.org/transaction/0x6d8c67d776d499b099649bcbc24d7647d42ec488c6f95e30a8c5177764980c42) | [0x0e98ab80…](https://testnet.explorer.nervos.org/transaction/0x0e98ab80f1b09993fcb78bf7ed835f1212c8c00f79c57483a4c242eabfdb88ec) |
|
|
467
|
+
| Nostr lock: NIP-07 extensions | [0x24e6503a…](https://testnet.explorer.nervos.org/transaction/0x24e6503a5e0113bef6b506434cf202e2fa6e10d47723e301d290bdcadcf4a079) | [0x297eaa51…](https://testnet.explorer.nervos.org/transaction/0x297eaa51014e9a4a6cdfa889527ac32e381aebe21d2cfa2b21078a849fb79989) | [0x05dfbc09…](https://testnet.explorer.nervos.org/transaction/0x05dfbc0979d09a784d4943d49ecbfcb6a5d718c01a620816b4cdb0bc4f57e62f) | [0xd9139c2e…](https://testnet.explorer.nervos.org/transaction/0xd9139c2ee919c133c3d107b0e5a60789e48553ba38f33024d90af10a7c9ce601) | [0x832c08a3…](https://testnet.explorer.nervos.org/transaction/0x832c08a309464e20ce3441034d1aebcdade51721f21bc14bbc11b7237ed7484b) | [0xf6cfb847…](https://testnet.explorer.nervos.org/transaction/0xf6cfb8478fd6f53bab2b192f6ebfd7b498522299d122b91205a759b43e97778f) |
|
|
468
|
+
|
|
469
|
+
The first run refused the MetaMask row: the check that refuses owner locks which
|
|
470
|
+
verify no signature did not know Omnilock's auth flag 0x12, the one CCC gives
|
|
471
|
+
Ethereum wallets. Fixed, with a test built from the lock CCC derives for an
|
|
472
|
+
Ethereum account. **JoyID** signs with a passkey and cannot be scripted; it was
|
|
473
|
+
tested by hand in the demo (10 Oct).
|
|
474
|
+
|
|
475
|
+
`openSession` and `openSubscription` check the owner's lock first
|
|
476
|
+
(`assertSigningLock`). The session lock gives owner mode to any transaction that
|
|
477
|
+
spends a cell under the owner's lock, so a lock that opens without the owner's
|
|
478
|
+
signature would let anyone sweep every session. Since 2 Nov the check is a list
|
|
479
|
+
of reviewed configurations:
|
|
480
|
+
|
|
481
|
+
| Owner lock | Result |
|
|
482
|
+
|---|---|
|
|
483
|
+
| secp256k1 (CKB default), secp256k1 multisig, with or without a since | accepted |
|
|
484
|
+
| Omnilock, auth 0x00-0x06 or 0x12, no mode or only time-lock or supply | accepted |
|
|
485
|
+
| Nostr lock, signing mode (`args[0]` is 0) | accepted |
|
|
486
|
+
| JoyID | accepted, reviewed from its documentation only: its contract is closed source, so it could not be reviewed from source; revisit if the source becomes available |
|
|
487
|
+
| Anyone-can-pay, PW-Lock, always-success (by type id, or their deployed binaries by data hash) | refused: they open with no signature |
|
|
488
|
+
| Omnilock in anyone-can-pay or administrator mode | refused |
|
|
489
|
+
| Nostr lock in proof of work mode (`args[0]` not 0) | refused: anyone who does the work can unlock it |
|
|
490
|
+
| Omnilock auth 0xFC-0xFE, the proxy locks, any other lock | refused unless you pass `allowUnreviewedOwnerLock: true` (`allowUnreviewedChargerLock` for a subscription's charger) |
|
|
491
|
+
|
|
492
|
+
PW-Lock is refused because the build deployed on CKB is its anyone-can-pay
|
|
493
|
+
variant: a transaction with no signature in the witness takes the payment path,
|
|
494
|
+
whatever the args. MetaMask and other Ethereum wallets get an Omnilock (0x12)
|
|
495
|
+
from CCC, which is accepted. `allowUnreviewedOwnerLock` lets an unreviewed lock
|
|
496
|
+
through with a warning; use it only once you have checked that the lock cannot
|
|
497
|
+
open without its holder's signature. It never lets a refused lock through.
|
|
498
|
+
|
|
499
|
+
JoyID is the one accepted lock this kit could not review from source. There is
|
|
500
|
+
no option to exclude it; an app that does not want JoyID owners can refuse them
|
|
501
|
+
itself before calling `openSession` (or `openSubscription`), next to the kit's
|
|
502
|
+
own `assertSigningLock` check:
|
|
503
|
+
|
|
504
|
+
```ts
|
|
505
|
+
import { ccc } from "@ckb-ccc/core";
|
|
506
|
+
import { assertSigningLock, openSession } from "ckb-session-kit";
|
|
507
|
+
|
|
508
|
+
const ownerLock = (await owner.getRecommendedAddressObj()).script;
|
|
509
|
+
const joyId = await owner.client.getKnownScript(ccc.KnownScript.JoyId);
|
|
510
|
+
if (ownerLock.codeHash === joyId.codeHash && ownerLock.hashType === joyId.hashType) {
|
|
511
|
+
throw new Error("JoyID owners are not accepted by this app");
|
|
512
|
+
}
|
|
513
|
+
await assertSigningLock(ownerLock, owner.client); // the kit's own check; openSession runs it again
|
|
514
|
+
await openSession(owner, session, deployment, { budget });
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
## The on-chain session lock
|
|
518
|
+
|
|
519
|
+
[`contracts/session-lock`](contracts/session-lock/src/main.rs) is a CKB lock script in
|
|
520
|
+
Rust. A cell under it can be spent two ways:
|
|
521
|
+
|
|
522
|
+
- **Owner mode** — the transaction also spends a cell locked by the owner's lock.
|
|
523
|
+
Anything goes: revoke, sweep, top up.
|
|
524
|
+
- **Session mode** — the transaction also spends the session key's *key cell*. The
|
|
525
|
+
network then enforces, on-chain:
|
|
526
|
+
- **outflow limit** — at most `max_per_tx` shannons leave per transaction, fees
|
|
527
|
+
included, so the fee cannot be used to drain the cell. The limit is for all
|
|
528
|
+
the session's cells together: cells under the same lock (same args) are one
|
|
529
|
+
script group, so taking `max_per_tx` from each of three cells is refused.
|
|
530
|
+
Cells of another session or subscription (other args) are checked on their
|
|
531
|
+
own, against their own limit and recipient; one session's unused limit never
|
|
532
|
+
covers another's;
|
|
533
|
+
- **recipient allowlist** (optional) — payments may only go to one lock (plus
|
|
534
|
+
change, the key cell, and the owner). The key cell may come back smaller
|
|
535
|
+
(it pays the fee) but never larger, so the key cannot park session funds in
|
|
536
|
+
its own cell and pay anyone from there. The fee is not an output, so the
|
|
537
|
+
allowlist does not see it: a leaked key can still give up to `max_per_tx`
|
|
538
|
+
per transaction to the miner as fee, paying the recipient nothing. Set a
|
|
539
|
+
rate limit (`min_interval`) whenever you set a recipient;
|
|
540
|
+
- **rate limit** (optional) — each session cell must have aged `min_interval`
|
|
541
|
+
blocks before it can be spent again (relative `since`). The limit is **per
|
|
542
|
+
cell**: with *n* session cells, the key can spend from each of them in the same
|
|
543
|
+
block. `openSession` creates one cell. With `min_interval` 0 there is no
|
|
544
|
+
rate limit: every spend is within `max_per_tx`, but spends can repeat in
|
|
545
|
+
every block until the cell is empty, and `openSession` warns
|
|
546
|
+
(`SessionWarning`, `"no-rate-limit"`; also for `min_interval` 1, almost the same). The interval is a number of blocks,
|
|
547
|
+
not a fixed time: block time varies (see the
|
|
548
|
+
[average block time](https://explorer.nervos.org/charts/average-block-time));
|
|
549
|
+
- **no splitting** — a session spend may send at most one cell back to the
|
|
550
|
+
session lock, so the key can merge session cells but never multiply them,
|
|
551
|
+
which makes the per-cell rate limit a per-session one for sessions opened
|
|
552
|
+
with `openSession`. (Anyone can add cells under a session lock, but only
|
|
553
|
+
with their own money: it never multiplies what the owner put in);
|
|
554
|
+
- **no type scripts** — in a plain session, the session cells spent and their
|
|
555
|
+
change carry no type script: a change cell given a type the key controls could
|
|
556
|
+
later refuse every spend, the owner's sweep included, freezing the whole
|
|
557
|
+
balance. A token session allows exactly one type, its own xUDT, and caps the
|
|
558
|
+
units that leave per spend (`max_per_tx_token`) the way it caps shannons.
|
|
559
|
+
|
|
560
|
+
Signatures are delegated to the standard secp256k1 locks of the owner and the
|
|
561
|
+
session key (the pattern sUDT's owner mode uses), so the script carries no
|
|
562
|
+
cryptography: **7.6 KB, no allocator, no C, 15,651 cycles per run** — about 0.9% of
|
|
563
|
+
a session spend (29,031 on a token spend, 1.7%). 144 tests run it in the real CKB-VM, most asserting a specific
|
|
564
|
+
rejection code, and CI builds the contract and requires them.
|
|
565
|
+
|
|
566
|
+
**What it cannot do:** enforce an expiry time. A CKB script can prove time has
|
|
567
|
+
passed (`since` is a lower bound) but never that it has not. Expiry is enforced
|
|
568
|
+
in the browser; the owner can revoke on-chain at any moment, and the session
|
|
569
|
+
cell's balance caps the total at risk. **Expiry in the browser revokes nothing
|
|
570
|
+
on chain.** After `expiresAt` the key still satisfies the lock, and the funds
|
|
571
|
+
stay at risk until the owner's sweep (`closeSession` or `recoverSessions`)
|
|
572
|
+
is confirmed.
|
|
573
|
+
|
|
574
|
+
**Enforced where:**
|
|
575
|
+
|
|
576
|
+
| Rule | Browser (`checkRequest`) | Session lock (on-chain) |
|
|
577
|
+
|---|---|---|
|
|
578
|
+
| `expiresAt` | yes | **no** (cannot be) |
|
|
579
|
+
| `maxPerTx` | yes | yes, fees included |
|
|
580
|
+
| recipient | yes | yes, if `scope.recipients` has one entry (`openSession` reads it from the scope) |
|
|
581
|
+
| rate limit | — | yes, per cell (`min_interval` blocks) |
|
|
582
|
+
| revocation | — | only by an owner transaction (`closeSession` / `recoverSessions`) |
|
|
583
|
+
|
|
584
|
+
### If a session key leaks
|
|
585
|
+
|
|
586
|
+
The key cannot be revoked by expiry: after `expiresAt` it still satisfies the lock
|
|
587
|
+
until the owner's sweep is confirmed on chain. The browser forgetting the key, or
|
|
588
|
+
refusing to use it, does not change that. Until then it can move at most `max_per_tx` per transaction,
|
|
589
|
+
only to the recipient if one is set (or to the miner as fee: the recipient rule
|
|
590
|
+
covers outputs, and the fee is not one), and, with a rate limit, once per
|
|
591
|
+
`min_interval` blocks per session cell. Without a rate limit, a leaked key can
|
|
592
|
+
repeat spends of `max_per_tx` in every block until the session is empty, each
|
|
593
|
+
one within the limit; a recipient does not slow it down, since it can burn
|
|
594
|
+
`max_per_tx` in fees every block. Set
|
|
595
|
+
`min_interval` whenever you set a recipient. The owner should sweep at once:
|
|
596
|
+
|
|
597
|
+
- with the key still at hand: `closeSession(wallet, session, deployment, binding)`;
|
|
598
|
+
- without it (device lost or wiped): `recoverSessions(wallet, deployment)` finds
|
|
599
|
+
every session the wallet opened and sweeps them, without the key.
|
|
600
|
+
|
|
601
|
+
An owner sweep is accepted at any time and ignores the scope. It is not certain
|
|
602
|
+
to beat a spend already racing it: the key holder can replace a pending sweep by
|
|
603
|
+
paying a higher fee, so sweep early, and resweep what is left. Keep budgets
|
|
604
|
+
small: the budget is the most a session can ever lose.
|
|
605
|
+
|
|
606
|
+
**Owner and session locks must need a signature.** The lock trusts that an input
|
|
607
|
+
under the owner's lock means the owner signed. Plain secp256k1, signing
|
|
608
|
+
Omnilock and the Nostr lock in signing mode are fine, and so is JoyID as far as
|
|
609
|
+
its documentation goes (closed source, not reviewed from source); anyone-can-pay, PW-Lock and
|
|
610
|
+
the Nostr lock in proof of work mode are not, since anyone could spend the
|
|
611
|
+
owner's cell and get owner mode. See [which wallets can own a
|
|
612
|
+
session](#which-wallets-can-own-a-session).
|
|
613
|
+
|
|
614
|
+
A session-lock cell occupies 121 CKB (153 CKB with a recipient), so its balance
|
|
615
|
+
and its change must stay above that.
|
|
616
|
+
|
|
617
|
+
### Enforced by the real network
|
|
618
|
+
|
|
619
|
+
Deployed on testnet: code hash `0xab419466…4c08ce` (`data2`, [deploy tx](https://testnet.explorer.nervos.org/transaction/0xf3098bdb9a7bb1c500990be0f1159b7e66f507fef6b7a4f904046024284877f2)),
|
|
620
|
+
see [`deployment/testnet.json`](deployment/testnet.json). The code is immutable.
|
|
621
|
+
Earlier deployments (8 Oct, before errors 16–18; 13 Oct, before token sessions)
|
|
622
|
+
are kept in `TESTNET_LEGACY_DEPLOYMENTS`, so `recoverSessions` still sweeps
|
|
623
|
+
sessions opened under them.
|
|
624
|
+
`openSession` and `openSubscription` refuse them: new cells go under the current
|
|
625
|
+
lock only.
|
|
626
|
+
|
|
627
|
+
Reference the lock with `hash_type` `data1` or `data2` (or `type`, for a
|
|
628
|
+
type-id deployment). Under `data` (CKB-VM version 0) the binary faults on load,
|
|
629
|
+
owner mode included, so a cell under it could never be spent; the library
|
|
630
|
+
refuses such a deployment when opening.
|
|
631
|
+
|
|
632
|
+
Before it builds an opening, the library reads the deployment's cell dep from
|
|
633
|
+
the client's network (`getCellLive`, a dep group resolved) and refuses unless it
|
|
634
|
+
holds code hashing to `codeHash` (`assertCurrentDeployment`), once per client
|
|
635
|
+
and deployment. `TESTNET_DEPLOYMENT` on a mainnet client is therefore refused.
|
|
636
|
+
There is no mainnet deployment and no `MAINNET_DEPLOYMENT` constant: to use the
|
|
637
|
+
lock on mainnet, deploy the binary yourself (byte-identical, see below) and pass
|
|
638
|
+
your own `SessionLockDeployment` (`codeHash`, `hashType: "data2"`, `cellDep`).
|
|
639
|
+
|
|
640
|
+
Verify the deployed lock yourself. The deployed binary was built with rustc
|
|
641
|
+
1.99.0 on the x86_64-pc-windows-gnu host (pinned in
|
|
642
|
+
[`rust-toolchain.toml`](contracts/session-lock/rust-toolchain.toml)), and a
|
|
643
|
+
rebuild there is byte-identical to the code on chain. On Windows, from the
|
|
644
|
+
repository root:
|
|
645
|
+
|
|
646
|
+
```sh
|
|
647
|
+
rustup toolchain install 1.99.0-x86_64-pc-windows-gnu --profile minimal --target riscv64imac-unknown-none-elf
|
|
648
|
+
(cd contracts/session-lock && cargo +1.99.0-x86_64-pc-windows-gnu build --release --locked)
|
|
649
|
+
npm ci && npm run check:lock-hash
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
The last command compares the build's data hash with `codeHash` in
|
|
653
|
+
`deployment/testnet.json` and fails on any difference; CI's `lock-hash` job runs
|
|
654
|
+
the same three steps. Another host or rustc builds a working lock with other
|
|
655
|
+
bytes, so its hash will not match.
|
|
656
|
+
|
|
657
|
+
[`docs/SECURITY-EVIDENCE.md`](docs/SECURITY-EVIDENCE.md) collects the evidence
|
|
658
|
+
for each rule and error code in one report: the check in the script, the accepted
|
|
659
|
+
transactions, the node's rejection text, the CKB-VM tests, and which rules have
|
|
660
|
+
only been shown in CKB-VM tests. `npm run evidence` re-checks its logs against
|
|
661
|
+
testnet, read-only.
|
|
662
|
+
|
|
663
|
+
`npm run smoke:lock` runs the rules in the table below against public testnet. Run on 14 Oct 2026, on the current lock:
|
|
664
|
+
|
|
665
|
+
| Step | Network said |
|
|
666
|
+
|---|---|
|
|
667
|
+
| Owner funds three session cells and a key cell | [accepted](https://testnet.explorer.nervos.org/transaction/0x12a553c12e733a50f18db09edf8112031c9e39b1949e1f11699a67e3e30ba350) |
|
|
668
|
+
| Session key spends exactly `max_per_tx` (100 CKB), no owner signature | [accepted](https://testnet.explorer.nervos.org/transaction/0xe2fced3a5d73059465f0b9400acdf0cba3fc27e441b82d1faebcbe44b1b20a8b) |
|
|
669
|
+
| Session key spends 100 CKB + 1 shannon | **rejected**, error 12 (outflow exceeds max_per_tx) |
|
|
670
|
+
| Rate-limited cell spent with no `since` | **rejected**, error 14 (rate limited) |
|
|
671
|
+
| Same cell with `since` = 3 relative blocks | [accepted once mature](https://testnet.explorer.nervos.org/transaction/0xe53e4ada7f76d847b7e1883115c9b31851d8962e195cc2eb1fdaeb53e6b07653) |
|
|
672
|
+
| Change split into two cells | **rejected**, error 16 (too many change cells) |
|
|
673
|
+
| Recipient-scoped cell, 50 CKB moved into the key cell | **rejected**, error 17 (key cell may not grow) |
|
|
674
|
+
| Change given a type script (a valid Type ID) | **rejected**, error 18 (no type scripts) |
|
|
675
|
+
| Owner sweeps everything back | [accepted](https://testnet.explorer.nervos.org/transaction/0x8019404b3708050660b921201ec534cc7f20ef68fa1e836012d33f531066bae5) |
|
|
676
|
+
|
|
677
|
+
Cycles, measured with [ckb-cycle-tools](https://github.com/luongthanhtung03/ckb-cycle-tools)
|
|
678
|
+
on the accepted session spend: the session lock 15,651, the key cell's
|
|
679
|
+
secp256k1 lock 1,652,327, matching the node's 1,667,978 exactly. On a token
|
|
680
|
+
payment from the /tokens page's flow: the session lock 29,031, xUDT 18,795, the
|
|
681
|
+
creator's anyone-can-pay lock 34,106, the key 1,642,571 (1,724,503 in all).
|
|
682
|
+
|
|
683
|
+
### v0.1 browser flow, verified on public testnet
|
|
684
|
+
|
|
685
|
+
`npm run smoke:testnet` funds a fresh session, spends inside its scope, and checks
|
|
686
|
+
that an over-limit spend is refused. Run on 8 Oct 2026:
|
|
687
|
+
|
|
688
|
+
| Step | Result |
|
|
689
|
+
|---|---|
|
|
690
|
+
| Fund the session (300 CKB) | [0x2acf…c45f](https://testnet.explorer.nervos.org/transaction/0x2acff2323c07cb36dd9d58343df83277cdab68725541020acbff4dc968d0c45f) |
|
|
691
|
+
| Spend 100 CKB, signed by the session key | [0x52a1…da50](https://testnet.explorer.nervos.org/transaction/0x52a1b3ba45a28614c30ddc52f0795c164141abbebd1558e6c69fb0ac45fada50) |
|
|
692
|
+
| Spend 150 CKB (limit 100) | refused before signing |
|
|
693
|
+
|
|
694
|
+
## Limits
|
|
695
|
+
|
|
696
|
+
What this does not do, in one place. Each is stated where it applies above.
|
|
697
|
+
What has and has not been shown on testnet is in
|
|
698
|
+
[`docs/SECURITY-EVIDENCE.md`](docs/SECURITY-EVIDENCE.md#5-not-covered-and-limits).
|
|
699
|
+
|
|
700
|
+
- **Testnet only, not audited.** The lock has had community and adversarial
|
|
701
|
+
reviews, each finding fixed with a regression test, but no external audit.
|
|
702
|
+
Do not put mainnet funds behind it.
|
|
703
|
+
- **Expiry is not on-chain.** A CKB script cannot prove that time has *not*
|
|
704
|
+
passed, so `expiresAt` is checked in the browser. Browser expiry does not
|
|
705
|
+
revoke the key's on-chain authority: a leaked key stays valid, and the funds
|
|
706
|
+
stay at risk, until the owner's sweep transaction is confirmed. The session's
|
|
707
|
+
balance is the most it can lose.
|
|
708
|
+
- **No rate limit means repeated spends.** With `min_interval` 0, each spend
|
|
709
|
+
is within `max_per_tx`, but spends can repeat every block until the balance
|
|
710
|
+
is gone. `openSession` warns; set `minInterval`.
|
|
711
|
+
- **The rate limit is per cell.** `openSession` makes one cell and the lock
|
|
712
|
+
forbids splitting, but anyone adding more cells under the same session lock
|
|
713
|
+
gives the key more cells to spend from (with their own money, never the owner's).
|
|
714
|
+
- **The key cell is not recoverable without the key.** `recoverSessions` sweeps
|
|
715
|
+
every session cell with the wallet alone; the 62–64 CKB key cell needs the
|
|
716
|
+
lost key.
|
|
717
|
+
- **An owner sweep can be raced.** A key holder can replace a pending sweep by
|
|
718
|
+
paying a higher fee. Sweep early and sweep again.
|
|
719
|
+
- **Paid access is checked at zero confirmations.** `verifyAccess` accepts a
|
|
720
|
+
payment once it is committed in a block; a reorg could undo it after the
|
|
721
|
+
content was released. Fine for articles and pixels, not for anything costly.
|
|
722
|
+
- **An access proof does not expire.** It is a bearer token for one article:
|
|
723
|
+
whoever holds the signed proof can fetch that article again. It cannot be
|
|
724
|
+
used for another article or by another payment.
|
|
725
|
+
- **Owner locks must need a signature.** An owner lock that opens without one
|
|
726
|
+
(anyone-can-pay, PW-Lock, Nostr in proof of work mode) would give anyone owner
|
|
727
|
+
mode; `openSession` refuses it, and refuses locks it has not reviewed unless
|
|
728
|
+
`allowUnreviewedOwnerLock` is set.
|
|
729
|
+
- **Subscription periods are in blocks, and charges need the creator online.**
|
|
730
|
+
The lock's rate limit is a block count, not a fixed duration: block time
|
|
731
|
+
varies (see the [average block time](https://explorer.nervos.org/charts/average-block-time)), so a period is an
|
|
732
|
+
estimate of time (`blocksForSeconds`). Nothing charges by itself: the creator's server must
|
|
733
|
+
send each charge, one transaction per subscriber. Cancelling ends access at
|
|
734
|
+
once, and the current period's price is not refunded.
|
|
735
|
+
- **Sessions are not cheap to open.** A session cell occupies 121 CKB (153 with a
|
|
736
|
+
recipient), returned on close; small payments go to anyone-can-pay cells
|
|
737
|
+
because a new cell needs 61 CKB.
|
|
738
|
+
|
|
739
|
+
## Why it is a separate repository
|
|
740
|
+
|
|
741
|
+
The session problem has nothing to do with any one application. Anything that wants
|
|
742
|
+
to act repeatedly on a user's behalf in a browser has it, so it should not be buried
|
|
743
|
+
inside one application's source tree.
|
|
744
|
+
|
|
745
|
+
## Related work
|
|
746
|
+
|
|
747
|
+
Other CKB projects work on nearby problems. This is what each one does, as
|
|
748
|
+
described in its own public post or repository, and where this kit differs.
|
|
749
|
+
|
|
750
|
+
- **CKAutomata** ([forum post](https://talk.nervos.org/t/introducing-ckautomata-payment-automation-and-nervos-dao-harvest-on-ckb/10813),
|
|
751
|
+
[CKBuilder review issue](https://github.com/Nervos-Community-Catalyst/CKBuilder-projects/issues/43)):
|
|
752
|
+
one-time, scheduled and recurring CKB payments, and Nervos DAO harvest. Each
|
|
753
|
+
automation is a cell holding its rules, schedule and funds; the user signs once
|
|
754
|
+
to set it up, and an executor submits each payment when it is due, without
|
|
755
|
+
being able to change amount, recipient or schedule. The overlap is with
|
|
756
|
+
subscriptions here: both let a payer sign once and be charged later while
|
|
757
|
+
offline. The differences: here the subscription is one use of a general
|
|
758
|
+
session lock, the creator's own server holds the key and pulls each charge,
|
|
759
|
+
and `subscriptionStatus` lets that server gate content on-chain with no
|
|
760
|
+
database. This kit does not schedule payments for third-party executors and
|
|
761
|
+
does nothing with the Nervos DAO.
|
|
762
|
+
- **QuotaCell** ([forum post](https://talk.nervos.org/t/quotacell-let-your-team-spend-without-handing-over-the-treasury/11002),
|
|
763
|
+
[repository](https://github.com/Ticoworld/QuotaCell)): a shared treasury
|
|
764
|
+
allowance for a team. Several delegates, each with their own key, a personal
|
|
765
|
+
limit and a per-payment ceiling, draw from one cumulative allowance that only
|
|
766
|
+
the owner can renew, enforced by an on-chain policy cell; agent spending is a
|
|
767
|
+
use it is exploring. The session lock also enforces spend limits on-chain, but
|
|
768
|
+
for a different shape: one user, one short-lived key generated and held in that
|
|
769
|
+
user's browser, a fixed recipient and a rate limit, so an app can act without
|
|
770
|
+
wallet popups. It has no notion of several delegates sharing one allowance.
|
|
771
|
+
- **FiberLatch Access** ([forum post](https://talk.nervos.org/t/dis-fiberlatch-access-open-source-access-control-for-fiber-payments/10414))
|
|
772
|
+
and Fiber's **Encrypted Data Payment** pattern
|
|
773
|
+
([docs](https://www.fiber.world/docs/build/encrypted-data-payment)): access to
|
|
774
|
+
content paid for over Fiber. FiberLatch issues and verifies signed access
|
|
775
|
+
receipts once an app has accepted a payment; in the Fiber pattern, the invoice
|
|
776
|
+
preimage is the key that decrypts the data. Pay-per-read here runs on CKB L1
|
|
777
|
+
instead: no channel to open, each payment is an ordinary transaction signed by
|
|
778
|
+
the session key inside the on-chain scope, and the server checks the payment on
|
|
779
|
+
chain (`verifyAccess`). The trade-off is that every L1 payment waits for a block
|
|
780
|
+
and pays a fee, where a Fiber payment settles off-chain once a channel is open.
|
|
781
|
+
- **Wallet and login projects**: [JoyID](https://docs.joyid.dev/) (passkey
|
|
782
|
+
wallet), [Corven Connect](https://corvanide.space/connect/docs/introduction)
|
|
783
|
+
(Google and passkey sign-in with an embedded CKB wallet) and
|
|
784
|
+
[CKB KeyWay](https://github.com/officialcmg/ckb-keyway) (email login with a
|
|
785
|
+
recoverable CKB account, plus Fiber). These create or unlock the user's
|
|
786
|
+
account. This kit does not: it starts from a wallet the user already has and
|
|
787
|
+
removes the repeated signing after that, so it can sit on top of any of them.
|
|
788
|
+
A JoyID owner works today (tested by hand); the other two have not been tested
|
|
789
|
+
as session owners.
|
|
790
|
+
|
|
791
|
+
## Licence
|
|
792
|
+
|
|
793
|
+
MIT. See [LICENSE](LICENSE).
|
|
794
|
+
|
|
795
|
+
## Author
|
|
796
|
+
|
|
797
|
+
Luong Thanh Tung — [@luongthanhtung03](https://github.com/luongthanhtung03)
|