ckb-session-kit 1.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,337 @@
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.1.0 (2026-11-05)
7
+
8
+ ### Added
9
+ - `fundInSession` and `sendWithSession`: fund a transaction the app built (its
10
+ own inputs, typed outputs, cell deps and witnesses) from a session without a
11
+ recipient, with no wallet prompt. The key cell pays the fee (default 2000
12
+ shannons/KB, `DEFAULT_FUND_FEE_RATE`, the same on every retry). Refused
13
+ before signing: a session with an on-chain recipient, an input under the
14
+ owner's lock (owner mode), an outflow over `maxPerTx`. `sendWithSession`
15
+ retries only when the node names one of the session's own cells as spent, and
16
+ rethrows any other refusal (the app's own cell taken, an RBF refusal) for the
17
+ app's loop. For CrowdCell pledges (CKBuilder-projects#6).
18
+ - `deadInputs` exported, `FundedTransaction`, `FundOptions`,
19
+ `SendWithSessionOptions` types, and `SessionPolicyError` reasons
20
+ `"session-has-recipient"`, `"owner-lock-input"`, `"app-tx-shape"`.
21
+ - CKB-VM tests of a session-funded pledge against CrowdCell's live testnet
22
+ scripts (`scripts/fetch-crowdcell.ts` saves them; not committed), and
23
+ `npm run smoke:fund-pledge`.
24
+ - `npm run smoke:pool-lag`: how long `get_live_cell(include_tx_pool=true)`
25
+ still shows a just-spent input live, per answering node (mentor review,
26
+ tracker #47, 3 Nov). On one node (testnet.ckb.dev) the node that took the
27
+ send never showed the input live (158 polls); on testnet.ckbapp.dev (two
28
+ backends) only the other backend did, in 13 of 20 sends, for up to about
29
+ 0.5 s. One kept-alive socket does not pin a backend there. Logs and table in
30
+ docs/SECURITY-EVIDENCE.md.
31
+
32
+ ### Documentation
33
+ - JoyID is accepted as an owner lock but reviewed from its documentation only:
34
+ its contract is closed source. README and docs/API.md show how an app refuses
35
+ JoyID owners itself before `openSession` (no option in the kit; no behaviour
36
+ change).
37
+
38
+ ## 2.0.0 (2026-11-03)
39
+
40
+ ### Upgrading from 1.0.0
41
+ Every 1.0.0 export is still there with the same signature; new parameters are
42
+ optional. Some calls that 1.0.0 accepted are now refused, on purpose:
43
+ - `assertSigningLock`, `openSession`, `openSubscription` refuse an owner (or
44
+ charger) lock that opens with no signature: PW-Lock (its deployed build is
45
+ anyone-can-pay) and the Nostr lock in proof of work mode
46
+ (`reason: "owner-lock-unsafe"`; no option lets these through). Sessions
47
+ already open under such a lock should be closed or recovered now.
48
+ - They also refuse configurations not reviewed: Omnilock auth 0x07 or mode bits
49
+ other than time-lock (0x04) and supply (0x08), secp256k1, multisig or Nostr
50
+ locks with args their code would not accept, and any unknown lock
51
+ (`reason: "owner-lock-unreviewed"`). Pass `allowUnreviewedOwnerLock: true`
52
+ once you have checked the lock to get the 1.0.0 behaviour back, with a warning;
53
+ for the creator's charger lock in `openSubscription`, pass
54
+ `allowUnreviewedChargerLock: true`.
55
+ - Anyone-can-pay, PW-Lock and always-success referenced by data hash
56
+ (`data`, `data1`, `data2`) are refused like their type-hash versions.
57
+ - A send whose outcome cannot be checked throws `SendOutcomeUnknownError`
58
+ (with the tx hash); it is never retried, so check the hash before resending.
59
+ - `openSession` and `openSubscription` refuse `TESTNET_LEGACY_DEPLOYMENTS`,
60
+ `hashType: "data"`, and a deployment whose cell dep does not hold its code on
61
+ the client's network (one `getCellLive` per client and deployment).
62
+ - `closeSession` refuses a signer whose lock is not the session's owner lock.
63
+ - `openSession` with no rate limit, or `minInterval` 1, calls `console.warn`
64
+ (or `opts.onWarning`).
65
+ - `SessionPolicyError` and `SessionBalanceError` carry a `reason`; code that
66
+ matched error messages should match `reason` instead.
67
+
68
+ The wallet matrix's locks (secp256k1, Omnilock 0x12 / Bitcoin / Dogecoin,
69
+ Nostr signing mode, JoyID) all pass unchanged.
70
+
71
+ ### Fixed (Week 6 adversarial review, 2 Nov)
72
+ - Token subscriptions: look-alike cells typed `xUDT(token.args ++ extra)`
73
+ (xUDT accepts them, anyone can create them with 0 units, and the indexer's
74
+ type filter matched them by prefix) counted toward `MAX_SUBSCRIPTION_CELLS`
75
+ but were never swept. The filter now asks for the exact script length, only
76
+ cells really of the subscription's kind count, raw reads are bounded, and
77
+ `clearPlantedCells` and `cancelSubscription` sweep spendable look-alikes back
78
+ to the subscriber (M1).
79
+ - A status or a charge counted every recent transaction under the lock toward
80
+ its budget of 64, creations by anyone included, and before the age check.
81
+ Now only spends count, an entry older than the period is never counted, and
82
+ creation-only entries are paged past under their own budget of 1024
83
+ (`MAX_CREATED_HISTORY`); past either budget it still refuses (M2).
84
+ - `clearPlantedCells`: sweeps at most 30 cells per transaction, so a status
85
+ never dates the cleanup as a charge (L3); with cells left unread, sweeps the
86
+ planted cells it read instead of refusing (L1); marks its transaction with
87
+ `CLEAR_PLANTED_MEMO` in the subscriber's own witness, so a status does not
88
+ take it for a cancel and a charge of the whole balance stays paid (L2).
89
+ - `signAndSend` throws `SendOutcomeUnknownError` (new export) when a send fails
90
+ and the status lookup fails too, instead of rethrowing the send's error: a
91
+ `Dead` answer from CCC's fallback node after a lost reply was retried and
92
+ could pay twice (L4). It is never retried.
93
+ - Payout cells under `payTo` are listed by the indexer's filter (plain and
94
+ empty, or exactly the token with 16 data bytes), so junk planted under an
95
+ anyone-can-pay address no longer pushes the real cells out of reach (L5).
96
+ - `assertSigningLock` refuses anyone-can-pay, PW-Lock and always-success by the
97
+ data hash of their deployed binaries under every data hash_type (`data`,
98
+ `data1`, `data2`), not only by type id (L6).
99
+
100
+ ### Changed (Week 6 adversarial review, 2 Nov)
101
+ - `openSubscription` checks the creator's charger lock with its own opt-in,
102
+ `allowUnreviewedChargerLock`; `allowUnreviewedOwnerLock` now covers the
103
+ subscriber's lock only (I1).
104
+ - `openSession` raises `no-rate-limit` for `minInterval` 1 too (I3).
105
+ - The testnet scripts keep `.env.smoke` at the repository root whatever the
106
+ current directory (I4).
107
+ - Docs: a multisig with a since or an Omnilock time-lock also delays the
108
+ owner's sweep (I2).
109
+
110
+ ### Added (mentor review, tracker #47, 2 Nov)
111
+ - Payout cell contention. A top-up (`spendInSession` with `topUp`,
112
+ `chargeSubscription`, `openSubscription`) picks the recipient's cell with
113
+ `pickPayoutCell`: confirmed cells first, shuffled, and the first free one of
114
+ up to `MAX_POOL_CHECKS` (4) cells checked with `get_live_cell` and
115
+ `include_tx_pool`, so a cell another payment in the node's pool spends is
116
+ skipped. At most `MAX_PAYOUT_CELLS` (64) of the recipient's cells are read.
117
+ - `spendInSession` and `chargeSubscription` take `retry: { attempts, delayMs }`
118
+ (`RetryOptions`, `DEFAULT_RETRY`: 3 attempts, 1000 ms doubling, with jitter).
119
+ A send the node refuses for a spent input (`Dead`, `Unknown`, or an RBF
120
+ refusal, `poolConflict`) is built again with another payout cell; the cell a
121
+ `Dead` refusal names is not picked again. It used to be retried once, with no
122
+ wait, and only with a client cache. A send whose outcome is unknown is still
123
+ never retried.
124
+ - `SessionBalanceError` reason `"payout-busy"`: every payout cell checked is
125
+ spent in the pool and none is left. Nothing is sent; it is retried like a
126
+ refusal. The demo's charge route answers it with 503 and retry.
127
+ - `createPayoutCells(funder, payTo, { count, capacity?, token? })` gives an
128
+ anyone-can-pay address more cells to top up. `npm run setup:creator` uses it
129
+ and now gives the demo's creator eight cells (was three).
130
+ - `clearPlantedCells(subscriber, deployment, subscription)`: the subscriber
131
+ sweeps cells a third party created under the subscription's lock back to
132
+ itself, keeping the subscription's own cells, so it no longer has to cancel
133
+ and open again past `MAX_SUBSCRIPTION_CELLS`.
134
+
135
+ ### Changed (mentor review, 2 Nov)
136
+ - `assertSigningLock` accepts only reviewed owner lock configurations:
137
+ secp256k1, multisig (v1, v2), Omnilock with auth 0x00-0x06 or 0x12 and no
138
+ mode other than time-lock or supply, the Nostr lock in signing mode
139
+ (`args[0]` = 0), and JoyID. The wallet matrix's locks all still pass.
140
+ - PW-Lock is now refused. The build deployed on mainnet and testnet is
141
+ lay2dev/pw-lock's anyone-can-pay variant: with no signature in the witness it
142
+ unlocks by payment, whatever the args, so anyone could sweep every session.
143
+ - The Nostr lock in proof of work mode (`args[0]` not 0) is now refused: it
144
+ unlocks with work, not the holder's key.
145
+ - Anyone-can-pay is refused by code hash on both networks (and the older
146
+ deployments pw-core lists), whatever the client's network; always-success
147
+ too. These refusals carry `reason: "owner-lock-unsafe"`.
148
+ - Locks and configurations not reviewed (Omnilock auth 0xFC-0xFE, other Omnilock
149
+ auth flags or mode bits, the ckb-proxy-locks, unknown locks, args the lock's
150
+ code would not accept, multisig v2 beta) are refused with
151
+ `reason: "owner-lock-unreviewed"`. Omnilock auth 0x07 and unknown Omnilock mode
152
+ bits used to pass.
153
+ - Subscription cells are listed with the indexer's type filter (no type, or the
154
+ subscription's token), so cells under any other type script no longer count
155
+ toward `MAX_SUBSCRIPTION_CELLS`.
156
+ - The last charge is the newest spend with an input under the charger's lock,
157
+ not any spend: a `clearPlantedCells` does not delay the next charge. A spend
158
+ that cannot be told apart still counts as a charge.
159
+ - Chained payments are not used; the README says why.
160
+
161
+ ### Added
162
+ - `openSession` warns when a session has no rate limit (`minInterval` left out
163
+ or 0): every spend is within `maxPerTx`, but spends can repeat every block
164
+ until the session is empty. The warning (`SessionWarning`, code
165
+ `"no-rate-limit"`) goes to the new `opts.onWarning` before anything is
166
+ signed, which may throw to stop the opening, or to `console.warn` without one.
167
+ The demos' sessions open with no rate limit, so they now warn in the console.
168
+ - 23 CKB-VM tests of aggregate outflow across inputs (mentor review, 2 Nov):
169
+ cells with the same args share one limit per transaction (CKB and token), a
170
+ subscription's merged cells pay one price, cells with other args are checked
171
+ on their own, and owner mode sweeps several cells at once. No fault found; the
172
+ lock is unchanged.
173
+ - README and SECURITY-EVIDENCE: browser expiry revokes nothing on chain (funds
174
+ stay at risk until the owner's sweep is confirmed); a period is a block count,
175
+ not a fixed duration; the aggregate-outflow behaviour.
176
+ - `allowUnreviewedOwnerLock` (`SigningLockOptions`) on `assertSigningLock`,
177
+ `openSession` and `openSubscription`: lets an unreviewed owner or charger lock
178
+ through with a `console.warn`. It never lets an "owner-lock-unsafe" lock
179
+ through.
180
+ - `SessionPolicyReason` gains `"owner-lock-unsafe"` and `"owner-lock-unreviewed"`.
181
+ - `SessionBalanceError.reason` (`SessionBalanceReason`): `"no-fee-cell"` or
182
+ `"no-payout-cell"` when the cells missing are the payer's or the recipient's own
183
+ (a charger with no fee cell, a `payTo` with no cell to top up), so a server can
184
+ tell them from the subscription's state without matching messages. The demo's
185
+ charge route now uses it.
186
+ - `SessionPolicyError.reason` (`SessionPolicyReason`): `"payto-not-anyone-can-pay"`
187
+ when a charge needs to top up `payTo` and `payTo` is not anyone-can-pay. The
188
+ demo's charge route answers it with 503 (the deployment's setup, not the
189
+ subscription's state) instead of 409.
190
+
191
+ ### Fixed (audit round 2, 31 Oct)
192
+ - Demo: enforced CSP now also sets `object-src 'none'`, `base-uri 'none'` and
193
+ `form-action 'self'`; the full policy (scripts, styles, images, testnet RPC and
194
+ JoyID aggregator only) is sent as `Content-Security-Policy-Report-Only`.
195
+ - CI: GitHub actions pinned to commit SHAs, checkouts with
196
+ `persist-credentials: false`.
197
+ - `openSession` and `openSubscription` refuse `TESTNET_LEGACY_DEPLOYMENTS`
198
+ (`assertCurrentDeployment`, new). `openSession` used to accept them: a token
199
+ session under the 13 Oct lock was stuck for good (that code refuses 128/160-byte
200
+ args before owner mode), and new sessions under the 8 Oct one lacked errors
201
+ 16-18. Spending, closing, recovering, charging and cancelling still accept old
202
+ deployments, so funds under them can come back.
203
+ - Before building an opening, the deployment's cell dep is read from the
204
+ client's network (`getCellLive`; a dep group is resolved) and must hold code
205
+ hashing to `codeHash` (data hash, or type hash for `hashType: "type"`).
206
+ `TESTNET_DEPLOYMENT` on a mainnet client, a typo in `codeHash` or a wrong out
207
+ point used to put funds under a lock with no code. One lookup per client and
208
+ deployment. `hashType: "data"` is refused too: the lock faults under CKB-VM
209
+ version 0. There is still no mainnet deployment; README and API.md say how to
210
+ pass your own.
211
+ - `spendInSession` and `chargeSubscription`: a key or charger cell above its
212
+ floor but short of the fee threw CCC's plain "Insufficient CKB"; it is now
213
+ `SessionBalanceError` with reason `"no-fee-cell"`. The charger's fee cells must
214
+ hold at least the floor plus 0.01 CKB. Found by the round-2 property suite,
215
+ added in `test/property-library.test.ts` and `test/property-fuzz.test.ts`,
216
+ which now fails on any plain error.
217
+ - `addTopUp` (top-ups in `spendInSession`, `chargeSubscription`,
218
+ `openSubscription`) skips a payee token cell the payment would take past
219
+ 2^128 - 1 units, and throws `SessionBalanceError` `"no-payout-cell"` if every
220
+ cell would, instead of a plain overflow error.
221
+ - `closeSession` refuses a signer whose lock is not `binding.ownerLock` with a
222
+ `SessionPolicyError`, before asking it to sign. After a wallet switch, the
223
+ new wallet was shown a sweep of the old wallet's session to itself (the lock
224
+ refused it on-chain, so nothing was lost).
225
+ - `closeSession` can finish once the session is empty on-chain: with the
226
+ funding committed and nothing indexed, it asks the node whether the funding's
227
+ outputs under the session and the key are still live, and reports
228
+ `nothingFound` when all are spent. Before, a close whose key return was taken
229
+ but whose reply was lost (or a close from a second tab) left a key that could
230
+ never be forgotten. The key return now goes through `signAndSend`, so a lost
231
+ reply the node did take is reported as `keyTx`.
232
+ It reports `nothingFound` only once the node's indexer has reached the tip
233
+ and a second search still finds nothing, so a later spend's key cell that is
234
+ not indexed yet never lets the key be forgotten.
235
+ - CCC's client cache lists a client's own outputs as live until cleared, so a
236
+ tab (or a warm server instance) built every later spend on cells another tab
237
+ had spent, and the node rejected each one. `spendInSession`, `closeSession`,
238
+ `chargeSubscription` and `cancelSubscription` now leave out cached-only cells
239
+ the node no longer has, and on a rejection for a spent or unknown input clear
240
+ the cache and build the transaction once more.
241
+ - `verifyAccess`, `verifySubscription` and `subscriptionStatus` read payments
242
+ with `getTransactionNoCache`. `getTransaction` answers from the cache when
243
+ the node no longer knows a transaction, so a payment that was reorged out kept
244
+ granting access on a long-lived server client.
245
+ - Demo: one "unknown" answer just after a lost send no longer makes a page
246
+ forget the session key (about 62 CKB stranded once the funding lands) or a
247
+ payment record. A key or record is forgotten only when the node rejected the
248
+ transaction, or has not known it for 10 minutes (the rule the payment polls
249
+ already used); meanwhile the page keeps polling it.
250
+ - Demo: every page's wallet lookup drops a late answer from the previous
251
+ wallet, and the subscribe page drops a status refresh started for the
252
+ previous wallet, asset or opening, and clears its status and notes on
253
+ disconnect.
254
+ - Demo: the CKB Place board asks again about a placement `get_transaction`
255
+ missed once, instead of leaving it off the board until a reload.
256
+ - Demo: the charge route builds a new CCC client per request.
257
+
258
+ ### Fixed (security audit, 30 Oct)
259
+ - `recoverSessions`: when the one sweep is refused, sessions are now swept one
260
+ session lock at a time (full lock hash), not one session key hash at a time. A
261
+ planted cell under other args carrying the victim's public key hash (a foreign
262
+ type script the sweep cannot consume) joined the victim's group and blocked its
263
+ recovery on every retry. `findOwnedSessionCells` keeps a typed cell only if its
264
+ type is a plain xUDT on the network (`assertPlainXudt`'s rules) holding exactly
265
+ the 16-byte amount a session makes.
266
+ - `spendInSession` pays the fee from the largest key cell (ties: lowest out
267
+ point), not the first one listed: a 61 CKB cell anyone sent to the key address,
268
+ or the old key cell beside a top-up, blocked every spend. Still one key cell in
269
+ and one smaller one out, as the lock requires.
270
+ - `verifyAccess` and `checkPlacement` count only memos in witnesses a signature
271
+ covers: the first witness of each secp256k1 lock group, and any past the
272
+ inputs. The session cell's and an anyone-can-pay cell's witnesses are signed by
273
+ nobody, so whoever relayed a payment could add a memo there and void it for
274
+ good (or, on a CKB Place board, choose the pixels of a payment without one).
275
+ Several signed memos still fail. `placementMemo` takes the resolved inputs as
276
+ an optional third argument for the exact check; without them it is a prefilter.
277
+ - Docs: with a recipient set, up to `max_per_tx` per transaction can still leave
278
+ as the miner fee (the recipient rule covers outputs only). The README and
279
+ `openSession` now say so and recommend a rate limit (`minInterval`) whenever a
280
+ recipient is set. The lock is unchanged.
281
+ - `subscriptionStatus`, `chargeSubscription` and `findSubscriptionCells` read at
282
+ most `MAX_SUBSCRIPTION_CELLS` (16, exported) cells under a subscription's lock,
283
+ and at most 64 recent transactions of its history. Beyond that they throw
284
+ `SessionPolicyError` with the new reason `"too-many-cells"` instead of reading
285
+ part of the cells, which could deny a paid-up subscriber or let a cell left out
286
+ of a charge pay a second price in the period. Planted cells (anyone can create
287
+ them) made each status request look up every creating transaction and all its
288
+ inputs again: 100 cells cost 3,307 RPC calls. `cancelSubscription` still reads
289
+ and sweeps every cell, so the subscriber can clear planted ones.
290
+ - Subscription payments are checked once per transaction within a call, and a
291
+ transaction whose outputs to `payTo` already fall short is refused before its
292
+ inputs are looked up.
293
+ - `chargeSubscription` picks its fee cell from the first 64 cells under the
294
+ charger's lock instead of reading all of them.
295
+ - Demo: `/api/subscription` answers `"too-many-cells"` with 409 (the page shows
296
+ it), not 503 with retry.
297
+ - Demo: the charger address is the creator's key, so `CHARGER_KEY` on the server
298
+ could spend the creator's income and payout cells. New `npm run setup:charger`
299
+ creates a separate key (`.env.charger`, written once, mode 0600) and prints its
300
+ address and next steps; the docs now require a charger key of its own holding
301
+ only fee cells. `CHARGER` is replaced once that key is funded and set on Vercel.
302
+
303
+ ### Fixed (30 Oct)
304
+ - `chargeSubscription` and `nextChargeIn` date the last charge from the
305
+ subscription lock's indexer history, not from its change cell: a charge of the
306
+ whole balance (the last 153 CKB with a price of 153 CKB or more) leaves no
307
+ change, so a younger top-up could pay a second price under a period later.
308
+ - `subscriptionStatus` counts that last whole-balance charge as a paid period;
309
+ it used to end access as soon as the last period was paid. A cancel still ends
310
+ access at once.
311
+
312
+ ### Fixed (code review, 28 Oct)
313
+ - Token overflow guard: a planted cell of 2^128-1 free tokens could push the
314
+ subscriber's own cell out of a cancel (the guard kept cells in indexer order), and
315
+ blocked `closeSession` and `recoverSessions` of token sessions (no guard at all).
316
+ One guard now serves sessions, recovery and subscriptions; it keeps cells created
317
+ by the owner or the key first.
318
+ - `recoverSessions`, `closeSession`, `cancelSubscription`: an owner sweep the node
319
+ accepted is reported as sent even when the reply times out (it skipped the
320
+ check `signAndSend` does). `recoverSessions` now throws when the owner holds no
321
+ plain cell, instead of reporting every session as failed.
322
+ - `chargeSubscription` waits a full period after the last charge, not only after
323
+ the oldest cell: a top-up cell older than the last charge's change could pay a
324
+ second price in the same period. `nextChargeIn` counts the same way.
325
+ - Session stores: a save of the same key without the on-chain binding (from a
326
+ stale tab) is refused with `SessionStoreConflict` instead of dropping the binding.
327
+ - Demo: the charge route answers 503 with `retry` when the creator's own cells are
328
+ missing (no fee cell for the charger, no payout cell to top up), not 409.
329
+ - `chargeSubscription` pays its fee from a random one of the charger's cells, and a
330
+ CKB `openSubscription` tops up a random payout cell, so concurrent charges and
331
+ openings rarely compete for one cell.
332
+
333
+ ### Changed
334
+ - `verifyAccess` resolves a payment's inputs in parallel once the memo check
335
+ passes; subscription charges and status fetch each creating transaction once.
336
+
6
337
  ## 1.0.0 (2026-10-27)
7
338
 
8
339
  First stable release. Everything below is on testnet, with a smoke script and explorer