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.
@@ -1,6 +1,7 @@
1
1
  import { ccc } from "@ckb-ccc/core";
2
- import { relativeBlocksSince, TESTNET_LEGACY_DEPLOYMENTS, sessionLockCellDep, sessionLockScript, } from "./lock.js";
3
- import { assertPlainXudt, assertSigningLock, collect, keyCellFloor, matureCells, ownerSweep, SessionBalanceError, signAndSend, tokenAmount, tokenData, } from "./onchain.js";
2
+ import { relativeBlocksSince, sessionLockCellDep, sessionLockScript, } from "./lock.js";
3
+ import { addTopUp, assertPlainXudt, assertSigningLock, checkSigningLock, isAnyoneCanPay, keyCellFloor, ownerSweep, ownOrigin, plainXudtFault, randomPick, completeFeeOrNoFeeCell, rebuildOnDeadInputs, SessionBalanceError, signAndSend, tokenAmount, tokenData, withoutDeadCached, withinU128, } from "./onchain.js";
4
+ import { assertCurrentDeployment } from "./deployment.js";
4
5
  import { SessionPolicyError } from "./session.js";
5
6
  const tokenOf = (sub) => sub.token && ccc.Script.from(sub.token.type);
6
7
  export function subscriptionLock(deployment, sub) {
@@ -32,10 +33,6 @@ function assertTerms(sub) {
32
33
  if (sub.payToLockHash === sub.subscriberLockHash)
33
34
  throw new SessionPolicyError("payTo must not be the subscriber");
34
35
  }
35
- async function isAnyoneCanPay(lock, client) {
36
- const acp = await client.getKnownScript(ccc.KnownScript.AnyoneCanPay);
37
- return lock.codeHash === acp.codeHash && lock.hashType === acp.hashType;
38
- }
39
36
  /**
40
37
  * The memo an opening commits to, binding the subscription to one account at the
41
38
  * creator: a hash, so the account id never appears on-chain and can double as a
@@ -52,54 +49,215 @@ export function subscriptionMemo(account) {
52
49
  * In a token subscription, the cells typed with exactly its token and holding at
53
50
  * least the 16-byte amount (the lock keeps a charge's change at exactly 16).
54
51
  * Plain CKB sent to the same lock holds no tokens and cannot pay a price; it is
55
- * left to `recoverSessions`.
52
+ * left to `recoverSessions`. Cells whose tokens would overflow a u128 are left out
53
+ * (`withinU128`).
54
+ *
55
+ * The indexer filters by kind (`kindFilter`: no type script; or exactly the
56
+ * token's type script), so cells of any other kind are never listed and never
57
+ * count toward `cap`: anyone can create them, they cannot pay a price, and one
58
+ * with a type script that refuses to be spent could never be cleared (mentor
59
+ * review, 2 Nov). Only cells that are really of the kind count toward `cap`
60
+ * (review W6 M1), and the listing itself is read at most `RAW_READS * cap`
61
+ * deep, for an indexer that would list more than the filter asks.
56
62
  */
57
- async function subscriptionCells(client, lock, token) {
63
+ async function subscriptionCells(client, lock, token,
64
+ /** Refuse ("too-many-cells") once more than this many cells of this kind sit under the lock. */
65
+ cap) {
58
66
  const cells = [];
59
- for await (const cell of client.findCellsByLock(lock, null, true)) {
67
+ let read = 0;
68
+ for await (const cell of client.findCells({ script: lock, scriptType: "lock", scriptSearchMode: "exact", filter: kindFilter(token), withData: true })) {
69
+ if (cap !== undefined && ++read > RAW_READS * cap)
70
+ throw tooManyCells(cap);
71
+ if (!ofKind(cell, token))
72
+ continue;
73
+ if (cap !== undefined && cells.length >= cap)
74
+ throw tooManyCells(cap);
75
+ cells.push(cell);
76
+ }
77
+ // Cells only CCC's cache still lists, spent since by another client, would make a charge or a cancel fail.
78
+ const live = await withoutDeadCached(client, lock, cells);
79
+ // A planted cell of 2^128-1 free tokens would block charges and the cancel; the
80
+ // subscriber's and the charger's own cells are always kept.
81
+ return token ? withinU128(client, live) : live;
82
+ }
83
+ /**
84
+ * The indexer filter for a subscription's own kind of cell under its lock: no
85
+ * type script, or exactly its token. The indexer matches a filter script by args
86
+ * prefix, and xUDT with a zero flags word ignores bytes after it, so anyone can
87
+ * create zero-amount cells of `xUDT(token.args ++ anything)` (review W6 M1);
88
+ * the length range (33 bytes plus the args, as the indexer and CCC count a
89
+ * script) leaves only the token itself, as CCC's `completeInputsByUdt` does.
90
+ */
91
+ const kindFilter = (token) => token
92
+ ? { script: token, scriptLenRange: [token.occupiedSize, token.occupiedSize + 1] }
93
+ : { scriptLenRange: [0, 1] };
94
+ /** Whether `cell` is of the subscription's kind: untyped, or typed with exactly `token` and holding its amount. */
95
+ const ofKind = (cell, token) => token ? cell.cellOutput.type?.eq(token) === true && ccc.bytesFrom(cell.outputData).length >= 16 : !cell.cellOutput.type;
96
+ /** Listed cells read per cell a scan may keep: only an indexer that ignored the filter would list other ones. */
97
+ const RAW_READS = 8;
98
+ /**
99
+ * Look-alikes of a token subscription's token under its lock (review W6 M1):
100
+ * cells typed with the token's code and args that start with the token's args
101
+ * but are longer. The subscription never makes them, so every one was planted.
102
+ * Only those an owner sweep can spend are kept: a plain xUDT (zero flags, so no
103
+ * extension script that could refuse the spend) holding an amount. At most
104
+ * `budget` listed cells are read when given; `more` says some were left unread.
105
+ */
106
+ async function lookalikeCells(client, lock, token, budget) {
107
+ const xudt = await client.getKnownScript(ccc.KnownScript.XUdt);
108
+ const cells = [];
109
+ let read = 0;
110
+ let more = false;
111
+ for await (const cell of client.findCells({ script: lock, scriptType: "lock", scriptSearchMode: "exact", filter: { script: token }, withData: true })) {
112
+ if (budget !== undefined && ++read > budget) {
113
+ more = true;
114
+ break;
115
+ }
60
116
  const type = cell.cellOutput.type;
61
- if (token ? type?.eq(token) && ccc.bytesFrom(cell.outputData).length >= 16 : !type)
62
- cells.push(cell);
117
+ if (!type || type.eq(token) || !type.args.startsWith(token.args) || type.codeHash !== token.codeHash || type.hashType !== token.hashType)
118
+ continue;
119
+ if (plainXudtFault(type, xudt) !== undefined || ccc.bytesFrom(cell.outputData).length < 16)
120
+ continue;
121
+ cells.push(cell);
63
122
  }
64
- return cells;
123
+ return { cells: await withinU128(client, await withoutDeadCached(client, lock, cells)), more };
65
124
  }
125
+ /**
126
+ * The most cells under one subscription's lock that a status or a charge reads
127
+ * (security audit, 30 Oct). Anyone can create cells under any lock, and each
128
+ * cell read costs RPC calls (its creating transaction, and that transaction's
129
+ * inputs), so without a bound one request could be made to cost thousands. A
130
+ * subscription in use holds one cell, or a few after top-ups; 16 is far above that.
131
+ *
132
+ * Beyond it, a status or a charge is refused with a `SessionPolicyError` of
133
+ * reason "too-many-cells", never answered from part of the cells: the cells
134
+ * left unread could hold the newest charge (so access would be denied as
135
+ * unpaid, with a wrong balance) or a mature cell (so a charge would leave it free
136
+ * to pay a second price in the same period). Refusing grants nothing and charges
137
+ * nothing. Only cells of the subscription's own kind count (plain CKB, or its
138
+ * token). The subscriber clears cells planted among them with
139
+ * `clearPlantedCells`, which leaves the subscription's own cells as they are,
140
+ * or with `cancelSubscription`.
141
+ */
142
+ export const MAX_SUBSCRIPTION_CELLS = 16;
143
+ /**
144
+ * The most transactions that spent a cell under the lock that `lastCharge` reads
145
+ * in the recent history (each is fetched, with its inputs), on the same grounds.
146
+ * Only the subscriber and the charger can make those.
147
+ */
148
+ const MAX_HISTORY = 4 * MAX_SUBSCRIPTION_CELLS;
149
+ /**
150
+ * The most transactions that only created cells under the lock that `lastCharge`
151
+ * pages past in the recent history (review W6 M2). Anyone can make those, but
152
+ * they are skipped without a fetch, so only the indexer's pages cost anything
153
+ * (`HISTORY_PAGE` entries each). Past it, a status or a charge is refused
154
+ * rather than stopping short of the last charge: blocking one period this way
155
+ * takes over 1024 transactions within it, each locking up at least a cell's
156
+ * size (153 CKB) under the subscription's lock.
157
+ */
158
+ export const MAX_CREATED_HISTORY = 64 * MAX_SUBSCRIPTION_CELLS;
159
+ /** Entries per indexer page when reading a lock's history. */
160
+ const HISTORY_PAGE = 128;
161
+ /** The most inputs a creating transaction may have before its inputs are looked up (each is an RPC call). */
162
+ const MAX_PAYMENT_INPUTS = 32;
163
+ /** The charger's cells read when picking one to pay a charge's fee: any of them will do. */
164
+ const FEE_CELL_SCAN = 4 * MAX_SUBSCRIPTION_CELLS;
165
+ const tooManyTransactions = (cap) => new SessionPolicyError(`over ${cap} recent transactions under this subscription's lock: refused rather than read in part; ` +
166
+ "they leave the window a period after they committed, or the subscriber can cancel", "too-many-cells");
167
+ const tooManyCells = (cap) => new SessionPolicyError(`over ${cap} cells or recent transactions under this subscription's lock: refused rather than read in part; ` +
168
+ "the subscriber can clear the planted cells (clearPlantedCells), or cancel", "too-many-cells");
66
169
  /** The token units in token cells (cells typed with `token`, with at least 16 data bytes). */
67
170
  function tokensIn(cells, token) {
68
171
  return cells.reduce((s, c) => (c.type?.eq(token) && ccc.bytesFrom(c.data).length >= 16 ? s + tokenAmount(c.data) : s), 0n);
69
172
  }
70
- const U128_MAX = (1n << 128n) - 1n;
71
173
  /**
72
- * Cells whose token amounts add up without overflowing a u128, in order; the rest
73
- * are left out. Anyone can plant a cell holding 2^128-1 units of a free token
74
- * under a subscription's lock, and xUDT refuses a transaction whose inputs
75
- * overflow, so taking every cell at once would block charges and the cancel
76
- * (review, 27 Oct). Only the planter's own tokens are left behind.
174
+ * Whether transaction `txHash` (which spent a cell under a subscription's lock)
175
+ * is a charge: one of its inputs is under the charger's lock. Only the charger
176
+ * (session mode) and the subscriber (owner mode) can spend those cells. When it
177
+ * cannot be told (the transaction or an input not found, too many inputs), it
178
+ * counts as a charge: that can only delay the next charge, never allow a second
179
+ * one in a period. The last input is looked at first: a charge puts the
180
+ * charger's cell there.
181
+ */
182
+ async function isCharge(client, txHash, chargerLockHash) {
183
+ const fetched = await client.getTransactionNoCache(txHash);
184
+ const inputs = fetched?.transaction.inputs ?? [];
185
+ if (!inputs.length || inputs.length > MAX_PAYMENT_INPUTS)
186
+ return { charge: true, fetched };
187
+ const byCharger = (c) => !c || c.cellOutput.lock.hash() === chargerLockHash;
188
+ const last = await client.getCell(inputs[inputs.length - 1].previousOutput);
189
+ if (byCharger(last))
190
+ return { charge: true, fetched };
191
+ const rest = await Promise.all(inputs.slice(0, -1).map((i) => client.getCell(i.previousOutput)));
192
+ return { charge: rest.some(byCharger), fetched, inputs: [...rest, last] };
193
+ }
194
+ /**
195
+ * The memo `clearPlantedCells` puts in the witness (`input_type`) of the
196
+ * subscriber's own input, under the subscriber's signature: a status tells its
197
+ * cleanup from a cancel by it, so a cleanup does not end a paid period (review
198
+ * W6 L2). Only the subscriber can sign it; a subscriber who marks a cancel
199
+ * with it keeps access only through a period already paid for.
77
200
  */
78
- function fitting(cells, token) {
79
- if (!token)
80
- return cells;
81
- const kept = [];
82
- let sum = 0n;
83
- for (const c of cells) {
84
- const units = c.cellOutput.type?.eq(token) && ccc.bytesFrom(c.outputData).length >= 16 ? tokenAmount(c.outputData) : 0n;
85
- if (sum + units > U128_MAX)
201
+ export const CLEAR_PLANTED_MEMO = ccc.hashCkb(ccc.bytesFrom("ckb-session-kit clear-planted", "utf8"));
202
+ /** Whether a subscriber's spend `fetched` (with its `inputs`) is marked as a `clearPlantedCells` cleanup. */
203
+ function isCleanup(fetched, inputs, subscriberLockHash) {
204
+ const tx = fetched?.transaction;
205
+ if (!tx || !inputs)
206
+ return false;
207
+ return inputs.some((c, i) => {
208
+ if (c?.cellOutput.lock.hash() !== subscriberLockHash)
209
+ return false;
210
+ try {
211
+ return tx.getWitnessArgsAt(i)?.inputType === CLEAR_PLANTED_MEMO;
212
+ }
213
+ catch {
214
+ return false; // not a WitnessArgs
215
+ }
216
+ });
217
+ }
218
+ /**
219
+ * The newest charge (a spend of a cell under `lock` by the charger), with its
220
+ * age in blocks, looking back under `window` blocks (undefined if none), from
221
+ * the indexer's history of the lock: a charge that took the whole balance leaves
222
+ * no change cell to tell it by (30 Oct). `ownerAfter`: the subscriber spent
223
+ * cells under the lock after it, other than in a cleanup marked with
224
+ * `CLEAR_PLANTED_MEMO` (a cancel, or any other owner-mode spend).
225
+ * Transactions that only created cells under the lock (anyone can) are skipped
226
+ * without being fetched, and counted under their own, larger budget
227
+ * (`MAX_CREATED_HISTORY`); only spends count toward `MAX_HISTORY`, and an
228
+ * entry older than the window is never counted (review W6 M2). Past either
229
+ * budget it refuses: stopping short could miss the last charge. A subscriber's
230
+ * spend does not date the last charge: a cleanup must not delay the next one
231
+ * (mentor review, 2 Nov).
232
+ */
233
+ async function lastCharge(client, lock, sub, tip, window) {
234
+ let spends = 0;
235
+ let created = 0;
236
+ let ownerAfter = false;
237
+ for await (const t of client.findTransactionsByLock(lock, null, true, "desc", HISTORY_PAGE)) {
238
+ const born = ccc.numFrom(t.blockNumber);
239
+ const age = tip > born ? tip - born : 0n; // the indexer may be a block past the tip read
240
+ if (age >= window)
241
+ break;
242
+ // Stopping short could miss the last charge and let a second one into the period: refuse instead.
243
+ if (!t.cells.some((c) => c.isInput)) {
244
+ if (++created > MAX_CREATED_HISTORY)
245
+ throw tooManyTransactions(MAX_CREATED_HISTORY);
86
246
  continue;
87
- sum += units;
88
- kept.push(c);
89
- }
90
- return kept;
247
+ }
248
+ if (++spends > MAX_HISTORY)
249
+ throw tooManyTransactions(MAX_HISTORY);
250
+ const { charge, fetched, inputs } = await isCharge(client, t.txHash, sub.chargerLockHash);
251
+ if (charge)
252
+ return { charge: { txHash: t.txHash, age, fetched }, ownerAfter };
253
+ if (!isCleanup(fetched, inputs, sub.subscriberLockHash))
254
+ ownerAfter = true;
255
+ }
256
+ return { ownerAfter };
91
257
  }
92
258
  /** Whether `p` spends a cell under `lockHash`: who took part in the transaction. */
93
259
  const spends = (p, lockHash) => p.inputs.some((c) => c?.cellOutput.lock.hash() === lockHash);
94
- /** A cell of `token` under `payTo`'s anyone-can-pay lock to top up; random, so payers at once rarely collide. */
95
- async function tokenTarget(client, payToLock, token) {
96
- const targets = (await collect(client, payToLock, token)).filter((c) => !!c.cellOutput.type);
97
- const target = targets[Math.floor(Math.random() * targets.length)];
98
- if (!target) {
99
- throw new SessionBalanceError("payTo has no cell of this token to top up: create one (any amount, even 0) first");
100
- }
101
- return target;
102
- }
260
+ const NO_TOKEN_TARGET = "payTo has no cell of this token to top up: create one (any amount, even 0) first";
103
261
  /**
104
262
  * The subscriber opens a subscription, in one transaction and one signature: the
105
263
  * first period's price goes to `payTo` at once (a new cell cannot be charged for a
@@ -128,10 +286,6 @@ async function tokenTarget(client, payToLock, token) {
128
286
  */
129
287
  export async function openSubscription(subscriber, deployment, opts) {
130
288
  const client = subscriber.client;
131
- if (TESTNET_LEGACY_DEPLOYMENTS.some((d) => ccc.hexFrom(d.codeHash) === ccc.hexFrom(deployment.codeHash))) {
132
- // The 8 Oct code lacks errors 16-18 (review M5); no new subscription goes under an old lock.
133
- throw new SessionPolicyError("this is an old deployment of the session lock: open subscriptions under the current one");
134
- }
135
289
  const token = opts.token && ccc.Script.from(opts.token.type);
136
290
  if (token && opts.token) {
137
291
  await assertPlainXudt(token, client);
@@ -142,10 +296,15 @@ export async function openSubscription(subscriber, deployment, opts) {
142
296
  throw new SessionBalanceError("budget is required: the shannons the subscription cell holds");
143
297
  }
144
298
  const subscriberLock = (await subscriber.getRecommendedAddressObj()).script;
145
- await assertSigningLock(subscriberLock, client);
299
+ await assertSigningLock(subscriberLock, client, opts);
146
300
  const [chargerLock, payToLock] = await Promise.all([opts.charger, opts.payTo].map(async (a) => (await ccc.Address.fromString(a, client)).script));
147
- // A charger lock that signs nothing would let anyone trigger charges (review M2).
148
- await assertSigningLock(chargerLock, client);
301
+ // A charger lock that signs nothing would let anyone trigger charges (review M2). Its own
302
+ // opt-in: opting in for the subscriber's wallet does not waive review of the creator's lock (review W6 I1).
303
+ await checkSigningLock(chargerLock, client, !!opts.allowUnreviewedChargerLock, {
304
+ whose: "the charger's lock",
305
+ can: "charge a subscription",
306
+ optIn: "allowUnreviewedChargerLock",
307
+ });
149
308
  const sub = {
150
309
  subscriberLockHash: subscriberLock.hash(),
151
310
  chargerLockHash: chargerLock.hash(),
@@ -164,6 +323,9 @@ export async function openSubscription(subscriber, deployment, opts) {
164
323
  throw new SessionPolicyError(`each charge creates a cell for payTo, which needs ${ccc.fixedPointToString(newCell)} CKB: ` +
165
324
  "raise the price or use an anyone-can-pay address");
166
325
  }
326
+ // Not a legacy lock (the 8 Oct code lacks errors 16-18, review M5), and its code
327
+ // is live on this network (audit round 2, 31 Oct).
328
+ await assertCurrentDeployment(client, deployment);
167
329
  const lock = subscriptionLock(deployment, sub);
168
330
  // Two live cells under the same lock are one group with two rate-limited cells:
169
331
  // two charges per period (review M1).
@@ -180,26 +342,19 @@ export async function openSubscription(subscriber, deployment, opts) {
180
342
  }
181
343
  // The first period, paid now.
182
344
  if (token) {
183
- const target = await tokenTarget(client, payToLock, token);
184
- tx.inputs.push(ccc.CellInput.from({ previousOutput: target.outPoint }));
185
- tx.addOutput({ lock: payToLock, type: token, capacity: target.cellOutput.capacity }, tokenData(tokenAmount(target.outputData) + opts.price));
186
- await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.AnyoneCanPay, ccc.KnownScript.XUdt);
345
+ if (!(await addTopUp(tx, client, payToLock, { token, units: opts.price })))
346
+ throw new SessionBalanceError(NO_TOKEN_TARGET, "no-payout-cell");
187
347
  await tx.completeInputsByUdt(subscriber, token);
188
348
  const extra = (await tx.getInputsUdtBalance(client, token)) - tx.getOutputsUdtBalance(token);
189
349
  if (extra > 0n)
190
350
  tx.addOutput({ lock: subscriberLock, type: token }, tokenData(extra));
191
351
  }
192
352
  else {
193
- const target = acp ? (await collect(client, payToLock))[0] : undefined;
194
- if (target) {
195
- tx.inputs.push(ccc.CellInput.from({ previousOutput: target.outPoint }));
196
- tx.addOutput({ lock: payToLock, capacity: target.cellOutput.capacity + opts.price });
197
- await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.AnyoneCanPay);
198
- }
199
- else {
353
+ // A random cell, so openings at once rarely compete for one (review, 28 Oct).
354
+ if (!acp || !(await addTopUp(tx, client, payToLock, { ckb: opts.price }))) {
200
355
  tx.addOutput({ lock: payToLock, capacity: opts.price });
201
356
  if (tx.outputs[1].capacity !== opts.price) {
202
- throw new SessionBalanceError(`payTo has no cell to top up, and a new one needs ${ccc.fixedPointToString(newCell)} CKB`);
357
+ throw new SessionBalanceError(`payTo has no cell to top up, and a new one needs ${ccc.fixedPointToString(newCell)} CKB`, "no-payout-cell");
203
358
  }
204
359
  }
205
360
  }
@@ -225,42 +380,85 @@ export async function openSubscription(subscriber, deployment, opts) {
225
380
  }
226
381
  /**
227
382
  * The live cells of a subscription, their balance (shannons) and token balance
228
- * (units; 0 without a token), and how many blocks until one can be charged.
383
+ * (units; 0 without a token), and how many blocks until one can be charged: once
384
+ * a cell is a period old and the last charge (the newest transaction that spent
385
+ * a cell under the lock) is too. Throws a `SessionPolicyError` ("too-many-cells")
386
+ * with over `MAX_SUBSCRIPTION_CELLS` cells under the lock.
229
387
  */
230
388
  export async function findSubscriptionCells(client, deployment, sub) {
389
+ const { lock, cells, balance, tokenBalance, nextChargeIn } = await subscriptionState(client, deployment, sub);
390
+ return { lock, cells, balance, tokenBalance, nextChargeIn };
391
+ }
392
+ /**
393
+ * `findSubscriptionCells`, plus what it looked up on the way: the tip, and each
394
+ * cell's creating transaction and age (undefined while not committed), so a
395
+ * charge or a status fetches each of them once (review, 28 Oct).
396
+ */
397
+ async function subscriptionState(client, deployment, sub) {
231
398
  const lock = subscriptionLock(deployment, sub);
232
399
  const token = tokenOf(sub);
233
- const cells = await subscriptionCells(client, lock, token);
400
+ const cells = await subscriptionCells(client, lock, token, MAX_SUBSCRIPTION_CELLS);
234
401
  const balance = cells.reduce((s, c) => s + c.cellOutput.capacity, 0n);
235
402
  const tokenBalance = token ? tokensIn(cells.map((c) => ({ type: c.cellOutput.type, data: c.outputData })), token) : 0n;
236
- if (!cells.length)
237
- return { lock, cells, balance, tokenBalance, nextChargeIn: undefined };
238
- const tip = await client.getTip();
403
+ // Cells made by one transaction share one lookup.
404
+ const byHash = new Map();
405
+ const creating = (h) => {
406
+ if (!byHash.has(h))
407
+ byHash.set(h, client.getTransactionNoCache(h)); // the node's answer, not the cache's
408
+ return byHash.get(h);
409
+ };
410
+ const [tip, created] = await Promise.all([client.getTip(), Promise.all(cells.map((c) => creating(c.outPoint.txHash)))]);
411
+ const ages = created.map((res) => {
412
+ const born = res?.blockNumber;
413
+ return born === undefined || born === null ? undefined : tip - ccc.numFrom(born);
414
+ });
239
415
  let nextChargeIn;
240
- for (const c of cells) {
241
- const born = (await client.getTransaction(c.outPoint.txHash))?.blockNumber;
242
- if (born === undefined || born === null)
416
+ for (const age of ages) {
417
+ if (age === undefined)
243
418
  continue; // not committed yet
244
- const age = tip - ccc.numFrom(born);
245
419
  const wait = age >= sub.periodBlocks ? 0n : sub.periodBlocks - age;
246
420
  if (nextChargeIn === undefined || wait < nextChargeIn)
247
421
  nextChargeIn = wait;
248
422
  }
249
- return { lock, cells, balance, tokenBalance, nextChargeIn };
423
+ // The rate limit is per cell, so a cell older than the last charge (a top-up)
424
+ // could pay a second price in the same period: wait out the newest charge as
425
+ // well (review, 28 Oct). It is read from the lock's history, not from its change
426
+ // cell, which a charge of the whole balance does not leave (30 Oct). Only the
427
+ // charger's spends count: the subscriber's own (clearing planted cells) leave
428
+ // the subscription's cells as they were (mentor review, 2 Nov), and a cancel
429
+ // takes every cell, so whatever comes after it is younger than it anyway.
430
+ // One block more than a period: a status counts the period's last block as paid.
431
+ const { charge: spent, ownerAfter } = await lastCharge(client, lock, sub, tip, sub.periodBlocks + 1n);
432
+ if (nextChargeIn !== undefined && spent && sub.periodBlocks - spent.age > nextChargeIn)
433
+ nextChargeIn = sub.periodBlocks - spent.age;
434
+ return { lock, cells, balance, tokenBalance, nextChargeIn, tip, created, ages, spent, ownerAfter };
250
435
  }
251
436
  /**
252
437
  * The creator's charger key takes one price from the subscription and pays it to
253
438
  * `payTo` (topping up its anyone-can-pay cell with `topUp`). The fee comes from
254
439
  * one of the charger's own plain cells, which goes back to it smaller. Throws a
255
- * `SessionBalanceError` while no cell is a full period old, or once what is left
256
- * cannot cover a price and still leave a cell behind.
440
+ * `SessionBalanceError` while no cell is a full period old or the last charge is
441
+ * under one, or once what is left cannot cover a price and still leave a cell behind.
257
442
  *
258
443
  * In a token subscription the price is in token units and always tops up
259
444
  * `payTo`'s cell of the token (`topUp` is implied). The change keeps every
260
445
  * shannon that came in (no CKB may leave) and the tokens less one price, in
261
446
  * exactly 16 data bytes.
447
+ *
448
+ * With `topUp` (or a token), `payTo`'s cell is a confirmed one that no
449
+ * transaction in the node's pool spends, where the node can tell
450
+ * (`pickPayoutCell`). A charge the node rejects for a spent or unknown input is
451
+ * built again after clearing the client's cache, with another payout cell,
452
+ * after a short wait: up to `retry.attempts` in all (default 3), waiting
453
+ * `retry.delayMs` (default 1000) doubling each time (`rebuildOnDeadInputs`).
454
+ * `beforeSend` runs again with each new hash. A send whose outcome is unknown
455
+ * is never retried.
262
456
  */
263
457
  export async function chargeSubscription(charger, deployment, sub, opts) {
458
+ // Built on a cell another instance or payer spent since: rebuilt from live cells (audit, 31 Oct; review, 2 Nov).
459
+ return rebuildOnDeadInputs(charger.client, (avoid) => chargeOnce(charger, deployment, sub, opts, avoid), opts.retry);
460
+ }
461
+ async function chargeOnce(charger, deployment, sub, opts, avoid) {
264
462
  const client = charger.client;
265
463
  assertTerms(sub);
266
464
  const token = tokenOf(sub);
@@ -270,7 +468,7 @@ export async function chargeSubscription(charger, deployment, sub, opts) {
270
468
  const { script: payToLock } = await ccc.Address.fromString(opts.payTo, client);
271
469
  if (payToLock.hash() !== sub.payToLockHash)
272
470
  throw new SessionPolicyError("payTo is not the subscription's payout address");
273
- const state = await findSubscriptionCells(client, deployment, sub);
471
+ const state = await subscriptionState(client, deployment, sub);
274
472
  if (!state.cells.length)
275
473
  throw new SessionBalanceError("no subscription cell: cancelled, or not committed yet");
276
474
  if (state.nextChargeIn !== 0n) {
@@ -281,22 +479,16 @@ export async function chargeSubscription(charger, deployment, sub, opts) {
281
479
  const tx = ccc.Transaction.from({ cellDeps: [sessionLockCellDep(deployment)] });
282
480
  if (token) {
283
481
  if (!(await isAnyoneCanPay(payToLock, client)))
284
- throw new SessionPolicyError("a token subscription needs an anyone-can-pay payTo");
285
- const target = await tokenTarget(client, payToLock, token);
286
- tx.inputs.push(ccc.CellInput.from({ previousOutput: target.outPoint }));
287
- tx.addOutput({ lock: payToLock, type: token, capacity: target.cellOutput.capacity }, tokenData(tokenAmount(target.outputData) + sub.price));
288
- await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.AnyoneCanPay, ccc.KnownScript.XUdt);
482
+ throw new SessionPolicyError("a token subscription needs an anyone-can-pay payTo", "payto-not-anyone-can-pay");
483
+ if (!(await addTopUp(tx, client, payToLock, { token, units: sub.price }, avoid)))
484
+ throw new SessionBalanceError(NO_TOKEN_TARGET, "no-payout-cell");
289
485
  }
290
486
  else if (opts.topUp) {
291
487
  if (!(await isAnyoneCanPay(payToLock, client)))
292
- throw new SessionPolicyError("topUp needs an anyone-can-pay payTo");
293
- const targets = await collect(client, payToLock);
294
- const target = targets[Math.floor(Math.random() * targets.length)];
295
- if (!target)
296
- throw new SessionBalanceError("payTo has no anyone-can-pay cell to top up");
297
- tx.inputs.push(ccc.CellInput.from({ previousOutput: target.outPoint }));
298
- tx.addOutput({ lock: payToLock, capacity: target.cellOutput.capacity + sub.price });
299
- await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.AnyoneCanPay);
488
+ throw new SessionPolicyError("topUp needs an anyone-can-pay payTo", "payto-not-anyone-can-pay");
489
+ if (!(await addTopUp(tx, client, payToLock, { ckb: sub.price }, avoid))) {
490
+ throw new SessionBalanceError("payTo has no anyone-can-pay cell to top up", "no-payout-cell");
491
+ }
300
492
  }
301
493
  else {
302
494
  tx.addOutput({ lock: payToLock, capacity: sub.price });
@@ -306,7 +498,7 @@ export async function chargeSubscription(charger, deployment, sub, opts) {
306
498
  }
307
499
  // Every mature cell, merged into one change: the rate limit is per cell, so
308
500
  // leaving one out would let it pay another price in the same period (code review, 26 Oct).
309
- const taken = fitting(await matureCells(client, state.cells, sub.periodBlocks), token);
501
+ const taken = state.cells.filter((_, i) => (state.ages[i] ?? -1n) >= sub.periodBlocks);
310
502
  const ckbIn = taken.reduce((s, c) => s + c.cellOutput.capacity, 0n);
311
503
  const since = relativeBlocksSince(sub.periodBlocks);
312
504
  tx.inputs.unshift(...taken.map((c) => ccc.CellInput.from({ previousOutput: c.outPoint, since })));
@@ -329,15 +521,41 @@ export async function chargeSubscription(charger, deployment, sub, opts) {
329
521
  tx.addOutput({ lock: state.lock, capacity: change });
330
522
  }
331
523
  // The charger's own cell authorises the charge and pays the fee; it comes back last, smaller.
524
+ // A random one, so charges of many subscriptions at once rarely collide (review, 28 Oct).
332
525
  const floor = keyCellFloor(chargerLock);
333
- const keyCell = (await collect(client, chargerLock)).find((c) => c.cellOutput.capacity > floor);
526
+ const keyCell = randomPick(await feeCells(client, chargerLock, floor));
334
527
  if (!keyCell)
335
- throw new SessionBalanceError(`the charger needs a plain cell above ${ccc.fixedPointToString(floor)} CKB for fees`);
528
+ throw new SessionBalanceError(`the charger needs a plain cell of at least ${ccc.fixedPointToString(floor + FEE_CELL_MARGIN)} CKB for fees`, "no-fee-cell");
336
529
  tx.inputs.push(ccc.CellInput.from({ previousOutput: keyCell.outPoint }));
337
530
  tx.addOutput({ lock: chargerLock, capacity: 0n });
338
- await tx.completeFeeChangeToOutput(charger, tx.outputs.length - 1);
531
+ await completeFeeOrNoFeeCell(tx, charger, `the charger's cell cannot cover the fee; send it a plain cell above ${ccc.fixedPointToString(floor + FEE_CELL_MARGIN)} CKB`);
339
532
  return signAndSend(charger, tx, opts.beforeSend);
340
533
  }
534
+ /**
535
+ * What a charger's fee cell holds above the cell it comes back as, at least: far
536
+ * more than a charge's fee (about 0.00001 CKB at the minimum fee rate). A cell
537
+ * just above the floor would pass the filter and then fail inside CCC (round-2
538
+ * fuzz, 31 Oct).
539
+ */
540
+ const FEE_CELL_MARGIN = 1000000n; // 0.01 CKB
541
+ /**
542
+ * Plain cells of the charger of at least `floor` plus `FEE_CELL_MARGIN`: at most `MAX_SUBSCRIPTION_CELLS` of
543
+ * them, from the first `FEE_CELL_SCAN` cells under its lock. Any one of them pays
544
+ * a fee, so there is no need to read every cell someone may have sent to the
545
+ * charger's address (security audit, 30 Oct).
546
+ */
547
+ async function feeCells(client, lock, floor) {
548
+ const cells = [];
549
+ let seen = 0;
550
+ for await (const cell of client.findCellsByLock(lock, null, true)) {
551
+ if (++seen > FEE_CELL_SCAN || cells.length >= MAX_SUBSCRIPTION_CELLS)
552
+ break;
553
+ if (!cell.cellOutput.type && cell.outputData === "0x" && cell.cellOutput.capacity >= floor + FEE_CELL_MARGIN)
554
+ cells.push(cell);
555
+ }
556
+ // The change of this client's last charge, spent since by another instance, must not pay this one's fee.
557
+ return withoutDeadCached(client, lock, cells);
558
+ }
341
559
  /**
342
560
  * The subscriber takes everything left back, in owner mode: no creator involved
343
561
  * and no wait. `nothingFound` means no cell is left (already cancelled, or run dry).
@@ -349,20 +567,119 @@ export async function cancelSubscription(subscriber, deployment, sub) {
349
567
  const lock = (await subscriber.getRecommendedAddressObj()).script;
350
568
  if (lock.hash() !== sub.subscriberLockHash)
351
569
  throw new SessionPolicyError("this signer is not the subscriber");
352
- const { cells, balance, tokenBalance, lock: subLock } = await findSubscriptionCells(subscriber.client, deployment, sub);
570
+ // Every cell, without MAX_SUBSCRIPTION_CELLS: cancelling is how a subscriber
571
+ // clears cells planted under its lock, and the reads are its own.
572
+ const subLock = subscriptionLock(deployment, sub);
353
573
  const token = tokenOf(sub);
574
+ const cells = await subscriptionCells(subscriber.client, subLock, token);
575
+ const balance = cells.reduce((s, c) => s + c.cellOutput.capacity, 0n);
576
+ const tokenBalance = token ? tokensIn(cells.map((c) => ({ type: c.cellOutput.type, data: c.outputData })), token) : 0n;
354
577
  // A charger may leave a token subscription's last change untyped; its CKB is the subscriber's too (review, 27 Oct).
355
578
  const untyped = token ? await subscriptionCells(subscriber.client, subLock) : [];
356
- const all = [...cells, ...untyped];
579
+ // Look-alikes of the token planted under the lock go back with the rest (review W6 M1).
580
+ const lookalikes = token ? (await lookalikeCells(subscriber.client, subLock, token)).cells : [];
581
+ const all = [...cells, ...untyped, ...lookalikes];
357
582
  if (!all.length)
358
583
  return { refunded: 0n, nothingFound: true };
359
- const sweep = fitting(all, token);
360
- const txHash = await ownerSweep(subscriber, deployment, sweep);
361
- const refunded = sweep.reduce((s, c) => s + c.cellOutput.capacity, 0n);
584
+ const txHash = await ownerSweep(subscriber, deployment, all);
585
+ const refunded = all.reduce((s, c) => s + c.cellOutput.capacity, 0n);
362
586
  return {
363
587
  txHash,
364
588
  refunded: token ? refunded : balance,
365
- ...(token && { refundedTokens: tokensIn(sweep.map((c) => ({ type: c.cellOutput.type, data: c.outputData })), token) }),
589
+ ...(token && { refundedTokens: tokenBalance }),
590
+ };
591
+ }
592
+ /** The most cells under a subscription's lock that one `clearPlantedCells` reads. */
593
+ const CLEAR_SCAN = 4 * MAX_SUBSCRIPTION_CELLS;
594
+ /**
595
+ * The most planted cells one `clearPlantedCells` transaction sweeps: with the
596
+ * subscriber's own input (and room for one more for the fee) it stays within
597
+ * `MAX_PAYMENT_INPUTS`, so a status can read it and never takes it for a
598
+ * charge, which would delay the next one (review W6 L3).
599
+ */
600
+ const CLEAR_BATCH = MAX_PAYMENT_INPUTS - 2;
601
+ /**
602
+ * The subscriber clears cells a third party created under the subscription's
603
+ * lock, without cancelling (mentor review, 2 Nov). Anyone can create cells under
604
+ * any lock; past `MAX_SUBSCRIPTION_CELLS` of the subscription's kind, a status or
605
+ * a charge is refused ("too-many-cells").
606
+ *
607
+ * A cell is the subscription's own when its creating transaction is the
608
+ * opening (`sub.openTx`), or had an input under the subscriber's or the
609
+ * charger's lock: an opening, a charge, a top-up by the subscriber. Nobody else
610
+ * can sign for those. Every other cell of the subscription's kind is planted:
611
+ * one owner-mode transaction (one signature) sweeps them to the subscriber,
612
+ * who keeps their CKB and tokens. The subscription's own cells are not touched,
613
+ * so their age and the payments that made them still count: access and the
614
+ * charge schedule go on as before.
615
+ *
616
+ * In a token subscription, look-alikes of the token (cells typed with the
617
+ * token's code and longer args that start with the token's, which xUDT accepts
618
+ * and anyone can create with 0 units) are planted too, and swept the same way
619
+ * when an owner sweep can spend them (plain xUDT, no flags; review W6 M1).
620
+ *
621
+ * Bounded: one call keeps at most `4 * MAX_SUBSCRIPTION_CELLS` cells of the
622
+ * kind, reads at most twice as many listed cells for each of the two scans,
623
+ * and looks each creating transaction up once. `more` says cells were left
624
+ * unread: call again once this sweep commits. Refused when none of the cells read is the
625
+ * subscription's own: cancel instead. `nothingFound` when nothing is planted.
626
+ */
627
+ export async function clearPlantedCells(subscriber, deployment, sub) {
628
+ const client = subscriber.client;
629
+ const lock = (await subscriber.getRecommendedAddressObj()).script;
630
+ if (lock.hash() !== sub.subscriberLockHash)
631
+ throw new SessionPolicyError("this signer is not the subscriber");
632
+ const subLock = subscriptionLock(deployment, sub);
633
+ const token = tokenOf(sub);
634
+ // At most CLEAR_SCAN cells of the kind, from at most twice as many listed (review W6 M1).
635
+ let cells = [];
636
+ let more = false;
637
+ let read = 0;
638
+ for await (const cell of client.findCells({ script: subLock, scriptType: "lock", scriptSearchMode: "exact", filter: kindFilter(token), withData: true })) {
639
+ if (cells.length >= CLEAR_SCAN || ++read > 2 * CLEAR_SCAN) {
640
+ more = true;
641
+ break;
642
+ }
643
+ if (ofKind(cell, token))
644
+ cells.push(cell);
645
+ }
646
+ cells = await withoutDeadCached(client, subLock, cells);
647
+ if (token)
648
+ cells = await withinU128(client, cells);
649
+ // Look-alikes of the token are always planted; the scan reads past the token's own cells too.
650
+ const lookalikes = token ? await lookalikeCells(client, subLock, token, 2 * CLEAR_SCAN) : { cells: [], more: false };
651
+ if (lookalikes.more)
652
+ more = true;
653
+ // One look-up per creating transaction, however many cells it made.
654
+ const origin = new Map();
655
+ const own = (c) => {
656
+ const h = c.outPoint.txHash;
657
+ if (sub.openTx && h === ccc.hexFrom(sub.openTx))
658
+ return Promise.resolve(true);
659
+ if (!origin.has(h))
660
+ origin.set(h, ownOrigin(client, c));
661
+ return origin.get(h);
662
+ };
663
+ const mine = await Promise.all(cells.map(own));
664
+ const plantedOfKind = cells.filter((_, i) => !mine[i]);
665
+ const found = [...plantedOfKind, ...lookalikes.cells];
666
+ if (!found.length)
667
+ return { swept: 0, refunded: 0n, more, ...(!more && { nothingFound: true }) };
668
+ // Every cell read and none the subscription's own: it is gone (cancel instead). With
669
+ // cells left unread, its own may lie past them (review W6 L1), and sweeping planted ones is harmless.
670
+ if (!more && !mine.some(Boolean) && plantedOfKind.length) {
671
+ throw new SessionPolicyError("none of these cells is the subscription's own: cancel it instead (cancelSubscription)");
672
+ }
673
+ const planted = found.slice(0, CLEAR_BATCH);
674
+ if (found.length > planted.length)
675
+ more = true;
676
+ const txHash = await ownerSweep(subscriber, deployment, planted, { memo: CLEAR_PLANTED_MEMO });
677
+ return {
678
+ txHash,
679
+ swept: planted.length,
680
+ refunded: planted.reduce((s, c) => s + c.cellOutput.capacity, 0n),
681
+ ...(token && { refundedTokens: tokensIn(planted.map((c) => ({ type: c.cellOutput.type, data: c.outputData })), token) }),
682
+ more,
366
683
  };
367
684
  }
368
685
  /** How many more prices a subscription's cells can pay. */
@@ -487,8 +804,6 @@ export async function verifySubscription(client, deployment, outPoint, expected)
487
804
  paidThroughBlock: created.block + sub.periodBlocks,
488
805
  };
489
806
  }
490
- /** The most inputs a creating transaction may have before its inputs are looked up (each is an RPC call). */
491
- const MAX_PAYMENT_INPUTS = 32;
492
807
  /**
493
808
  * The committed transaction `txHash`, if it paid `sub`'s payout address enough;
494
809
  * otherwise why not. Enough is the sum of the prices of every subscription cell
@@ -501,8 +816,11 @@ const MAX_PAYMENT_INPUTS = 32;
501
816
  * the units the payout address gained in cells typed with exactly that token
502
817
  * and holding at least 16 data bytes. CKB and token payments never count for each other.
503
818
  */
504
- async function paymentOf(client, deployment, txHash, sub) {
505
- const created = await client.getTransaction(txHash);
819
+ async function paymentOf(client, deployment, txHash, sub,
820
+ /** `txHash`'s response, when already fetched. */
821
+ fetched) {
822
+ // The node's answer, never CCC's cache: a cached "committed" outlives a reorg (audit, 31 Oct).
823
+ const created = fetched ?? (await client.getTransactionNoCache(txHash));
506
824
  if (!created || created.status !== "committed" || created.blockNumber === undefined || created.blockNumber === null) {
507
825
  return "the transaction that created the cell is not committed";
508
826
  }
@@ -530,20 +848,22 @@ async function paymentOf(client, deployment, txHash, sub) {
530
848
  }
531
849
  if (owed < sub.price)
532
850
  owed = sub.price;
851
+ const toPayTo = (cells) => cells.filter((c) => c.cellOutput.lock.hash() === sub.payToLockHash);
852
+ const amount = (cells) => token
853
+ ? tokensIn(cells.map((c) => ({ type: c.cellOutput.type, data: c.data })), token)
854
+ : cells.reduce((s, c) => (!c.cellOutput.type ? s + c.cellOutput.capacity : s), 0n);
855
+ const out = amount(toPayTo(tx.outputs.map((cellOutput, i) => ({ cellOutput, data: tx.outputsData[i] ?? "0x" }))));
856
+ const notPaid = "the transaction that created the cell did not pay the price to payTo";
857
+ // What payTo gained is at most what its outputs hold: a transaction that falls
858
+ // short already is refused before its inputs are looked up (security audit, 30 Oct).
859
+ if (out < owed)
860
+ return notPaid;
533
861
  const inputs = await Promise.all(tx.inputs.map((i) => client.getCell(i.previousOutput)));
534
862
  if (inputs.some((c) => !c))
535
863
  return "an input of the transaction could not be looked up";
536
- const toPayTo = (cells) => cells.filter((c) => c.cellOutput.lock.hash() === sub.payToLockHash);
537
- const outs = toPayTo(tx.outputs.map((cellOutput, i) => ({ cellOutput, data: tx.outputsData[i] ?? "0x" })));
538
- const ins = toPayTo(inputs.map((c) => ({ cellOutput: c.cellOutput, data: c.outputData })));
539
- const paid = token
540
- ? tokensIn(outs.map((c) => ({ type: c.cellOutput.type, data: c.data })), token) -
541
- tokensIn(ins.map((c) => ({ type: c.cellOutput.type, data: c.data })), token)
542
- : [outs, ins]
543
- .map((cells) => cells.reduce((s, c) => (!c.cellOutput.type ? s + c.cellOutput.capacity : s), 0n))
544
- .reduce((o, i) => o - i);
864
+ const paid = out - amount(toPayTo(inputs.map((c) => ({ cellOutput: c.cellOutput, data: c.outputData }))));
545
865
  if (paid < owed)
546
- return "the transaction that created the cell did not pay the price to payTo";
866
+ return notPaid;
547
867
  return { tx, block: ccc.numFrom(created.blockNumber), inputs };
548
868
  }
549
869
  /** Whether `p` carries `subscriptionMemo(account)` in the witness of an input under the subscriber's lock. */
@@ -560,6 +880,11 @@ function commitsToAccount(p, sub, account) {
560
880
  * Each must have been created by a payment, so a cell re-created by the
561
881
  * subscriber to reset its age counts for nothing. Cancelling ends access at once.
562
882
  * A token subscription (`sub.token`) counts payments in its token, as `verifySubscription` does.
883
+ *
884
+ * Throws a `SessionPolicyError` of reason "too-many-cells" when over
885
+ * `MAX_SUBSCRIPTION_CELLS` cells (or recent transactions) sit under the lock:
886
+ * not a verdict on the subscription, so not an `ok: false`. A server should
887
+ * answer it as a conflict the subscriber can clear by cancelling and opening again.
563
888
  */
564
889
  export async function subscriptionStatus(client, deployment, sub, proof) {
565
890
  try {
@@ -572,24 +897,36 @@ export async function subscriptionStatus(client, deployment, sub, proof) {
572
897
  return { ok: false, reason: e instanceof Error ? e.message : String(e) };
573
898
  }
574
899
  const lock = subscriptionLock(deployment, sub);
575
- const opening = await paymentOf(client, deployment, proof.openTx, sub);
900
+ // One check per transaction, however many cells it made (security audit, 30 Oct).
901
+ const checked = new Map();
902
+ const payment = (txHash, fetched) => {
903
+ if (!checked.has(txHash))
904
+ checked.set(txHash, paymentOf(client, deployment, txHash, sub, fetched));
905
+ return checked.get(txHash);
906
+ };
907
+ const openHash = ccc.hexFrom(proof.openTx);
908
+ const opening = await payment(openHash);
576
909
  if (typeof opening === "string")
577
910
  return { ok: false, reason: `opening: ${opening}` };
578
911
  if (!opening.tx.outputs.some((o) => o.lock.eq(lock)))
579
912
  return { ok: false, reason: "the opening created no cell under this subscription's lock" };
580
913
  if (!commitsToAccount(opening, sub, proof.account))
581
914
  return { ok: false, reason: "the opening does not commit to this account" };
582
- const { cells, balance, tokenBalance, nextChargeIn } = await findSubscriptionCells(client, deployment, sub);
583
- const tip = await client.getTip();
915
+ const { cells, balance, tokenBalance, nextChargeIn, tip, created, spent, ownerAfter } = await subscriptionState(client, deployment, sub);
584
916
  let paidThroughBlock;
585
- const openHash = ccc.hexFrom(proof.openTx);
586
- for (const c of cells) {
587
- const p = await paymentOf(client, deployment, c.outPoint.txHash, sub);
588
- if (typeof p === "string")
589
- continue;
590
- // Only the proven opening or a charge renews; another payment to payTo that
591
- // also creates a cell here (a paid read, say) is not a period (review, 27 Oct).
592
- if (c.outPoint.txHash !== openHash && !spends(p, sub.chargerLockHash))
917
+ const renews = (p, txHash) =>
918
+ // Only the proven opening or a charge renews; another payment to payTo that
919
+ // also creates a cell here (a paid read, say) is not a period (review, 27 Oct).
920
+ typeof p !== "string" && (txHash === openHash || spends(p, sub.chargerLockHash));
921
+ const paying = cells.map((c, i) => ({ txHash: c.outPoint.txHash, fetched: created[i] }));
922
+ // A charge of the whole balance leaves no cell, but still paid for its period
923
+ // (30 Oct), unless the subscriber spent cells after it: a cancel ends access at once.
924
+ if (spent && !ownerAfter && !paying.some((c) => c.txHash === spent.txHash))
925
+ paying.push({ txHash: spent.txHash, fetched: spent.fetched });
926
+ const payments = await Promise.all(paying.map((c) => payment(c.txHash, c.fetched)));
927
+ for (const [i, { txHash }] of paying.entries()) {
928
+ const p = payments[i];
929
+ if (!renews(p, txHash))
593
930
  continue;
594
931
  const through = p.block + sub.periodBlocks;
595
932
  if (paidThroughBlock === undefined || through > paidThroughBlock)