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/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
- ## Try it
42
-
43
- ```bash
44
- npm install
45
- npm test # unit + CKB-VM tests, no network needed
46
- npm run demo # the Next.js web demo at http://localhost:3000
47
- ```
48
-
49
- The demo (v0.2) connects a wallet through CCC (JoyID, MetaMask, OKX, UniSat, …).
50
- The wallet signs **once** to open a session: it funds a session cell under the
51
- on-chain session lock plus a small key cell that pays the session's fees. Payments
52
- are then signed by a key held only in the browser, with **no wallet popup**, and the
53
- network enforces the limits. The wallet signs **once more** to close the session and
54
- take everything back.
55
-
56
- New here? [`docs/WALKTHROUGH.md`](docs/WALKTHROUGH.md) tours every demo page on
57
- testnet in about fifteen minutes, with one wallet.
58
-
59
- ```ts
60
- import { createSession, openSession, spendInSession, closeSession, TESTNET_DEPLOYMENT as deployment } from "ckb-session-kit";
61
-
62
- const session = createSession({ expiresAt, scope: { maxPerTx: 100n * 10n ** 8n } });
63
- const { binding } = await openSession(wallet, session, deployment, { budget: 400n * 10n ** 8n });
64
- await spendInSession(session, client, deployment, binding, { to, amount }); // no wallet
65
- await closeSession(wallet, session, deployment, binding); // one signature
66
- ```
67
-
68
- `npm run smoke:flow` runs exactly that against testnet with a key standing in for
69
- the wallet. Run on 8 Oct 2026: [open](https://testnet.explorer.nervos.org/transaction/0x5835048d4226f0b74be01cd79664d7e4dce67ea8aa77bb9d3468e896d27bf2b8)
70
- (owner signs) → [pay 100 CKB](https://testnet.explorer.nervos.org/transaction/0x9448e354252d71913d18fda2ba6d2460e1d78c2c0e4123eb8068482d7ec05c77)
71
- and [pay 80 CKB](https://testnet.explorer.nervos.org/transaction/0x9391051dcc7f490ade402dbc2057de4382e005e9c9d21ad8d7f835328ddcd182)
72
- (session key only) → 150 CKB refused → [key cell returned](https://testnet.explorer.nervos.org/transaction/0xe8eb8954674665aa1a4b990ecfd8ffea1ad728e8689a5f5aafdb9fc17c00b8d9)
73
- and [session swept](https://testnet.explorer.nervos.org/transaction/0x0d2a05460bdbfd5693c2d91a55e9c96b2eaaa6fbb7e0b1e3237a1fe9c2d6f1e4) (owner signs).
74
-
75
- ### Lost the device? The wallet alone gets it back
76
-
77
- The session key lives only in one browser. If that browser is gone, the owner's
78
- wallet can still recover every session it ever opened:
79
-
80
- ```ts
81
- await recoverSessions(wallet, deployment); // finds them all, usually one signature
82
- ```
83
-
84
- Session-lock args begin with the owner's lock hash, so an indexer prefix search
85
- finds the session cells without knowing the lost key, and owner mode sweeps them.
86
- Cells whose args are malformed are skipped: the lock rejects them even in owner
87
- mode, and anyone could plant one with the owner's hash as a prefix to block a
88
- sweep. Anyone can also plant a *well-formed* cell with the owner's hash and their
89
- own key, and spend it just before the sweep lands; if the single sweep fails,
90
- each session is swept on its own (one signature each), so only the planted cell
91
- is left out. What cannot come back is the key cell (the fee budget), which only
92
- the session key can spend; keep it small. It must stay big enough to go back to
93
- the owner after paying fees, so `openSession` sizes it from the owner's lock: 62
94
- CKB by default for a secp256k1 owner, 64 for JoyID or Omnilock (MetaMask, UniSat,
95
- OKX), whose cells need 63. The extra 1 CKB pays thousands of fees.
96
-
97
- `npm run smoke:recover` runs it on testnet. Run on 9 Oct 2026: two sessions
98
- [opened](https://testnet.explorer.nervos.org/transaction/0x289291b3026af3d1e1929308b75276bc72d0c8de151f1b74f6b7926f29bda74b)
99
- ([second](https://testnet.explorer.nervos.org/transaction/0xb4d91cdb0751c7fd9908ac18930c07c74d09c67f7338916f9893eb8190acd883)),
100
- one [spent from](https://testnet.explorer.nervos.org/transaction/0xf173fd2b02b1b8e208bd493c9d31ead9d5052b5aae190119a2393554de0e8079),
101
- a [decoy](https://testnet.explorer.nervos.org/transaction/0x40781ca16108dfde33328ded076721137c6be935d2417258c881dfe4266f000f)
102
- planted, both keys dropped → [one owner transaction](https://testnet.explorer.nervos.org/transaction/0x4efd8b7875c8a0f2883ac2b21ed509ff86c72012e6f4a24816783aa25e02eeaf)
103
- swept every session cell back, decoy skipped.
104
-
105
- ## Examples and API
106
-
107
- - [`examples/node-quickstart.ts`](examples/node-quickstart.ts): open, pay twice
108
- with no wallet, close, in one file (`OWNER_KEY=0x… npx tsx --tsconfig examples/tsconfig.json examples/node-quickstart.ts`).
109
- Run on 13 Oct 2026: [open](https://testnet.explorer.nervos.org/transaction/0x8229533c8769bb30730d075abd5c6cf60b39ab9765a3fc24adeb6d6af649cf8c)
110
- → [pay 61](https://testnet.explorer.nervos.org/transaction/0x75e3673ce82d202dd76bd30903f199b83eddd0e4b2085fd45b2cd286cf27fea7)
111
- → [pay 65](https://testnet.explorer.nervos.org/transaction/0x821816ec1916c805e98b0de1ec5e3c606837950edb468a7a4d998dd7f8f9598a)
112
- → [sweep](https://testnet.explorer.nervos.org/transaction/0x36d47d37139d21affe8d0660395dba03aba2d3b247566f1e34684d85b9aac67d)
113
- and [key cell back](https://testnet.explorer.nervos.org/transaction/0x41604d6d13fc7f7b0e7adc92316651a25507a7a2ffe8b2964887b3935498de75).
114
- - [`examples/browser-session.ts`](examples/browser-session.ts): what an app does
115
- around any CCC wallet: save before funding, one session per tab, restore on load.
116
- - [`examples/paywall-server.ts`](examples/paywall-server.ts): a paywall with no
117
- database, on `node:http`.
118
- - [`docs/API.md`](docs/API.md): every export, grouped by task. CI typechecks the
119
- examples and fails if an export is missing from the reference.
120
-
121
- ## Example: pay-per-read
122
-
123
- A reader's wallet signs once to open a session scoped to one creator. Each article
124
- unlock is then a 1 CKB payment signed by the session key alone. Two things make
125
- this work on CKB:
126
-
127
- - **Payments smaller than a cell.** A new cell needs at least 61 CKB, so a 1 CKB
128
- payment cannot create one. Instead the creator keeps an
129
- [anyone-can-pay](https://github.com/nervosnetwork/rfcs/blob/master/rfcs/0026-anyone-can-pay/0026-anyone-can-pay.md)
130
- cell, and each payment tops it up: `spendInSession(…, { to, amount, topUp: true })`.
131
- The anyone-can-pay lock accepts the top-up without the creator's signature.
132
- - **A stolen key can only pay the creator.** The session's recipient is the
133
- creator's anyone-can-pay address, so the session lock refuses any other output,
134
- and `max_per_tx` caps each unlock.
135
-
136
- ```ts
137
- const session = createSession({ expiresAt, scope: { maxPerTx: 5n * CKB, recipients: [creatorAcp] } });
138
- const { binding } = await openSession(wallet, session, deployment, { budget: 200n * CKB });
139
- await spendInSession(session, client, deployment, binding, { to: creatorAcp, amount: 1n * CKB, topUp: true });
140
- ```
141
-
142
- **Server-verified unlocks, no database.** Each payment names its article in a
143
- memo (`spendInSession(…, { memo })`), stored in the witness the session key signs,
144
- so the payment commits to what it buys. To read, the browser signs a request with
145
- the same key (`signAccess`), and the server checks the chain (`verifyAccess`):
146
- committed, the creator gained at least the price, the memo matches, and the key
147
- that signed the request is the key that paid. Anyone can see a payment on-chain;
148
- only its payer can use it, and only for that article. The article text is never
149
- in the page bundle.
150
-
151
- ```ts
152
- // browser
153
- const tx = await spendInSession(session, client, deployment, binding, { to: creatorAcp, amount: 1n * CKB, topUp: true, memo: paymentMemo("read:since") });
154
- const proof = await signAccess(session, tx, paymentMemo("read:since"));
155
- // server
156
- const check = await verifyAccess(client, proof, { to: creatorLock, minAmount: 1n * CKB, memo: paymentMemo("read:since") });
157
- ```
158
-
159
- `npm run smoke:pay-per-read` runs it on testnet; with `ARTICLE_API` set it also
160
- claims each article through the demo's `/api/article` route. Run on 9 Oct 2026
161
- against the production build:
162
- [session opened](https://testnet.explorer.nervos.org/transaction/0xbaa5657c27d44e37e9557b1dbb0ee38fb3cda95924098529892906857b7ae72d)
163
- (reader signs once, recipient = creator) → reads
164
- [#1](https://testnet.explorer.nervos.org/transaction/0x5aeeb65326efb1ff2d651ba22f5a685c224162e974b51ceb9638f6576d056942),
165
- [#2](https://testnet.explorer.nervos.org/transaction/0x575becbaa7aa2b03d26406c974ca417b82f06d044b863919d07cf5d760e6910b),
166
- [#3](https://testnet.explorer.nervos.org/transaction/0x3b54027483f7239c1b5af7c82a773263107493c8db88183d9aef740091680eb0)
167
- at 1 CKB each, session key only, each released by the route to the payer, while
168
- a claim by another key and a claim for another article were refused →
169
- paying anyone else and paying 6 CKB (limit 5) refused before signing →
170
- [key cell returned](https://testnet.explorer.nervos.org/transaction/0xf7d9f2cc05d0841d59fa82d66ae5e3152b69860155fd6c6b2ca82dba15fa9493)
171
- and [session swept](https://testnet.explorer.nervos.org/transaction/0x978e186aceeadca6d963cf6f570066c36028d7ba0273e5f84bfb7c6e46c9e281).
172
- The on-chain side of the refusals is covered by the CKB-VM tests below.
173
-
174
- **Try it in the browser:** [ckb-session-kit.vercel.app/read](https://ckb-session-kit.vercel.app/read)
175
- (the `/read` page of the demo): open a reading session with one wallet signature,
176
- then unlock articles for 1 CKB each with no popup.
177
-
178
- A session cell with a recipient occupies 153 CKB, so a 200 CKB session has 47 CKB
179
- to spend; the rest comes back on close. Each payment consumes one of the creator's
180
- cells, so readers paying at the same moment compete for them; the library picks
181
- one at random, and `npm run setup:creator` gives the demo's creator three.
182
-
183
- ## Example: CKB Place, a pixel board on testnet
184
-
185
- [`/place`](https://ckb-session-kit.vercel.app/place): a shared 32×32 board. Open a
186
- session once (one wallet signature; the session lock lets it pay only the board),
187
- then every batch of up to 32 pixels is one 0.1 CKB-per-pixel payment signed by
188
- the session key, with no wallet popup. The pixels travel in the payment's memo
189
- (`"px1"` + `x, y, colour` per pixel); the board is rebuilt from the chain by
190
- reading those memos in chain order, counting only payments that carry exactly one
191
- memo, signed by the payer, for at least the price. No server, no database:
192
- [`src/place.ts`](src/place.ts).
193
-
194
- `npm run smoke:place` paints "CKB" on testnet. Run on 14 Oct 2026:
195
- [board cells created](https://testnet.explorer.nervos.org/transaction/0x2dbcf33a2c8360a1ad2a7984f2f1331f345b0c9874511e7f5c7f828db24dea1f)
196
- → [session opened](https://testnet.explorer.nervos.org/transaction/0x91cfac448029eba2354e08a6a3f6fbeaf4227a6507ebcda267e5a4e0060d3715)
197
- → [29 pixels, 2.9 CKB, no wallet](https://testnet.explorer.nervos.org/transaction/0x91acae7b00a0210e3779b76ec974395964ba65e7797780dd3a9d815e4245d0f5)
198
- → [swept](https://testnet.explorer.nervos.org/transaction/0x3c3b734fdda064e108dbcf0d989d573625e5823a54f92233a66f0cfa0e08b1e3)
199
- and [key cell back](https://testnet.explorer.nervos.org/transaction/0x3c369a5c81e66847003ff4019fd01773d97563378a07d05857a5c18d4096e006).
200
-
201
- ## Example: pay-per-minute
202
-
203
- [`/listen`](https://ckb-session-kit.vercel.app/listen): a player for three
204
- generative tracks (Web Audio, composed per minute on the server). Open a session
205
- once; while it plays, the session key pays 0.5 CKB for the next minute 30 s
206
- before it is needed, with memo `listen:<track>:<minute>`, and the server releases
207
- that minute only to the key that paid for it (`verifyAccess`). Pause, and the
208
- payments stop. No database.
209
-
210
- `npm run smoke:listen` runs it on testnet with the server's own gating. Run on 14 Oct 2026:
211
- [session opened](https://testnet.explorer.nervos.org/transaction/0x818094035373e3a62d34af39f98fab664f6dc089d3149d57b0a53ea7e3a30c1b)
212
- → [minute 0](https://testnet.explorer.nervos.org/transaction/0x10df4596aaad21201c33784fb28387d8f129d4bcf546c6a54b49ea8cb48c55c7)
213
- → [minute 1](https://testnet.explorer.nervos.org/transaction/0xb53479536932ff07a19d87d4d0d48fada4981e1bc66687a7a9ddd5f0fac1b6f1),
214
- each released by the server; minute 2 claimed with minute 0's payment: refused
215
- → [swept](https://testnet.explorer.nervos.org/transaction/0x2f091ff6430b62d157743450cf18cc9d5f52d91852012cc348ff377f79dab8e0)
216
- and [key cell back](https://testnet.explorer.nervos.org/transaction/0x5024c47cd6eacb822bc6c9392270bc6b4389110a353b0139475aafdbddb8cf2e).
217
-
218
- ## Token sessions (xUDT)
219
-
220
- A session can hold a token as well as CKB: a stablecoin, game credits, a
221
- creator's token. The owner signs once; the session key pays in the token with
222
- no wallet, and the lock caps the units per spend the way it caps shannons, with
223
- the same recipient and rate-limit rules. Payments top up the recipient's
224
- anyone-can-pay token cell, so no CKB leaves the session. Closing or recovering
225
- returns the tokens as tokens.
226
-
227
- ```ts
228
- const { binding } = await openSession(wallet, session, TESTNET_DEPLOYMENT, {
229
- budget: 300n * CKB,
230
- token: { type: xudtType, amount: 100n, maxPerTx: 10n },
231
- });
232
- await spendInSession(session, client, TESTNET_DEPLOYMENT, binding, { to: creator, token: 3n, topUp: true });
233
- ```
234
-
235
- Design, threats and both reviews: [`docs/design/token-sessions.md`](docs/design/token-sessions.md).
236
- `npm run smoke:token` runs it on testnet. Run on 14 Oct 2026, with a test xUDT:
237
- [session opened, 100 units](https://testnet.explorer.nervos.org/transaction/0x7dac7740cf9be29cf900085e98f43cb3557e2c956b9946a2d20ddc3dec49105d)
238
- → [3 units](https://testnet.explorer.nervos.org/transaction/0xa8d1d98f8fd5b22cab9fd989b58a39995171e7099b1df5df46292c6ca8f94a88)
239
- → [3 units](https://testnet.explorer.nervos.org/transaction/0xba27bcc564c00cdf2b8b3d3badc30091a29f17fbc4ce71bbb84117af6cb7b19d),
240
- no wallet; 11 units in a 10-unit session: rejected by the network (code 20)
241
- → [94 units swept back](https://testnet.explorer.nervos.org/transaction/0x798c65c8ab49de39eb5aeb084c386f6ec1e1803e12636129ba73ace24b710a5d).
242
-
243
- **Try it in the browser:** [ckb-session-kit.vercel.app/tokens](https://ckb-session-kit.vercel.app/tokens):
244
- pay-per-read again, 2 tokens per article. The demo token is free to mint from the
245
- page: its issuer is an anyone-can-pay cell nobody holds the key to, and xUDT lets
246
- whoever spends an issuer cell mint, so anyone can (it is worth nothing). The
247
- server releases an article after `verifyAccess(…, { token })` finds, on-chain,
248
- that this key paid 2 units of this token for it. `npm run setup:token` creates
249
- the issuer cells; `npm run smoke:read-tokens` runs the page's flow on testnet.
250
- Run on 14 Oct 2026:
251
- [minted 20 units with only the minter's key](https://testnet.explorer.nervos.org/transaction/0x37e5005a74ee503a86813907c6aef1d02e930c9575c271751d6fafed9cdeef75)
252
- → [session opened](https://testnet.explorer.nervos.org/transaction/0x366d40dad9c8be024af28e93aa4b65e8f0a7ad9903ff923eb5c192d49c99c678)
253
- → [2 units for an article](https://testnet.explorer.nervos.org/transaction/0x9a16f6601309af1deec985c55a7537195047c28630d0cae719e8e8afc3d01dee),
254
- released by the demo's server route; at a 3-unit price the same payment is refused
255
- → [18 units back](https://testnet.explorer.nervos.org/transaction/0x3688288b9c49c771b85a38a80806f9fefead39b936caea92ecb97e6d4a3aa2ee).
256
-
257
- ## Subscriptions
258
-
259
- The same lock with the roles moved: the subscriber owns the cell, the creator's
260
- server holds the session key. The network lets the creator charge at most the
261
- price, at most once per period, and only to the creator's address, while the
262
- subscriber is offline; the subscriber cancels alone at any time. **No change to
263
- the deployed lock**: the price is `max_per_tx`, the period is `min_interval`,
264
- the payout address is the recipient.
265
-
266
- ```ts
267
- // subscriber: one signature; the first period is paid at once
268
- const { subscription } = await openSubscription(wallet, TESTNET_DEPLOYMENT, {
269
- charger, payTo, price: 10n * CKB, periodBlocks: 10n, budget: 173n * CKB, account,
270
- });
271
- // creator's server, once per period, no subscriber involved
272
- await chargeSubscription(chargerKey, TESTNET_DEPLOYMENT, subscription, { payTo, topUp: true });
273
- // creator's server, on every request, with no database
274
- const r = await subscriptionStatus(client, TESTNET_DEPLOYMENT, subscription, { openTx, account });
275
- // subscriber, any time
276
- await cancelSubscription(wallet, TESTNET_DEPLOYMENT, subscription);
277
- ```
278
-
279
- The design was attacked on paper before any code
280
- ([`docs/design/subscriptions.md`](docs/design/subscriptions.md)). The
281
- subscriber's money held; the creator's side did not, and the library now covers it:
282
- - the opening pays the first period, since a new cell cannot be charged for a whole period;
283
- - access follows payments, not the live cell, so re-creating the cell to reset its age gains nothing;
284
- - the opening commits to an account id (hashed) under the subscriber's signature, so nobody can present someone else's subscription.
285
-
286
- A charge costs the session lock 20,999 cycles.
287
-
288
- `npm run smoke:subscription` runs it on testnet. Run on 26 Oct 2026:
289
- [opened, first period paid](https://testnet.explorer.nervos.org/transaction/0x383242a3bf89ae8007fc8fc4f7ec04420718c2e32590bdbbd445bcf4c4e2a1b9)
290
- → the creator's check accepts the terms, the first payment and the account, and refuses another account
291
- → a charge 1 block in: rejected by the network (code 14)
292
- → [charge 1](https://testnet.explorer.nervos.org/transaction/0xf98e36b2f5ef1bb86875fb0989e32f25f67807bc95e34aa273b497db5eebdb16)
293
- → [charge 2](https://testnet.explorer.nervos.org/transaction/0x930beb93436c884f679f691a901eec1db4742fd69cd28e8d63b4eb386de260e0), a period later, charger key only
294
- → a third charge refused: what is left cannot cover a price and a cell
295
- → [cancelled by the subscriber alone, 153 CKB back](https://testnet.explorer.nervos.org/transaction/0x235c9d2e4fc2205fa552c144890002f611c362402b62ceb1dc8540160c459c4f).
296
-
297
- **Paid in a token.** The same flow in an xUDT (a stablecoin, say): pass
298
- `token: { type, amount }` to `openSubscription`. The lock's per-payment CKB limit
299
- is 0, so no CKB ever leaves the cell; the price is the per-payment token limit, and
300
- charges top up the creator's anyone-can-pay token cell. `npm run smoke:subscription-token`
301
- ran it on 27 Oct 2026 with the demo token:
302
- [opened, first 2 units paid](https://testnet.explorer.nervos.org/transaction/0xc168cc4f0122a0c84add3449a261dd400defacffc59934d2c906da60d10353ba)
303
- → a charge 1 block in: rejected (code 14); a charge moving 1 shannon of CKB too: rejected (code 12)
304
- → [charge 1](https://testnet.explorer.nervos.org/transaction/0x7aa3872fec7e32e5b1f4e719d034a0d7310f717aa128642a1bfb2d252efceb6d)
305
- → [charge 2](https://testnet.explorer.nervos.org/transaction/0x20ee3154a4db0d375f3f444e48fe9d4e47587abb2df0111c7979c0d03823335c), each 2 units with no CKB out
306
- → a third refused: no tokens left
307
- → [cancelled by the subscriber alone](https://testnet.explorer.nervos.org/transaction/0x9121f5f3bcd7614a2c191ec99550bf57f56117733cdd670a3e2fb300f9c05f51).
308
-
309
- **Try it in the browser:** [ckb-session-kit.vercel.app/subscribe](https://ckb-session-kit.vercel.app/subscribe):
310
- 2 CKB per 60 blocks; members' notes the server releases only while you are paid up.
311
-
312
- ## Which wallets can own a session
313
-
314
- The owner signs to open, close and recover, so the owner's wallet must work. `npm
315
- run matrix:wallets` runs the session and subscription flows on testnet with each
316
- kind of owner lock the demo's wallet picker gives, signing the way that wallet
317
- signs (EIP-191 for Ethereum wallets, Bitcoin message signing, and so on). Run on
318
- 26-27 Oct 2026:
319
-
320
- | Owner wallet | session opened | paid 1 CKB | session swept | key cell back | subscribed | cancelled |
321
- |---|---|---|---|---|---|---|
322
- | 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) |
323
- | 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) |
324
- | 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) |
325
- | 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) |
326
- | 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) |
327
-
328
- The first run refused the MetaMask row: the check that refuses owner locks which
329
- verify no signature did not know Omnilock's auth flag 0x12, the one CCC gives
330
- Ethereum wallets. Fixed, with a test built from the lock CCC derives for an
331
- Ethereum account. **JoyID** signs with a passkey and cannot be scripted; it was
332
- tested by hand in the demo (10 Oct).
333
-
334
- ## The on-chain session lock
335
-
336
- [`contracts/session-lock`](contracts/session-lock/src/main.rs) is a CKB lock script in
337
- Rust. A cell under it can be spent two ways:
338
-
339
- - **Owner mode** — the transaction also spends a cell locked by the owner's lock.
340
- Anything goes: revoke, sweep, top up.
341
- - **Session mode** — the transaction also spends the session key's *key cell*. The
342
- network then enforces, on-chain:
343
- - **outflow limit** — at most `max_per_tx` shannons leave per transaction, fees
344
- included, so the fee cannot be used to drain the cell;
345
- - **recipient allowlist** (optional) — payments may only go to one lock (plus
346
- change, the key cell, and the owner). The key cell may come back smaller
347
- (it pays the fee) but never larger, so the key cannot park session funds in
348
- its own cell and pay anyone from there;
349
- - **rate limit** (optional) — each session cell must have aged `min_interval`
350
- blocks before it can be spent again (relative `since`). The limit is **per
351
- cell**: with *n* session cells, the key can spend from each of them in the same
352
- block. `openSession` creates one cell;
353
- - **no splitting** — a session spend may send at most one cell back to the
354
- session lock, so the key can merge session cells but never multiply them,
355
- which makes the per-cell rate limit a per-session one for sessions opened
356
- with `openSession`. (Anyone can add cells under a session lock, but only
357
- with their own money: it never multiplies what the owner put in);
358
- - **no type scripts** — in a plain session, the session cells spent and their
359
- change carry no type script: a change cell given a type the key controls could
360
- later refuse every spend, the owner's sweep included, freezing the whole
361
- balance. A token session allows exactly one type, its own xUDT, and caps the
362
- units that leave per spend (`max_per_tx_token`) the way it caps shannons.
363
-
364
- Signatures are delegated to the standard secp256k1 locks of the owner and the
365
- session key (the pattern sUDT's owner mode uses), so the script carries no
366
- cryptography: **7.6 KB, no allocator, no C, 15,651 cycles per run** — about 0.9% of
367
- a session spend (29,031 on a token spend, 1.7%). 74 tests run it in the real CKB-VM, most asserting a specific
368
- rejection code, and CI builds the contract and requires them.
369
-
370
- **What it cannot do:** enforce an expiry time. A CKB script can prove time has
371
- passed (`since` is a lower bound) but never that it has not. Expiry is enforced
372
- in the browser; the owner can revoke on-chain at any moment, and the session
373
- cell's balance caps the total at risk.
374
-
375
- **Enforced where:**
376
-
377
- | Rule | Browser (`checkRequest`) | Session lock (on-chain) |
378
- |---|---|---|
379
- | `expiresAt` | yes | **no** (cannot be) |
380
- | `maxPerTx` | yes | yes, fees included |
381
- | recipient | yes | yes, if `scope.recipients` has one entry (`openSession` reads it from the scope) |
382
- | rate limit | — | yes, per cell (`min_interval` blocks) |
383
- | revocation | — | only by an owner transaction (`closeSession` / `recoverSessions`) |
384
-
385
- ### If a session key leaks
386
-
387
- The key cannot be revoked by expiry: after `expiresAt` it still satisfies the lock
388
- until the owner acts. Until then it can move at most `max_per_tx` per transaction,
389
- only to the recipient if one is set, and, with a rate limit, once per
390
- `min_interval` blocks per session cell. The owner should sweep at once:
391
-
392
- - with the key still at hand: `closeSession(wallet, session, deployment, binding)`;
393
- - without it (device lost or wiped): `recoverSessions(wallet, deployment)` finds
394
- every session the wallet opened and sweeps them, without the key.
395
-
396
- An owner sweep is accepted at any time and ignores the scope. It is not certain
397
- to beat a spend already racing it: the key holder can replace a pending sweep by
398
- paying a higher fee, so sweep early, and resweep what is left. Keep budgets
399
- small: the budget is the most a session can ever lose.
400
-
401
- **Owner and session locks must need a signature.** The lock trusts that an input
402
- under the owner's lock means the owner signed. Plain secp256k1, JoyID and
403
- Omnilock are fine; an anyone-can-pay owner lock is not, since anyone may spend
404
- into it and would get owner mode.
405
-
406
- A session-lock cell occupies 121 CKB (153 CKB with a recipient), so its balance
407
- and its change must stay above that.
408
-
409
- ### Enforced by the real network
410
-
411
- Deployed on testnet: code hash `0xab419466…4c08ce` (`data2`, [deploy tx](https://testnet.explorer.nervos.org/transaction/0xf3098bdb9a7bb1c500990be0f1159b7e66f507fef6b7a4f904046024284877f2)),
412
- see [`deployment/testnet.json`](deployment/testnet.json). The code is immutable.
413
- Earlier deployments (8 Oct, before errors 16–18; 13 Oct, before token sessions)
414
- are kept in `TESTNET_LEGACY_DEPLOYMENTS`, so `recoverSessions` still sweeps
415
- sessions opened under them.
416
-
417
- Verify the deployed lock yourself. The deployed binary was built with rustc
418
- 1.99.0 on the x86_64-pc-windows-gnu host (pinned in
419
- [`rust-toolchain.toml`](contracts/session-lock/rust-toolchain.toml)), and a
420
- rebuild there is byte-identical to the code on chain. On Windows, from the
421
- repository root:
422
-
423
- ```sh
424
- rustup toolchain install 1.99.0-x86_64-pc-windows-gnu --profile minimal --target riscv64imac-unknown-none-elf
425
- (cd contracts/session-lock && cargo +1.99.0-x86_64-pc-windows-gnu build --release --locked)
426
- npm ci && npm run check:lock-hash
427
- ```
428
-
429
- The last command compares the build's data hash with `codeHash` in
430
- `deployment/testnet.json` and fails on any difference; CI's `lock-hash` job runs
431
- the same three steps. Another host or rustc builds a working lock with other
432
- bytes, so its hash will not match.
433
-
434
- `npm run smoke:lock` runs every rule against public testnet. Run on 14 Oct 2026, on the current lock:
435
-
436
- | Step | Network said |
437
- |---|---|
438
- | Owner funds three session cells and a key cell | [accepted](https://testnet.explorer.nervos.org/transaction/0x12a553c12e733a50f18db09edf8112031c9e39b1949e1f11699a67e3e30ba350) |
439
- | Session key spends exactly `max_per_tx` (100 CKB), no owner signature | [accepted](https://testnet.explorer.nervos.org/transaction/0xe2fced3a5d73059465f0b9400acdf0cba3fc27e441b82d1faebcbe44b1b20a8b) |
440
- | Session key spends 100 CKB + 1 shannon | **rejected**, error 12 (outflow exceeds max_per_tx) |
441
- | Rate-limited cell spent with no `since` | **rejected**, error 14 (rate limited) |
442
- | Same cell with `since` = 3 relative blocks | [accepted once mature](https://testnet.explorer.nervos.org/transaction/0xe53e4ada7f76d847b7e1883115c9b31851d8962e195cc2eb1fdaeb53e6b07653) |
443
- | Change split into two cells | **rejected**, error 16 (too many change cells) |
444
- | Recipient-scoped cell, 50 CKB moved into the key cell | **rejected**, error 17 (key cell may not grow) |
445
- | Change given a type script (a valid Type ID) | **rejected**, error 18 (no type scripts) |
446
- | Owner sweeps everything back | [accepted](https://testnet.explorer.nervos.org/transaction/0x8019404b3708050660b921201ec534cc7f20ef68fa1e836012d33f531066bae5) |
447
-
448
- Cycles, measured with [ckb-cycle-tools](https://github.com/luongthanhtung03/ckb-cycle-tools)
449
- on the accepted session spend: the session lock 15,651, the key cell's
450
- secp256k1 lock 1,652,327, matching the node's 1,667,978 exactly. On a token
451
- payment from the /tokens page's flow: the session lock 29,031, xUDT 18,795, the
452
- creator's anyone-can-pay lock 34,106, the key 1,642,571 (1,724,503 in all).
453
-
454
- ### v0.1 browser flow, verified on public testnet
455
-
456
- `npm run smoke:testnet` funds a fresh session, spends inside its scope, and checks
457
- that an over-limit spend is refused. Run on 8 Oct 2026:
458
-
459
- | Step | Result |
460
- |---|---|
461
- | Fund the session (300 CKB) | [0x2acf…c45f](https://testnet.explorer.nervos.org/transaction/0x2acff2323c07cb36dd9d58343df83277cdab68725541020acbff4dc968d0c45f) |
462
- | Spend 100 CKB, signed by the session key | [0x52a1…da50](https://testnet.explorer.nervos.org/transaction/0x52a1b3ba45a28614c30ddc52f0795c164141abbebd1558e6c69fb0ac45fada50) |
463
- | Spend 150 CKB (limit 100) | refused before signing |
464
-
465
- ## Limits
466
-
467
- What this does not do, in one place. Each is stated where it applies above.
468
-
469
- - **Testnet only, not audited.** The lock has had community and adversarial
470
- reviews, each finding fixed with a regression test, but no external audit.
471
- Do not put mainnet funds behind it.
472
- - **Expiry is not on-chain.** A CKB script cannot prove that time has *not*
473
- passed, so `expiresAt` is checked in the browser. A leaked key stays valid
474
- until the owner sweeps; the session's balance is the most it can lose.
475
- - **The rate limit is per cell.** `openSession` makes one cell and the lock
476
- forbids splitting, but anyone adding more cells under the same session lock
477
- gives the key more cells to spend from (with their own money, never the owner's).
478
- - **The key cell is not recoverable without the key.** `recoverSessions` sweeps
479
- every session cell with the wallet alone; the 62–64 CKB key cell needs the
480
- lost key.
481
- - **An owner sweep can be raced.** A key holder can replace a pending sweep by
482
- paying a higher fee. Sweep early and sweep again.
483
- - **Paid access is checked at zero confirmations.** `verifyAccess` accepts a
484
- payment once it is committed in a block; a reorg could undo it after the
485
- content was released. Fine for articles and pixels, not for anything costly.
486
- - **An access proof does not expire.** It is a bearer token for one article:
487
- whoever holds the signed proof can fetch that article again. It cannot be
488
- used for another article or by another payment.
489
- - **Owner locks must need a signature.** An anyone-can-pay owner lock would give
490
- anyone owner mode; `openSession` refuses one.
491
- - **Subscription periods are in blocks, and charges need the creator online.**
492
- The lock's rate limit is a block count, so a period is an estimate of time
493
- (`blocksForSeconds`). Nothing charges by itself: the creator's server must
494
- send each charge, one transaction per subscriber. Cancelling ends access at
495
- once, and the current period's price is not refunded.
496
- - **Sessions are not cheap to open.** A session cell occupies 121 CKB (153 with a
497
- recipient), returned on close; small payments go to anyone-can-pay cells
498
- because a new cell needs 61 CKB.
499
-
500
- ## Why it is a separate repository
501
-
502
- The session problem has nothing to do with any one application. Anything that wants
503
- to act repeatedly on a user's behalf in a browser has it, so it should not be buried
504
- inside one application's source tree.
505
-
506
- ## Related work
507
-
508
- Other CKB projects work on nearby problems. This is what each one does, as
509
- described in its own public post or repository, and where this kit differs.
510
-
511
- - **CKAutomata** ([forum post](https://talk.nervos.org/t/introducing-ckautomata-payment-automation-and-nervos-dao-harvest-on-ckb/10813),
512
- [CKBuilder review issue](https://github.com/Nervos-Community-Catalyst/CKBuilder-projects/issues/43)):
513
- one-time, scheduled and recurring CKB payments, and Nervos DAO harvest. Each
514
- automation is a cell holding its rules, schedule and funds; the user signs once
515
- to set it up, and an executor submits each payment when it is due, without
516
- being able to change amount, recipient or schedule. The overlap is with
517
- subscriptions here: both let a payer sign once and be charged later while
518
- offline. The differences: here the subscription is one use of a general
519
- session lock, the creator's own server holds the key and pulls each charge,
520
- and `subscriptionStatus` lets that server gate content on-chain with no
521
- database. This kit does not schedule payments for third-party executors and
522
- does nothing with the Nervos DAO.
523
- - **QuotaCell** ([forum post](https://talk.nervos.org/t/quotacell-let-your-team-spend-without-handing-over-the-treasury/11002),
524
- [repository](https://github.com/Ticoworld/QuotaCell)): a shared treasury
525
- allowance for a team. Several delegates, each with their own key, a personal
526
- limit and a per-payment ceiling, draw from one cumulative allowance that only
527
- the owner can renew, enforced by an on-chain policy cell; agent spending is a
528
- use it is exploring. The session lock also enforces spend limits on-chain, but
529
- for a different shape: one user, one short-lived key generated and held in that
530
- user's browser, a fixed recipient and a rate limit, so an app can act without
531
- wallet popups. It has no notion of several delegates sharing one allowance.
532
- - **FiberLatch Access** ([forum post](https://talk.nervos.org/t/dis-fiberlatch-access-open-source-access-control-for-fiber-payments/10414))
533
- and Fiber's **Encrypted Data Payment** pattern
534
- ([docs](https://www.fiber.world/docs/build/encrypted-data-payment)): access to
535
- content paid for over Fiber. FiberLatch issues and verifies signed access
536
- receipts once an app has accepted a payment; in the Fiber pattern, the invoice
537
- preimage is the key that decrypts the data. Pay-per-read here runs on CKB L1
538
- instead: no channel to open, each payment is an ordinary transaction signed by
539
- the session key inside the on-chain scope, and the server checks the payment on
540
- chain (`verifyAccess`). The trade-off is that every L1 payment waits for a block
541
- and pays a fee, where a Fiber payment settles off-chain once a channel is open.
542
- - **Wallet and login projects**: [JoyID](https://docs.joyid.dev/) (passkey
543
- wallet), [Corven Connect](https://corvanide.space/connect/docs/introduction)
544
- (Google and passkey sign-in with an embedded CKB wallet) and
545
- [CKB KeyWay](https://github.com/officialcmg/ckb-keyway) (email login with a
546
- recoverable CKB account, plus Fiber). These create or unlock the user's
547
- account. This kit does not: it starts from a wallet the user already has and
548
- removes the repeated signing after that, so it can sit on top of any of them.
549
- A JoyID owner works today (tested by hand); the other two have not been tested
550
- as session owners.
551
-
552
- ## Licence
553
-
554
- MIT. See [LICENSE](LICENSE).
555
-
556
- ## Author
557
-
558
- Luong Thanh Tung — [@luongthanhtung03](https://github.com/luongthanhtung03)
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)