ckb-session-kit 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,305 @@
3
3
  This project follows [semantic versioning](https://semver.org/). The session lock is
4
4
  deployed on CKB **testnet only**; there is no mainnet deployment yet.
5
5
 
6
+ ## 2.0.0 (2026-11-03)
7
+
8
+ ### Upgrading from 1.0.0
9
+ Every 1.0.0 export is still there with the same signature; new parameters are
10
+ optional. Some calls that 1.0.0 accepted are now refused, on purpose:
11
+ - `assertSigningLock`, `openSession`, `openSubscription` refuse an owner (or
12
+ charger) lock that opens with no signature: PW-Lock (its deployed build is
13
+ anyone-can-pay) and the Nostr lock in proof of work mode
14
+ (`reason: "owner-lock-unsafe"`; no option lets these through). Sessions
15
+ already open under such a lock should be closed or recovered now.
16
+ - They also refuse configurations not reviewed: Omnilock auth 0x07 or mode bits
17
+ other than time-lock (0x04) and supply (0x08), secp256k1, multisig or Nostr
18
+ locks with args their code would not accept, and any unknown lock
19
+ (`reason: "owner-lock-unreviewed"`). Pass `allowUnreviewedOwnerLock: true`
20
+ once you have checked the lock to get the 1.0.0 behaviour back, with a warning;
21
+ for the creator's charger lock in `openSubscription`, pass
22
+ `allowUnreviewedChargerLock: true`.
23
+ - Anyone-can-pay, PW-Lock and always-success referenced by data hash
24
+ (`data`, `data1`, `data2`) are refused like their type-hash versions.
25
+ - A send whose outcome cannot be checked throws `SendOutcomeUnknownError`
26
+ (with the tx hash); it is never retried, so check the hash before resending.
27
+ - `openSession` and `openSubscription` refuse `TESTNET_LEGACY_DEPLOYMENTS`,
28
+ `hashType: "data"`, and a deployment whose cell dep does not hold its code on
29
+ the client's network (one `getCellLive` per client and deployment).
30
+ - `closeSession` refuses a signer whose lock is not the session's owner lock.
31
+ - `openSession` with no rate limit, or `minInterval` 1, calls `console.warn`
32
+ (or `opts.onWarning`).
33
+ - `SessionPolicyError` and `SessionBalanceError` carry a `reason`; code that
34
+ matched error messages should match `reason` instead.
35
+
36
+ The wallet matrix's locks (secp256k1, Omnilock 0x12 / Bitcoin / Dogecoin,
37
+ Nostr signing mode, JoyID) all pass unchanged.
38
+
39
+ ### Fixed (Week 6 adversarial review, 2 Nov)
40
+ - Token subscriptions: look-alike cells typed `xUDT(token.args ++ extra)`
41
+ (xUDT accepts them, anyone can create them with 0 units, and the indexer's
42
+ type filter matched them by prefix) counted toward `MAX_SUBSCRIPTION_CELLS`
43
+ but were never swept. The filter now asks for the exact script length, only
44
+ cells really of the subscription's kind count, raw reads are bounded, and
45
+ `clearPlantedCells` and `cancelSubscription` sweep spendable look-alikes back
46
+ to the subscriber (M1).
47
+ - A status or a charge counted every recent transaction under the lock toward
48
+ its budget of 64, creations by anyone included, and before the age check.
49
+ Now only spends count, an entry older than the period is never counted, and
50
+ creation-only entries are paged past under their own budget of 1024
51
+ (`MAX_CREATED_HISTORY`); past either budget it still refuses (M2).
52
+ - `clearPlantedCells`: sweeps at most 30 cells per transaction, so a status
53
+ never dates the cleanup as a charge (L3); with cells left unread, sweeps the
54
+ planted cells it read instead of refusing (L1); marks its transaction with
55
+ `CLEAR_PLANTED_MEMO` in the subscriber's own witness, so a status does not
56
+ take it for a cancel and a charge of the whole balance stays paid (L2).
57
+ - `signAndSend` throws `SendOutcomeUnknownError` (new export) when a send fails
58
+ and the status lookup fails too, instead of rethrowing the send's error: a
59
+ `Dead` answer from CCC's fallback node after a lost reply was retried and
60
+ could pay twice (L4). It is never retried.
61
+ - Payout cells under `payTo` are listed by the indexer's filter (plain and
62
+ empty, or exactly the token with 16 data bytes), so junk planted under an
63
+ anyone-can-pay address no longer pushes the real cells out of reach (L5).
64
+ - `assertSigningLock` refuses anyone-can-pay, PW-Lock and always-success by the
65
+ data hash of their deployed binaries under every data hash_type (`data`,
66
+ `data1`, `data2`), not only by type id (L6).
67
+
68
+ ### Changed (Week 6 adversarial review, 2 Nov)
69
+ - `openSubscription` checks the creator's charger lock with its own opt-in,
70
+ `allowUnreviewedChargerLock`; `allowUnreviewedOwnerLock` now covers the
71
+ subscriber's lock only (I1).
72
+ - `openSession` raises `no-rate-limit` for `minInterval` 1 too (I3).
73
+ - The testnet scripts keep `.env.smoke` at the repository root whatever the
74
+ current directory (I4).
75
+ - Docs: a multisig with a since or an Omnilock time-lock also delays the
76
+ owner's sweep (I2).
77
+
78
+ ### Added (mentor review, tracker #47, 2 Nov)
79
+ - Payout cell contention. A top-up (`spendInSession` with `topUp`,
80
+ `chargeSubscription`, `openSubscription`) picks the recipient's cell with
81
+ `pickPayoutCell`: confirmed cells first, shuffled, and the first free one of
82
+ up to `MAX_POOL_CHECKS` (4) cells checked with `get_live_cell` and
83
+ `include_tx_pool`, so a cell another payment in the node's pool spends is
84
+ skipped. At most `MAX_PAYOUT_CELLS` (64) of the recipient's cells are read.
85
+ - `spendInSession` and `chargeSubscription` take `retry: { attempts, delayMs }`
86
+ (`RetryOptions`, `DEFAULT_RETRY`: 3 attempts, 1000 ms doubling, with jitter).
87
+ A send the node refuses for a spent input (`Dead`, `Unknown`, or an RBF
88
+ refusal, `poolConflict`) is built again with another payout cell; the cell a
89
+ `Dead` refusal names is not picked again. It used to be retried once, with no
90
+ wait, and only with a client cache. A send whose outcome is unknown is still
91
+ never retried.
92
+ - `SessionBalanceError` reason `"payout-busy"`: every payout cell checked is
93
+ spent in the pool and none is left. Nothing is sent; it is retried like a
94
+ refusal. The demo's charge route answers it with 503 and retry.
95
+ - `createPayoutCells(funder, payTo, { count, capacity?, token? })` gives an
96
+ anyone-can-pay address more cells to top up. `npm run setup:creator` uses it
97
+ and now gives the demo's creator eight cells (was three).
98
+ - `clearPlantedCells(subscriber, deployment, subscription)`: the subscriber
99
+ sweeps cells a third party created under the subscription's lock back to
100
+ itself, keeping the subscription's own cells, so it no longer has to cancel
101
+ and open again past `MAX_SUBSCRIPTION_CELLS`.
102
+
103
+ ### Changed (mentor review, 2 Nov)
104
+ - `assertSigningLock` accepts only reviewed owner lock configurations:
105
+ secp256k1, multisig (v1, v2), Omnilock with auth 0x00-0x06 or 0x12 and no
106
+ mode other than time-lock or supply, the Nostr lock in signing mode
107
+ (`args[0]` = 0), and JoyID. The wallet matrix's locks all still pass.
108
+ - PW-Lock is now refused. The build deployed on mainnet and testnet is
109
+ lay2dev/pw-lock's anyone-can-pay variant: with no signature in the witness it
110
+ unlocks by payment, whatever the args, so anyone could sweep every session.
111
+ - The Nostr lock in proof of work mode (`args[0]` not 0) is now refused: it
112
+ unlocks with work, not the holder's key.
113
+ - Anyone-can-pay is refused by code hash on both networks (and the older
114
+ deployments pw-core lists), whatever the client's network; always-success
115
+ too. These refusals carry `reason: "owner-lock-unsafe"`.
116
+ - Locks and configurations not reviewed (Omnilock auth 0xFC-0xFE, other Omnilock
117
+ auth flags or mode bits, the ckb-proxy-locks, unknown locks, args the lock's
118
+ code would not accept, multisig v2 beta) are refused with
119
+ `reason: "owner-lock-unreviewed"`. Omnilock auth 0x07 and unknown Omnilock mode
120
+ bits used to pass.
121
+ - Subscription cells are listed with the indexer's type filter (no type, or the
122
+ subscription's token), so cells under any other type script no longer count
123
+ toward `MAX_SUBSCRIPTION_CELLS`.
124
+ - The last charge is the newest spend with an input under the charger's lock,
125
+ not any spend: a `clearPlantedCells` does not delay the next charge. A spend
126
+ that cannot be told apart still counts as a charge.
127
+ - Chained payments are not used; the README says why.
128
+
129
+ ### Added
130
+ - `openSession` warns when a session has no rate limit (`minInterval` left out
131
+ or 0): every spend is within `maxPerTx`, but spends can repeat every block
132
+ until the session is empty. The warning (`SessionWarning`, code
133
+ `"no-rate-limit"`) goes to the new `opts.onWarning` before anything is
134
+ signed, which may throw to stop the opening, or to `console.warn` without one.
135
+ The demos' sessions open with no rate limit, so they now warn in the console.
136
+ - 23 CKB-VM tests of aggregate outflow across inputs (mentor review, 2 Nov):
137
+ cells with the same args share one limit per transaction (CKB and token), a
138
+ subscription's merged cells pay one price, cells with other args are checked
139
+ on their own, and owner mode sweeps several cells at once. No fault found; the
140
+ lock is unchanged.
141
+ - README and SECURITY-EVIDENCE: browser expiry revokes nothing on chain (funds
142
+ stay at risk until the owner's sweep is confirmed); a period is a block count,
143
+ not a fixed duration; the aggregate-outflow behaviour.
144
+ - `allowUnreviewedOwnerLock` (`SigningLockOptions`) on `assertSigningLock`,
145
+ `openSession` and `openSubscription`: lets an unreviewed owner or charger lock
146
+ through with a `console.warn`. It never lets an "owner-lock-unsafe" lock
147
+ through.
148
+ - `SessionPolicyReason` gains `"owner-lock-unsafe"` and `"owner-lock-unreviewed"`.
149
+ - `SessionBalanceError.reason` (`SessionBalanceReason`): `"no-fee-cell"` or
150
+ `"no-payout-cell"` when the cells missing are the payer's or the recipient's own
151
+ (a charger with no fee cell, a `payTo` with no cell to top up), so a server can
152
+ tell them from the subscription's state without matching messages. The demo's
153
+ charge route now uses it.
154
+ - `SessionPolicyError.reason` (`SessionPolicyReason`): `"payto-not-anyone-can-pay"`
155
+ when a charge needs to top up `payTo` and `payTo` is not anyone-can-pay. The
156
+ demo's charge route answers it with 503 (the deployment's setup, not the
157
+ subscription's state) instead of 409.
158
+
159
+ ### Fixed (audit round 2, 31 Oct)
160
+ - Demo: enforced CSP now also sets `object-src 'none'`, `base-uri 'none'` and
161
+ `form-action 'self'`; the full policy (scripts, styles, images, testnet RPC and
162
+ JoyID aggregator only) is sent as `Content-Security-Policy-Report-Only`.
163
+ - CI: GitHub actions pinned to commit SHAs, checkouts with
164
+ `persist-credentials: false`.
165
+ - `openSession` and `openSubscription` refuse `TESTNET_LEGACY_DEPLOYMENTS`
166
+ (`assertCurrentDeployment`, new). `openSession` used to accept them: a token
167
+ session under the 13 Oct lock was stuck for good (that code refuses 128/160-byte
168
+ args before owner mode), and new sessions under the 8 Oct one lacked errors
169
+ 16-18. Spending, closing, recovering, charging and cancelling still accept old
170
+ deployments, so funds under them can come back.
171
+ - Before building an opening, the deployment's cell dep is read from the
172
+ client's network (`getCellLive`; a dep group is resolved) and must hold code
173
+ hashing to `codeHash` (data hash, or type hash for `hashType: "type"`).
174
+ `TESTNET_DEPLOYMENT` on a mainnet client, a typo in `codeHash` or a wrong out
175
+ point used to put funds under a lock with no code. One lookup per client and
176
+ deployment. `hashType: "data"` is refused too: the lock faults under CKB-VM
177
+ version 0. There is still no mainnet deployment; README and API.md say how to
178
+ pass your own.
179
+ - `spendInSession` and `chargeSubscription`: a key or charger cell above its
180
+ floor but short of the fee threw CCC's plain "Insufficient CKB"; it is now
181
+ `SessionBalanceError` with reason `"no-fee-cell"`. The charger's fee cells must
182
+ hold at least the floor plus 0.01 CKB. Found by the round-2 property suite,
183
+ added in `test/property-library.test.ts` and `test/property-fuzz.test.ts`,
184
+ which now fails on any plain error.
185
+ - `addTopUp` (top-ups in `spendInSession`, `chargeSubscription`,
186
+ `openSubscription`) skips a payee token cell the payment would take past
187
+ 2^128 - 1 units, and throws `SessionBalanceError` `"no-payout-cell"` if every
188
+ cell would, instead of a plain overflow error.
189
+ - `closeSession` refuses a signer whose lock is not `binding.ownerLock` with a
190
+ `SessionPolicyError`, before asking it to sign. After a wallet switch, the
191
+ new wallet was shown a sweep of the old wallet's session to itself (the lock
192
+ refused it on-chain, so nothing was lost).
193
+ - `closeSession` can finish once the session is empty on-chain: with the
194
+ funding committed and nothing indexed, it asks the node whether the funding's
195
+ outputs under the session and the key are still live, and reports
196
+ `nothingFound` when all are spent. Before, a close whose key return was taken
197
+ but whose reply was lost (or a close from a second tab) left a key that could
198
+ never be forgotten. The key return now goes through `signAndSend`, so a lost
199
+ reply the node did take is reported as `keyTx`.
200
+ It reports `nothingFound` only once the node's indexer has reached the tip
201
+ and a second search still finds nothing, so a later spend's key cell that is
202
+ not indexed yet never lets the key be forgotten.
203
+ - CCC's client cache lists a client's own outputs as live until cleared, so a
204
+ tab (or a warm server instance) built every later spend on cells another tab
205
+ had spent, and the node rejected each one. `spendInSession`, `closeSession`,
206
+ `chargeSubscription` and `cancelSubscription` now leave out cached-only cells
207
+ the node no longer has, and on a rejection for a spent or unknown input clear
208
+ the cache and build the transaction once more.
209
+ - `verifyAccess`, `verifySubscription` and `subscriptionStatus` read payments
210
+ with `getTransactionNoCache`. `getTransaction` answers from the cache when
211
+ the node no longer knows a transaction, so a payment that was reorged out kept
212
+ granting access on a long-lived server client.
213
+ - Demo: one "unknown" answer just after a lost send no longer makes a page
214
+ forget the session key (about 62 CKB stranded once the funding lands) or a
215
+ payment record. A key or record is forgotten only when the node rejected the
216
+ transaction, or has not known it for 10 minutes (the rule the payment polls
217
+ already used); meanwhile the page keeps polling it.
218
+ - Demo: every page's wallet lookup drops a late answer from the previous
219
+ wallet, and the subscribe page drops a status refresh started for the
220
+ previous wallet, asset or opening, and clears its status and notes on
221
+ disconnect.
222
+ - Demo: the CKB Place board asks again about a placement `get_transaction`
223
+ missed once, instead of leaving it off the board until a reload.
224
+ - Demo: the charge route builds a new CCC client per request.
225
+
226
+ ### Fixed (security audit, 30 Oct)
227
+ - `recoverSessions`: when the one sweep is refused, sessions are now swept one
228
+ session lock at a time (full lock hash), not one session key hash at a time. A
229
+ planted cell under other args carrying the victim's public key hash (a foreign
230
+ type script the sweep cannot consume) joined the victim's group and blocked its
231
+ recovery on every retry. `findOwnedSessionCells` keeps a typed cell only if its
232
+ type is a plain xUDT on the network (`assertPlainXudt`'s rules) holding exactly
233
+ the 16-byte amount a session makes.
234
+ - `spendInSession` pays the fee from the largest key cell (ties: lowest out
235
+ point), not the first one listed: a 61 CKB cell anyone sent to the key address,
236
+ or the old key cell beside a top-up, blocked every spend. Still one key cell in
237
+ and one smaller one out, as the lock requires.
238
+ - `verifyAccess` and `checkPlacement` count only memos in witnesses a signature
239
+ covers: the first witness of each secp256k1 lock group, and any past the
240
+ inputs. The session cell's and an anyone-can-pay cell's witnesses are signed by
241
+ nobody, so whoever relayed a payment could add a memo there and void it for
242
+ good (or, on a CKB Place board, choose the pixels of a payment without one).
243
+ Several signed memos still fail. `placementMemo` takes the resolved inputs as
244
+ an optional third argument for the exact check; without them it is a prefilter.
245
+ - Docs: with a recipient set, up to `max_per_tx` per transaction can still leave
246
+ as the miner fee (the recipient rule covers outputs only). The README and
247
+ `openSession` now say so and recommend a rate limit (`minInterval`) whenever a
248
+ recipient is set. The lock is unchanged.
249
+ - `subscriptionStatus`, `chargeSubscription` and `findSubscriptionCells` read at
250
+ most `MAX_SUBSCRIPTION_CELLS` (16, exported) cells under a subscription's lock,
251
+ and at most 64 recent transactions of its history. Beyond that they throw
252
+ `SessionPolicyError` with the new reason `"too-many-cells"` instead of reading
253
+ part of the cells, which could deny a paid-up subscriber or let a cell left out
254
+ of a charge pay a second price in the period. Planted cells (anyone can create
255
+ them) made each status request look up every creating transaction and all its
256
+ inputs again: 100 cells cost 3,307 RPC calls. `cancelSubscription` still reads
257
+ and sweeps every cell, so the subscriber can clear planted ones.
258
+ - Subscription payments are checked once per transaction within a call, and a
259
+ transaction whose outputs to `payTo` already fall short is refused before its
260
+ inputs are looked up.
261
+ - `chargeSubscription` picks its fee cell from the first 64 cells under the
262
+ charger's lock instead of reading all of them.
263
+ - Demo: `/api/subscription` answers `"too-many-cells"` with 409 (the page shows
264
+ it), not 503 with retry.
265
+ - Demo: the charger address is the creator's key, so `CHARGER_KEY` on the server
266
+ could spend the creator's income and payout cells. New `npm run setup:charger`
267
+ creates a separate key (`.env.charger`, written once, mode 0600) and prints its
268
+ address and next steps; the docs now require a charger key of its own holding
269
+ only fee cells. `CHARGER` is replaced once that key is funded and set on Vercel.
270
+
271
+ ### Fixed (30 Oct)
272
+ - `chargeSubscription` and `nextChargeIn` date the last charge from the
273
+ subscription lock's indexer history, not from its change cell: a charge of the
274
+ whole balance (the last 153 CKB with a price of 153 CKB or more) leaves no
275
+ change, so a younger top-up could pay a second price under a period later.
276
+ - `subscriptionStatus` counts that last whole-balance charge as a paid period;
277
+ it used to end access as soon as the last period was paid. A cancel still ends
278
+ access at once.
279
+
280
+ ### Fixed (code review, 28 Oct)
281
+ - Token overflow guard: a planted cell of 2^128-1 free tokens could push the
282
+ subscriber's own cell out of a cancel (the guard kept cells in indexer order), and
283
+ blocked `closeSession` and `recoverSessions` of token sessions (no guard at all).
284
+ One guard now serves sessions, recovery and subscriptions; it keeps cells created
285
+ by the owner or the key first.
286
+ - `recoverSessions`, `closeSession`, `cancelSubscription`: an owner sweep the node
287
+ accepted is reported as sent even when the reply times out (it skipped the
288
+ check `signAndSend` does). `recoverSessions` now throws when the owner holds no
289
+ plain cell, instead of reporting every session as failed.
290
+ - `chargeSubscription` waits a full period after the last charge, not only after
291
+ the oldest cell: a top-up cell older than the last charge's change could pay a
292
+ second price in the same period. `nextChargeIn` counts the same way.
293
+ - Session stores: a save of the same key without the on-chain binding (from a
294
+ stale tab) is refused with `SessionStoreConflict` instead of dropping the binding.
295
+ - Demo: the charge route answers 503 with `retry` when the creator's own cells are
296
+ missing (no fee cell for the charger, no payout cell to top up), not 409.
297
+ - `chargeSubscription` pays its fee from a random one of the charger's cells, and a
298
+ CKB `openSubscription` tops up a random payout cell, so concurrent charges and
299
+ openings rarely compete for one cell.
300
+
301
+ ### Changed
302
+ - `verifyAccess` resolves a payment's inputs in parallel once the memo check
303
+ passes; subscription charges and status fetch each creating transaction once.
304
+
6
305
  ## 1.0.0 (2026-10-27)
7
306
 
8
307
  First stable release. Everything below is on testnet, with a smoke script and explorer
package/README.md CHANGED
@@ -38,6 +38,42 @@ Its first example application is pay-per-read: a reader unlocks articles for
38
38
  1 CKB each with no wallet prompt, and the session can only ever pay the creator.
39
39
  The problem is not specific to that application.
40
40
 
41
+ ## Install
42
+
43
+ ```bash
44
+ npm install ckb-session-kit
45
+ ```
46
+
47
+ [ckb-session-kit on npm](https://www.npmjs.com/package/ckb-session-kit) (v2.0.0, ESM, Node 20 or later
48
+ and browsers). The API is in [docs/API.md](docs/API.md); the walkthrough in
49
+ [docs/WALKTHROUGH.md](docs/WALKTHROUGH.md).
50
+
51
+ New in 2.0.0 (details and upgrade notes in [CHANGELOG.md](CHANGELOG.md)):
52
+
53
+ - `openSession(owner, session, deployment, { ..., onWarning })`: called with a
54
+ `SessionWarning` (`"no-rate-limit"`) before anything is signed; throw from it
55
+ to stop the opening. Without it the warning goes to `console.warn`.
56
+ - `SigningLockOptions` (`{ allowUnreviewedOwnerLock?: boolean }`), taken by
57
+ `assertSigningLock`, `openSession` and `openSubscription`: owner locks are
58
+ checked against a list of reviewed configurations (see
59
+ [Which wallets can own a session](#which-wallets-can-own-a-session)). Refusals
60
+ carry `reason: "owner-lock-unsafe"` (never allowed) or
61
+ `"owner-lock-unreviewed"` (allowed, with a warning, by this option).
62
+ - `RetryOptions` (`{ attempts?, delayMs? }`, defaults in `DEFAULT_RETRY`: 3
63
+ attempts from 1000 ms, doubling, with jitter), passed as `retry` to
64
+ `spendInSession` and `chargeSubscription`: a send the node refuses for a spent
65
+ input is built again with another payout cell (`poolConflict` tells an RBF
66
+ refusal apart). The retry loop itself is internal (not exported).
67
+ - `pickPayoutCell(client, lock, cells, avoid?)`: the payout cell a top-up uses,
68
+ confirmed and free in the node's pool (`MAX_POOL_CHECKS`, `MAX_PAYOUT_CELLS`).
69
+ When none is free, `SessionBalanceError` with reason `"payout-busy"`.
70
+ - `createPayoutCells(funder, payTo, { count, capacity?, token? })`: more
71
+ anyone-can-pay cells for a creator, so concurrent payments do not compete.
72
+ - `clearPlantedCells(subscriber, deployment, subscription)`: sweeps cells a third
73
+ party created under a subscription's lock, keeping the subscription's own.
74
+ - `assertCurrentDeployment(client, deployment)`, `MAX_SUBSCRIPTION_CELLS`, and the
75
+ `SessionPolicyReason` / `SessionBalanceReason` types for the errors' `reason`.
76
+
41
77
  ## Try it
42
78
 
43
79
  ```bash
@@ -131,7 +167,9 @@ this work on CKB:
131
167
  The anyone-can-pay lock accepts the top-up without the creator's signature.
132
168
  - **A stolen key can only pay the creator.** The session's recipient is the
133
169
  creator's anyone-can-pay address, so the session lock refuses any other output,
134
- and `max_per_tx` caps each unlock.
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.
135
173
 
136
174
  ```ts
137
175
  const session = createSession({ expiresAt, scope: { maxPerTx: 5n * CKB, recipients: [creatorAcp] } });
@@ -177,8 +215,22 @@ then unlock articles for 1 CKB each with no popup.
177
215
 
178
216
  A session cell with a recipient occupies 153 CKB, so a 200 CKB session has 47 CKB
179
217
  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.
218
+ anyone-can-pay cells, so readers paying at the same moment compete for them. A
219
+ top-up picks a confirmed cell that no transaction in the node's pool spends
220
+ (`pickPayoutCell`; at most 4 cells checked, 64 read). If the node still refuses
221
+ the payment for a spent cell, it is built again with another cell after a short
222
+ wait, up to 3 attempts by default (`retry: { attempts, delayMs }`). A send whose
223
+ outcome is unknown is never retried. When every cell is busy, nothing is sent
224
+ and the error says `"payout-busy"`. Give a creator more cells with
225
+ `createPayoutCells(funder, payTo, { count })`; `npm run setup:creator` gives the
226
+ demo's creator eight.
227
+ `npm run smoke:contention` checks it on testnet: four payers pay one creator at the same moment, every payment must commit, and a hand-built double top-up shows the node's own refusal.
228
+
229
+ Chained payments (topping up a cell another payment created while that payment
230
+ is still in the pool) are not used: if the first payment is dropped or slow to
231
+ propagate, every payment built on it is stuck or dropped too, and a reader's
232
+ session would wait on someone else's transaction. More cells and a pool-aware
233
+ pick avoid that.
182
234
 
183
235
  ## Example: CKB Place, a pixel board on testnet
184
236
 
@@ -283,6 +335,13 @@ subscriber's money held; the creator's side did not, and the library now covers
283
335
  - access follows payments, not the live cell, so re-creating the cell to reset its age gains nothing;
284
336
  - the opening commits to an account id (hashed) under the subscriber's signature, so nobody can present someone else's subscription.
285
337
 
338
+ Several mature cells of one subscription can be merged in one charge, and
339
+ together they still pay at most one price. Each subscription is checked on its
340
+ own: two subscribers' cells charged in one transaction are refused (error 13),
341
+ since each sees the other's change as a third party, so a creator sends one
342
+ charge per subscriber. A subscriber's cancel sweeps every cell of the
343
+ subscription in one transaction.
344
+
286
345
  A charge costs the session lock 20,999 cycles.
287
346
 
288
347
  `npm run smoke:subscription` runs it on testnet. Run on 26 Oct 2026:
@@ -309,6 +368,28 @@ ran it on 27 Oct 2026 with the demo token:
309
368
  **Try it in the browser:** [ckb-session-kit.vercel.app/subscribe](https://ckb-session-kit.vercel.app/subscribe):
310
369
  2 CKB per 60 blocks; members' notes the server releases only while you are paid up.
311
370
 
371
+ **Deploying it.** The server that charges holds the charger key (`CHARGER_KEY` in
372
+ the deployment's environment, on Vercel for the demo). Make it a **key of its
373
+ own, never the creator's key** or the key behind the payout address: the lock
374
+ limits what the charger can take from subscriptions, but nothing limits what a
375
+ key can do with its own cells, so a leak of the server's environment would also
376
+ hand over all the creator's income. Give it only a few fee cells. `npm run
377
+ setup:charger` creates one in `.env.charger` (gitignored) and prints its address
378
+ and the next steps; nothing is sent. A status or a charge reads at most
379
+ `MAX_SUBSCRIPTION_CELLS` (16) cells under one subscription's lock and refuses
380
+ beyond that (`SessionPolicyError`, reason `"too-many-cells"`), so planted cells
381
+ cannot make one request cost thousands of RPC calls. Only cells of the
382
+ subscription's kind count (plain CKB, or exactly its token): the indexer leaves
383
+ other cells out, including look-alikes of the token with longer args. The
384
+ subscriber clears planted ones without cancelling:
385
+ `clearPlantedCells(wallet, deployment, subscription)` sweeps every cell not made
386
+ by the opening, a charge or the subscriber (and any look-alike of the token)
387
+ back to the subscriber, at most 30 per transaction, one signature each, and
388
+ leaves the subscription's own cells as they are. Only the subscriber's and the
389
+ charger's spends count toward the 64 recent transactions a status reads;
390
+ creations by anyone else are paged past, up to 1024 per period.
391
+ `npm run smoke:clear-planted` checks it on testnet: 16 planted cells block the status and charges, `clearPlantedCells` clears them, and the next charge stays on time.
392
+
312
393
  ## Which wallets can own a session
313
394
 
314
395
  The owner signs to open, close and recover, so the owner's wallet must work. `npm
@@ -331,6 +412,30 @@ Ethereum wallets. Fixed, with a test built from the lock CCC derives for an
331
412
  Ethereum account. **JoyID** signs with a passkey and cannot be scripted; it was
332
413
  tested by hand in the demo (10 Oct).
333
414
 
415
+ `openSession` and `openSubscription` check the owner's lock first
416
+ (`assertSigningLock`). The session lock gives owner mode to any transaction that
417
+ spends a cell under the owner's lock, so a lock that opens without the owner's
418
+ signature would let anyone sweep every session. Since 2 Nov the check is a list
419
+ of reviewed configurations:
420
+
421
+ | Owner lock | Result |
422
+ |---|---|
423
+ | secp256k1 (CKB default), secp256k1 multisig, with or without a since | accepted |
424
+ | Omnilock, auth 0x00-0x06 or 0x12, no mode or only time-lock or supply | accepted |
425
+ | Nostr lock, signing mode (`args[0]` is 0) | accepted |
426
+ | JoyID | accepted (its code is closed: reviewed from its documentation) |
427
+ | Anyone-can-pay, PW-Lock, always-success (by type id, or their deployed binaries by data hash) | refused: they open with no signature |
428
+ | Omnilock in anyone-can-pay or administrator mode | refused |
429
+ | Nostr lock in proof of work mode (`args[0]` not 0) | refused: anyone who does the work can unlock it |
430
+ | Omnilock auth 0xFC-0xFE, the proxy locks, any other lock | refused unless you pass `allowUnreviewedOwnerLock: true` (`allowUnreviewedChargerLock` for a subscription's charger) |
431
+
432
+ PW-Lock is refused because the build deployed on CKB is its anyone-can-pay
433
+ variant: a transaction with no signature in the witness takes the payment path,
434
+ whatever the args. MetaMask and other Ethereum wallets get an Omnilock (0x12)
435
+ from CCC, which is accepted. `allowUnreviewedOwnerLock` lets an unreviewed lock
436
+ through with a warning; use it only once you have checked that the lock cannot
437
+ open without its holder's signature. It never lets a refused lock through.
438
+
334
439
  ## The on-chain session lock
335
440
 
336
441
  [`contracts/session-lock`](contracts/session-lock/src/main.rs) is a CKB lock script in
@@ -341,15 +446,28 @@ Rust. A cell under it can be spent two ways:
341
446
  - **Session mode** — the transaction also spends the session key's *key cell*. The
342
447
  network then enforces, on-chain:
343
448
  - **outflow limit** — at most `max_per_tx` shannons leave per transaction, fees
344
- included, so the fee cannot be used to drain the cell;
449
+ included, so the fee cannot be used to drain the cell. The limit is for all
450
+ the session's cells together: cells under the same lock (same args) are one
451
+ script group, so taking `max_per_tx` from each of three cells is refused.
452
+ Cells of another session or subscription (other args) are checked on their
453
+ own, against their own limit and recipient; one session's unused limit never
454
+ covers another's;
345
455
  - **recipient allowlist** (optional) — payments may only go to one lock (plus
346
456
  change, the key cell, and the owner). The key cell may come back smaller
347
457
  (it pays the fee) but never larger, so the key cannot park session funds in
348
- its own cell and pay anyone from there;
458
+ its own cell and pay anyone from there. The fee is not an output, so the
459
+ allowlist does not see it: a leaked key can still give up to `max_per_tx`
460
+ per transaction to the miner as fee, paying the recipient nothing. Set a
461
+ rate limit (`min_interval`) whenever you set a recipient;
349
462
  - **rate limit** (optional) — each session cell must have aged `min_interval`
350
463
  blocks before it can be spent again (relative `since`). The limit is **per
351
464
  cell**: with *n* session cells, the key can spend from each of them in the same
352
- block. `openSession` creates one cell;
465
+ block. `openSession` creates one cell. With `min_interval` 0 there is no
466
+ rate limit: every spend is within `max_per_tx`, but spends can repeat in
467
+ every block until the cell is empty, and `openSession` warns
468
+ (`SessionWarning`, `"no-rate-limit"`; also for `min_interval` 1, almost the same). The interval is a number of blocks,
469
+ not a fixed time: block time varies (see the
470
+ [average block time](https://explorer.nervos.org/charts/average-block-time));
353
471
  - **no splitting** — a session spend may send at most one cell back to the
354
472
  session lock, so the key can merge session cells but never multiply them,
355
473
  which makes the per-cell rate limit a per-session one for sessions opened
@@ -364,13 +482,16 @@ Rust. A cell under it can be spent two ways:
364
482
  Signatures are delegated to the standard secp256k1 locks of the owner and the
365
483
  session key (the pattern sUDT's owner mode uses), so the script carries no
366
484
  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
485
+ a session spend (29,031 on a token spend, 1.7%). 144 tests run it in the real CKB-VM, most asserting a specific
368
486
  rejection code, and CI builds the contract and requires them.
369
487
 
370
488
  **What it cannot do:** enforce an expiry time. A CKB script can prove time has
371
489
  passed (`since` is a lower bound) but never that it has not. Expiry is enforced
372
490
  in the browser; the owner can revoke on-chain at any moment, and the session
373
- cell's balance caps the total at risk.
491
+ cell's balance caps the total at risk. **Expiry in the browser revokes nothing
492
+ on chain.** After `expiresAt` the key still satisfies the lock, and the funds
493
+ stay at risk until the owner's sweep (`closeSession` or `recoverSessions`)
494
+ is confirmed.
374
495
 
375
496
  **Enforced where:**
376
497
 
@@ -385,9 +506,15 @@ cell's balance caps the total at risk.
385
506
  ### If a session key leaks
386
507
 
387
508
  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:
509
+ until the owner's sweep is confirmed on chain. The browser forgetting the key, or
510
+ refusing to use it, does not change that. Until then it can move at most `max_per_tx` per transaction,
511
+ only to the recipient if one is set (or to the miner as fee: the recipient rule
512
+ covers outputs, and the fee is not one), and, with a rate limit, once per
513
+ `min_interval` blocks per session cell. Without a rate limit, a leaked key can
514
+ repeat spends of `max_per_tx` in every block until the session is empty, each
515
+ one within the limit; a recipient does not slow it down, since it can burn
516
+ `max_per_tx` in fees every block. Set
517
+ `min_interval` whenever you set a recipient. The owner should sweep at once:
391
518
 
392
519
  - with the key still at hand: `closeSession(wallet, session, deployment, binding)`;
393
520
  - without it (device lost or wiped): `recoverSessions(wallet, deployment)` finds
@@ -399,9 +526,11 @@ paying a higher fee, so sweep early, and resweep what is left. Keep budgets
399
526
  small: the budget is the most a session can ever lose.
400
527
 
401
528
  **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.
529
+ under the owner's lock means the owner signed. Plain secp256k1, JoyID, signing
530
+ Omnilock and the Nostr lock in signing mode are fine; anyone-can-pay, PW-Lock and
531
+ the Nostr lock in proof of work mode are not, since anyone could spend the
532
+ owner's cell and get owner mode. See [which wallets can own a
533
+ session](#which-wallets-can-own-a-session).
405
534
 
406
535
  A session-lock cell occupies 121 CKB (153 CKB with a recipient), so its balance
407
536
  and its change must stay above that.
@@ -413,6 +542,21 @@ see [`deployment/testnet.json`](deployment/testnet.json). The code is immutable.
413
542
  Earlier deployments (8 Oct, before errors 16–18; 13 Oct, before token sessions)
414
543
  are kept in `TESTNET_LEGACY_DEPLOYMENTS`, so `recoverSessions` still sweeps
415
544
  sessions opened under them.
545
+ `openSession` and `openSubscription` refuse them: new cells go under the current
546
+ lock only.
547
+
548
+ Reference the lock with `hash_type` `data1` or `data2` (or `type`, for a
549
+ type-id deployment). Under `data` (CKB-VM version 0) the binary faults on load,
550
+ owner mode included, so a cell under it could never be spent; the library
551
+ refuses such a deployment when opening.
552
+
553
+ Before it builds an opening, the library reads the deployment's cell dep from
554
+ the client's network (`getCellLive`, a dep group resolved) and refuses unless it
555
+ holds code hashing to `codeHash` (`assertCurrentDeployment`), once per client
556
+ and deployment. `TESTNET_DEPLOYMENT` on a mainnet client is therefore refused.
557
+ There is no mainnet deployment and no `MAINNET_DEPLOYMENT` constant: to use the
558
+ lock on mainnet, deploy the binary yourself (byte-identical, see below) and pass
559
+ your own `SessionLockDeployment` (`codeHash`, `hashType: "data2"`, `cellDep`).
416
560
 
417
561
  Verify the deployed lock yourself. The deployed binary was built with rustc
418
562
  1.99.0 on the x86_64-pc-windows-gnu host (pinned in
@@ -431,7 +575,13 @@ The last command compares the build's data hash with `codeHash` in
431
575
  the same three steps. Another host or rustc builds a working lock with other
432
576
  bytes, so its hash will not match.
433
577
 
434
- `npm run smoke:lock` runs every rule against public testnet. Run on 14 Oct 2026, on the current lock:
578
+ [`docs/SECURITY-EVIDENCE.md`](docs/SECURITY-EVIDENCE.md) collects the evidence
579
+ for each rule and error code in one report: the check in the script, the accepted
580
+ transactions, the node's rejection text, the CKB-VM tests, and which rules have
581
+ only been shown in CKB-VM tests. `npm run evidence` re-checks its logs against
582
+ testnet, read-only.
583
+
584
+ `npm run smoke:lock` runs the rules in the table below against public testnet. Run on 14 Oct 2026, on the current lock:
435
585
 
436
586
  | Step | Network said |
437
587
  |---|---|
@@ -465,13 +615,20 @@ that an over-limit spend is refused. Run on 8 Oct 2026:
465
615
  ## Limits
466
616
 
467
617
  What this does not do, in one place. Each is stated where it applies above.
618
+ What has and has not been shown on testnet is in
619
+ [`docs/SECURITY-EVIDENCE.md`](docs/SECURITY-EVIDENCE.md#5-not-covered-and-limits).
468
620
 
469
621
  - **Testnet only, not audited.** The lock has had community and adversarial
470
622
  reviews, each finding fixed with a regression test, but no external audit.
471
623
  Do not put mainnet funds behind it.
472
624
  - **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.
625
+ passed, so `expiresAt` is checked in the browser. Browser expiry does not
626
+ revoke the key's on-chain authority: a leaked key stays valid, and the funds
627
+ stay at risk, until the owner's sweep transaction is confirmed. The session's
628
+ balance is the most it can lose.
629
+ - **No rate limit means repeated spends.** With `min_interval` 0, each spend
630
+ is within `max_per_tx`, but spends can repeat every block until the balance
631
+ is gone. `openSession` warns; set `minInterval`.
475
632
  - **The rate limit is per cell.** `openSession` makes one cell and the lock
476
633
  forbids splitting, but anyone adding more cells under the same session lock
477
634
  gives the key more cells to spend from (with their own money, never the owner's).
@@ -486,11 +643,14 @@ What this does not do, in one place. Each is stated where it applies above.
486
643
  - **An access proof does not expire.** It is a bearer token for one article:
487
644
  whoever holds the signed proof can fetch that article again. It cannot be
488
645
  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.
646
+ - **Owner locks must need a signature.** An owner lock that opens without one
647
+ (anyone-can-pay, PW-Lock, Nostr in proof of work mode) would give anyone owner
648
+ mode; `openSession` refuses it, and refuses locks it has not reviewed unless
649
+ `allowUnreviewedOwnerLock` is set.
491
650
  - **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
651
+ The lock's rate limit is a block count, not a fixed duration: block time
652
+ varies (see the [average block time](https://explorer.nervos.org/charts/average-block-time)), so a period is an
653
+ estimate of time (`blocksForSeconds`). Nothing charges by itself: the creator's server must
494
654
  send each charge, one transaction per subscriber. Cancelling ends access at
495
655
  once, and the current period's price is not refunded.
496
656
  - **Sessions are not cheap to open.** A session cell occupies 121 CKB (153 with a
@@ -0,0 +1,21 @@
1
+ import { ccc } from "@ckb-ccc/core";
2
+ import { type SessionLockDeployment } from "./lock.js";
3
+ /**
4
+ * Throws a `SessionPolicyError` unless new cells may go under `deployment` on
5
+ * `client`'s network:
6
+ *
7
+ * - it is not one of `TESTNET_LEGACY_DEPLOYMENTS` (older code with fewer rules;
8
+ * the 13 Oct one cannot even spend a token session's 128/160-byte args);
9
+ * - its `hashType` is not `data`: under CKB-VM version 0 the lock faults on
10
+ * load, owner mode included, so such cells could never be spent;
11
+ * - its cell dep is live on this network and holds the code: the code cell's
12
+ * data hashes to `codeHash` (`data1`, `data2`) or its type script hashes to
13
+ * it (`type`). A `depGroup` cell dep is resolved and any member may match.
14
+ * This catches a testnet deployment on a mainnet client, a typo in
15
+ * `codeHash` and a wrong out point, any of which would leave funds under a
16
+ * lock no code can run.
17
+ *
18
+ * A passing check is remembered for that client and deployment; a failing one
19
+ * is not, so the next call looks again.
20
+ */
21
+ export declare function assertCurrentDeployment(client: ccc.Client, deployment: SessionLockDeployment): Promise<void>;