ckb-session-kit 0.0.0-stage → 2.0.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
@@ -1,3 +1,718 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.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
+
229
+ Chained payments (topping up a cell another payment created while that payment
230
+ is still in the pool) are not used: if the first payment is dropped or slow to
231
+ propagate, every payment built on it is stuck or dropped too, and a reader's
232
+ session would wait on someone else's transaction. More cells and a pool-aware
233
+ pick avoid that.
234
+
235
+ ## Example: CKB Place, a pixel board on testnet
236
+
237
+ [`/place`](https://ckb-session-kit.vercel.app/place): a shared 32×32 board. Open a
238
+ session once (one wallet signature; the session lock lets it pay only the board),
239
+ then every batch of up to 32 pixels is one 0.1 CKB-per-pixel payment signed by
240
+ the session key, with no wallet popup. The pixels travel in the payment's memo
241
+ (`"px1"` + `x, y, colour` per pixel); the board is rebuilt from the chain by
242
+ reading those memos in chain order, counting only payments that carry exactly one
243
+ memo, signed by the payer, for at least the price. No server, no database:
244
+ [`src/place.ts`](src/place.ts).
245
+
246
+ `npm run smoke:place` paints "CKB" on testnet. Run on 14 Oct 2026:
247
+ [board cells created](https://testnet.explorer.nervos.org/transaction/0x2dbcf33a2c8360a1ad2a7984f2f1331f345b0c9874511e7f5c7f828db24dea1f)
248
+ → [session opened](https://testnet.explorer.nervos.org/transaction/0x91cfac448029eba2354e08a6a3f6fbeaf4227a6507ebcda267e5a4e0060d3715)
249
+ → [29 pixels, 2.9 CKB, no wallet](https://testnet.explorer.nervos.org/transaction/0x91acae7b00a0210e3779b76ec974395964ba65e7797780dd3a9d815e4245d0f5)
250
+ → [swept](https://testnet.explorer.nervos.org/transaction/0x3c3b734fdda064e108dbcf0d989d573625e5823a54f92233a66f0cfa0e08b1e3)
251
+ and [key cell back](https://testnet.explorer.nervos.org/transaction/0x3c369a5c81e66847003ff4019fd01773d97563378a07d05857a5c18d4096e006).
252
+
253
+ ## Example: pay-per-minute
254
+
255
+ [`/listen`](https://ckb-session-kit.vercel.app/listen): a player for three
256
+ generative tracks (Web Audio, composed per minute on the server). Open a session
257
+ once; while it plays, the session key pays 0.5 CKB for the next minute 30 s
258
+ before it is needed, with memo `listen:<track>:<minute>`, and the server releases
259
+ that minute only to the key that paid for it (`verifyAccess`). Pause, and the
260
+ payments stop. No database.
261
+
262
+ `npm run smoke:listen` runs it on testnet with the server's own gating. Run on 14 Oct 2026:
263
+ [session opened](https://testnet.explorer.nervos.org/transaction/0x818094035373e3a62d34af39f98fab664f6dc089d3149d57b0a53ea7e3a30c1b)
264
+ → [minute 0](https://testnet.explorer.nervos.org/transaction/0x10df4596aaad21201c33784fb28387d8f129d4bcf546c6a54b49ea8cb48c55c7)
265
+ → [minute 1](https://testnet.explorer.nervos.org/transaction/0xb53479536932ff07a19d87d4d0d48fada4981e1bc66687a7a9ddd5f0fac1b6f1),
266
+ each released by the server; minute 2 claimed with minute 0's payment: refused
267
+ → [swept](https://testnet.explorer.nervos.org/transaction/0x2f091ff6430b62d157743450cf18cc9d5f52d91852012cc348ff377f79dab8e0)
268
+ and [key cell back](https://testnet.explorer.nervos.org/transaction/0x5024c47cd6eacb822bc6c9392270bc6b4389110a353b0139475aafdbddb8cf2e).
269
+
270
+ ## Token sessions (xUDT)
271
+
272
+ A session can hold a token as well as CKB: a stablecoin, game credits, a
273
+ creator's token. The owner signs once; the session key pays in the token with
274
+ no wallet, and the lock caps the units per spend the way it caps shannons, with
275
+ the same recipient and rate-limit rules. Payments top up the recipient's
276
+ anyone-can-pay token cell, so no CKB leaves the session. Closing or recovering
277
+ returns the tokens as tokens.
278
+
279
+ ```ts
280
+ const { binding } = await openSession(wallet, session, TESTNET_DEPLOYMENT, {
281
+ budget: 300n * CKB,
282
+ token: { type: xudtType, amount: 100n, maxPerTx: 10n },
283
+ });
284
+ await spendInSession(session, client, TESTNET_DEPLOYMENT, binding, { to: creator, token: 3n, topUp: true });
285
+ ```
286
+
287
+ Design, threats and both reviews: [`docs/design/token-sessions.md`](docs/design/token-sessions.md).
288
+ `npm run smoke:token` runs it on testnet. Run on 14 Oct 2026, with a test xUDT:
289
+ [session opened, 100 units](https://testnet.explorer.nervos.org/transaction/0x7dac7740cf9be29cf900085e98f43cb3557e2c956b9946a2d20ddc3dec49105d)
290
+ → [3 units](https://testnet.explorer.nervos.org/transaction/0xa8d1d98f8fd5b22cab9fd989b58a39995171e7099b1df5df46292c6ca8f94a88)
291
+ → [3 units](https://testnet.explorer.nervos.org/transaction/0xba27bcc564c00cdf2b8b3d3badc30091a29f17fbc4ce71bbb84117af6cb7b19d),
292
+ no wallet; 11 units in a 10-unit session: rejected by the network (code 20)
293
+ → [94 units swept back](https://testnet.explorer.nervos.org/transaction/0x798c65c8ab49de39eb5aeb084c386f6ec1e1803e12636129ba73ace24b710a5d).
294
+
295
+ **Try it in the browser:** [ckb-session-kit.vercel.app/tokens](https://ckb-session-kit.vercel.app/tokens):
296
+ pay-per-read again, 2 tokens per article. The demo token is free to mint from the
297
+ page: its issuer is an anyone-can-pay cell nobody holds the key to, and xUDT lets
298
+ whoever spends an issuer cell mint, so anyone can (it is worth nothing). The
299
+ server releases an article after `verifyAccess(…, { token })` finds, on-chain,
300
+ that this key paid 2 units of this token for it. `npm run setup:token` creates
301
+ the issuer cells; `npm run smoke:read-tokens` runs the page's flow on testnet.
302
+ Run on 14 Oct 2026:
303
+ [minted 20 units with only the minter's key](https://testnet.explorer.nervos.org/transaction/0x37e5005a74ee503a86813907c6aef1d02e930c9575c271751d6fafed9cdeef75)
304
+ → [session opened](https://testnet.explorer.nervos.org/transaction/0x366d40dad9c8be024af28e93aa4b65e8f0a7ad9903ff923eb5c192d49c99c678)
305
+ → [2 units for an article](https://testnet.explorer.nervos.org/transaction/0x9a16f6601309af1deec985c55a7537195047c28630d0cae719e8e8afc3d01dee),
306
+ released by the demo's server route; at a 3-unit price the same payment is refused
307
+ → [18 units back](https://testnet.explorer.nervos.org/transaction/0x3688288b9c49c771b85a38a80806f9fefead39b936caea92ecb97e6d4a3aa2ee).
308
+
309
+ ## Subscriptions
310
+
311
+ The same lock with the roles moved: the subscriber owns the cell, the creator's
312
+ server holds the session key. The network lets the creator charge at most the
313
+ price, at most once per period, and only to the creator's address, while the
314
+ subscriber is offline; the subscriber cancels alone at any time. **No change to
315
+ the deployed lock**: the price is `max_per_tx`, the period is `min_interval`,
316
+ the payout address is the recipient.
317
+
318
+ ```ts
319
+ // subscriber: one signature; the first period is paid at once
320
+ const { subscription } = await openSubscription(wallet, TESTNET_DEPLOYMENT, {
321
+ charger, payTo, price: 10n * CKB, periodBlocks: 10n, budget: 173n * CKB, account,
322
+ });
323
+ // creator's server, once per period, no subscriber involved
324
+ await chargeSubscription(chargerKey, TESTNET_DEPLOYMENT, subscription, { payTo, topUp: true });
325
+ // creator's server, on every request, with no database
326
+ const r = await subscriptionStatus(client, TESTNET_DEPLOYMENT, subscription, { openTx, account });
327
+ // subscriber, any time
328
+ await cancelSubscription(wallet, TESTNET_DEPLOYMENT, subscription);
329
+ ```
330
+
331
+ The design was attacked on paper before any code
332
+ ([`docs/design/subscriptions.md`](docs/design/subscriptions.md)). The
333
+ subscriber's money held; the creator's side did not, and the library now covers it:
334
+ - the opening pays the first period, since a new cell cannot be charged for a whole period;
335
+ - access follows payments, not the live cell, so re-creating the cell to reset its age gains nothing;
336
+ - the opening commits to an account id (hashed) under the subscriber's signature, so nobody can present someone else's subscription.
337
+
338
+ Several mature cells of one subscription can be merged in one charge, and
339
+ together they still pay at most one price. Each subscription is checked on its
340
+ own: two subscribers' cells charged in one transaction are refused (error 13),
341
+ since each sees the other's change as a third party, so a creator sends one
342
+ charge per subscriber. A subscriber's cancel sweeps every cell of the
343
+ subscription in one transaction.
344
+
345
+ A charge costs the session lock 20,999 cycles.
346
+
347
+ `npm run smoke:subscription` runs it on testnet. Run on 26 Oct 2026:
348
+ [opened, first period paid](https://testnet.explorer.nervos.org/transaction/0x383242a3bf89ae8007fc8fc4f7ec04420718c2e32590bdbbd445bcf4c4e2a1b9)
349
+ → the creator's check accepts the terms, the first payment and the account, and refuses another account
350
+ → a charge 1 block in: rejected by the network (code 14)
351
+ → [charge 1](https://testnet.explorer.nervos.org/transaction/0xf98e36b2f5ef1bb86875fb0989e32f25f67807bc95e34aa273b497db5eebdb16)
352
+ → [charge 2](https://testnet.explorer.nervos.org/transaction/0x930beb93436c884f679f691a901eec1db4742fd69cd28e8d63b4eb386de260e0), a period later, charger key only
353
+ → a third charge refused: what is left cannot cover a price and a cell
354
+ → [cancelled by the subscriber alone, 153 CKB back](https://testnet.explorer.nervos.org/transaction/0x235c9d2e4fc2205fa552c144890002f611c362402b62ceb1dc8540160c459c4f).
355
+
356
+ **Paid in a token.** The same flow in an xUDT (a stablecoin, say): pass
357
+ `token: { type, amount }` to `openSubscription`. The lock's per-payment CKB limit
358
+ is 0, so no CKB ever leaves the cell; the price is the per-payment token limit, and
359
+ charges top up the creator's anyone-can-pay token cell. `npm run smoke:subscription-token`
360
+ ran it on 27 Oct 2026 with the demo token:
361
+ [opened, first 2 units paid](https://testnet.explorer.nervos.org/transaction/0xc168cc4f0122a0c84add3449a261dd400defacffc59934d2c906da60d10353ba)
362
+ → a charge 1 block in: rejected (code 14); a charge moving 1 shannon of CKB too: rejected (code 12)
363
+ → [charge 1](https://testnet.explorer.nervos.org/transaction/0x7aa3872fec7e32e5b1f4e719d034a0d7310f717aa128642a1bfb2d252efceb6d)
364
+ → [charge 2](https://testnet.explorer.nervos.org/transaction/0x20ee3154a4db0d375f3f444e48fe9d4e47587abb2df0111c7979c0d03823335c), each 2 units with no CKB out
365
+ → a third refused: no tokens left
366
+ → [cancelled by the subscriber alone](https://testnet.explorer.nervos.org/transaction/0x9121f5f3bcd7614a2c191ec99550bf57f56117733cdd670a3e2fb300f9c05f51).
367
+
368
+ **Try it in the browser:** [ckb-session-kit.vercel.app/subscribe](https://ckb-session-kit.vercel.app/subscribe):
369
+ 2 CKB per 60 blocks; members' notes the server releases only while you are paid up.
370
+
371
+ **Deploying it.** The server that charges holds the charger key (`CHARGER_KEY` in
372
+ the deployment's environment, on Vercel for the demo). Make it a **key of its
373
+ own, never the creator's key** or the key behind the payout address: the lock
374
+ limits what the charger can take from subscriptions, but nothing limits what a
375
+ key can do with its own cells, so a leak of the server's environment would also
376
+ hand over all the creator's income. Give it only a few fee cells. `npm run
377
+ setup:charger` creates one in `.env.charger` (gitignored) and prints its address
378
+ and the next steps; nothing is sent. A status or a charge reads at most
379
+ `MAX_SUBSCRIPTION_CELLS` (16) cells under one subscription's lock and refuses
380
+ beyond that (`SessionPolicyError`, reason `"too-many-cells"`), so planted cells
381
+ cannot make one request cost thousands of RPC calls. Only cells of the
382
+ subscription's kind count (plain CKB, or exactly its token): the indexer leaves
383
+ other cells out, including look-alikes of the token with longer args. The
384
+ subscriber clears planted ones without cancelling:
385
+ `clearPlantedCells(wallet, deployment, subscription)` sweeps every cell not made
386
+ by the opening, a charge or the subscriber (and any look-alike of the token)
387
+ back to the subscriber, at most 30 per transaction, one signature each, and
388
+ leaves the subscription's own cells as they are. Only the subscriber's and the
389
+ charger's spends count toward the 64 recent transactions a status reads;
390
+ creations by anyone else are paged past, up to 1024 per period.
391
+ `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.
392
+
393
+ ## Which wallets can own a session
394
+
395
+ The owner signs to open, close and recover, so the owner's wallet must work. `npm
396
+ run matrix:wallets` runs the session and subscription flows on testnet with each
397
+ kind of owner lock the demo's wallet picker gives, signing the way that wallet
398
+ signs (EIP-191 for Ethereum wallets, Bitcoin message signing, and so on). Run on
399
+ 26-27 Oct 2026:
400
+
401
+ | Owner wallet | session opened | paid 1 CKB | session swept | key cell back | subscribed | cancelled |
402
+ |---|---|---|---|---|---|---|
403
+ | 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) |
404
+ | 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) |
405
+ | 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) |
406
+ | 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) |
407
+ | 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) |
408
+
409
+ The first run refused the MetaMask row: the check that refuses owner locks which
410
+ verify no signature did not know Omnilock's auth flag 0x12, the one CCC gives
411
+ Ethereum wallets. Fixed, with a test built from the lock CCC derives for an
412
+ Ethereum account. **JoyID** signs with a passkey and cannot be scripted; it was
413
+ tested by hand in the demo (10 Oct).
414
+
415
+ `openSession` and `openSubscription` check the owner's lock first
416
+ (`assertSigningLock`). The session lock gives owner mode to any transaction that
417
+ spends a cell under the owner's lock, so a lock that opens without the owner's
418
+ signature would let anyone sweep every session. Since 2 Nov the check is a list
419
+ of reviewed configurations:
420
+
421
+ | Owner lock | Result |
422
+ |---|---|
423
+ | secp256k1 (CKB default), secp256k1 multisig, with or without a since | accepted |
424
+ | Omnilock, auth 0x00-0x06 or 0x12, no mode or only time-lock or supply | accepted |
425
+ | Nostr lock, signing mode (`args[0]` is 0) | accepted |
426
+ | JoyID | accepted (its code is closed: reviewed from its documentation) |
427
+ | Anyone-can-pay, PW-Lock, always-success (by type id, or their deployed binaries by data hash) | refused: they open with no signature |
428
+ | Omnilock in anyone-can-pay or administrator mode | refused |
429
+ | Nostr lock in proof of work mode (`args[0]` not 0) | refused: anyone who does the work can unlock it |
430
+ | Omnilock auth 0xFC-0xFE, the proxy locks, any other lock | refused unless you pass `allowUnreviewedOwnerLock: true` (`allowUnreviewedChargerLock` for a subscription's charger) |
431
+
432
+ PW-Lock is refused because the build deployed on CKB is its anyone-can-pay
433
+ variant: a transaction with no signature in the witness takes the payment path,
434
+ whatever the args. MetaMask and other Ethereum wallets get an Omnilock (0x12)
435
+ from CCC, which is accepted. `allowUnreviewedOwnerLock` lets an unreviewed lock
436
+ through with a warning; use it only once you have checked that the lock cannot
437
+ open without its holder's signature. It never lets a refused lock through.
438
+
439
+ ## The on-chain session lock
440
+
441
+ [`contracts/session-lock`](contracts/session-lock/src/main.rs) is a CKB lock script in
442
+ Rust. A cell under it can be spent two ways:
443
+
444
+ - **Owner mode** — the transaction also spends a cell locked by the owner's lock.
445
+ Anything goes: revoke, sweep, top up.
446
+ - **Session mode** — the transaction also spends the session key's *key cell*. The
447
+ network then enforces, on-chain:
448
+ - **outflow limit** — at most `max_per_tx` shannons leave per transaction, fees
449
+ included, so the fee cannot be used to drain the cell. The limit is for all
450
+ the session's cells together: cells under the same lock (same args) are one
451
+ script group, so taking `max_per_tx` from each of three cells is refused.
452
+ Cells of another session or subscription (other args) are checked on their
453
+ own, against their own limit and recipient; one session's unused limit never
454
+ covers another's;
455
+ - **recipient allowlist** (optional) — payments may only go to one lock (plus
456
+ change, the key cell, and the owner). The key cell may come back smaller
457
+ (it pays the fee) but never larger, so the key cannot park session funds in
458
+ its own cell and pay anyone from there. The fee is not an output, so the
459
+ allowlist does not see it: a leaked key can still give up to `max_per_tx`
460
+ per transaction to the miner as fee, paying the recipient nothing. Set a
461
+ rate limit (`min_interval`) whenever you set a recipient;
462
+ - **rate limit** (optional) — each session cell must have aged `min_interval`
463
+ blocks before it can be spent again (relative `since`). The limit is **per
464
+ cell**: with *n* session cells, the key can spend from each of them in the same
465
+ block. `openSession` creates one cell. With `min_interval` 0 there is no
466
+ rate limit: every spend is within `max_per_tx`, but spends can repeat in
467
+ every block until the cell is empty, and `openSession` warns
468
+ (`SessionWarning`, `"no-rate-limit"`; also for `min_interval` 1, almost the same). The interval is a number of blocks,
469
+ not a fixed time: block time varies (see the
470
+ [average block time](https://explorer.nervos.org/charts/average-block-time));
471
+ - **no splitting** — a session spend may send at most one cell back to the
472
+ session lock, so the key can merge session cells but never multiply them,
473
+ which makes the per-cell rate limit a per-session one for sessions opened
474
+ with `openSession`. (Anyone can add cells under a session lock, but only
475
+ with their own money: it never multiplies what the owner put in);
476
+ - **no type scripts** — in a plain session, the session cells spent and their
477
+ change carry no type script: a change cell given a type the key controls could
478
+ later refuse every spend, the owner's sweep included, freezing the whole
479
+ balance. A token session allows exactly one type, its own xUDT, and caps the
480
+ units that leave per spend (`max_per_tx_token`) the way it caps shannons.
481
+
482
+ Signatures are delegated to the standard secp256k1 locks of the owner and the
483
+ session key (the pattern sUDT's owner mode uses), so the script carries no
484
+ cryptography: **7.6 KB, no allocator, no C, 15,651 cycles per run** — about 0.9% of
485
+ a session spend (29,031 on a token spend, 1.7%). 144 tests run it in the real CKB-VM, most asserting a specific
486
+ rejection code, and CI builds the contract and requires them.
487
+
488
+ **What it cannot do:** enforce an expiry time. A CKB script can prove time has
489
+ passed (`since` is a lower bound) but never that it has not. Expiry is enforced
490
+ in the browser; the owner can revoke on-chain at any moment, and the session
491
+ cell's balance caps the total at risk. **Expiry in the browser revokes nothing
492
+ on chain.** After `expiresAt` the key still satisfies the lock, and the funds
493
+ stay at risk until the owner's sweep (`closeSession` or `recoverSessions`)
494
+ is confirmed.
495
+
496
+ **Enforced where:**
497
+
498
+ | Rule | Browser (`checkRequest`) | Session lock (on-chain) |
499
+ |---|---|---|
500
+ | `expiresAt` | yes | **no** (cannot be) |
501
+ | `maxPerTx` | yes | yes, fees included |
502
+ | recipient | yes | yes, if `scope.recipients` has one entry (`openSession` reads it from the scope) |
503
+ | rate limit | — | yes, per cell (`min_interval` blocks) |
504
+ | revocation | — | only by an owner transaction (`closeSession` / `recoverSessions`) |
505
+
506
+ ### If a session key leaks
507
+
508
+ The key cannot be revoked by expiry: after `expiresAt` it still satisfies the lock
509
+ until the owner's sweep is confirmed on chain. The browser forgetting the key, or
510
+ refusing to use it, does not change that. Until then it can move at most `max_per_tx` per transaction,
511
+ only to the recipient if one is set (or to the miner as fee: the recipient rule
512
+ covers outputs, and the fee is not one), and, with a rate limit, once per
513
+ `min_interval` blocks per session cell. Without a rate limit, a leaked key can
514
+ repeat spends of `max_per_tx` in every block until the session is empty, each
515
+ one within the limit; a recipient does not slow it down, since it can burn
516
+ `max_per_tx` in fees every block. Set
517
+ `min_interval` whenever you set a recipient. The owner should sweep at once:
518
+
519
+ - with the key still at hand: `closeSession(wallet, session, deployment, binding)`;
520
+ - without it (device lost or wiped): `recoverSessions(wallet, deployment)` finds
521
+ every session the wallet opened and sweeps them, without the key.
522
+
523
+ An owner sweep is accepted at any time and ignores the scope. It is not certain
524
+ to beat a spend already racing it: the key holder can replace a pending sweep by
525
+ paying a higher fee, so sweep early, and resweep what is left. Keep budgets
526
+ small: the budget is the most a session can ever lose.
527
+
528
+ **Owner and session locks must need a signature.** The lock trusts that an input
529
+ under the owner's lock means the owner signed. Plain secp256k1, JoyID, signing
530
+ Omnilock and the Nostr lock in signing mode are fine; anyone-can-pay, PW-Lock and
531
+ the Nostr lock in proof of work mode are not, since anyone could spend the
532
+ owner's cell and get owner mode. See [which wallets can own a
533
+ session](#which-wallets-can-own-a-session).
534
+
535
+ A session-lock cell occupies 121 CKB (153 CKB with a recipient), so its balance
536
+ and its change must stay above that.
537
+
538
+ ### Enforced by the real network
539
+
540
+ Deployed on testnet: code hash `0xab419466…4c08ce` (`data2`, [deploy tx](https://testnet.explorer.nervos.org/transaction/0xf3098bdb9a7bb1c500990be0f1159b7e66f507fef6b7a4f904046024284877f2)),
541
+ see [`deployment/testnet.json`](deployment/testnet.json). The code is immutable.
542
+ Earlier deployments (8 Oct, before errors 16–18; 13 Oct, before token sessions)
543
+ are kept in `TESTNET_LEGACY_DEPLOYMENTS`, so `recoverSessions` still sweeps
544
+ sessions opened under them.
545
+ `openSession` and `openSubscription` refuse them: new cells go under the current
546
+ lock only.
547
+
548
+ Reference the lock with `hash_type` `data1` or `data2` (or `type`, for a
549
+ type-id deployment). Under `data` (CKB-VM version 0) the binary faults on load,
550
+ owner mode included, so a cell under it could never be spent; the library
551
+ refuses such a deployment when opening.
552
+
553
+ Before it builds an opening, the library reads the deployment's cell dep from
554
+ the client's network (`getCellLive`, a dep group resolved) and refuses unless it
555
+ holds code hashing to `codeHash` (`assertCurrentDeployment`), once per client
556
+ and deployment. `TESTNET_DEPLOYMENT` on a mainnet client is therefore refused.
557
+ There is no mainnet deployment and no `MAINNET_DEPLOYMENT` constant: to use the
558
+ lock on mainnet, deploy the binary yourself (byte-identical, see below) and pass
559
+ your own `SessionLockDeployment` (`codeHash`, `hashType: "data2"`, `cellDep`).
560
+
561
+ Verify the deployed lock yourself. The deployed binary was built with rustc
562
+ 1.99.0 on the x86_64-pc-windows-gnu host (pinned in
563
+ [`rust-toolchain.toml`](contracts/session-lock/rust-toolchain.toml)), and a
564
+ rebuild there is byte-identical to the code on chain. On Windows, from the
565
+ repository root:
566
+
567
+ ```sh
568
+ rustup toolchain install 1.99.0-x86_64-pc-windows-gnu --profile minimal --target riscv64imac-unknown-none-elf
569
+ (cd contracts/session-lock && cargo +1.99.0-x86_64-pc-windows-gnu build --release --locked)
570
+ npm ci && npm run check:lock-hash
571
+ ```
572
+
573
+ The last command compares the build's data hash with `codeHash` in
574
+ `deployment/testnet.json` and fails on any difference; CI's `lock-hash` job runs
575
+ the same three steps. Another host or rustc builds a working lock with other
576
+ bytes, so its hash will not match.
577
+
578
+ [`docs/SECURITY-EVIDENCE.md`](docs/SECURITY-EVIDENCE.md) collects the evidence
579
+ for each rule and error code in one report: the check in the script, the accepted
580
+ transactions, the node's rejection text, the CKB-VM tests, and which rules have
581
+ only been shown in CKB-VM tests. `npm run evidence` re-checks its logs against
582
+ testnet, read-only.
583
+
584
+ `npm run smoke:lock` runs the rules in the table below against public testnet. Run on 14 Oct 2026, on the current lock:
585
+
586
+ | Step | Network said |
587
+ |---|---|
588
+ | Owner funds three session cells and a key cell | [accepted](https://testnet.explorer.nervos.org/transaction/0x12a553c12e733a50f18db09edf8112031c9e39b1949e1f11699a67e3e30ba350) |
589
+ | Session key spends exactly `max_per_tx` (100 CKB), no owner signature | [accepted](https://testnet.explorer.nervos.org/transaction/0xe2fced3a5d73059465f0b9400acdf0cba3fc27e441b82d1faebcbe44b1b20a8b) |
590
+ | Session key spends 100 CKB + 1 shannon | **rejected**, error 12 (outflow exceeds max_per_tx) |
591
+ | Rate-limited cell spent with no `since` | **rejected**, error 14 (rate limited) |
592
+ | Same cell with `since` = 3 relative blocks | [accepted once mature](https://testnet.explorer.nervos.org/transaction/0xe53e4ada7f76d847b7e1883115c9b31851d8962e195cc2eb1fdaeb53e6b07653) |
593
+ | Change split into two cells | **rejected**, error 16 (too many change cells) |
594
+ | Recipient-scoped cell, 50 CKB moved into the key cell | **rejected**, error 17 (key cell may not grow) |
595
+ | Change given a type script (a valid Type ID) | **rejected**, error 18 (no type scripts) |
596
+ | Owner sweeps everything back | [accepted](https://testnet.explorer.nervos.org/transaction/0x8019404b3708050660b921201ec534cc7f20ef68fa1e836012d33f531066bae5) |
597
+
598
+ Cycles, measured with [ckb-cycle-tools](https://github.com/luongthanhtung03/ckb-cycle-tools)
599
+ on the accepted session spend: the session lock 15,651, the key cell's
600
+ secp256k1 lock 1,652,327, matching the node's 1,667,978 exactly. On a token
601
+ payment from the /tokens page's flow: the session lock 29,031, xUDT 18,795, the
602
+ creator's anyone-can-pay lock 34,106, the key 1,642,571 (1,724,503 in all).
603
+
604
+ ### v0.1 browser flow, verified on public testnet
605
+
606
+ `npm run smoke:testnet` funds a fresh session, spends inside its scope, and checks
607
+ that an over-limit spend is refused. Run on 8 Oct 2026:
608
+
609
+ | Step | Result |
610
+ |---|---|
611
+ | Fund the session (300 CKB) | [0x2acf…c45f](https://testnet.explorer.nervos.org/transaction/0x2acff2323c07cb36dd9d58343df83277cdab68725541020acbff4dc968d0c45f) |
612
+ | Spend 100 CKB, signed by the session key | [0x52a1…da50](https://testnet.explorer.nervos.org/transaction/0x52a1b3ba45a28614c30ddc52f0795c164141abbebd1558e6c69fb0ac45fada50) |
613
+ | Spend 150 CKB (limit 100) | refused before signing |
614
+
615
+ ## Limits
616
+
617
+ What this does not do, in one place. Each is stated where it applies above.
618
+ What has and has not been shown on testnet is in
619
+ [`docs/SECURITY-EVIDENCE.md`](docs/SECURITY-EVIDENCE.md#5-not-covered-and-limits).
620
+
621
+ - **Testnet only, not audited.** The lock has had community and adversarial
622
+ reviews, each finding fixed with a regression test, but no external audit.
623
+ Do not put mainnet funds behind it.
624
+ - **Expiry is not on-chain.** A CKB script cannot prove that time has *not*
625
+ passed, so `expiresAt` is checked in the browser. Browser expiry does not
626
+ revoke the key's on-chain authority: a leaked key stays valid, and the funds
627
+ stay at risk, until the owner's sweep transaction is confirmed. The session's
628
+ balance is the most it can lose.
629
+ - **No rate limit means repeated spends.** With `min_interval` 0, each spend
630
+ is within `max_per_tx`, but spends can repeat every block until the balance
631
+ is gone. `openSession` warns; set `minInterval`.
632
+ - **The rate limit is per cell.** `openSession` makes one cell and the lock
633
+ forbids splitting, but anyone adding more cells under the same session lock
634
+ gives the key more cells to spend from (with their own money, never the owner's).
635
+ - **The key cell is not recoverable without the key.** `recoverSessions` sweeps
636
+ every session cell with the wallet alone; the 62–64 CKB key cell needs the
637
+ lost key.
638
+ - **An owner sweep can be raced.** A key holder can replace a pending sweep by
639
+ paying a higher fee. Sweep early and sweep again.
640
+ - **Paid access is checked at zero confirmations.** `verifyAccess` accepts a
641
+ payment once it is committed in a block; a reorg could undo it after the
642
+ content was released. Fine for articles and pixels, not for anything costly.
643
+ - **An access proof does not expire.** It is a bearer token for one article:
644
+ whoever holds the signed proof can fetch that article again. It cannot be
645
+ used for another article or by another payment.
646
+ - **Owner locks must need a signature.** An owner lock that opens without one
647
+ (anyone-can-pay, PW-Lock, Nostr in proof of work mode) would give anyone owner
648
+ mode; `openSession` refuses it, and refuses locks it has not reviewed unless
649
+ `allowUnreviewedOwnerLock` is set.
650
+ - **Subscription periods are in blocks, and charges need the creator online.**
651
+ The lock's rate limit is a block count, not a fixed duration: block time
652
+ varies (see the [average block time](https://explorer.nervos.org/charts/average-block-time)), so a period is an
653
+ estimate of time (`blocksForSeconds`). Nothing charges by itself: the creator's server must
654
+ send each charge, one transaction per subscriber. Cancelling ends access at
655
+ once, and the current period's price is not refunded.
656
+ - **Sessions are not cheap to open.** A session cell occupies 121 CKB (153 with a
657
+ recipient), returned on close; small payments go to anyone-can-pay cells
658
+ because a new cell needs 61 CKB.
659
+
660
+ ## Why it is a separate repository
661
+
662
+ The session problem has nothing to do with any one application. Anything that wants
663
+ to act repeatedly on a user's behalf in a browser has it, so it should not be buried
664
+ inside one application's source tree.
665
+
666
+ ## Related work
667
+
668
+ Other CKB projects work on nearby problems. This is what each one does, as
669
+ described in its own public post or repository, and where this kit differs.
670
+
671
+ - **CKAutomata** ([forum post](https://talk.nervos.org/t/introducing-ckautomata-payment-automation-and-nervos-dao-harvest-on-ckb/10813),
672
+ [CKBuilder review issue](https://github.com/Nervos-Community-Catalyst/CKBuilder-projects/issues/43)):
673
+ one-time, scheduled and recurring CKB payments, and Nervos DAO harvest. Each
674
+ automation is a cell holding its rules, schedule and funds; the user signs once
675
+ to set it up, and an executor submits each payment when it is due, without
676
+ being able to change amount, recipient or schedule. The overlap is with
677
+ subscriptions here: both let a payer sign once and be charged later while
678
+ offline. The differences: here the subscription is one use of a general
679
+ session lock, the creator's own server holds the key and pulls each charge,
680
+ and `subscriptionStatus` lets that server gate content on-chain with no
681
+ database. This kit does not schedule payments for third-party executors and
682
+ does nothing with the Nervos DAO.
683
+ - **QuotaCell** ([forum post](https://talk.nervos.org/t/quotacell-let-your-team-spend-without-handing-over-the-treasury/11002),
684
+ [repository](https://github.com/Ticoworld/QuotaCell)): a shared treasury
685
+ allowance for a team. Several delegates, each with their own key, a personal
686
+ limit and a per-payment ceiling, draw from one cumulative allowance that only
687
+ the owner can renew, enforced by an on-chain policy cell; agent spending is a
688
+ use it is exploring. The session lock also enforces spend limits on-chain, but
689
+ for a different shape: one user, one short-lived key generated and held in that
690
+ user's browser, a fixed recipient and a rate limit, so an app can act without
691
+ wallet popups. It has no notion of several delegates sharing one allowance.
692
+ - **FiberLatch Access** ([forum post](https://talk.nervos.org/t/dis-fiberlatch-access-open-source-access-control-for-fiber-payments/10414))
693
+ and Fiber's **Encrypted Data Payment** pattern
694
+ ([docs](https://www.fiber.world/docs/build/encrypted-data-payment)): access to
695
+ content paid for over Fiber. FiberLatch issues and verifies signed access
696
+ receipts once an app has accepted a payment; in the Fiber pattern, the invoice
697
+ preimage is the key that decrypts the data. Pay-per-read here runs on CKB L1
698
+ instead: no channel to open, each payment is an ordinary transaction signed by
699
+ the session key inside the on-chain scope, and the server checks the payment on
700
+ chain (`verifyAccess`). The trade-off is that every L1 payment waits for a block
701
+ and pays a fee, where a Fiber payment settles off-chain once a channel is open.
702
+ - **Wallet and login projects**: [JoyID](https://docs.joyid.dev/) (passkey
703
+ wallet), [Corven Connect](https://corvanide.space/connect/docs/introduction)
704
+ (Google and passkey sign-in with an embedded CKB wallet) and
705
+ [CKB KeyWay](https://github.com/officialcmg/ckb-keyway) (email login with a
706
+ recoverable CKB account, plus Fiber). These create or unlock the user's
707
+ account. This kit does not: it starts from a wallet the user already has and
708
+ removes the repeated signing after that, so it can sit on top of any of them.
709
+ A JoyID owner works today (tested by hand); the other two have not been tested
710
+ as session owners.
711
+
712
+ ## Licence
713
+
714
+ MIT. See [LICENSE](LICENSE).
715
+
716
+ ## Author
717
+
718
+ Luong Thanh Tung — [@luongthanhtung03](https://github.com/luongthanhtung03)