ckb-session-kit 0.0.0-stage → 1.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,561 @@
1
+ import { ccc } from "@ckb-ccc/core";
2
+ import { relativeBlocksSince, sessionLockCellDep, sessionLockScript, } from "./lock.js";
3
+ import { checkRequest, isActive, SessionPolicyError } from "./session.js";
4
+ import { ScopeError, sessionSigner } from "./transfer.js";
5
+ export class SessionBalanceError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "SessionBalanceError";
9
+ }
10
+ }
11
+ const KEY_CELL_MIN = 61n * 100000000n;
12
+ /** What the key cell keeps above the cell it returns to the owner as, for fees. */
13
+ const FEE_MARGIN = 1n * 100000000n;
14
+ /**
15
+ * The smallest key cell that can still be returned to the owner: a cell under the
16
+ * owner's lock (63 CKB for JoyID or Omnilock, whose args are 22 bytes), and never
17
+ * below a key cell's own 61 CKB.
18
+ */
19
+ export function keyCellFloor(ownerLock) {
20
+ const ownerCell = ccc.CellOutput.from({ lock: ownerLock }, "0x").capacity;
21
+ return ownerCell > KEY_CELL_MIN ? ownerCell : KEY_CELL_MIN;
22
+ }
23
+ /**
24
+ * Signs `tx`, hands its final hash to `beforeSend`, then sends it. The hash is
25
+ * known before anything leaves, so a caller can record the payment or the key
26
+ * 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.
28
+ */
29
+ export async function signAndSend(signer, tx, beforeSend) {
30
+ const signed = await signer.signTransaction(tx);
31
+ const hash = signed.hash();
32
+ await beforeSend?.(hash);
33
+ try {
34
+ return await signer.client.sendTransaction(signed);
35
+ }
36
+ catch (e) {
37
+ const known = await signer.client.getTransaction(hash).catch(() => undefined);
38
+ if (known && ["pending", "proposed", "committed"].includes(known.status))
39
+ return hash;
40
+ throw e;
41
+ }
42
+ }
43
+ export async function keyLockOf(session, client) {
44
+ return (await sessionSigner(session, client).getRecommendedAddressObj()).script;
45
+ }
46
+ function params(session, keyLock, binding) {
47
+ return {
48
+ ownerLockHash: ccc.Script.from(binding.ownerLock).hash(),
49
+ sessionLockHash: keyLock.hash(),
50
+ maxPerTx: session.policy.scope.maxPerTx,
51
+ minInterval: binding.minInterval,
52
+ ...(binding.recipientLockHash && { recipientLockHash: binding.recipientLockHash }),
53
+ ...(binding.token && {
54
+ token: { typeHash: ccc.Script.from(binding.token.type).hash(), maxPerTx: binding.token.maxPerTx },
55
+ }),
56
+ };
57
+ }
58
+ /** The xUDT amount in a cell's data (RFC 0052: u128 LE in the first 16 bytes). */
59
+ export function tokenAmount(data) {
60
+ const bytes = ccc.bytesFrom(data);
61
+ if (bytes.length < 16)
62
+ throw new RangeError("not an xUDT cell: under 16 bytes of data");
63
+ return ccc.numLeFromBytes(bytes.slice(0, 16));
64
+ }
65
+ export const tokenData = (amount, rest = new Uint8Array()) => ccc.hexFrom(ccc.bytesConcat(ccc.numLeToBytes(amount, 16), rest));
66
+ /** A cell's data is exactly a 16-byte amount: what the session lock accepts as token change. */
67
+ export const plainTokenCell = (cell, type) => cell.cellOutput.type?.eq(type) === true && ccc.bytesFrom(cell.outputData).length === 16;
68
+ export async function sessionLockOf(session, client, deployment, binding) {
69
+ return sessionLockScript(deployment, params(session, await keyLockOf(session, client), binding));
70
+ }
71
+ /** Plain cells under `lock`; with `token`, also cells holding exactly that token. */
72
+ export async function collect(client, lock, token) {
73
+ const cells = [];
74
+ for await (const cell of client.findCellsByLock(lock, null, true)) {
75
+ if ((!cell.cellOutput.type && cell.outputData === "0x") || (token && plainTokenCell(cell, token)))
76
+ cells.push(cell);
77
+ }
78
+ return cells;
79
+ }
80
+ export async function findSessionCells(session, client, deployment, binding) {
81
+ const keyLock = await keyLockOf(session, client);
82
+ const lock = sessionLockScript(deployment, params(session, keyLock, binding));
83
+ const token = binding.token && ccc.Script.from(binding.token.type);
84
+ const [sessionCells, keyCells] = await Promise.all([collect(client, lock, token), collect(client, keyLock)]);
85
+ const sum = (cells) => cells.reduce((s, c) => s + c.cellOutput.capacity, 0n);
86
+ const tokenBalance = sessionCells.reduce((s, c) => s + (c.cellOutput.type ? tokenAmount(c.outputData) : 0n), 0n);
87
+ return { lock, keyLock, sessionCells, keyCells, balance: sum(sessionCells), feeBalance: sum(keyCells), tokenBalance };
88
+ }
89
+ /**
90
+ * The recipient the session lock will enforce, taken from the session's own scope
91
+ * so the browser and the chain cannot disagree. The lock holds one recipient, so a
92
+ * scope listing several cannot be enforced on-chain and is refused.
93
+ */
94
+ export function onchainRecipient(session) {
95
+ const { recipients } = session.policy.scope;
96
+ if (!recipients)
97
+ return undefined;
98
+ if (recipients.length !== 1) {
99
+ throw new SessionPolicyError(`the session lock enforces one recipient; scope.recipients lists ${recipients.length}`);
100
+ }
101
+ return recipients[0];
102
+ }
103
+ /**
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):
108
+ *
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.
114
+ *
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.
117
+ *
118
+ * The owner must also never sign a partial (open) transaction with this lock.
119
+ */
120
+ export async function assertSigningLock(lock, client) {
121
+ const is = async (known) => {
122
+ try {
123
+ const s = await client.getKnownScript(known);
124
+ return s.codeHash === lock.codeHash && s.hashType === lock.hashType;
125
+ }
126
+ catch {
127
+ return false; // not deployed on this network
128
+ }
129
+ };
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");
132
+ }
133
+ 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");
139
+ }
140
+ if (omniFlags & 0x01) {
141
+ throw new SessionPolicyError("the owner's Omnilock has an administrator, who could unlock it: it cannot own a session");
142
+ }
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");
148
+ }
149
+ return;
150
+ }
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");
163
+ }
164
+ /**
165
+ * Owner funds the session: one transaction, one signature from `owner`.
166
+ * The on-chain recipient comes from `session.policy.scope.recipients`.
167
+ *
168
+ * `beforeSend` runs once the transaction is signed and before it is sent: save the
169
+ * session there (`store.save({ ...session, onchain: binding })`). Saved after
170
+ * sending, a failed save would leave funds under a key that exists nowhere. The
171
+ * binding then carries `openTx`, the funding transaction's hash, which
172
+ * `closeSession` checks before reporting that nothing is left.
173
+ *
174
+ * The key cell (`feeBudget`) must be able to return to the owner at the end: by
175
+ * default it is the size of a cell under the owner's lock plus 1 CKB for fees.
176
+ */
177
+ export async function openSession(owner, session, deployment, opts) {
178
+ const client = owner.client;
179
+ const token = opts.token && ccc.Script.from(opts.token.type);
180
+ if (token && opts.token) {
181
+ await assertPlainXudt(token, client);
182
+ if (opts.token.amount < 0n || opts.token.maxPerTx <= 0n) {
183
+ throw new SessionBalanceError("token amount must not be negative and token maxPerTx must be positive");
184
+ }
185
+ }
186
+ if (opts.minInterval !== undefined)
187
+ relativeBlocksSince(opts.minInterval); // a spend must be able to meet it
188
+ const ownerLock = (await owner.getRecommendedAddressObj()).script;
189
+ await assertSigningLock(ownerLock, client);
190
+ const floor = keyCellFloor(ownerLock);
191
+ const feeBudget = opts.feeBudget ?? floor + FEE_MARGIN;
192
+ if (feeBudget <= floor) {
193
+ throw new SessionBalanceError(`feeBudget must be above ${ccc.fixedPointToString(floor)} CKB: the key cell pays fees and must still ` +
194
+ "be able to return to the owner's lock");
195
+ }
196
+ const recipient = onchainRecipient(session);
197
+ const recipientLockHash = recipient
198
+ ? (await ccc.Address.fromString(recipient, client)).script.hash()
199
+ : undefined;
200
+ const binding = {
201
+ ownerLock: { codeHash: ownerLock.codeHash, hashType: ownerLock.hashType, args: ownerLock.args },
202
+ minInterval: opts.minInterval ?? 0n,
203
+ ...(recipientLockHash && { recipientLockHash }),
204
+ ...(token &&
205
+ opts.token && {
206
+ token: { type: { codeHash: token.codeHash, hashType: token.hashType, args: token.args }, maxPerTx: opts.token.maxPerTx },
207
+ }),
208
+ };
209
+ const keyLock = await keyLockOf(session, client);
210
+ if (recipientLockHash === keyLock.hash()) {
211
+ throw new SessionPolicyError("the recipient is the session key itself: it could then pay anyone");
212
+ }
213
+ const lock = sessionLockScript(deployment, params(session, keyLock, binding));
214
+ const tx = ccc.Transaction.from({
215
+ outputs: [
216
+ { lock, capacity: opts.budget, ...(token && { type: token }) },
217
+ { lock: keyLock, capacity: feeBudget },
218
+ ],
219
+ outputsData: [token && opts.token ? tokenData(opts.token.amount) : "0x", "0x"],
220
+ });
221
+ // CCC raises an output to its occupied capacity; a budget below that is a mistake.
222
+ if (tx.outputs[0].capacity !== opts.budget) {
223
+ throw new SessionBalanceError(`budget must be at least ${ccc.fixedPointToString(tx.outputs[0].capacity)} CKB`);
224
+ }
225
+ if (token) {
226
+ await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.XUdt);
227
+ await tx.completeInputsByUdt(owner, token);
228
+ const extra = (await tx.getInputsUdtBalance(client, token)) - tx.getOutputsUdtBalance(token);
229
+ if (extra > 0n)
230
+ tx.addOutput({ lock: ownerLock, type: token }, tokenData(extra));
231
+ }
232
+ await tx.completeInputsByCapacity(owner);
233
+ await tx.completeFeeBy(owner);
234
+ let funded = binding;
235
+ const txHash = await signAndSend(owner, tx, async (hash) => {
236
+ funded = { ...binding, openTx: hash };
237
+ await opts.beforeSend?.(funded);
238
+ });
239
+ return { txHash, binding: funded };
240
+ }
241
+ /**
242
+ * Throws unless `type` is a plain xUDT (RFC 0052) on this network: the session
243
+ * lock reads amounts as xUDT stores them, and an xUDT with any flag set in its
244
+ * args either runs extension scripts the owner did not choose on every spend, or
245
+ * widens who may mint (owner mode by input or output type, code review 14 Oct).
246
+ */
247
+ export async function assertPlainXudt(type, client) {
248
+ const xudt = await client.getKnownScript(ccc.KnownScript.XUdt);
249
+ 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");
251
+ }
252
+ const args = ccc.bytesFrom(type.args);
253
+ if (args.length < 32)
254
+ throw new SessionPolicyError("xUDT args must start with the issuer's 32-byte lock hash");
255
+ 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");
257
+ }
258
+ }
259
+ /**
260
+ * The session key pays `to`: `amount` shannons, or in a token session `token`
261
+ * units of its xUDT. The scope is checked here first, then the network checks it
262
+ * again in the session lock. No wallet is involved.
263
+ *
264
+ * `topUp: true` pays into an existing cell of `to` instead of creating one. `to`
265
+ * must be an anyone-can-pay address with at least one live cell to add to (plain
266
+ * for CKB, holding the session's token for `token`); that cell is consumed and
267
+ * recreated holding more, which the anyone-can-pay lock allows without the
268
+ * recipient's signature. A token paid without `topUp` goes in a new cell, whose
269
+ * capacity (about 142 CKB) the session also pays and must fit in `maxPerTx`.
270
+ *
271
+ * In a token session, tokens always stay in the change: a CKB payment from a
272
+ * cell holding tokens returns them, never burns them.
273
+ *
274
+ * `memo` is recorded in the witness of the session key's own input, which that
275
+ * key's signature covers: the payment commits to it (see `verifyAccess`).
276
+ */
277
+ export async function spendInSession(session, client, deployment, binding, request, now = new Date()) {
278
+ const tokenType = binding.token && ccc.Script.from(binding.token.type);
279
+ const tokenOut = request.token ?? 0n;
280
+ if (request.token !== undefined) {
281
+ if (!binding.token || !tokenType)
282
+ throw new ScopeError("this session holds no token");
283
+ if (!isActive(session, now))
284
+ throw new ScopeError("session expired");
285
+ if (tokenOut <= 0n)
286
+ throw new ScopeError("token amount must be positive");
287
+ if (tokenOut > binding.token.maxPerTx) {
288
+ throw new ScopeError(`token amount exceeds the session's token maxPerTx (${binding.token.maxPerTx})`);
289
+ }
290
+ const { recipients } = session.policy.scope;
291
+ if (recipients && !recipients.includes(request.to))
292
+ throw new ScopeError("recipient not in scope");
293
+ }
294
+ else {
295
+ const check = checkRequest(session, { to: request.to, amount: request.amount }, now);
296
+ if (!check.ok)
297
+ throw new ScopeError(check.reason);
298
+ }
299
+ const state = await findSessionCells(session, client, deployment, binding);
300
+ const { script: toLock } = await ccc.Address.fromString(request.to, client);
301
+ if (binding.recipientLockHash && toLock.hash() !== binding.recipientLockHash) {
302
+ throw new ScopeError("recipient not in scope");
303
+ }
304
+ if (!state.keyCells.length)
305
+ throw new SessionBalanceError("no key cell left to pay fees");
306
+ const since = binding.minInterval > 0n ? relativeBlocksSince(binding.minInterval) : 0n;
307
+ const tx = ccc.Transaction.from({ cellDeps: [sessionLockCellDep(deployment)] });
308
+ if (tokenType)
309
+ await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.XUdt);
310
+ // The payment: output 0, plus the recipient's cell when topping up.
311
+ let ckbOut;
312
+ if (request.topUp) {
313
+ const acp = await client.getKnownScript(ccc.KnownScript.AnyoneCanPay);
314
+ if (toLock.codeHash !== acp.codeHash || toLock.hashType !== acp.hashType) {
315
+ 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 }));
324
+ 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);
327
+ }
328
+ else if (request.token !== undefined) {
329
+ tx.addOutput({ lock: toLock, type: tokenType, capacity: 0n }, tokenData(tokenOut)); // CCC sizes it
330
+ ckbOut = tx.outputs[0].capacity;
331
+ if (ckbOut > session.policy.scope.maxPerTx) {
332
+ throw new ScopeError(`a new token cell needs ${ccc.fixedPointToString(ckbOut)} CKB, over maxPerTx; pay with topUp instead`);
333
+ }
334
+ }
335
+ else {
336
+ ckbOut = request.amount;
337
+ tx.addOutput({ lock: toLock, capacity: ckbOut });
338
+ if (tx.outputs[0].capacity !== ckbOut) {
339
+ throw new SessionBalanceError(`a payment must be at least ${ccc.fixedPointToString(tx.outputs[0].capacity)} CKB`);
340
+ }
341
+ }
342
+ // With a rate limit, only cells at least `minInterval` blocks old can be spent.
343
+ // Anyone can create a cell under this lock; a fresh one picked here would make
344
+ // the whole spend fail (code review, 14 Oct).
345
+ const usable = binding.minInterval > 0n ? await matureCells(client, state.sessionCells, binding.minInterval) : state.sessionCells;
346
+ if (state.sessionCells.length && !usable.length) {
347
+ throw new SessionBalanceError(`rate limited: no session cell is ${binding.minInterval} blocks old yet`);
348
+ }
349
+ // Take session cells until they cover both the shannons and the tokens.
350
+ const taken = [];
351
+ let ckbIn = 0n;
352
+ let tokenIn = 0n;
353
+ for (const cell of usable) {
354
+ if (ckbIn >= ckbOut && tokenIn >= tokenOut)
355
+ break;
356
+ if (ckbIn >= ckbOut && !cell.cellOutput.type)
357
+ continue; // only tokens still needed
358
+ taken.push(cell);
359
+ ckbIn += cell.cellOutput.capacity;
360
+ if (cell.cellOutput.type)
361
+ tokenIn += tokenAmount(cell.outputData);
362
+ }
363
+ if (ckbIn < ckbOut) {
364
+ throw new SessionBalanceError(`session balance ${ccc.fixedPointToString(state.balance)} CKB is too low`);
365
+ }
366
+ if (tokenIn < tokenOut)
367
+ throw new SessionBalanceError(`session token balance ${state.tokenBalance} is too low`);
368
+ tx.inputs.unshift(...taken.map((c) => ccc.CellInput.from({ previousOutput: c.outPoint, since })));
369
+ // One change cell (the lock allows no more); it keeps every token that came in and did not leave.
370
+ const change = ckbIn - ckbOut;
371
+ const keepsTokens = taken.some((c) => c.cellOutput.type);
372
+ if (keepsTokens || change > 0n) {
373
+ tx.addOutput({ lock: state.lock, capacity: change, ...(keepsTokens && { type: tokenType }) }, keepsTokens ? tokenData(tokenIn - tokenOut) : "0x");
374
+ const needed = tx.outputs[tx.outputs.length - 1].capacity;
375
+ if (needed !== change) {
376
+ throw new SessionBalanceError(`the session would keep ${ccc.fixedPointToString(change)} CKB, below the ` +
377
+ `${ccc.fixedPointToString(needed)} CKB a session cell needs; ` +
378
+ (keepsTokens ? "it still holds tokens, so spend less CKB" : "spend all of it or less"));
379
+ }
380
+ }
381
+ // 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 }));
383
+ if (request.memo !== undefined)
384
+ tx.setWitnessArgsAt(tx.inputs.length - 1, { inputType: request.memo });
385
+ tx.addOutput({ lock: state.keyLock, capacity: 0n });
386
+ await tx.completeFeeChangeToOutput(sessionSigner(session, client), tx.outputs.length - 1);
387
+ // Below this, the key cell could no longer go back to the owner at the end.
388
+ 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");
390
+ }
391
+ return signAndSend(sessionSigner(session, client), tx, request.beforeSend);
392
+ }
393
+ /** The cells created at least `blocks` blocks before the tip: those a relative `since` of `blocks` can spend. */
394
+ export async function matureCells(client, cells, blocks) {
395
+ const tip = await client.getTip();
396
+ const born = await Promise.all(cells.map(async (c) => (await client.getTransaction(c.outPoint.txHash))?.blockNumber));
397
+ return cells.filter((_, i) => {
398
+ const b = born[i];
399
+ return b !== undefined && b !== null && tip - ccc.numFrom(b) >= blocks;
400
+ });
401
+ }
402
+ /**
403
+ * Ends the session: the owner sweeps the session cells (one wallet signature),
404
+ * then the key cell returns to the owner (session key signs, no wallet).
405
+ *
406
+ * The sweep goes first because it is what matters: a key cell too small to pay
407
+ * for the owner's lock (some wallets' locks need more than 61 CKB) must not block
408
+ * it. A key-cell failure is reported in `keyError`; the key stays usable for a retry.
409
+ *
410
+ * `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.
414
+ */
415
+ export async function closeSession(owner, session, deployment, binding) {
416
+ const client = owner.client;
417
+ const ownerLock = ccc.Script.from(binding.ownerLock);
418
+ const state = await findSessionCells(session, client, deployment, binding);
419
+ const result = {};
420
+ if (!state.sessionCells.length && !state.keyCells.length) {
421
+ 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`);
424
+ }
425
+ return { nothingFound: true };
426
+ }
427
+ if (state.sessionCells.length)
428
+ result.sweepTx = await ownerSweep(owner, deployment, state.sessionCells);
429
+ if (state.keyCells.length) {
430
+ try {
431
+ const tx = ccc.Transaction.from({
432
+ inputs: state.keyCells.map((c) => ({ previousOutput: c.outPoint })),
433
+ outputs: [{ lock: ownerLock, capacity: 0n }],
434
+ });
435
+ await tx.completeFeeChangeToOutput(sessionSigner(session, client), 0);
436
+ result.keyTx = await sessionSigner(session, client).sendTransaction(tx);
437
+ }
438
+ catch (e) {
439
+ result.keyError = e instanceof Error ? e.message : String(e);
440
+ }
441
+ }
442
+ return result;
443
+ }
444
+ /**
445
+ * Every live session cell this owner opened, found without any session key.
446
+ *
447
+ * Session-lock args begin with the owner's lock hash, so an indexer prefix search
448
+ * on the args finds them all. Only well-formed args (80 or 112 bytes; 128 or 160
449
+ * for a token session) are kept: a cell with malformed args would fail the lock
450
+ * even in owner mode, and anyone can create one with this owner's hash as a prefix
451
+ * 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.
453
+ *
454
+ * Pass several deployments (`[TESTNET_DEPLOYMENT, ...TESTNET_LEGACY_DEPLOYMENTS]`)
455
+ * to find sessions opened under earlier versions of the lock as well.
456
+ */
457
+ export async function findOwnedSessionCells(owner, deployments) {
458
+ const ownerLock = (await owner.getRecommendedAddressObj()).script;
459
+ const cells = [];
460
+ for (const deployment of [deployments].flat()) {
461
+ for await (const cell of owner.client.findCells({
462
+ script: { codeHash: deployment.codeHash, hashType: deployment.hashType, args: ownerLock.hash() },
463
+ scriptType: "lock",
464
+ scriptSearchMode: "prefix",
465
+ withData: true,
466
+ })) {
467
+ if (sweepable(cell))
468
+ cells.push(cell);
469
+ }
470
+ }
471
+ return cells;
472
+ }
473
+ /**
474
+ * Device-loss recovery: sweeps every session this owner opened back to the owner,
475
+ * in one transaction and one wallet signature. Needs nothing from the lost device.
476
+ *
477
+ * Anyone can create a cell under a session lock carrying this owner's hash and
478
+ * 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.
482
+ *
483
+ * The key cell (the fee budget) is under the session key's own lock and cannot be
484
+ * recovered without that key; keep it small.
485
+ */
486
+ export async function recoverSessions(owner, deployments) {
487
+ const deployment = [deployments].flat();
488
+ const cells = await findOwnedSessionCells(owner, deployment);
489
+ const total = (cs) => cs.reduce((sum, c) => sum + c.cellOutput.capacity, 0n);
490
+ if (!cells.length)
491
+ return { txHashes: [], recovered: 0n, cells: 0, failed: 0 };
492
+ try {
493
+ return { txHashes: [await ownerSweep(owner, deployment, cells)], recovered: total(cells), cells: cells.length, failed: 0 };
494
+ }
495
+ catch {
496
+ // Fall through: one session at a time.
497
+ }
498
+ const sessions = new Map();
499
+ 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]);
502
+ }
503
+ const result = { txHashes: [], recovered: 0n, cells: 0, failed: 0 };
504
+ for (const group of sessions.values()) {
505
+ try {
506
+ result.txHashes.push(await ownerSweep(owner, deployment, group));
507
+ result.recovered += total(group);
508
+ result.cells += group.length;
509
+ }
510
+ catch {
511
+ result.failed++;
512
+ }
513
+ }
514
+ return result;
515
+ }
516
+ /** A session cell an owner sweep can safely include. */
517
+ function sweepable(cell) {
518
+ const args = ccc.bytesFrom(cell.cellOutput.lock.args);
519
+ const type = cell.cellOutput.type;
520
+ if (args.length === 80 || args.length === 112)
521
+ return !type;
522
+ if (args.length !== 128 && args.length !== 160)
523
+ return false;
524
+ if (!type)
525
+ return true;
526
+ return type.hash() === ccc.hexFrom(args.slice(80, 112)) && ccc.bytesFrom(cell.outputData).length >= 16;
527
+ }
528
+ /**
529
+ * Spends `cells` in owner mode: everything returns to the owner. Tokens go back
530
+ * in one cell per token type (xUDT would let a sweep burn them otherwise).
531
+ */
532
+ export async function ownerSweep(owner, deployments, cells) {
533
+ // The code of every lock version these cells are under, and only those.
534
+ const used = [deployments].flat().filter((d) => cells.some((c) => c.cellOutput.lock.codeHash === d.codeHash));
535
+ const tx = ccc.Transaction.from({
536
+ inputs: cells.map((c) => ({ previousOutput: c.outPoint })),
537
+ outputs: [],
538
+ cellDeps: used.map(sessionLockCellDep),
539
+ });
540
+ const ownerLock = (await owner.getRecommendedAddressObj()).script;
541
+ const tokens = new Map();
542
+ for (const c of cells) {
543
+ const type = c.cellOutput.type;
544
+ if (!type)
545
+ continue;
546
+ const held = tokens.get(type.hash()) ?? { type, amount: 0n };
547
+ held.amount += tokenAmount(c.outputData);
548
+ tokens.set(type.hash(), held);
549
+ }
550
+ for (const { type, amount } of tokens.values())
551
+ tx.addOutput({ lock: ownerLock, type }, tokenData(amount));
552
+ if (tokens.size)
553
+ await tx.addCellDepsOfKnownScripts(owner.client, ccc.KnownScript.XUdt);
554
+ // Owner mode needs an owner-locked input in the transaction.
555
+ for await (const cell of owner.findCells({ scriptLenRange: [0, 1], outputDataLenRange: [0, 1] }, true)) {
556
+ tx.inputs.push(ccc.CellInput.from({ previousOutput: cell.outPoint }));
557
+ await tx.completeFeeBy(owner);
558
+ return owner.sendTransaction(tx);
559
+ }
560
+ throw new SessionBalanceError("the owner needs one plain cell to prove ownership");
561
+ }
@@ -0,0 +1,31 @@
1
+ import { ccc } from "@ckb-ccc/core";
2
+ import type { Session } from "./session.js";
3
+ /** The memo for a payment: a short UTF-8 label, e.g. `read:article-1`. */
4
+ export declare function paymentMemo(label: string): ccc.Hex;
5
+ export interface AccessProof {
6
+ txHash: ccc.Hex;
7
+ memo: ccc.Hex;
8
+ /** The session key's public key. */
9
+ publicKey: ccc.Hex;
10
+ signature: ccc.Hex;
11
+ }
12
+ /** The payer's side: prove that the key which paid `txHash` is asking. */
13
+ export declare function signAccess(session: Session, txHash: ccc.HexLike, memo: ccc.HexLike): Promise<AccessProof>;
14
+ export type AccessCheck = {
15
+ ok: true;
16
+ paid: bigint;
17
+ } | {
18
+ ok: false;
19
+ reason: string;
20
+ };
21
+ /**
22
+ * The server's side. `to` is the recipient that must have been paid, as a lock
23
+ * script; `minAmount` in shannons. With `token` (an xUDT type script), the
24
+ * payment is counted in that token's units instead, and `minAmount` is in units.
25
+ */
26
+ export declare function verifyAccess(client: ccc.Client, proof: AccessProof, expect: {
27
+ to: ccc.ScriptLike;
28
+ minAmount: bigint;
29
+ memo: ccc.HexLike;
30
+ token?: ccc.ScriptLike;
31
+ }): Promise<AccessCheck>;