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/dist/onchain.js CHANGED
@@ -1,13 +1,33 @@
1
1
  import { ccc } from "@ckb-ccc/core";
2
2
  import { relativeBlocksSince, sessionLockCellDep, sessionLockScript, } from "./lock.js";
3
+ import { assertCurrentDeployment } from "./deployment.js";
3
4
  import { checkRequest, isActive, SessionPolicyError } from "./session.js";
4
5
  import { ScopeError, sessionSigner } from "./transfer.js";
5
6
  export class SessionBalanceError extends Error {
6
- constructor(message) {
7
+ reason;
8
+ constructor(message, reason) {
7
9
  super(message);
8
10
  this.name = "SessionBalanceError";
11
+ if (reason)
12
+ this.reason = reason;
9
13
  }
10
14
  }
15
+ /** The warnings for a session opened with these terms. */
16
+ function sessionWarnings(minInterval, recipient) {
17
+ if (minInterval > 1n)
18
+ return [];
19
+ return [
20
+ {
21
+ code: "no-rate-limit",
22
+ message: (minInterval === 0n
23
+ ? "minInterval is 0: no rate limit. Each spend is held to maxPerTx, but spends can repeat every block "
24
+ : "minInterval is 1: almost no rate limit. Each spend is held to maxPerTx, but spends can repeat one block apart ") +
25
+ "until the session is empty, so a leaked key can drain the whole budget" +
26
+ (recipient ? " (with a recipient too: the fee is not an output, so up to maxPerTx can go to the miner each time)" : "") +
27
+ ". Set minInterval to limit how often the key can spend.",
28
+ },
29
+ ];
30
+ }
11
31
  const KEY_CELL_MIN = 61n * 100000000n;
12
32
  /** What the key cell keeps above the cell it returns to the owner as, for fees. */
13
33
  const FEE_MARGIN = 1n * 100000000n;
@@ -20,11 +40,28 @@ export function keyCellFloor(ownerLock) {
20
40
  const ownerCell = ccc.CellOutput.from({ lock: ownerLock }, "0x").capacity;
21
41
  return ownerCell > KEY_CELL_MIN ? ownerCell : KEY_CELL_MIN;
22
42
  }
43
+ /**
44
+ * A send failed and the node could not then be asked whether it has the
45
+ * transaction (review W6 L4): it may have been taken and committed. Never
46
+ * retried; `txHash` is the transaction to look for, and `cause` the send's own
47
+ * error. Its message never repeats the cause's, so nothing reads it as a
48
+ * refusal for a spent input.
49
+ */
50
+ export class SendOutcomeUnknownError extends Error {
51
+ txHash;
52
+ constructor(txHash, cause) {
53
+ super(`the send of ${txHash} failed and its status could not be looked up: it may still commit; check it before paying again`, { cause });
54
+ this.name = "SendOutcomeUnknownError";
55
+ this.txHash = txHash;
56
+ }
57
+ }
23
58
  /**
24
59
  * Signs `tx`, hands its final hash to `beforeSend`, then sends it. The hash is
25
60
  * known before anything leaves, so a caller can record the payment or the key
26
61
  * against it first. If the send throws but the node did take the transaction (a
27
- * timeout on the reply, say), the hash is returned all the same.
62
+ * timeout on the reply, say), the hash is returned all the same. If the send
63
+ * throws and the status lookup fails too, a `SendOutcomeUnknownError` is thrown
64
+ * instead of the send's error, which `rebuildOnDeadInputs` never retries.
28
65
  */
29
66
  export async function signAndSend(signer, tx, beforeSend) {
30
67
  const signed = await signer.signTransaction(tx);
@@ -34,7 +71,14 @@ export async function signAndSend(signer, tx, beforeSend) {
34
71
  return await signer.client.sendTransaction(signed);
35
72
  }
36
73
  catch (e) {
37
- const known = await signer.client.getTransaction(hash).catch(() => undefined);
74
+ let known;
75
+ try {
76
+ known = await signer.client.getTransaction(hash);
77
+ }
78
+ catch {
79
+ // A lost reply, then a fallback node answering Dead because this one committed: not a refusal.
80
+ throw new SendOutcomeUnknownError(hash, e);
81
+ }
38
82
  if (known && ["pending", "proposed", "committed"].includes(known.status))
39
83
  return hash;
40
84
  throw e;
@@ -68,6 +112,175 @@ export const plainTokenCell = (cell, type) => cell.cellOutput.type?.eq(type) ===
68
112
  export async function sessionLockOf(session, client, deployment, binding) {
69
113
  return sessionLockScript(deployment, params(session, await keyLockOf(session, client), binding));
70
114
  }
115
+ const U128_MAX = (1n << 128n) - 1n;
116
+ /** The most inputs a creating transaction may have for its origin to be looked up (each is an RPC call). */
117
+ const MAX_ORIGIN_INPUTS = 32;
118
+ /**
119
+ * Whether a session-lock cell was created by a transaction its owner or its
120
+ * session key took part in (an input under the lock hash in args bytes 0..32 or
121
+ * 32..64). Nobody else can sign for those, so a planted cell never passes.
122
+ */
123
+ export async function ownOrigin(client, cell) {
124
+ const args = ccc.bytesFrom(cell.cellOutput.lock.args);
125
+ const own = [ccc.hexFrom(args.slice(0, 32)), ccc.hexFrom(args.slice(32, 64))];
126
+ const inputs = (await client.getTransaction(cell.outPoint.txHash))?.transaction.inputs ?? [];
127
+ if (!inputs.length || inputs.length > MAX_ORIGIN_INPUTS)
128
+ return false;
129
+ const spent = await Promise.all(inputs.map((i) => client.getCell(i.previousOutput)));
130
+ return spent.some((c) => !!c && own.includes(c.cellOutput.lock.hash()));
131
+ }
132
+ /**
133
+ * `cells` less those whose token amounts would overflow a u128, per token type.
134
+ * Anyone can plant a cell holding 2^128-1 units of a free token under a session
135
+ * lock, and xUDT refuses a transaction whose inputs overflow, so taking every
136
+ * cell would block spends, closes, recovery, charges and cancels (review, 27 and
137
+ * 28 Oct). Which cells stay does not depend on indexer order: cells of the
138
+ * owner's or the key's own making first, then the smallest amounts. Without an
139
+ * overflow nothing is looked up and every cell stays, in order.
140
+ */
141
+ export async function withinU128(client, cells) {
142
+ const units = (c) => c.cellOutput.type && ccc.bytesFrom(c.outputData).length >= 16 ? tokenAmount(c.outputData) : 0n;
143
+ const byType = new Map();
144
+ for (const c of cells) {
145
+ if (!units(c))
146
+ continue;
147
+ const key = c.cellOutput.type.hash();
148
+ byType.set(key, [...(byType.get(key) ?? []), c]);
149
+ }
150
+ const dropped = new Set();
151
+ for (const group of byType.values()) {
152
+ if (group.reduce((s, c) => s + units(c), 0n) <= U128_MAX)
153
+ continue;
154
+ const own = await Promise.all(group.map((c) => ownOrigin(client, c)));
155
+ const ranked = group
156
+ .map((cell, i) => ({ cell, own: own[i], units: units(cell) }))
157
+ .sort((a, b) => (a.own !== b.own ? (a.own ? -1 : 1) : a.units < b.units ? -1 : a.units > b.units ? 1 : 0));
158
+ let sum = 0n;
159
+ for (const r of ranked) {
160
+ if (sum + r.units > U128_MAX)
161
+ dropped.add(r.cell);
162
+ else
163
+ sum += r.units;
164
+ }
165
+ }
166
+ return dropped.size ? cells.filter((c) => !dropped.has(c)) : cells;
167
+ }
168
+ /** One of `cells` at random: payers or chargers at once then rarely compete for the same cell. */
169
+ export const randomPick = (cells) => cells[Math.floor(Math.random() * cells.length)];
170
+ /** Whether `lock` is this network's anyone-can-pay lock (RFC 0026). */
171
+ export async function isAnyoneCanPay(lock, client) {
172
+ const acp = await client.getKnownScript(ccc.KnownScript.AnyoneCanPay);
173
+ return lock.codeHash === acp.codeHash && lock.hashType === acp.hashType;
174
+ }
175
+ /** The most cells of a recipient read when picking one to top up: any of them will do. */
176
+ export const MAX_PAYOUT_CELLS = 64;
177
+ /** The most payout cells a pick asks the node about (`get_live_cell` with the pool), all at once. */
178
+ export const MAX_POOL_CHECKS = 4;
179
+ const outPointKey = (op) => ccc.hexFrom(ccc.OutPoint.from(op).toBytes());
180
+ /** The out points CCC's client cache lists as live under `lock`: outputs of transactions this client sent. */
181
+ async function cachedOutPoints(client, lock) {
182
+ const cached = new Set();
183
+ if (!client.cache)
184
+ return cached;
185
+ for await (const c of client.cache.findCells({ script: lock, scriptType: "lock", scriptSearchMode: "exact", withData: true })) {
186
+ cached.add(outPointKey(c.outPoint));
187
+ }
188
+ return cached;
189
+ }
190
+ function shuffled(xs) {
191
+ const a = [...xs];
192
+ for (let i = a.length - 1; i > 0; i--) {
193
+ const j = Math.floor(Math.random() * (i + 1));
194
+ [a[i], a[j]] = [a[j], a[i]];
195
+ }
196
+ return a;
197
+ }
198
+ const payoutBusy = () => new SessionBalanceError("every payout cell is being spent by another payment in the node's pool: try again after a block, " +
199
+ "or give the recipient more cells (createPayoutCells)", "payout-busy");
200
+ /**
201
+ * Which of `cells` (the recipient's anyone-can-pay cells) to top up, so payers at
202
+ * the same moment rarely take the same one (mentor review, 2 Nov):
203
+ *
204
+ * - confirmed cells first (listed by the indexer), then this client's own
205
+ * outputs still pending (only CCC's cache lists them), each group shuffled;
206
+ * - `avoid` (out points a node rejection named as spent) left out;
207
+ * - the first `MAX_POOL_CHECKS` asked at once whether a transaction in the
208
+ * node's pool spends them (`get_live_cell` with `include_tx_pool`: a cell a
209
+ * pool transaction consumes is dead); the first free one is taken.
210
+ *
211
+ * If the node cannot be asked, an unchecked cell is taken (a rejected send is
212
+ * retried). If every cell checked is spent in the pool, an unchecked one is taken
213
+ * if any is left; otherwise a `SessionBalanceError` "payout-busy" is thrown and
214
+ * nothing is sent. Undefined when `cells` is empty.
215
+ */
216
+ export async function pickPayoutCell(client, lock, cells, avoid = new Set()) {
217
+ if (!cells.length)
218
+ return undefined;
219
+ const usable = cells.filter((c) => !avoid.has(outPointKey(c.outPoint)));
220
+ if (!usable.length)
221
+ throw payoutBusy();
222
+ const cached = await cachedOutPoints(client, lock);
223
+ const isCached = (c) => cached.has(outPointKey(c.outPoint));
224
+ const order = [...shuffled(usable.filter((c) => !isCached(c))), ...shuffled(usable.filter(isCached))];
225
+ const checked = order.slice(0, MAX_POOL_CHECKS);
226
+ const states = await Promise.all(checked.map(async (c) => {
227
+ try {
228
+ return (await client.getCellLive(c.outPoint, false, true)) ? "free" : "taken";
229
+ }
230
+ catch {
231
+ return "unknown"; // the node cannot be asked
232
+ }
233
+ }));
234
+ const first = (state) => checked[states.indexOf(state)];
235
+ const picked = first("free") ?? first("unknown") ?? randomPick(order.slice(MAX_POOL_CHECKS));
236
+ if (!picked)
237
+ throw payoutBusy();
238
+ return picked;
239
+ }
240
+ /**
241
+ * Plain cells under the recipient's `lock` (with `token`, cells holding exactly
242
+ * that token), at most `MAX_PAYOUT_CELLS` of them from the first
243
+ * `4 * MAX_PAYOUT_CELLS` listed: anyone can create cells under any lock. The
244
+ * indexer lists only cells of that kind (no type and no data; or exactly the
245
+ * token, with a 16-byte amount), so cells of any other kind planted under an
246
+ * anyone-can-pay `lock` never push the real ones out of reach (review W6 L5).
247
+ */
248
+ async function payoutCells(client, lock, token) {
249
+ const cells = [];
250
+ let seen = 0;
251
+ const filter = token
252
+ ? { script: token, scriptLenRange: [token.occupiedSize, token.occupiedSize + 1], outputDataLenRange: [16, 17] }
253
+ : { scriptLenRange: [0, 1], outputDataLenRange: [0, 1] };
254
+ for await (const cell of client.findCells({ script: lock, scriptType: "lock", scriptSearchMode: "exact", filter, withData: true })) {
255
+ if (++seen > 4 * MAX_PAYOUT_CELLS || cells.length >= MAX_PAYOUT_CELLS)
256
+ break;
257
+ if (token ? plainTokenCell(cell, token) : !cell.cellOutput.type && cell.outputData === "0x")
258
+ cells.push(cell);
259
+ }
260
+ return withoutDeadCached(client, lock, cells);
261
+ }
262
+ /**
263
+ * Tops up one cell under the anyone-can-pay `lock` in `tx`: one input, and one
264
+ * output holding `ckb` shannons more, or with `token`, `units` more of it in a
265
+ * cell of that token. The cell is chosen by `pickPayoutCell`, never one in
266
+ * `avoid`. Returns the cell, or undefined when `lock` has none to add to.
267
+ */
268
+ export async function addTopUp(tx, client, lock, add, avoid) {
269
+ const { token } = add;
270
+ const cells = await payoutCells(client, lock, token);
271
+ // A cell the payment would take past a u128 of tokens cannot be topped up (xUDT refuses it).
272
+ const room = token ? cells.filter((c) => tokenAmount(c.outputData) + (add.units ?? 0n) <= U128_MAX) : cells;
273
+ if (cells.length && !room.length) {
274
+ throw new SessionBalanceError("every token cell of the recipient would hold over 2^128 - 1 units after this payment", "no-payout-cell");
275
+ }
276
+ const target = await pickPayoutCell(client, lock, room, avoid);
277
+ if (!target)
278
+ return undefined;
279
+ tx.inputs.push(ccc.CellInput.from({ previousOutput: target.outPoint }));
280
+ tx.addOutput({ lock, type: target.cellOutput.type, capacity: target.cellOutput.capacity + (add.ckb ?? 0n) }, token ? tokenData(tokenAmount(target.outputData) + (add.units ?? 0n)) : "0x");
281
+ await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.AnyoneCanPay, ...(token ? [ccc.KnownScript.XUdt] : []));
282
+ return target;
283
+ }
71
284
  /** Plain cells under `lock`; with `token`, also cells holding exactly that token. */
72
285
  export async function collect(client, lock, token) {
73
286
  const cells = [];
@@ -75,13 +288,131 @@ export async function collect(client, lock, token) {
75
288
  if ((!cell.cellOutput.type && cell.outputData === "0x") || (token && plainTokenCell(cell, token)))
76
289
  cells.push(cell);
77
290
  }
78
- return cells;
291
+ return withoutDeadCached(client, lock, cells);
292
+ }
293
+ /**
294
+ * `cells` (found under `lock`) less those only CCC's client cache vouches for
295
+ * that are gone. The cache lists the outputs of every transaction this client
296
+ * sent as live, ahead of the indexer's cells, and never drops them: once another
297
+ * tab, device or server instance spends them, every transaction built here on
298
+ * them would be rejected until a reload (audit, 31 Oct). A cached cell is kept
299
+ * while the transaction that made it is pending or proposed (a chain of
300
+ * payments faster than the indexer needs it), or once committed while the node
301
+ * says it is still live; it is dropped once the node no longer knows that
302
+ * transaction, or says the cell is spent. When the node cannot be asked, it is kept.
303
+ */
304
+ export async function withoutDeadCached(client, lock, cells) {
305
+ if (!cells.length || !client.cache)
306
+ return cells;
307
+ const cached = await cachedOutPoints(client, lock);
308
+ if (!cached.size)
309
+ return cells;
310
+ const keep = await Promise.all(cells.map(async (c) => {
311
+ if (!cached.has(ccc.hexFrom(c.outPoint.toBytes())))
312
+ return true;
313
+ try {
314
+ const status = (await client.getTransactionNoCache(c.outPoint.txHash))?.status;
315
+ if (status === "pending" || status === "proposed")
316
+ return true;
317
+ if (status !== "committed")
318
+ return false;
319
+ return !!(await client.getCellLive(c.outPoint, false, false));
320
+ }
321
+ catch {
322
+ return true; // the node cannot be asked: a dead one is rejected, then rebuilt (rebuildOnDeadInputs)
323
+ }
324
+ }));
325
+ return cells.filter((_, i) => keep[i]);
326
+ }
327
+ /** Whether a send was rejected because an input is spent or unknown to the node. */
328
+ export function deadInputs(e) {
329
+ if (e instanceof SendOutcomeUnknownError)
330
+ return false;
331
+ if (e instanceof ccc.ErrorClientResolveUnknown)
332
+ return true;
333
+ const text = e instanceof ccc.ErrorClientBase ? `${e.message} ${e.data}` : e instanceof Error ? e.message : String(e);
334
+ return /\b(Dead|Unknown)\(OutPoint\(/.test(text);
335
+ }
336
+ const errorText = (e) => e instanceof ccc.ErrorClientBase ? `${e.message} ${e.data}` : e instanceof Error ? e.message : String(e);
337
+ /**
338
+ * Whether a send was rejected because an input is already spent by a
339
+ * transaction in the node's pool, on a node with replace-by-fee on: CKB then
340
+ * runs its RBF checks instead of answering `Dead`, and refuses the newcomer
341
+ * (`PoolRejectedRBF`, -1111) unless it pays enough more to replace the other.
342
+ */
343
+ export function poolConflict(e) {
344
+ if (e instanceof SendOutcomeUnknownError)
345
+ return false;
346
+ if (e instanceof ccc.ErrorClientRBFRejected)
347
+ return true;
348
+ if (e instanceof ccc.ErrorClientBase && e.code === -1111)
349
+ return true;
350
+ return /\b(RBFRejected|PoolRejectedRBF)\b/.test(errorText(e));
351
+ }
352
+ /** The out point a `Dead` or `Unknown` rejection names (as `outPointKey`), if any. */
353
+ function rejectedOutPoint(e) {
354
+ if (e instanceof ccc.ErrorClientResolveUnknown)
355
+ return outPointKey(e.outPoint);
356
+ const hex = /\b(?:Dead|Unknown)\(OutPoint\((0x[0-9a-fA-F]+)\)/.exec(errorText(e))?.[1];
357
+ if (!hex)
358
+ return undefined;
359
+ try {
360
+ return outPointKey(ccc.OutPoint.fromBytes(hex));
361
+ }
362
+ catch {
363
+ return undefined;
364
+ }
365
+ }
366
+ export const DEFAULT_RETRY = { attempts: 3, delayMs: 1000 };
367
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
368
+ /**
369
+ * Runs `build` (which builds and sends a transaction). If the node definitely
370
+ * refused it because an input was already spent (`Dead`, `Unknown`, or an RBF
371
+ * refusal for a conflict in its pool), or `build` sent nothing because every
372
+ * payout cell was busy ("payout-busy"), clears the client's cache, waits, and
373
+ * runs it again, up to `retry.attempts` in all (audit, 31 Oct; mentor review,
374
+ * 2 Nov). `build` is given the out points rejections named, so a payout cell
375
+ * the node called spent is not picked again. The wait is `retry.delayMs`,
376
+ * doubling each time, times a random 0.5 to 1.5, so payers that collided do
377
+ * not retry in step.
378
+ *
379
+ * Any other error is thrown at once. That includes a send whose outcome is
380
+ * unknown (a timeout, a lost reply): `signAndSend` returns the hash when the
381
+ * node did take the transaction, and otherwise throws the send's own error,
382
+ * which is never retried, so nothing is ever paid twice. When the node cannot
383
+ * be asked either, a refusal for a spent input may only mean the first send
384
+ * committed (CCC retries a send on a fallback node): `signAndSend` then throws
385
+ * `SendOutcomeUnknownError`, never retried (review W6 L4). A `beforeSend` inside
386
+ * runs again with each new transaction's hash: a refused one never commits.
387
+ */
388
+ export async function rebuildOnDeadInputs(client, build, retry = {}) {
389
+ const attempts = Math.max(1, Math.floor(retry.attempts ?? DEFAULT_RETRY.attempts));
390
+ const delay = Math.max(0, retry.delayMs ?? DEFAULT_RETRY.delayMs);
391
+ const avoid = new Set();
392
+ for (let attempt = 1;; attempt++) {
393
+ try {
394
+ return await build(avoid);
395
+ }
396
+ catch (e) {
397
+ const busy = e instanceof SessionBalanceError && e.reason === "payout-busy";
398
+ if (!(busy || deadInputs(e) || poolConflict(e)) || attempt >= attempts)
399
+ throw e;
400
+ const named = rejectedOutPoint(e);
401
+ if (named)
402
+ avoid.add(named);
403
+ await client.cache?.clear();
404
+ const wait = delay * 2 ** (attempt - 1) * (0.5 + Math.random());
405
+ if (wait > 0)
406
+ await sleep(wait);
407
+ }
408
+ }
79
409
  }
80
410
  export async function findSessionCells(session, client, deployment, binding) {
81
411
  const keyLock = await keyLockOf(session, client);
82
412
  const lock = sessionLockScript(deployment, params(session, keyLock, binding));
83
413
  const token = binding.token && ccc.Script.from(binding.token.type);
84
- const [sessionCells, keyCells] = await Promise.all([collect(client, lock, token), collect(client, keyLock)]);
414
+ const [found, keyCells] = await Promise.all([collect(client, lock, token), collect(client, keyLock)]);
415
+ const sessionCells = await withinU128(client, found);
85
416
  const sum = (cells) => cells.reduce((s, c) => s + c.cellOutput.capacity, 0n);
86
417
  const tokenBalance = sessionCells.reduce((s, c) => s + (c.cellOutput.type ? tokenAmount(c.outputData) : 0n), 0n);
87
418
  return { lock, keyLock, sessionCells, keyCells, balance: sum(sessionCells), feeBalance: sum(keyCells), tokenBalance };
@@ -101,23 +432,84 @@ export function onchainRecipient(session) {
101
432
  return recipients[0];
102
433
  }
103
434
  /**
104
- * Throws unless `lock` is known to need a signature over the whole transaction:
105
- * the session lock grants owner mode to any transaction with an input under the
106
- * owner's lock, so that lock is the only thing standing between a stranger and
107
- * every session. An allowlist, not a blocklist (code review, 14 Oct):
435
+ * Locks that open without any signature, on mainnet and testnet, refused
436
+ * whatever the client's network: anyone-can-pay (RFC 0026, and the older
437
+ * deployments pw-core lists as anyone-can-pay), PW-Lock (the deployed build is
438
+ * lay2dev/pw-lock's `secp256k1_keccak256_sighash_all_acpl`: with no signature in
439
+ * the witness it takes the anyone-can-pay path, whatever its args) and
440
+ * always-success (ckb-proxy-locks).
441
+ *
442
+ * `type`: the type-id code hash the script is deployed under (hash_type `type`).
443
+ * `data`: the hash of the binary itself, refused under every data hash_type
444
+ * (`data`, `data1`, `data2`): the same code referenced by its data hash opens
445
+ * the same way (review W6 L6). The data hashes are those of the code cells CCC
446
+ * lists for each script on testnet and mainnet, read on 2 Nov 2026.
447
+ */
448
+ const SIGNATURE_FREE = [
449
+ { codeHash: "0xd369597ff47f29fbc0d47d2e3775370d1250b85140c670e4718af712983a2354", by: "type", what: "anyone-can-pay" },
450
+ { codeHash: "0x3419a1c09eb2567f6552ee7a8ecffd64155cffe0f1796e6e61ec088d740c1356", by: "type", what: "anyone-can-pay" },
451
+ { codeHash: "0xcd69ba816f7471e59110058aa37387c362ed9a240cd178f7bb1ecee386cb31e6", by: "data", what: "anyone-can-pay" },
452
+ { codeHash: "0x0fb343953ee78c9986b091defb6252154e0bb51044fd2879fde5b27314506111", by: "data", what: "anyone-can-pay" },
453
+ { codeHash: "0x86a1c6987a4acbe1a887cca4c9dd2ac9fcb07405bbeda51b861b18bbf7492c4b", by: "type", what: "anyone-can-pay" },
454
+ { codeHash: "0xbf43c3602455798c1a61a596e0d95278864c552fafe231c063b3fabf97a8febc", by: "type", what: "PW-Lock" },
455
+ { codeHash: "0x58c5f491aba6d61678b7cf7edf4910b1f5e00ec0cde2f42e0abb4fd9aff25a63", by: "type", what: "PW-Lock" },
456
+ { codeHash: "0xd6a5a0edb152e88e8bbc702e164441cb3890fae35da672b408d28ca9a1bde3ee", by: "data", what: "PW-Lock" },
457
+ { codeHash: "0xeffe377419256d150d68368d7cb5731edad39d1805a8c2c73ce8e9615b9f9878", by: "data", what: "PW-Lock" },
458
+ { codeHash: "0x3b521cc4b552f109d092d8cc468a8048acb53c5952dbe769d2b2f9cf6e47f7f1", by: "data", what: "always-success" },
459
+ ];
460
+ /** The signature-free lock `lock` runs, if it is one of `SIGNATURE_FREE` under a matching hash_type. */
461
+ const signatureFree = (lock) => SIGNATURE_FREE.find((s) => s.codeHash === lock.codeHash && (s.by === "type" ? lock.hashType === "type" : lock.hashType !== "type"));
462
+ /** Omnilock auth flags that verify a signature over the whole transaction (RFC 0042, nervosnetwork/omnilock). */
463
+ const OMNI_SIGNING_AUTH = new Set([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x12]);
464
+ /** Omnilock modes that only add checks: time-lock (0x04) and supply (0x08). */
465
+ const OMNI_ADDITIVE_MODES = 0x04 | 0x08;
466
+ /**
467
+ * Throws `SessionPolicyError` unless `lock` is a reviewed configuration of a lock
468
+ * that needs the owner's signature over the whole transaction. The session lock
469
+ * grants owner mode to any transaction with an input under the owner's lock, so
470
+ * that lock is the only thing standing between a stranger and every session.
471
+ * An allowlist, not a blocklist (code review, 14 Oct; mentor review, 2 Nov):
472
+ *
473
+ * Reviewed, accepted:
474
+ * - secp256k1-blake160 (20-byte args); secp256k1 multisig, v1 and v2 (20 bytes,
475
+ * or 28 with a since, which only delays);
476
+ * - Omnilock with a signature auth flag (0x00-0x06: CKB, Ethereum, EOS, Tron,
477
+ * Bitcoin, Dogecoin, multisig; 0x12: Ethereum with the message displayed), and
478
+ * no mode other than time-lock or supply;
479
+ * - Nostr lock in signing mode (args[0] = 0, 21-byte args);
480
+ * - JoyID (closed source: reviewed from its documentation only).
108
481
  *
109
- * - secp256k1-blake160 (single or multisig), JoyID, Nostr, PW lock;
110
- * - Omnilock with a signature auth flag (0x00-0x07: CKB, Ethereum, EOS, Tron,
111
- * Bitcoin, Dogecoin, multisig; 0x12: Ethereum with the message displayed) and neither administrator (0x01) nor
112
- * anyone-can-pay (0x02) mode. Auth flags 0xFC-0xFE hand the decision to
113
- * another lock or to arbitrary code, and are refused.
482
+ * Known to unlock without the owner's signature, refused always (reason
483
+ * `"owner-lock-unsafe"`): anyone-can-pay, PW-Lock, always-success (by their
484
+ * type id, or by the data hash of their deployed binaries under any data
485
+ * hash_type), Omnilock in anyone-can-pay or administrator mode, Nostr lock in
486
+ * PoW mode (args[0] != 0). Another build of those locks (a binary with a
487
+ * different data hash) is not recognised: it is refused as unreviewed.
114
488
  *
115
- * Anyone-can-pay (RFC 0026) unlocks with no signature at all: anyone could spend
116
- * the owner's cell back to itself unchanged and sweep every session.
489
+ * Anything else (Omnilock auth 0xFC-0xFE, which hand the decision to another lock
490
+ * or to other code; the proxy locks; unknown locks; args the lock's code would
491
+ * not accept) is refused with reason `"owner-lock-unreviewed"`, unless
492
+ * `allowUnreviewedOwnerLock` is set: then it passes with a warning.
117
493
  *
118
494
  * The owner must also never sign a partial (open) transaction with this lock.
119
495
  */
120
- export async function assertSigningLock(lock, client) {
496
+ export async function assertSigningLock(lock, client, opts = {}) {
497
+ return checkSigningLock(lock, client, !!opts.allowUnreviewedOwnerLock, OWNER_ROLE);
498
+ }
499
+ const OWNER_ROLE = { whose: "the owner's lock", can: "own a session", optIn: "allowUnreviewedOwnerLock" };
500
+ /** `assertSigningLock` for any role: a subscription's charger lock is checked the same way (review M2), with its own opt-in. */
501
+ export async function checkSigningLock(lock, client, allowUnreviewed, role) {
502
+ const unsafe = (why) => {
503
+ throw new SessionPolicyError(`${role.whose} ${why}: it cannot ${role.can}`, "owner-lock-unsafe");
504
+ };
505
+ const unreviewed = (why) => {
506
+ const message = `${role.whose} ${why}`;
507
+ if (allowUnreviewed) {
508
+ console.warn(`ckb-session-kit: ${message}; allowed by ${role.optIn}`);
509
+ return;
510
+ }
511
+ throw new SessionPolicyError(`${message}, so it cannot ${role.can} unless you review it and pass ${role.optIn}`, "owner-lock-unreviewed");
512
+ };
121
513
  const is = async (known) => {
122
514
  try {
123
515
  const s = await client.getKnownScript(known);
@@ -127,39 +519,55 @@ export async function assertSigningLock(lock, client) {
127
519
  return false; // not deployed on this network
128
520
  }
129
521
  };
130
- if (await is(ccc.KnownScript.AnyoneCanPay)) {
131
- throw new SessionPolicyError("the owner's lock is anyone-can-pay: it needs no signature, so it cannot own a session");
522
+ const args = ccc.bytesFrom(lock.args);
523
+ const free = signatureFree(lock);
524
+ if (free?.what === "PW-Lock" || (await is(ccc.KnownScript.PWLock))) {
525
+ return unsafe("is PW-Lock, whose deployed build is anyone-can-pay: it opens with no signature");
526
+ }
527
+ if (free || (await is(ccc.KnownScript.AnyoneCanPay))) {
528
+ return unsafe(`is ${free?.what ?? "anyone-can-pay"}: it opens with no signature`);
132
529
  }
530
+ if (await is(ccc.KnownScript.AlwaysSuccess))
531
+ return unsafe("is always-success: it opens with no signature");
133
532
  if (await is(ccc.KnownScript.OmniLock)) {
134
- // Omnilock args: auth flag (1) + auth content (20) + omnilock flags (1) + ...
135
- const args = ccc.bytesFrom(lock.args);
136
- const omniFlags = args[21] ?? 0;
137
- if (omniFlags & 0x02) {
138
- throw new SessionPolicyError("the owner's Omnilock is in anyone-can-pay mode: it cannot own a session");
533
+ // Omnilock args: auth flag (1) + auth content (20) + Omnilock flags (1) + mode args.
534
+ if (args.length < 22)
535
+ return unreviewed("is an Omnilock with args its code would not accept");
536
+ const auth = args[0];
537
+ const modes = args[21];
538
+ if (modes & 0x02)
539
+ return unsafe("is an Omnilock in anyone-can-pay mode, which opens with no signature");
540
+ if (modes & 0x01)
541
+ return unsafe("is an Omnilock with an administrator, who could unlock it without the owner");
542
+ if (auth >= 0xfc && auth <= 0xfe) {
543
+ const flag = auth.toString(16);
544
+ return unreviewed(`is an Omnilock that does not verify a signature itself (auth 0x${flag} hands the check to another lock or code)`);
139
545
  }
140
- if (omniFlags & 0x01) {
141
- throw new SessionPolicyError("the owner's Omnilock has an administrator, who could unlock it: it cannot own a session");
546
+ if (!OMNI_SIGNING_AUTH.has(auth)) {
547
+ return unreviewed(`is an Omnilock with auth flag 0x${auth.toString(16).padStart(2, "0")}, which is not one this kit has reviewed`);
142
548
  }
143
- const auth = args[0] ?? 0xff;
144
- // 0x12 is Ethereum with the message displayed: what CCC gives MetaMask and
145
- // OKX (EVM) users. Missing from this list until the wallet matrix (26 Oct).
146
- if (auth > 0x07 && auth !== 0x12) {
147
- throw new SessionPolicyError("the owner's Omnilock does not verify a signature itself: it cannot own a session");
549
+ if (modes & ~OMNI_ADDITIVE_MODES) {
550
+ return unreviewed(`is an Omnilock with mode flags 0x${modes.toString(16).padStart(2, "0")}, not one this kit has reviewed`);
148
551
  }
149
552
  return;
150
553
  }
151
- const signing = [
152
- ccc.KnownScript.Secp256k1Blake160,
153
- ccc.KnownScript.Secp256k1Multisig,
154
- ccc.KnownScript.Secp256k1MultisigV2,
155
- ccc.KnownScript.JoyId,
156
- ccc.KnownScript.NostrLock,
157
- ccc.KnownScript.PWLock,
158
- ];
159
- for (const known of signing)
160
- if (await is(known))
161
- return;
162
- throw new SessionPolicyError("the owner's lock is not one known to sign the whole transaction, so it cannot own a session");
554
+ if (await is(ccc.KnownScript.NostrLock)) {
555
+ // Nostr lock args: PoW difficulty (1) + blake160 of the Schnorr public key (20).
556
+ if (args.length !== 21)
557
+ return unreviewed("is a Nostr lock with args its code would not accept");
558
+ if (args[0] !== 0)
559
+ return unsafe("is a Nostr lock in proof of work mode, which anyone who does the work can unlock");
560
+ return;
561
+ }
562
+ if (await is(ccc.KnownScript.Secp256k1Blake160)) {
563
+ return args.length === 20 ? undefined : unreviewed("is secp256k1-blake160 with args its code would not accept");
564
+ }
565
+ if ((await is(ccc.KnownScript.Secp256k1Multisig)) || (await is(ccc.KnownScript.Secp256k1MultisigV2))) {
566
+ return args.length === 20 || args.length === 28 ? undefined : unreviewed("is a multisig with args its code would not accept");
567
+ }
568
+ if (await is(ccc.KnownScript.JoyId))
569
+ return;
570
+ return unreviewed("is not one this kit has reviewed: it may open without the owner's signature");
163
571
  }
164
572
  /**
165
573
  * Owner funds the session: one transaction, one signature from `owner`.
@@ -186,7 +594,7 @@ export async function openSession(owner, session, deployment, opts) {
186
594
  if (opts.minInterval !== undefined)
187
595
  relativeBlocksSince(opts.minInterval); // a spend must be able to meet it
188
596
  const ownerLock = (await owner.getRecommendedAddressObj()).script;
189
- await assertSigningLock(ownerLock, client);
597
+ await assertSigningLock(ownerLock, client, opts);
190
598
  const floor = keyCellFloor(ownerLock);
191
599
  const feeBudget = opts.feeBudget ?? floor + FEE_MARGIN;
192
600
  if (feeBudget <= floor) {
@@ -210,6 +618,15 @@ export async function openSession(owner, session, deployment, opts) {
210
618
  if (recipientLockHash === keyLock.hash()) {
211
619
  throw new SessionPolicyError("the recipient is the session key itself: it could then pay anyone");
212
620
  }
621
+ // Before any signature, so the caller can still stop (review 2 Nov).
622
+ for (const w of sessionWarnings(binding.minInterval, recipientLockHash !== undefined)) {
623
+ if (opts.onWarning)
624
+ await opts.onWarning(w);
625
+ else
626
+ console.warn(`ckb-session-kit: ${w.message}`);
627
+ }
628
+ // Not a legacy lock, and its code is live on this network (audit round 2, 31 Oct).
629
+ await assertCurrentDeployment(client, deployment);
213
630
  const lock = sessionLockScript(deployment, params(session, keyLock, binding));
214
631
  const tx = ccc.Transaction.from({
215
632
  outputs: [
@@ -245,16 +662,22 @@ export async function openSession(owner, session, deployment, opts) {
245
662
  * widens who may mint (owner mode by input or output type, code review 14 Oct).
246
663
  */
247
664
  export async function assertPlainXudt(type, client) {
248
- const xudt = await client.getKnownScript(ccc.KnownScript.XUdt);
665
+ const fault = plainXudtFault(type, await client.getKnownScript(ccc.KnownScript.XUdt));
666
+ if (fault)
667
+ throw new SessionPolicyError(fault);
668
+ }
669
+ /** Why `type` is not a plain xUDT under the network's xUDT script `xudt`, or undefined if it is. */
670
+ export function plainXudtFault(type, xudt) {
249
671
  if (type.codeHash !== xudt.codeHash || type.hashType !== xudt.hashType) {
250
- throw new SessionPolicyError("a token session holds an xUDT token; this type script is not xUDT");
672
+ return "a token session holds an xUDT token; this type script is not xUDT";
251
673
  }
252
674
  const args = ccc.bytesFrom(type.args);
253
675
  if (args.length < 32)
254
- throw new SessionPolicyError("xUDT args must start with the issuer's 32-byte lock hash");
676
+ return "xUDT args must start with the issuer's 32-byte lock hash";
255
677
  if (args.length >= 36 && ccc.numLeFromBytes(args.slice(32, 36)) !== 0n) {
256
- throw new SessionPolicyError("xUDT tokens with flags (extension scripts, other owner modes) are not supported in a session");
678
+ return "xUDT tokens with flags (extension scripts, other owner modes) are not supported in a session";
257
679
  }
680
+ return undefined;
258
681
  }
259
682
  /**
260
683
  * The session key pays `to`: `amount` shannons, or in a token session `token`
@@ -273,8 +696,24 @@ export async function assertPlainXudt(type, client) {
273
696
  *
274
697
  * `memo` is recorded in the witness of the session key's own input, which that
275
698
  * key's signature covers: the payment commits to it (see `verifyAccess`).
699
+ *
700
+ * With `topUp`, the recipient's cell is a confirmed one that no transaction in
701
+ * the node's pool spends, where the node can tell (`pickPayoutCell`): readers
702
+ * paying at the same moment compete for those cells.
703
+ *
704
+ * If the node rejects the payment because an input is already spent (another
705
+ * tab, device or payer took it first) or unknown, the client's cache is cleared
706
+ * and the payment is built again from the live cells, with another payout
707
+ * cell, after a short wait: up to `retry.attempts` in all (default 3), waiting
708
+ * `retry.delayMs` (default 1000) doubling each time. `beforeSend` runs again
709
+ * with each new hash (`rebuildOnDeadInputs`). A send whose outcome is unknown
710
+ * is never retried.
276
711
  */
277
712
  export async function spendInSession(session, client, deployment, binding, request, now = new Date()) {
713
+ // Built on a cell another tab or payer spent since: rebuilt from live cells (audit, 31 Oct; review, 2 Nov).
714
+ return rebuildOnDeadInputs(client, (avoid) => spendOnce(session, client, deployment, binding, request, now, avoid), request.retry);
715
+ }
716
+ async function spendOnce(session, client, deployment, binding, request, now, avoid) {
278
717
  const tokenType = binding.token && ccc.Script.from(binding.token.type);
279
718
  const tokenOut = request.token ?? 0n;
280
719
  if (request.token !== undefined) {
@@ -302,7 +741,7 @@ export async function spendInSession(session, client, deployment, binding, reque
302
741
  throw new ScopeError("recipient not in scope");
303
742
  }
304
743
  if (!state.keyCells.length)
305
- throw new SessionBalanceError("no key cell left to pay fees");
744
+ throw new SessionBalanceError("no key cell left to pay fees", "no-fee-cell");
306
745
  const since = binding.minInterval > 0n ? relativeBlocksSince(binding.minInterval) : 0n;
307
746
  const tx = ccc.Transaction.from({ cellDeps: [sessionLockCellDep(deployment)] });
308
747
  if (tokenType)
@@ -310,20 +749,15 @@ export async function spendInSession(session, client, deployment, binding, reque
310
749
  // The payment: output 0, plus the recipient's cell when topping up.
311
750
  let ckbOut;
312
751
  if (request.topUp) {
313
- const acp = await client.getKnownScript(ccc.KnownScript.AnyoneCanPay);
314
- if (toLock.codeHash !== acp.codeHash || toLock.hashType !== acp.hashType) {
752
+ if (!(await isAnyoneCanPay(toLock, client)))
315
753
  throw new ScopeError("top-up needs an anyone-can-pay recipient");
316
- }
317
- // Payers at once compete for a cell; a random pick spreads them out when the
318
- // recipient keeps several.
319
- const targets = (await collect(client, toLock, tokenType)).filter((c) => request.token !== undefined ? !!c.cellOutput.type : !c.cellOutput.type);
320
- const target = targets[Math.floor(Math.random() * targets.length)];
321
- if (!target)
322
- throw new SessionBalanceError("the recipient has no anyone-can-pay cell to top up");
323
- tx.inputs.push(ccc.CellInput.from({ previousOutput: target.outPoint }));
754
+ // Payers at once compete for a cell: a confirmed one no pool transaction spends,
755
+ // at random among the recipient's (pickPayoutCell).
324
756
  ckbOut = request.amount ?? 0n;
325
- tx.addOutput({ lock: toLock, type: target.cellOutput.type, capacity: target.cellOutput.capacity + ckbOut }, request.token !== undefined ? tokenData(tokenAmount(target.outputData) + tokenOut) : "0x");
326
- await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.AnyoneCanPay);
757
+ const paid = request.token !== undefined ? { token: tokenType, units: tokenOut } : { ckb: ckbOut };
758
+ if (!(await addTopUp(tx, client, toLock, paid, avoid))) {
759
+ throw new SessionBalanceError("the recipient has no anyone-can-pay cell to top up", "no-payout-cell");
760
+ }
327
761
  }
328
762
  else if (request.token !== undefined) {
329
763
  tx.addOutput({ lock: toLock, type: tokenType, capacity: 0n }, tokenData(tokenOut)); // CCC sizes it
@@ -379,17 +813,46 @@ export async function spendInSession(session, client, deployment, binding, reque
379
813
  }
380
814
  }
381
815
  // The key cell authorises the spend, comes back as the last output and pays the fee.
382
- tx.inputs.push(ccc.CellInput.from({ previousOutput: state.keyCells[0].outPoint }));
816
+ // The largest one: any other plain cell sent to the key address (a stranger's
817
+ // 61 CKB, or a top-up beside a key cell run down to its floor) is listed first
818
+ // and may not cover the fee (audit, 30 Oct). One key cell in, one out, no larger.
819
+ tx.inputs.push(ccc.CellInput.from({ previousOutput: largestCell(state.keyCells).outPoint }));
383
820
  if (request.memo !== undefined)
384
821
  tx.setWitnessArgsAt(tx.inputs.length - 1, { inputType: request.memo });
385
822
  tx.addOutput({ lock: state.keyLock, capacity: 0n });
386
- await tx.completeFeeChangeToOutput(sessionSigner(session, client), tx.outputs.length - 1);
823
+ await completeFeeOrNoFeeCell(tx, sessionSigner(session, client), "the key cell cannot cover the fee; top it up");
387
824
  // Below this, the key cell could no longer go back to the owner at the end.
388
825
  if (tx.outputs[tx.outputs.length - 1].capacity < keyCellFloor(binding.ownerLock)) {
389
- throw new SessionBalanceError("the key cell cannot cover another fee; top it up");
826
+ throw new SessionBalanceError("the key cell cannot cover another fee; top it up", "no-fee-cell");
390
827
  }
391
828
  return signAndSend(sessionSigner(session, client), tx, request.beforeSend);
392
829
  }
830
+ /**
831
+ * `completeFeeChangeToOutput` into the last output, with CCC's "Insufficient CKB"
832
+ * (the paying cell holds its floor but not the fee too) as a
833
+ * `SessionBalanceError` with reason `no-fee-cell` (audit round 2, 31 Oct).
834
+ */
835
+ export async function completeFeeOrNoFeeCell(tx, signer, message) {
836
+ try {
837
+ await tx.completeFeeChangeToOutput(signer, tx.outputs.length - 1);
838
+ }
839
+ catch (e) {
840
+ // By class, or by message where a second copy of CCC threw it (a bundler's CJS and ESM builds).
841
+ const short = e instanceof ccc.ErrorTransactionInsufficientCapacity || (e instanceof Error && /^Insufficient CKB/.test(e.message));
842
+ if (short)
843
+ throw new SessionBalanceError(message, "no-fee-cell");
844
+ throw e;
845
+ }
846
+ }
847
+ /** The largest of `cells` by capacity; on a tie, the lowest out point (tx hash, then index), whatever the indexer order. */
848
+ function largestCell(cells) {
849
+ const before = (x, y) => (x.txHash !== y.txHash ? x.txHash < y.txHash : x.index < y.index);
850
+ return cells.reduce((best, c) => {
851
+ const a = c.cellOutput.capacity;
852
+ const b = best.cellOutput.capacity;
853
+ return a > b || (a === b && before(c.outPoint, best.outPoint)) ? c : best;
854
+ });
855
+ }
393
856
  /** The cells created at least `blocks` blocks before the tip: those a relative `since` of `blocks` can spend. */
394
857
  export async function matureCells(client, cells, blocks) {
395
858
  const tip = await client.getTip();
@@ -408,19 +871,80 @@ export async function matureCells(client, cells, blocks) {
408
871
  * it. A key-cell failure is reported in `keyError`; the key stays usable for a retry.
409
872
  *
410
873
  * `nothingFound` means no cell is left under the session or the key: the key can
411
- * be forgotten. If the funding transaction (`binding.openTx`) is still in flight
412
- * or not yet indexed, this throws instead, so the key is never forgotten before
413
- * the funds it controls arrive.
874
+ * be forgotten. If the funding transaction (`binding.openTx`) is still in flight,
875
+ * or committed with an output under the session or the key the node still holds
876
+ * live (not indexed yet), this throws instead, so the key is never forgotten
877
+ * before the funds it controls arrive. Once the funding is committed and every
878
+ * one of those outputs is spent (a close whose reply was lost, another tab's
879
+ * close), it is `nothingFound` (audit, 31 Oct).
880
+ *
881
+ * `owner` must be the wallet that opened the session (its lock is
882
+ * `binding.ownerLock`): any other is refused with a `SessionPolicyError` before
883
+ * it is asked to sign anything.
414
884
  */
415
885
  export async function closeSession(owner, session, deployment, binding) {
886
+ const client = owner.client;
887
+ const ownerLock = ccc.Script.from(binding.ownerLock);
888
+ // After a wallet switch, the connected wallet may not be the owner: it must not
889
+ // be shown a sweep of someone else's session to itself (audit, 31 Oct).
890
+ const signerLock = (await owner.getRecommendedAddressObj()).script;
891
+ if (signerLock.hash() !== ownerLock.hash()) {
892
+ throw new SessionPolicyError("this signer is not the session's owner: connect the wallet that opened it");
893
+ }
894
+ // Built on a cell another tab spent since: rebuilt once from live cells.
895
+ return rebuildOnDeadInputs(client, () => closeOnce(owner, session, deployment, binding));
896
+ }
897
+ /**
898
+ * Whether the node's indexer has reached the chain tip: false when it is behind,
899
+ * undefined for a client with no JSON-RPC requestor to ask (tests, custom clients).
900
+ */
901
+ async function indexerCaughtUp(client) {
902
+ const requestor = client.requestor;
903
+ if (!requestor)
904
+ return undefined;
905
+ try {
906
+ const tip = (await requestor.request("get_indexer_tip", []));
907
+ if (!tip?.block_number)
908
+ return false;
909
+ return BigInt(tip.block_number) >= (await client.getTip());
910
+ }
911
+ catch {
912
+ return false; // cannot tell: keep the key
913
+ }
914
+ }
915
+ async function closeOnce(owner, session, deployment, binding) {
416
916
  const client = owner.client;
417
917
  const ownerLock = ccc.Script.from(binding.ownerLock);
418
918
  const state = await findSessionCells(session, client, deployment, binding);
419
919
  const result = {};
420
920
  if (!state.sessionCells.length && !state.keyCells.length) {
421
921
  const open = binding.openTx && (await client.getTransaction(binding.openTx));
422
- if (open && ["pending", "proposed", "committed"].includes(open.status)) {
423
- throw new SessionBalanceError(`the session's funding transaction is ${open.status === "committed" ? "not indexed yet" : open.status}; close again in a minute`);
922
+ if (binding.openTx && open && open.status === "committed") {
923
+ const openTx = binding.openTx;
924
+ // Nothing indexed under the session: the indexer lags, or every output the
925
+ // funding made under the session and the key is spent already. Ask the node.
926
+ const ours = (open.transaction?.outputs ?? [])
927
+ .map((o, index) => ({ o, index }))
928
+ .filter(({ o }) => o.lock.eq(state.lock) || o.lock.eq(state.keyLock));
929
+ if (!ours.length) {
930
+ throw new SessionBalanceError("the funding transaction made no cell under this session: is it the session's deployment?");
931
+ }
932
+ const live = await Promise.all(ours.map(({ index }) => client.getCellLive({ txHash: openTx, index }, false, false)));
933
+ if (live.every((c) => !c)) {
934
+ // Spent, but a later spend's new key cell may not be indexed yet: trust the
935
+ // empty search only once the indexer has reached the tip, and search again.
936
+ if ((await indexerCaughtUp(client)) === false) {
937
+ throw new SessionBalanceError("the indexer is behind the chain; close again in a minute");
938
+ }
939
+ const again = await findSessionCells(session, client, deployment, binding);
940
+ if (!again.sessionCells.length && !again.keyCells.length)
941
+ return { nothingFound: true };
942
+ throw new SessionBalanceError("the session's cells were not indexed yet; close again");
943
+ }
944
+ throw new SessionBalanceError("the session's funding transaction is not indexed yet; close again in a minute");
945
+ }
946
+ if (open && (open.status === "pending" || open.status === "proposed")) {
947
+ throw new SessionBalanceError(`the session's funding transaction is ${open.status}; close again in a minute`);
424
948
  }
425
949
  return { nothingFound: true };
426
950
  }
@@ -432,8 +956,10 @@ export async function closeSession(owner, session, deployment, binding) {
432
956
  inputs: state.keyCells.map((c) => ({ previousOutput: c.outPoint })),
433
957
  outputs: [{ lock: ownerLock, capacity: 0n }],
434
958
  });
435
- await tx.completeFeeChangeToOutput(sessionSigner(session, client), 0);
436
- result.keyTx = await sessionSigner(session, client).sendTransaction(tx);
959
+ const key = sessionSigner(session, client);
960
+ await tx.completeFeeChangeToOutput(key, 0);
961
+ // A key return the node took but whose reply was lost is still the key return.
962
+ result.keyTx = await signAndSend(key, tx);
437
963
  }
438
964
  catch (e) {
439
965
  result.keyError = e instanceof Error ? e.message : String(e);
@@ -449,7 +975,9 @@ export async function closeSession(owner, session, deployment, binding) {
449
975
  * for a token session) are kept: a cell with malformed args would fail the lock
450
976
  * even in owner mode, and anyone can create one with this owner's hash as a prefix
451
977
  * to block a sweep. Likewise a typed cell is kept only if it holds the session's
452
- * own token in well-formed data; anything else could make the sweep fail.
978
+ * own token, a plain xUDT, in exactly 16 bytes of data; anything else could make
979
+ * the sweep fail. Cells
980
+ * whose tokens would overflow a u128 together are left out (`withinU128`).
453
981
  *
454
982
  * Pass several deployments (`[TESTNET_DEPLOYMENT, ...TESTNET_LEGACY_DEPLOYMENTS]`)
455
983
  * to find sessions opened under earlier versions of the lock as well.
@@ -457,6 +985,7 @@ export async function closeSession(owner, session, deployment, binding) {
457
985
  export async function findOwnedSessionCells(owner, deployments) {
458
986
  const ownerLock = (await owner.getRecommendedAddressObj()).script;
459
987
  const cells = [];
988
+ let xudt; // looked up once, at the first typed cell
460
989
  for (const deployment of [deployments].flat()) {
461
990
  for await (const cell of owner.client.findCells({
462
991
  script: { codeHash: deployment.codeHash, hashType: deployment.hashType, args: ownerLock.hash() },
@@ -464,11 +993,13 @@ export async function findOwnedSessionCells(owner, deployments) {
464
993
  scriptSearchMode: "prefix",
465
994
  withData: true,
466
995
  })) {
467
- if (sweepable(cell))
996
+ if (cell.cellOutput.type && !xudt)
997
+ xudt = await owner.client.getKnownScript(ccc.KnownScript.XUdt);
998
+ if (sweepable(cell, xudt))
468
999
  cells.push(cell);
469
1000
  }
470
1001
  }
471
- return cells;
1002
+ return withinU128(owner.client, cells);
472
1003
  }
473
1004
  /**
474
1005
  * Device-loss recovery: sweeps every session this owner opened back to the owner,
@@ -476,9 +1007,10 @@ export async function findOwnedSessionCells(owner, deployments) {
476
1007
  *
477
1008
  * Anyone can create a cell under a session lock carrying this owner's hash and
478
1009
  * their own key, and spend it before the sweep lands, which would make a single
479
- * sweep fail. So if the one transaction is refused, each session (cells sharing a
480
- * session key) is swept on its own, one signature each: a cell someone else can
481
- * spend fails alone, and `failed` counts the sessions not recovered.
1010
+ * sweep fail. So if the one transaction is refused, each session (cells under one
1011
+ * session lock) is swept on its own, one signature each: a cell someone else can
1012
+ * spend fails alone, and `failed` counts the sessions not recovered. A fault no
1013
+ * retry can fix (the owner holds no plain cell to prove ownership) is thrown.
482
1014
  *
483
1015
  * The key cell (the fee budget) is under the session key's own lock and cannot be
484
1016
  * recovered without that key; keep it small.
@@ -489,16 +1021,24 @@ export async function recoverSessions(owner, deployments) {
489
1021
  const total = (cs) => cs.reduce((sum, c) => sum + c.cellOutput.capacity, 0n);
490
1022
  if (!cells.length)
491
1023
  return { txHashes: [], recovered: 0n, cells: 0, failed: 0 };
1024
+ // A fault no other set of cells can fix (no plain owner cell) is thrown, not
1025
+ // retried once per session (review, 28 Oct).
1026
+ const fixed = (e) => e instanceof SessionBalanceError || e instanceof SessionPolicyError;
492
1027
  try {
493
1028
  return { txHashes: [await ownerSweep(owner, deployment, cells)], recovered: total(cells), cells: cells.length, failed: 0 };
494
1029
  }
495
- catch {
1030
+ catch (e) {
1031
+ if (fixed(e))
1032
+ throw e;
496
1033
  // Fall through: one session at a time.
497
1034
  }
1035
+ // One group per session lock (its full hash), not per session key: anyone can
1036
+ // plant a cell carrying the victim's (public) key hash under other args, and a
1037
+ // group it joined would fail on every retry (audit, 30 Oct).
498
1038
  const sessions = new Map();
499
1039
  for (const c of cells) {
500
- const keyHash = c.cellOutput.lock.args.slice(66, 130); // session_lock_hash, bytes 32..64
501
- sessions.set(keyHash, [...(sessions.get(keyHash) ?? []), c]);
1040
+ const lockHash = c.cellOutput.lock.hash();
1041
+ sessions.set(lockHash, [...(sessions.get(lockHash) ?? []), c]);
502
1042
  }
503
1043
  const result = { txHashes: [], recovered: 0n, cells: 0, failed: 0 };
504
1044
  for (const group of sessions.values()) {
@@ -507,14 +1047,22 @@ export async function recoverSessions(owner, deployments) {
507
1047
  result.recovered += total(group);
508
1048
  result.cells += group.length;
509
1049
  }
510
- catch {
1050
+ catch (e) {
1051
+ if (fixed(e))
1052
+ throw e;
511
1053
  result.failed++;
512
1054
  }
513
1055
  }
514
1056
  return result;
515
1057
  }
516
- /** A session cell an owner sweep can safely include. */
517
- function sweepable(cell) {
1058
+ /**
1059
+ * A session cell an owner sweep can safely include. A typed cell must hold the
1060
+ * session's own token, a plain xUDT (`assertPlainXudt`'s rules, against the
1061
+ * network's xUDT script `xudt`), in exactly the 16-byte amount a session makes:
1062
+ * any other type script can refuse to be consumed, or needs code the sweep does
1063
+ * not bring (audit, 30 Oct).
1064
+ */
1065
+ function sweepable(cell, xudt) {
518
1066
  const args = ccc.bytesFrom(cell.cellOutput.lock.args);
519
1067
  const type = cell.cellOutput.type;
520
1068
  if (args.length === 80 || args.length === 112)
@@ -523,13 +1071,18 @@ function sweepable(cell) {
523
1071
  return false;
524
1072
  if (!type)
525
1073
  return true;
526
- return type.hash() === ccc.hexFrom(args.slice(80, 112)) && ccc.bytesFrom(cell.outputData).length >= 16;
1074
+ return (!!xudt &&
1075
+ plainXudtFault(type, xudt) === undefined &&
1076
+ type.hash() === ccc.hexFrom(args.slice(80, 112)) &&
1077
+ ccc.bytesFrom(cell.outputData).length === 16);
527
1078
  }
528
1079
  /**
529
1080
  * Spends `cells` in owner mode: everything returns to the owner. Tokens go back
530
1081
  * in one cell per token type (xUDT would let a sweep burn them otherwise).
1082
+ * `memo` goes in the witness (`input_type`) of the owner's own input, which the
1083
+ * owner's signature covers: what the sweep is for (`CLEAR_PLANTED_MEMO`).
531
1084
  */
532
- export async function ownerSweep(owner, deployments, cells) {
1085
+ export async function ownerSweep(owner, deployments, cells, opts = {}) {
533
1086
  // The code of every lock version these cells are under, and only those.
534
1087
  const used = [deployments].flat().filter((d) => cells.some((c) => c.cellOutput.lock.codeHash === d.codeHash));
535
1088
  const tx = ccc.Transaction.from({
@@ -554,8 +1107,44 @@ export async function ownerSweep(owner, deployments, cells) {
554
1107
  // Owner mode needs an owner-locked input in the transaction.
555
1108
  for await (const cell of owner.findCells({ scriptLenRange: [0, 1], outputDataLenRange: [0, 1] }, true)) {
556
1109
  tx.inputs.push(ccc.CellInput.from({ previousOutput: cell.outPoint }));
1110
+ if (opts.memo)
1111
+ tx.setWitnessArgsAt(tx.inputs.length - 1, { inputType: opts.memo });
557
1112
  await tx.completeFeeBy(owner);
558
- return owner.sendTransaction(tx);
1113
+ // A sweep the node took but whose reply was lost is still the sweep (review, 28 Oct).
1114
+ return signAndSend(owner, tx);
559
1115
  }
560
1116
  throw new SessionBalanceError("the owner needs one plain cell to prove ownership");
561
1117
  }
1118
+ /**
1119
+ * Gives an anyone-can-pay address (a creator's `payTo`) `count` more cells to be
1120
+ * topped up, paid by `funder`, in one transaction. Each top-up consumes one of
1121
+ * these cells, so readers paying at the same moment compete for them; with more
1122
+ * cells they rarely pick the same one (mentor review, 2 Nov). Anyone can be the
1123
+ * funder: the anyone-can-pay lock needs no signature to receive a new cell.
1124
+ *
1125
+ * Each cell is as small as a cell under `payTo` can be (61 CKB for a 20-byte
1126
+ * args address) unless `capacity` is given. With `token`, each is a cell of that
1127
+ * token holding 0 units (about 142 CKB), for token payments. `count` is 1 to
1128
+ * `MAX_PAYOUT_CELLS`: a payment reads no more than that many.
1129
+ */
1130
+ export async function createPayoutCells(funder, payTo, opts) {
1131
+ const client = funder.client;
1132
+ if (!Number.isInteger(opts.count) || opts.count < 1 || opts.count > MAX_PAYOUT_CELLS) {
1133
+ throw new RangeError(`count must be 1 to ${MAX_PAYOUT_CELLS}`);
1134
+ }
1135
+ const { script: lock } = await ccc.Address.fromString(payTo, client);
1136
+ if (!(await isAnyoneCanPay(lock, client))) {
1137
+ throw new SessionPolicyError("payout cells go under an anyone-can-pay address", "payto-not-anyone-can-pay");
1138
+ }
1139
+ const token = opts.token && ccc.Script.from(opts.token);
1140
+ const tx = ccc.Transaction.from({ outputs: [], outputsData: [] });
1141
+ // CCC raises a capacity below what the cell occupies to that size.
1142
+ for (let i = 0; i < opts.count; i++) {
1143
+ tx.addOutput({ lock, capacity: opts.capacity ?? 0n, ...(token && { type: token }) }, token ? tokenData(0n) : "0x");
1144
+ }
1145
+ if (token)
1146
+ await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.XUdt);
1147
+ await tx.completeInputsByCapacity(funder);
1148
+ await tx.completeFeeBy(funder);
1149
+ return signAndSend(funder, tx, opts.beforeSend);
1150
+ }