ckb-session-kit 0.0.0-stage → 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.
@@ -0,0 +1,1150 @@
1
+ import { ccc } from "@ckb-ccc/core";
2
+ import { relativeBlocksSince, sessionLockCellDep, sessionLockScript, } from "./lock.js";
3
+ import { assertCurrentDeployment } from "./deployment.js";
4
+ import { checkRequest, isActive, SessionPolicyError } from "./session.js";
5
+ import { ScopeError, sessionSigner } from "./transfer.js";
6
+ export class SessionBalanceError extends Error {
7
+ reason;
8
+ constructor(message, reason) {
9
+ super(message);
10
+ this.name = "SessionBalanceError";
11
+ if (reason)
12
+ this.reason = reason;
13
+ }
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
+ }
31
+ const KEY_CELL_MIN = 61n * 100000000n;
32
+ /** What the key cell keeps above the cell it returns to the owner as, for fees. */
33
+ const FEE_MARGIN = 1n * 100000000n;
34
+ /**
35
+ * The smallest key cell that can still be returned to the owner: a cell under the
36
+ * owner's lock (63 CKB for JoyID or Omnilock, whose args are 22 bytes), and never
37
+ * below a key cell's own 61 CKB.
38
+ */
39
+ export function keyCellFloor(ownerLock) {
40
+ const ownerCell = ccc.CellOutput.from({ lock: ownerLock }, "0x").capacity;
41
+ return ownerCell > KEY_CELL_MIN ? ownerCell : KEY_CELL_MIN;
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
+ }
58
+ /**
59
+ * Signs `tx`, hands its final hash to `beforeSend`, then sends it. The hash is
60
+ * known before anything leaves, so a caller can record the payment or the key
61
+ * against it first. If the send throws but the node did take the transaction (a
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.
65
+ */
66
+ export async function signAndSend(signer, tx, beforeSend) {
67
+ const signed = await signer.signTransaction(tx);
68
+ const hash = signed.hash();
69
+ await beforeSend?.(hash);
70
+ try {
71
+ return await signer.client.sendTransaction(signed);
72
+ }
73
+ catch (e) {
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
+ }
82
+ if (known && ["pending", "proposed", "committed"].includes(known.status))
83
+ return hash;
84
+ throw e;
85
+ }
86
+ }
87
+ export async function keyLockOf(session, client) {
88
+ return (await sessionSigner(session, client).getRecommendedAddressObj()).script;
89
+ }
90
+ function params(session, keyLock, binding) {
91
+ return {
92
+ ownerLockHash: ccc.Script.from(binding.ownerLock).hash(),
93
+ sessionLockHash: keyLock.hash(),
94
+ maxPerTx: session.policy.scope.maxPerTx,
95
+ minInterval: binding.minInterval,
96
+ ...(binding.recipientLockHash && { recipientLockHash: binding.recipientLockHash }),
97
+ ...(binding.token && {
98
+ token: { typeHash: ccc.Script.from(binding.token.type).hash(), maxPerTx: binding.token.maxPerTx },
99
+ }),
100
+ };
101
+ }
102
+ /** The xUDT amount in a cell's data (RFC 0052: u128 LE in the first 16 bytes). */
103
+ export function tokenAmount(data) {
104
+ const bytes = ccc.bytesFrom(data);
105
+ if (bytes.length < 16)
106
+ throw new RangeError("not an xUDT cell: under 16 bytes of data");
107
+ return ccc.numLeFromBytes(bytes.slice(0, 16));
108
+ }
109
+ export const tokenData = (amount, rest = new Uint8Array()) => ccc.hexFrom(ccc.bytesConcat(ccc.numLeToBytes(amount, 16), rest));
110
+ /** A cell's data is exactly a 16-byte amount: what the session lock accepts as token change. */
111
+ export const plainTokenCell = (cell, type) => cell.cellOutput.type?.eq(type) === true && ccc.bytesFrom(cell.outputData).length === 16;
112
+ export async function sessionLockOf(session, client, deployment, binding) {
113
+ return sessionLockScript(deployment, params(session, await keyLockOf(session, client), binding));
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
+ }
284
+ /** Plain cells under `lock`; with `token`, also cells holding exactly that token. */
285
+ export async function collect(client, lock, token) {
286
+ const cells = [];
287
+ for await (const cell of client.findCellsByLock(lock, null, true)) {
288
+ if ((!cell.cellOutput.type && cell.outputData === "0x") || (token && plainTokenCell(cell, token)))
289
+ cells.push(cell);
290
+ }
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
+ }
409
+ }
410
+ export async function findSessionCells(session, client, deployment, binding) {
411
+ const keyLock = await keyLockOf(session, client);
412
+ const lock = sessionLockScript(deployment, params(session, keyLock, binding));
413
+ const token = binding.token && ccc.Script.from(binding.token.type);
414
+ const [found, keyCells] = await Promise.all([collect(client, lock, token), collect(client, keyLock)]);
415
+ const sessionCells = await withinU128(client, found);
416
+ const sum = (cells) => cells.reduce((s, c) => s + c.cellOutput.capacity, 0n);
417
+ const tokenBalance = sessionCells.reduce((s, c) => s + (c.cellOutput.type ? tokenAmount(c.outputData) : 0n), 0n);
418
+ return { lock, keyLock, sessionCells, keyCells, balance: sum(sessionCells), feeBalance: sum(keyCells), tokenBalance };
419
+ }
420
+ /**
421
+ * The recipient the session lock will enforce, taken from the session's own scope
422
+ * so the browser and the chain cannot disagree. The lock holds one recipient, so a
423
+ * scope listing several cannot be enforced on-chain and is refused.
424
+ */
425
+ export function onchainRecipient(session) {
426
+ const { recipients } = session.policy.scope;
427
+ if (!recipients)
428
+ return undefined;
429
+ if (recipients.length !== 1) {
430
+ throw new SessionPolicyError(`the session lock enforces one recipient; scope.recipients lists ${recipients.length}`);
431
+ }
432
+ return recipients[0];
433
+ }
434
+ /**
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).
481
+ *
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.
488
+ *
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.
493
+ *
494
+ * The owner must also never sign a partial (open) transaction with this lock.
495
+ */
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
+ };
513
+ const is = async (known) => {
514
+ try {
515
+ const s = await client.getKnownScript(known);
516
+ return s.codeHash === lock.codeHash && s.hashType === lock.hashType;
517
+ }
518
+ catch {
519
+ return false; // not deployed on this network
520
+ }
521
+ };
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`);
529
+ }
530
+ if (await is(ccc.KnownScript.AlwaysSuccess))
531
+ return unsafe("is always-success: it opens with no signature");
532
+ if (await is(ccc.KnownScript.OmniLock)) {
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)`);
545
+ }
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`);
548
+ }
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`);
551
+ }
552
+ return;
553
+ }
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");
571
+ }
572
+ /**
573
+ * Owner funds the session: one transaction, one signature from `owner`.
574
+ * The on-chain recipient comes from `session.policy.scope.recipients`.
575
+ *
576
+ * `beforeSend` runs once the transaction is signed and before it is sent: save the
577
+ * session there (`store.save({ ...session, onchain: binding })`). Saved after
578
+ * sending, a failed save would leave funds under a key that exists nowhere. The
579
+ * binding then carries `openTx`, the funding transaction's hash, which
580
+ * `closeSession` checks before reporting that nothing is left.
581
+ *
582
+ * The key cell (`feeBudget`) must be able to return to the owner at the end: by
583
+ * default it is the size of a cell under the owner's lock plus 1 CKB for fees.
584
+ */
585
+ export async function openSession(owner, session, deployment, opts) {
586
+ const client = owner.client;
587
+ const token = opts.token && ccc.Script.from(opts.token.type);
588
+ if (token && opts.token) {
589
+ await assertPlainXudt(token, client);
590
+ if (opts.token.amount < 0n || opts.token.maxPerTx <= 0n) {
591
+ throw new SessionBalanceError("token amount must not be negative and token maxPerTx must be positive");
592
+ }
593
+ }
594
+ if (opts.minInterval !== undefined)
595
+ relativeBlocksSince(opts.minInterval); // a spend must be able to meet it
596
+ const ownerLock = (await owner.getRecommendedAddressObj()).script;
597
+ await assertSigningLock(ownerLock, client, opts);
598
+ const floor = keyCellFloor(ownerLock);
599
+ const feeBudget = opts.feeBudget ?? floor + FEE_MARGIN;
600
+ if (feeBudget <= floor) {
601
+ throw new SessionBalanceError(`feeBudget must be above ${ccc.fixedPointToString(floor)} CKB: the key cell pays fees and must still ` +
602
+ "be able to return to the owner's lock");
603
+ }
604
+ const recipient = onchainRecipient(session);
605
+ const recipientLockHash = recipient
606
+ ? (await ccc.Address.fromString(recipient, client)).script.hash()
607
+ : undefined;
608
+ const binding = {
609
+ ownerLock: { codeHash: ownerLock.codeHash, hashType: ownerLock.hashType, args: ownerLock.args },
610
+ minInterval: opts.minInterval ?? 0n,
611
+ ...(recipientLockHash && { recipientLockHash }),
612
+ ...(token &&
613
+ opts.token && {
614
+ token: { type: { codeHash: token.codeHash, hashType: token.hashType, args: token.args }, maxPerTx: opts.token.maxPerTx },
615
+ }),
616
+ };
617
+ const keyLock = await keyLockOf(session, client);
618
+ if (recipientLockHash === keyLock.hash()) {
619
+ throw new SessionPolicyError("the recipient is the session key itself: it could then pay anyone");
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);
630
+ const lock = sessionLockScript(deployment, params(session, keyLock, binding));
631
+ const tx = ccc.Transaction.from({
632
+ outputs: [
633
+ { lock, capacity: opts.budget, ...(token && { type: token }) },
634
+ { lock: keyLock, capacity: feeBudget },
635
+ ],
636
+ outputsData: [token && opts.token ? tokenData(opts.token.amount) : "0x", "0x"],
637
+ });
638
+ // CCC raises an output to its occupied capacity; a budget below that is a mistake.
639
+ if (tx.outputs[0].capacity !== opts.budget) {
640
+ throw new SessionBalanceError(`budget must be at least ${ccc.fixedPointToString(tx.outputs[0].capacity)} CKB`);
641
+ }
642
+ if (token) {
643
+ await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.XUdt);
644
+ await tx.completeInputsByUdt(owner, token);
645
+ const extra = (await tx.getInputsUdtBalance(client, token)) - tx.getOutputsUdtBalance(token);
646
+ if (extra > 0n)
647
+ tx.addOutput({ lock: ownerLock, type: token }, tokenData(extra));
648
+ }
649
+ await tx.completeInputsByCapacity(owner);
650
+ await tx.completeFeeBy(owner);
651
+ let funded = binding;
652
+ const txHash = await signAndSend(owner, tx, async (hash) => {
653
+ funded = { ...binding, openTx: hash };
654
+ await opts.beforeSend?.(funded);
655
+ });
656
+ return { txHash, binding: funded };
657
+ }
658
+ /**
659
+ * Throws unless `type` is a plain xUDT (RFC 0052) on this network: the session
660
+ * lock reads amounts as xUDT stores them, and an xUDT with any flag set in its
661
+ * args either runs extension scripts the owner did not choose on every spend, or
662
+ * widens who may mint (owner mode by input or output type, code review 14 Oct).
663
+ */
664
+ export async function assertPlainXudt(type, client) {
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) {
671
+ if (type.codeHash !== xudt.codeHash || type.hashType !== xudt.hashType) {
672
+ return "a token session holds an xUDT token; this type script is not xUDT";
673
+ }
674
+ const args = ccc.bytesFrom(type.args);
675
+ if (args.length < 32)
676
+ return "xUDT args must start with the issuer's 32-byte lock hash";
677
+ if (args.length >= 36 && ccc.numLeFromBytes(args.slice(32, 36)) !== 0n) {
678
+ return "xUDT tokens with flags (extension scripts, other owner modes) are not supported in a session";
679
+ }
680
+ return undefined;
681
+ }
682
+ /**
683
+ * The session key pays `to`: `amount` shannons, or in a token session `token`
684
+ * units of its xUDT. The scope is checked here first, then the network checks it
685
+ * again in the session lock. No wallet is involved.
686
+ *
687
+ * `topUp: true` pays into an existing cell of `to` instead of creating one. `to`
688
+ * must be an anyone-can-pay address with at least one live cell to add to (plain
689
+ * for CKB, holding the session's token for `token`); that cell is consumed and
690
+ * recreated holding more, which the anyone-can-pay lock allows without the
691
+ * recipient's signature. A token paid without `topUp` goes in a new cell, whose
692
+ * capacity (about 142 CKB) the session also pays and must fit in `maxPerTx`.
693
+ *
694
+ * In a token session, tokens always stay in the change: a CKB payment from a
695
+ * cell holding tokens returns them, never burns them.
696
+ *
697
+ * `memo` is recorded in the witness of the session key's own input, which that
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.
711
+ */
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) {
717
+ const tokenType = binding.token && ccc.Script.from(binding.token.type);
718
+ const tokenOut = request.token ?? 0n;
719
+ if (request.token !== undefined) {
720
+ if (!binding.token || !tokenType)
721
+ throw new ScopeError("this session holds no token");
722
+ if (!isActive(session, now))
723
+ throw new ScopeError("session expired");
724
+ if (tokenOut <= 0n)
725
+ throw new ScopeError("token amount must be positive");
726
+ if (tokenOut > binding.token.maxPerTx) {
727
+ throw new ScopeError(`token amount exceeds the session's token maxPerTx (${binding.token.maxPerTx})`);
728
+ }
729
+ const { recipients } = session.policy.scope;
730
+ if (recipients && !recipients.includes(request.to))
731
+ throw new ScopeError("recipient not in scope");
732
+ }
733
+ else {
734
+ const check = checkRequest(session, { to: request.to, amount: request.amount }, now);
735
+ if (!check.ok)
736
+ throw new ScopeError(check.reason);
737
+ }
738
+ const state = await findSessionCells(session, client, deployment, binding);
739
+ const { script: toLock } = await ccc.Address.fromString(request.to, client);
740
+ if (binding.recipientLockHash && toLock.hash() !== binding.recipientLockHash) {
741
+ throw new ScopeError("recipient not in scope");
742
+ }
743
+ if (!state.keyCells.length)
744
+ throw new SessionBalanceError("no key cell left to pay fees", "no-fee-cell");
745
+ const since = binding.minInterval > 0n ? relativeBlocksSince(binding.minInterval) : 0n;
746
+ const tx = ccc.Transaction.from({ cellDeps: [sessionLockCellDep(deployment)] });
747
+ if (tokenType)
748
+ await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.XUdt);
749
+ // The payment: output 0, plus the recipient's cell when topping up.
750
+ let ckbOut;
751
+ if (request.topUp) {
752
+ if (!(await isAnyoneCanPay(toLock, client)))
753
+ throw new ScopeError("top-up needs an anyone-can-pay recipient");
754
+ // Payers at once compete for a cell: a confirmed one no pool transaction spends,
755
+ // at random among the recipient's (pickPayoutCell).
756
+ ckbOut = request.amount ?? 0n;
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
+ }
761
+ }
762
+ else if (request.token !== undefined) {
763
+ tx.addOutput({ lock: toLock, type: tokenType, capacity: 0n }, tokenData(tokenOut)); // CCC sizes it
764
+ ckbOut = tx.outputs[0].capacity;
765
+ if (ckbOut > session.policy.scope.maxPerTx) {
766
+ throw new ScopeError(`a new token cell needs ${ccc.fixedPointToString(ckbOut)} CKB, over maxPerTx; pay with topUp instead`);
767
+ }
768
+ }
769
+ else {
770
+ ckbOut = request.amount;
771
+ tx.addOutput({ lock: toLock, capacity: ckbOut });
772
+ if (tx.outputs[0].capacity !== ckbOut) {
773
+ throw new SessionBalanceError(`a payment must be at least ${ccc.fixedPointToString(tx.outputs[0].capacity)} CKB`);
774
+ }
775
+ }
776
+ // With a rate limit, only cells at least `minInterval` blocks old can be spent.
777
+ // Anyone can create a cell under this lock; a fresh one picked here would make
778
+ // the whole spend fail (code review, 14 Oct).
779
+ const usable = binding.minInterval > 0n ? await matureCells(client, state.sessionCells, binding.minInterval) : state.sessionCells;
780
+ if (state.sessionCells.length && !usable.length) {
781
+ throw new SessionBalanceError(`rate limited: no session cell is ${binding.minInterval} blocks old yet`);
782
+ }
783
+ // Take session cells until they cover both the shannons and the tokens.
784
+ const taken = [];
785
+ let ckbIn = 0n;
786
+ let tokenIn = 0n;
787
+ for (const cell of usable) {
788
+ if (ckbIn >= ckbOut && tokenIn >= tokenOut)
789
+ break;
790
+ if (ckbIn >= ckbOut && !cell.cellOutput.type)
791
+ continue; // only tokens still needed
792
+ taken.push(cell);
793
+ ckbIn += cell.cellOutput.capacity;
794
+ if (cell.cellOutput.type)
795
+ tokenIn += tokenAmount(cell.outputData);
796
+ }
797
+ if (ckbIn < ckbOut) {
798
+ throw new SessionBalanceError(`session balance ${ccc.fixedPointToString(state.balance)} CKB is too low`);
799
+ }
800
+ if (tokenIn < tokenOut)
801
+ throw new SessionBalanceError(`session token balance ${state.tokenBalance} is too low`);
802
+ tx.inputs.unshift(...taken.map((c) => ccc.CellInput.from({ previousOutput: c.outPoint, since })));
803
+ // One change cell (the lock allows no more); it keeps every token that came in and did not leave.
804
+ const change = ckbIn - ckbOut;
805
+ const keepsTokens = taken.some((c) => c.cellOutput.type);
806
+ if (keepsTokens || change > 0n) {
807
+ tx.addOutput({ lock: state.lock, capacity: change, ...(keepsTokens && { type: tokenType }) }, keepsTokens ? tokenData(tokenIn - tokenOut) : "0x");
808
+ const needed = tx.outputs[tx.outputs.length - 1].capacity;
809
+ if (needed !== change) {
810
+ throw new SessionBalanceError(`the session would keep ${ccc.fixedPointToString(change)} CKB, below the ` +
811
+ `${ccc.fixedPointToString(needed)} CKB a session cell needs; ` +
812
+ (keepsTokens ? "it still holds tokens, so spend less CKB" : "spend all of it or less"));
813
+ }
814
+ }
815
+ // The key cell authorises the spend, comes back as the last output and pays the fee.
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 }));
820
+ if (request.memo !== undefined)
821
+ tx.setWitnessArgsAt(tx.inputs.length - 1, { inputType: request.memo });
822
+ tx.addOutput({ lock: state.keyLock, capacity: 0n });
823
+ await completeFeeOrNoFeeCell(tx, sessionSigner(session, client), "the key cell cannot cover the fee; top it up");
824
+ // Below this, the key cell could no longer go back to the owner at the end.
825
+ if (tx.outputs[tx.outputs.length - 1].capacity < keyCellFloor(binding.ownerLock)) {
826
+ throw new SessionBalanceError("the key cell cannot cover another fee; top it up", "no-fee-cell");
827
+ }
828
+ return signAndSend(sessionSigner(session, client), tx, request.beforeSend);
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
+ }
856
+ /** The cells created at least `blocks` blocks before the tip: those a relative `since` of `blocks` can spend. */
857
+ export async function matureCells(client, cells, blocks) {
858
+ const tip = await client.getTip();
859
+ const born = await Promise.all(cells.map(async (c) => (await client.getTransaction(c.outPoint.txHash))?.blockNumber));
860
+ return cells.filter((_, i) => {
861
+ const b = born[i];
862
+ return b !== undefined && b !== null && tip - ccc.numFrom(b) >= blocks;
863
+ });
864
+ }
865
+ /**
866
+ * Ends the session: the owner sweeps the session cells (one wallet signature),
867
+ * then the key cell returns to the owner (session key signs, no wallet).
868
+ *
869
+ * The sweep goes first because it is what matters: a key cell too small to pay
870
+ * for the owner's lock (some wallets' locks need more than 61 CKB) must not block
871
+ * it. A key-cell failure is reported in `keyError`; the key stays usable for a retry.
872
+ *
873
+ * `nothingFound` means no cell is left under the session or the key: the key can
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.
884
+ */
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) {
916
+ const client = owner.client;
917
+ const ownerLock = ccc.Script.from(binding.ownerLock);
918
+ const state = await findSessionCells(session, client, deployment, binding);
919
+ const result = {};
920
+ if (!state.sessionCells.length && !state.keyCells.length) {
921
+ const open = binding.openTx && (await client.getTransaction(binding.openTx));
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`);
948
+ }
949
+ return { nothingFound: true };
950
+ }
951
+ if (state.sessionCells.length)
952
+ result.sweepTx = await ownerSweep(owner, deployment, state.sessionCells);
953
+ if (state.keyCells.length) {
954
+ try {
955
+ const tx = ccc.Transaction.from({
956
+ inputs: state.keyCells.map((c) => ({ previousOutput: c.outPoint })),
957
+ outputs: [{ lock: ownerLock, capacity: 0n }],
958
+ });
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);
963
+ }
964
+ catch (e) {
965
+ result.keyError = e instanceof Error ? e.message : String(e);
966
+ }
967
+ }
968
+ return result;
969
+ }
970
+ /**
971
+ * Every live session cell this owner opened, found without any session key.
972
+ *
973
+ * Session-lock args begin with the owner's lock hash, so an indexer prefix search
974
+ * on the args finds them all. Only well-formed args (80 or 112 bytes; 128 or 160
975
+ * for a token session) are kept: a cell with malformed args would fail the lock
976
+ * even in owner mode, and anyone can create one with this owner's hash as a prefix
977
+ * to block a sweep. Likewise a typed cell is kept only if it holds the session's
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`).
981
+ *
982
+ * Pass several deployments (`[TESTNET_DEPLOYMENT, ...TESTNET_LEGACY_DEPLOYMENTS]`)
983
+ * to find sessions opened under earlier versions of the lock as well.
984
+ */
985
+ export async function findOwnedSessionCells(owner, deployments) {
986
+ const ownerLock = (await owner.getRecommendedAddressObj()).script;
987
+ const cells = [];
988
+ let xudt; // looked up once, at the first typed cell
989
+ for (const deployment of [deployments].flat()) {
990
+ for await (const cell of owner.client.findCells({
991
+ script: { codeHash: deployment.codeHash, hashType: deployment.hashType, args: ownerLock.hash() },
992
+ scriptType: "lock",
993
+ scriptSearchMode: "prefix",
994
+ withData: true,
995
+ })) {
996
+ if (cell.cellOutput.type && !xudt)
997
+ xudt = await owner.client.getKnownScript(ccc.KnownScript.XUdt);
998
+ if (sweepable(cell, xudt))
999
+ cells.push(cell);
1000
+ }
1001
+ }
1002
+ return withinU128(owner.client, cells);
1003
+ }
1004
+ /**
1005
+ * Device-loss recovery: sweeps every session this owner opened back to the owner,
1006
+ * in one transaction and one wallet signature. Needs nothing from the lost device.
1007
+ *
1008
+ * Anyone can create a cell under a session lock carrying this owner's hash and
1009
+ * their own key, and spend it before the sweep lands, which would make a single
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.
1014
+ *
1015
+ * The key cell (the fee budget) is under the session key's own lock and cannot be
1016
+ * recovered without that key; keep it small.
1017
+ */
1018
+ export async function recoverSessions(owner, deployments) {
1019
+ const deployment = [deployments].flat();
1020
+ const cells = await findOwnedSessionCells(owner, deployment);
1021
+ const total = (cs) => cs.reduce((sum, c) => sum + c.cellOutput.capacity, 0n);
1022
+ if (!cells.length)
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;
1027
+ try {
1028
+ return { txHashes: [await ownerSweep(owner, deployment, cells)], recovered: total(cells), cells: cells.length, failed: 0 };
1029
+ }
1030
+ catch (e) {
1031
+ if (fixed(e))
1032
+ throw e;
1033
+ // Fall through: one session at a time.
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).
1038
+ const sessions = new Map();
1039
+ for (const c of cells) {
1040
+ const lockHash = c.cellOutput.lock.hash();
1041
+ sessions.set(lockHash, [...(sessions.get(lockHash) ?? []), c]);
1042
+ }
1043
+ const result = { txHashes: [], recovered: 0n, cells: 0, failed: 0 };
1044
+ for (const group of sessions.values()) {
1045
+ try {
1046
+ result.txHashes.push(await ownerSweep(owner, deployment, group));
1047
+ result.recovered += total(group);
1048
+ result.cells += group.length;
1049
+ }
1050
+ catch (e) {
1051
+ if (fixed(e))
1052
+ throw e;
1053
+ result.failed++;
1054
+ }
1055
+ }
1056
+ return result;
1057
+ }
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) {
1066
+ const args = ccc.bytesFrom(cell.cellOutput.lock.args);
1067
+ const type = cell.cellOutput.type;
1068
+ if (args.length === 80 || args.length === 112)
1069
+ return !type;
1070
+ if (args.length !== 128 && args.length !== 160)
1071
+ return false;
1072
+ if (!type)
1073
+ return true;
1074
+ return (!!xudt &&
1075
+ plainXudtFault(type, xudt) === undefined &&
1076
+ type.hash() === ccc.hexFrom(args.slice(80, 112)) &&
1077
+ ccc.bytesFrom(cell.outputData).length === 16);
1078
+ }
1079
+ /**
1080
+ * Spends `cells` in owner mode: everything returns to the owner. Tokens go back
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`).
1084
+ */
1085
+ export async function ownerSweep(owner, deployments, cells, opts = {}) {
1086
+ // The code of every lock version these cells are under, and only those.
1087
+ const used = [deployments].flat().filter((d) => cells.some((c) => c.cellOutput.lock.codeHash === d.codeHash));
1088
+ const tx = ccc.Transaction.from({
1089
+ inputs: cells.map((c) => ({ previousOutput: c.outPoint })),
1090
+ outputs: [],
1091
+ cellDeps: used.map(sessionLockCellDep),
1092
+ });
1093
+ const ownerLock = (await owner.getRecommendedAddressObj()).script;
1094
+ const tokens = new Map();
1095
+ for (const c of cells) {
1096
+ const type = c.cellOutput.type;
1097
+ if (!type)
1098
+ continue;
1099
+ const held = tokens.get(type.hash()) ?? { type, amount: 0n };
1100
+ held.amount += tokenAmount(c.outputData);
1101
+ tokens.set(type.hash(), held);
1102
+ }
1103
+ for (const { type, amount } of tokens.values())
1104
+ tx.addOutput({ lock: ownerLock, type }, tokenData(amount));
1105
+ if (tokens.size)
1106
+ await tx.addCellDepsOfKnownScripts(owner.client, ccc.KnownScript.XUdt);
1107
+ // Owner mode needs an owner-locked input in the transaction.
1108
+ for await (const cell of owner.findCells({ scriptLenRange: [0, 1], outputDataLenRange: [0, 1] }, true)) {
1109
+ tx.inputs.push(ccc.CellInput.from({ previousOutput: cell.outPoint }));
1110
+ if (opts.memo)
1111
+ tx.setWitnessArgsAt(tx.inputs.length - 1, { inputType: opts.memo });
1112
+ await tx.completeFeeBy(owner);
1113
+ // A sweep the node took but whose reply was lost is still the sweep (review, 28 Oct).
1114
+ return signAndSend(owner, tx);
1115
+ }
1116
+ throw new SessionBalanceError("the owner needs one plain cell to prove ownership");
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
+ }