ckb-session-kit 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/paywall.d.ts CHANGED
@@ -2,6 +2,35 @@ import { ccc } from "@ckb-ccc/core";
2
2
  import type { Session } from "./session.js";
3
3
  /** The memo for a payment: a short UTF-8 label, e.g. `read:article-1`. */
4
4
  export declare function paymentMemo(label: string): ccc.Hex;
5
+ /** Whether `lock` runs the standard secp256k1-blake160 sighash_all code (`code`: that script on this network). */
6
+ export declare const isSecp256k1Lock: (lock: ccc.Script, code?: {
7
+ codeHash: ccc.Hex;
8
+ hashType: ccc.HashType;
9
+ }) => boolean;
10
+ /**
11
+ * Which witnesses of `tx` a signature covers, given the locks of its inputs in
12
+ * order: the first witness of each input lock group whose lock signs (`signs`),
13
+ * and every witness past the inputs, which a sighash_all signature also covers.
14
+ *
15
+ * Any other witness can be changed by whoever relays the transaction without
16
+ * changing its hash: a session cell's (the session lock reads no witness) or an
17
+ * anyone-can-pay cell's taking a top-up (audit, 30 Oct). A memo there is nobody's.
18
+ */
19
+ export declare function signedWitnesses(tx: ccc.Transaction, inputLocks: ccc.ScriptLike[], signs: (lock: ccc.Script) => boolean): (index: number) => boolean;
20
+ /** Every witness carrying a memo (an `inputType`), with its index. */
21
+ export declare function memoWitnesses(tx: ccc.Transaction): {
22
+ index: number;
23
+ memo: ccc.Hex;
24
+ }[];
25
+ /**
26
+ * The one witness carrying a memo and its index; undefined with none or several.
27
+ * Only witnesses `counts` accepts are looked at (`signedWitnesses`: those a
28
+ * signature covers); by default, all of them.
29
+ */
30
+ export declare function soleMemo(tx: ccc.Transaction, counts?: (index: number) => boolean): {
31
+ index: number;
32
+ memo: ccc.Hex;
33
+ } | undefined;
5
34
  export interface AccessProof {
6
35
  txHash: ccc.Hex;
7
36
  memo: ccc.Hex;
package/dist/paywall.js CHANGED
@@ -14,9 +14,11 @@ import { sessionSigner } from "./transfer.js";
14
14
  * signed by the key that paid. Anyone can see the payment on-chain; only the
15
15
  * payer can use it.
16
16
  *
17
- * One payment, one claim: a payment transaction may carry exactly one memo.
18
- * Otherwise one transaction could pay the creator once and carry memos from
19
- * several keys, each claiming the full amount for a different article or reader.
17
+ * One payment, one claim: a payment transaction may carry exactly one memo in
18
+ * the witnesses a signature covers. Otherwise one transaction could pay the
19
+ * creator once and carry memos from several keys, each claiming the full amount
20
+ * for a different article or reader. A memo in a witness nobody signed (the
21
+ * session cell's) does not count: whoever relays the payment could add one.
20
22
  */
21
23
  /** A session payment has a handful of inputs; more is not one, and costs RPC calls to check. */
22
24
  const MAX_PAYMENT_INPUTS = 16;
@@ -24,6 +26,55 @@ const MAX_PAYMENT_INPUTS = 16;
24
26
  export function paymentMemo(label) {
25
27
  return ccc.hexFrom(ccc.bytesFrom(label, "utf8"));
26
28
  }
29
+ /**
30
+ * The standard secp256k1-blake160 sighash_all lock, the session key's own lock: the
31
+ * same code on mainnet and testnet. Its signature covers the first witness of its
32
+ * lock group (and the rest of the group, and every witness past the inputs).
33
+ */
34
+ const SECP256K1_BLAKE160 = {
35
+ codeHash: "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8",
36
+ hashType: "type",
37
+ };
38
+ /** Whether `lock` runs the standard secp256k1-blake160 sighash_all code (`code`: that script on this network). */
39
+ export const isSecp256k1Lock = (lock, code = SECP256K1_BLAKE160) => lock.codeHash === code.codeHash && lock.hashType === code.hashType;
40
+ /**
41
+ * Which witnesses of `tx` a signature covers, given the locks of its inputs in
42
+ * order: the first witness of each input lock group whose lock signs (`signs`),
43
+ * and every witness past the inputs, which a sighash_all signature also covers.
44
+ *
45
+ * Any other witness can be changed by whoever relays the transaction without
46
+ * changing its hash: a session cell's (the session lock reads no witness) or an
47
+ * anyone-can-pay cell's taking a top-up (audit, 30 Oct). A memo there is nobody's.
48
+ */
49
+ export function signedWitnesses(tx, inputLocks, signs) {
50
+ const locks = inputLocks.map((l) => ccc.Script.from(l));
51
+ return (i) => i >= tx.inputs.length || (i < locks.length && signs(locks[i]) && !locks.slice(0, i).some((l) => l.eq(locks[i])));
52
+ }
53
+ /** Every witness carrying a memo (an `inputType`), with its index. */
54
+ export function memoWitnesses(tx) {
55
+ const found = [];
56
+ for (let i = 0; i < tx.witnesses.length; i++) {
57
+ let inputType;
58
+ try {
59
+ inputType = tx.getWitnessArgsAt(i)?.inputType;
60
+ }
61
+ catch {
62
+ continue; // not WitnessArgs: carries no memo
63
+ }
64
+ if (inputType !== undefined)
65
+ found.push({ index: i, memo: inputType });
66
+ }
67
+ return found;
68
+ }
69
+ /**
70
+ * The one witness carrying a memo and its index; undefined with none or several.
71
+ * Only witnesses `counts` accepts are looked at (`signedWitnesses`: those a
72
+ * signature covers); by default, all of them.
73
+ */
74
+ export function soleMemo(tx, counts = () => true) {
75
+ const found = memoWitnesses(tx).filter((m) => counts(m.index));
76
+ return found.length === 1 ? found[0] : undefined;
77
+ }
27
78
  function accessMessage(txHash, memo) {
28
79
  return `ckb-session-kit access ${ccc.hexFrom(txHash)} ${ccc.hexFrom(memo)}`;
29
80
  }
@@ -51,7 +102,10 @@ export async function verifyAccess(client, proof, expect) {
51
102
  }
52
103
  if (!signed)
53
104
  return { ok: false, reason: "request not signed by the given key" };
54
- const res = await client.getTransaction(proof.txHash);
105
+ // Not getTransaction: when the node no longer knows a transaction (reorged out,
106
+ // replaced by a conflicting spend), it answers from CCC's cache, where a payment
107
+ // once seen committed stays committed (audit, 31 Oct).
108
+ const res = await client.getTransactionNoCache(proof.txHash);
55
109
  if (!res)
56
110
  return { ok: false, reason: "payment not found" };
57
111
  if (res.status !== "committed")
@@ -60,39 +114,38 @@ export async function verifyAccess(client, proof, expect) {
60
114
  // Cheap checks first, before resolving any input over RPC.
61
115
  if (tx.inputs.length > MAX_PAYMENT_INPUTS)
62
116
  return { ok: false, reason: "not a session payment: too many inputs" };
63
- let memos = 0;
64
- let memoAt = -1;
65
- for (let i = 0; i < tx.witnesses.length; i++) {
66
- let inputType;
67
- try {
68
- inputType = tx.getWitnessArgsAt(i)?.inputType;
69
- }
70
- catch {
71
- continue; // not WitnessArgs: carries no memo
72
- }
73
- if (inputType !== undefined) {
74
- memos++;
75
- memoAt = i;
76
- }
77
- }
78
- if (memos !== 1)
117
+ const memos = memoWitnesses(tx);
118
+ if (!memos.length)
79
119
  return { ok: false, reason: "a payment must carry exactly one memo" };
80
120
  // The payer's lock: the standard secp256k1 lock of the key that signed the request.
81
121
  const payerLock = (await new ccc.SignerCkbPublicKey(client, proof.publicKey).getAddressObjSecp256k1()).script;
82
- // One lookup before all of them: anyone can sign a request for someone else's
122
+ // Before all of them, only the inputs whose witness carries a memo, until one is
123
+ // the payer's (usually the first): anyone can sign a request for someone else's
83
124
  // public payment, and must not get to make the server resolve every input.
84
- const memoCell = memoAt < tx.inputs.length ? await client.getCell(tx.inputs[memoAt].previousOutput) : undefined;
85
- if (!memoCell?.cellOutput.lock.eq(payerLock))
125
+ const known = new Map();
126
+ let paidByKey = false;
127
+ for (const { index } of memos) {
128
+ if (index >= tx.inputs.length)
129
+ continue;
130
+ const cell = await client.getCell(tx.inputs[index].previousOutput);
131
+ known.set(index, cell);
132
+ if (cell?.cellOutput.lock.eq(payerLock)) {
133
+ paidByKey = true;
134
+ break;
135
+ }
136
+ }
137
+ if (!paidByKey)
86
138
  return { ok: false, reason: "the signing key did not pay this transaction" };
87
139
  const to = ccc.Script.from(expect.to);
88
140
  const token = expect.token && ccc.Script.from(expect.token);
89
141
  // What a cell gives the recipient: its capacity, or with a token its units of that exact token
90
142
  // (a committed xUDT cell always holds the 16-byte amount).
91
143
  const value = (o, data) => !o.lock.eq(to) ? 0n : !token ? o.capacity : o.type?.eq(token) ? tokenAmount(data) : 0n;
144
+ // The rest at once, reusing those already looked up.
145
+ const inputs = await Promise.all(tx.inputs.map((input, i) => (known.has(i) ? known.get(i) : client.getCell(input.previousOutput))));
92
146
  let payerInput = -1;
93
147
  let toIn = 0n;
94
- for (let i = 0; i < tx.inputs.length; i++) {
95
- const cell = await client.getCell(tx.inputs[i].previousOutput);
148
+ for (const [i, cell] of inputs.entries()) {
96
149
  if (!cell)
97
150
  return { ok: false, reason: "an input of the payment cannot be resolved" };
98
151
  if (payerInput < 0 && cell.cellOutput.lock.eq(payerLock))
@@ -101,6 +154,12 @@ export async function verifyAccess(client, proof, expect) {
101
154
  }
102
155
  if (payerInput < 0)
103
156
  return { ok: false, reason: "the signing key did not pay this transaction" };
157
+ // One memo among the witnesses a signature covers. A memo in a witness nobody
158
+ // signed (the session cell's, say) was not put there by the payer and cannot
159
+ // void the payment; several signed ones could each claim it (audit, 30 Oct).
160
+ const covered = signedWitnesses(tx, inputs.map((c) => c.cellOutput.lock), (lock) => isSecp256k1Lock(lock, payerLock));
161
+ if (!soleMemo(tx, covered))
162
+ return { ok: false, reason: "a payment must carry exactly one memo" };
104
163
  // The witness the payer's lock verified (the first of its group) must carry the memo.
105
164
  const witness = tx.getWitnessArgsAt(payerInput);
106
165
  if (!witness?.inputType || ccc.hexFrom(witness.inputType) !== ccc.hexFrom(expect.memo)) {
package/dist/place.d.ts CHANGED
@@ -51,11 +51,20 @@ export declare function decodePlacement(memo: ccc.HexLike, board?: PlaceBoard):
51
51
  /** What a placement costs, in shannons. */
52
52
  export declare function placementPrice(pixels: number, board?: PlaceBoard): bigint;
53
53
  /**
54
- * The cheap part of the check, before resolving any input: the transaction
55
- * carries exactly one memo, it is a valid placement, and where it sits (the
56
- * witness index). `undefined` if not.
54
+ * The placement memo of `tx` and where it sits (the witness index), or `undefined`.
55
+ *
56
+ * With `inputs` (the cells `tx` spends, resolved, in input order) this is exact:
57
+ * exactly one memo among the witnesses a signature covers (`signedWitnesses`: the
58
+ * first witness of a secp256k1 lock group, as the session key's key cell has, or
59
+ * one past the inputs), at an input's index, and a valid placement. A memo in a
60
+ * witness nobody signed (the session cell's, the board's) is ignored: whoever
61
+ * relays the transaction could have put it there (audit, 30 Oct).
62
+ *
63
+ * Without `inputs` it is the cheap check, before resolving any input: `undefined`
64
+ * means `tx` is certainly no placement; otherwise the first witness at an input's
65
+ * index holding a valid placement, which `checkPlacement` then settles.
57
66
  */
58
- export declare function placementMemo(tx: ccc.TransactionLike, board?: PlaceBoard): {
67
+ export declare function placementMemo(tx: ccc.TransactionLike, board?: PlaceBoard, inputs?: ccc.CellOutputLike[]): {
59
68
  index: number;
60
69
  pixels: Pixel[];
61
70
  } | undefined;
@@ -71,11 +80,11 @@ export type PlacementCheck = {
71
80
  * Whether `tx` is a valid placement on the board under `boardLock`. `inputs` are
72
81
  * the cells `tx` spends, resolved, in input order.
73
82
  *
74
- * - exactly one memo, and a valid one (`placementMemo`);
75
- * - the memo sits in the first witness of an input's lock group, and that lock is
76
- * not the board's: it is the witness the payer's lock verifies, so a lock that
77
- * signs its witnesses (secp256k1: the session key's key cell) commits the
78
- * payment to these pixels and no relay can swap them;
83
+ * - exactly one memo among the witnesses a signature covers, and a valid one,
84
+ * in the first witness of a secp256k1 lock group that is not the board's
85
+ * (`placementMemo` with `inputs`): the witness the payer's lock signs (the
86
+ * session key's key cell), so the payment commits to these pixels and no relay
87
+ * can swap them, nor add a memo of its own that counts;
79
88
  * - the board's lock gained at least `price × pixels` (outputs minus inputs).
80
89
  */
81
90
  export declare function checkPlacement(tx: ccc.TransactionLike, inputs: ccc.CellOutputLike[], boardLock: ccc.ScriptLike, board?: PlaceBoard): PlacementCheck;
package/dist/place.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ccc } from "@ckb-ccc/core";
2
+ import { isSecp256k1Lock, memoWitnesses, signedWitnesses, soleMemo } from "./paywall.js";
2
3
  /** The demo's board: 32×32, 16 colours, 0.1 CKB a pixel, up to 32 pixels a payment. */
3
4
  export const PLACE_BOARD = { width: 32, height: 32, colours: 16, price: 10000000n, maxPixels: 32 };
4
5
  export class PlaceMemoError extends Error {
@@ -67,27 +68,34 @@ export function placementPrice(pixels, board = PLACE_BOARD) {
67
68
  return board.price * BigInt(pixels);
68
69
  }
69
70
  /**
70
- * The cheap part of the check, before resolving any input: the transaction
71
- * carries exactly one memo, it is a valid placement, and where it sits (the
72
- * witness index). `undefined` if not.
71
+ * The placement memo of `tx` and where it sits (the witness index), or `undefined`.
72
+ *
73
+ * With `inputs` (the cells `tx` spends, resolved, in input order) this is exact:
74
+ * exactly one memo among the witnesses a signature covers (`signedWitnesses`: the
75
+ * first witness of a secp256k1 lock group, as the session key's key cell has, or
76
+ * one past the inputs), at an input's index, and a valid placement. A memo in a
77
+ * witness nobody signed (the session cell's, the board's) is ignored: whoever
78
+ * relays the transaction could have put it there (audit, 30 Oct).
79
+ *
80
+ * Without `inputs` it is the cheap check, before resolving any input: `undefined`
81
+ * means `tx` is certainly no placement; otherwise the first witness at an input's
82
+ * index holding a valid placement, which `checkPlacement` then settles.
73
83
  */
74
- export function placementMemo(tx, board = PLACE_BOARD) {
84
+ export function placementMemo(tx, board = PLACE_BOARD, inputs) {
75
85
  const t = ccc.Transaction.from(tx);
76
- let found;
77
- for (let i = 0; i < t.witnesses.length; i++) {
78
- let inputType;
79
- try {
80
- inputType = t.getWitnessArgsAt(i)?.inputType;
86
+ if (!inputs) {
87
+ for (const m of memoWitnesses(t)) {
88
+ const pixels = m.index < t.inputs.length ? decodePlacement(m.memo, board) : undefined;
89
+ if (pixels)
90
+ return { index: m.index, pixels };
81
91
  }
82
- catch {
83
- continue; // not WitnessArgs: carries no memo
84
- }
85
- if (inputType === undefined)
86
- continue;
87
- if (found)
88
- return undefined; // two memos: which one was paid for is ambiguous
89
- found = { index: i, memo: inputType };
92
+ return undefined;
90
93
  }
94
+ if (inputs.length !== t.inputs.length)
95
+ return undefined;
96
+ const locks = inputs.map((c) => ccc.CellOutput.from(c).lock);
97
+ // Two signed memos: which one was paid for is ambiguous.
98
+ const found = soleMemo(t, signedWitnesses(t, locks, (lock) => isSecp256k1Lock(lock)));
91
99
  if (!found || found.index >= t.inputs.length)
92
100
  return undefined;
93
101
  const pixels = decodePlacement(found.memo, board);
@@ -97,28 +105,24 @@ export function placementMemo(tx, board = PLACE_BOARD) {
97
105
  * Whether `tx` is a valid placement on the board under `boardLock`. `inputs` are
98
106
  * the cells `tx` spends, resolved, in input order.
99
107
  *
100
- * - exactly one memo, and a valid one (`placementMemo`);
101
- * - the memo sits in the first witness of an input's lock group, and that lock is
102
- * not the board's: it is the witness the payer's lock verifies, so a lock that
103
- * signs its witnesses (secp256k1: the session key's key cell) commits the
104
- * payment to these pixels and no relay can swap them;
108
+ * - exactly one memo among the witnesses a signature covers, and a valid one,
109
+ * in the first witness of a secp256k1 lock group that is not the board's
110
+ * (`placementMemo` with `inputs`): the witness the payer's lock signs (the
111
+ * session key's key cell), so the payment commits to these pixels and no relay
112
+ * can swap them, nor add a memo of its own that counts;
105
113
  * - the board's lock gained at least `price × pixels` (outputs minus inputs).
106
114
  */
107
115
  export function checkPlacement(tx, inputs, boardLock, board = PLACE_BOARD) {
108
116
  const t = ccc.Transaction.from(tx);
109
117
  if (inputs.length !== t.inputs.length)
110
118
  return { ok: false, reason: "inputs not resolved" };
111
- const memo = placementMemo(t, board);
119
+ const memo = placementMemo(t, board, inputs);
112
120
  if (!memo)
113
121
  return { ok: false, reason: "no valid placement memo" };
114
122
  const lock = ccc.Script.from(boardLock);
115
123
  const cells = inputs.map((c) => ccc.CellOutput.from(c));
116
- const memoLock = cells[memo.index].lock;
117
- if (memoLock.eq(lock))
124
+ if (cells[memo.index].lock.eq(lock))
118
125
  return { ok: false, reason: "the memo is in the board's own witness" };
119
- if (cells.slice(0, memo.index).some((c) => c.lock.eq(memoLock))) {
120
- return { ok: false, reason: "the memo is not in the first witness of its lock group" };
121
- }
122
126
  const sum = (cs) => cs.reduce((s, c) => (c.lock.eq(lock) ? s + c.capacity : s), 0n);
123
127
  const paid = sum(t.outputs) - sum(cells);
124
128
  const price = placementPrice(memo.pixels.length, board);
package/dist/session.d.ts CHANGED
@@ -46,8 +46,16 @@ export interface CreateOptions {
46
46
  /** Injectable for tests; defaults to the platform CSPRNG. */
47
47
  randomBytes?: (n: number) => Uint8Array;
48
48
  }
49
+ /**
50
+ * Why a policy error is not about the request itself: "payto-not-anyone-can-pay",
51
+ * the payer's own setup (a charge can never top such a `payTo` up); or
52
+ * "too-many-cells", more cells under a subscription's lock than a status or a
53
+ * charge reads (`MAX_SUBSCRIPTION_CELLS`).
54
+ */
55
+ export type SessionPolicyReason = "payto-not-anyone-can-pay" | "too-many-cells" | "owner-lock-unsafe" | "owner-lock-unreviewed";
49
56
  export declare class SessionPolicyError extends Error {
50
- constructor(message: string);
57
+ readonly reason?: SessionPolicyReason;
58
+ constructor(message: string, reason?: SessionPolicyReason);
51
59
  }
52
60
  export declare function createSession(policy: SessionPolicy, options?: CreateOptions): Session;
53
61
  export declare function isActive(session: Session, now?: Date): boolean;
package/dist/session.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { ccc } from "@ckb-ccc/core";
2
2
  export class SessionPolicyError extends Error {
3
- constructor(message) {
3
+ reason;
4
+ constructor(message, reason) {
4
5
  super(message);
5
6
  this.name = "SessionPolicyError";
7
+ if (reason)
8
+ this.reason = reason;
6
9
  }
7
10
  }
8
11
  const SECP256K1_N = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141");
package/dist/store.d.ts CHANGED
@@ -7,7 +7,8 @@ export interface SessionStore {
7
7
  load(): Promise<Session | undefined>;
8
8
  /**
9
9
  * Stores `session`. Refuses (SessionStoreConflict) to replace a *different*
10
- * session: its key may hold funds, and another tab may have just saved it.
10
+ * session: its key may hold funds, and another tab may have just saved it. Also
11
+ * refuses to replace the same session's on-chain binding with none.
11
12
  */
12
13
  save(session: Session): Promise<void>;
13
14
  /** Forgets `session`, only if it is still the stored one; another tab may have replaced it. */
package/dist/store.js CHANGED
@@ -20,6 +20,21 @@ function storedKey(raw) {
20
20
  throw new Error(UNREADABLE);
21
21
  return key;
22
22
  }
23
+ /**
24
+ * Throws SessionStoreConflict unless `session` may replace the stored value: a
25
+ * different key may hold funds, and a save of the same key without the on-chain
26
+ * binding (a stale tab that never saw the funding) would lose what finds them
27
+ * (review, 28 Oct).
28
+ */
29
+ function assertReplaceable(raw, session) {
30
+ const held = storedKey(raw);
31
+ if (held === undefined)
32
+ return;
33
+ if (held !== session.publicKey)
34
+ throw new SessionStoreConflict();
35
+ if (!session.onchain && JSON.parse(raw).onchain)
36
+ throw new SessionStoreConflict();
37
+ }
23
38
  export function serializeSession(s) {
24
39
  const stored = {
25
40
  v: 1,
@@ -91,9 +106,7 @@ export function memoryStore() {
91
106
  return {
92
107
  load: async () => (value === undefined ? undefined : deserializeSession(value)),
93
108
  save: async (s) => {
94
- const held = storedKey(value);
95
- if (held !== undefined && held !== s.publicKey)
96
- throw new SessionStoreConflict();
109
+ assertReplaceable(value, s);
97
110
  value = serializeSession(s);
98
111
  },
99
112
  clear: async (s) => {
@@ -168,9 +181,7 @@ export function indexedDbStore(idb = globalThis.indexedDB, key = KEY) {
168
181
  save: async (session) => {
169
182
  const value = serializeSession(session);
170
183
  await update((raw) => {
171
- const held = storedKey(raw);
172
- if (held !== undefined && held !== session.publicKey)
173
- throw new SessionStoreConflict();
184
+ assertReplaceable(raw, session);
174
185
  return "put";
175
186
  }, value);
176
187
  },
@@ -1,5 +1,6 @@
1
1
  import { ccc } from "@ckb-ccc/core";
2
2
  import { type SessionLockDeployment } from "./lock.js";
3
+ import { type SigningLockOptions, type RetryOptions } from "./onchain.js";
3
4
  /**
4
5
  * Subscriptions (docs/design/subscriptions.md): the same session lock, with the
5
6
  * roles moved. The subscriber is the owner; the creator's charger key is the
@@ -47,6 +48,42 @@ export declare function subscriptionLock(deployment: SessionLockDeployment, sub:
47
48
  * secret the subscriber's browser presents to the creator.
48
49
  */
49
50
  export declare function subscriptionMemo(account: string): ccc.Hex;
51
+ /**
52
+ * The most cells under one subscription's lock that a status or a charge reads
53
+ * (security audit, 30 Oct). Anyone can create cells under any lock, and each
54
+ * cell read costs RPC calls (its creating transaction, and that transaction's
55
+ * inputs), so without a bound one request could be made to cost thousands. A
56
+ * subscription in use holds one cell, or a few after top-ups; 16 is far above that.
57
+ *
58
+ * Beyond it, a status or a charge is refused with a `SessionPolicyError` of
59
+ * reason "too-many-cells", never answered from part of the cells: the cells
60
+ * left unread could hold the newest charge (so access would be denied as
61
+ * unpaid, with a wrong balance) or a mature cell (so a charge would leave it free
62
+ * to pay a second price in the same period). Refusing grants nothing and charges
63
+ * nothing. Only cells of the subscription's own kind count (plain CKB, or its
64
+ * token). The subscriber clears cells planted among them with
65
+ * `clearPlantedCells`, which leaves the subscription's own cells as they are,
66
+ * or with `cancelSubscription`.
67
+ */
68
+ export declare const MAX_SUBSCRIPTION_CELLS = 16;
69
+ /**
70
+ * The most transactions that only created cells under the lock that `lastCharge`
71
+ * pages past in the recent history (review W6 M2). Anyone can make those, but
72
+ * they are skipped without a fetch, so only the indexer's pages cost anything
73
+ * (`HISTORY_PAGE` entries each). Past it, a status or a charge is refused
74
+ * rather than stopping short of the last charge: blocking one period this way
75
+ * takes over 1024 transactions within it, each locking up at least a cell's
76
+ * size (153 CKB) under the subscription's lock.
77
+ */
78
+ export declare const MAX_CREATED_HISTORY: number;
79
+ /**
80
+ * The memo `clearPlantedCells` puts in the witness (`input_type`) of the
81
+ * subscriber's own input, under the subscriber's signature: a status tells its
82
+ * cleanup from a cancel by it, so a cleanup does not end a paid period (review
83
+ * W6 L2). Only the subscriber can sign it; a subscriber who marks a cancel
84
+ * with it keeps access only through a period already paid for.
85
+ */
86
+ export declare const CLEAR_PLANTED_MEMO: `0x${string}`;
50
87
  /**
51
88
  * The subscriber opens a subscription, in one transaction and one signature: the
52
89
  * first period's price goes to `payTo` at once (a new cell cannot be charged for a
@@ -87,13 +124,18 @@ export declare function openSubscription(subscriber: ccc.Signer, deployment: Ses
87
124
  };
88
125
  account?: string;
89
126
  beforeSend?: (sub: Subscription) => Promise<void>;
90
- }): Promise<{
127
+ /** Let an unreviewed charger lock through, with a warning (`allowUnreviewedOwnerLock` covers the subscriber's own lock only). */
128
+ allowUnreviewedChargerLock?: boolean;
129
+ } & SigningLockOptions): Promise<{
91
130
  txHash: ccc.Hex;
92
131
  subscription: Subscription;
93
132
  }>;
94
133
  /**
95
134
  * The live cells of a subscription, their balance (shannons) and token balance
96
- * (units; 0 without a token), and how many blocks until one can be charged.
135
+ * (units; 0 without a token), and how many blocks until one can be charged: once
136
+ * a cell is a period old and the last charge (the newest transaction that spent
137
+ * a cell under the lock) is too. Throws a `SessionPolicyError` ("too-many-cells")
138
+ * with over `MAX_SUBSCRIPTION_CELLS` cells under the lock.
97
139
  */
98
140
  export declare function findSubscriptionCells(client: ccc.Client, deployment: SessionLockDeployment, sub: Subscription): Promise<{
99
141
  lock: ccc.Script;
@@ -106,19 +148,30 @@ export declare function findSubscriptionCells(client: ccc.Client, deployment: Se
106
148
  * The creator's charger key takes one price from the subscription and pays it to
107
149
  * `payTo` (topping up its anyone-can-pay cell with `topUp`). The fee comes from
108
150
  * one of the charger's own plain cells, which goes back to it smaller. Throws a
109
- * `SessionBalanceError` while no cell is a full period old, or once what is left
110
- * cannot cover a price and still leave a cell behind.
151
+ * `SessionBalanceError` while no cell is a full period old or the last charge is
152
+ * under one, or once what is left cannot cover a price and still leave a cell behind.
111
153
  *
112
154
  * In a token subscription the price is in token units and always tops up
113
155
  * `payTo`'s cell of the token (`topUp` is implied). The change keeps every
114
156
  * shannon that came in (no CKB may leave) and the tokens less one price, in
115
157
  * exactly 16 data bytes.
158
+ *
159
+ * With `topUp` (or a token), `payTo`'s cell is a confirmed one that no
160
+ * transaction in the node's pool spends, where the node can tell
161
+ * (`pickPayoutCell`). A charge the node rejects for a spent or unknown input is
162
+ * built again after clearing the client's cache, with another payout cell,
163
+ * after a short wait: up to `retry.attempts` in all (default 3), waiting
164
+ * `retry.delayMs` (default 1000) doubling each time (`rebuildOnDeadInputs`).
165
+ * `beforeSend` runs again with each new hash. A send whose outcome is unknown
166
+ * is never retried.
116
167
  */
117
- export declare function chargeSubscription(charger: ccc.Signer, deployment: SessionLockDeployment, sub: Subscription, opts: {
168
+ export declare function chargeSubscription(charger: ccc.Signer, deployment: SessionLockDeployment, sub: Subscription, opts: ChargeOptions): Promise<ccc.Hex>;
169
+ type ChargeOptions = {
118
170
  payTo: string;
119
171
  topUp?: boolean;
120
172
  beforeSend?: (txHash: ccc.Hex) => Promise<void>;
121
- }): Promise<ccc.Hex>;
173
+ retry?: RetryOptions;
174
+ };
122
175
  /**
123
176
  * The subscriber takes everything left back, in owner mode: no creator involved
124
177
  * and no wait. `nothingFound` means no cell is left (already cancelled, or run dry).
@@ -132,6 +185,40 @@ export declare function cancelSubscription(subscriber: ccc.Signer, deployment: S
132
185
  refundedTokens?: bigint;
133
186
  nothingFound?: true;
134
187
  }>;
188
+ /**
189
+ * The subscriber clears cells a third party created under the subscription's
190
+ * lock, without cancelling (mentor review, 2 Nov). Anyone can create cells under
191
+ * any lock; past `MAX_SUBSCRIPTION_CELLS` of the subscription's kind, a status or
192
+ * a charge is refused ("too-many-cells").
193
+ *
194
+ * A cell is the subscription's own when its creating transaction is the
195
+ * opening (`sub.openTx`), or had an input under the subscriber's or the
196
+ * charger's lock: an opening, a charge, a top-up by the subscriber. Nobody else
197
+ * can sign for those. Every other cell of the subscription's kind is planted:
198
+ * one owner-mode transaction (one signature) sweeps them to the subscriber,
199
+ * who keeps their CKB and tokens. The subscription's own cells are not touched,
200
+ * so their age and the payments that made them still count: access and the
201
+ * charge schedule go on as before.
202
+ *
203
+ * In a token subscription, look-alikes of the token (cells typed with the
204
+ * token's code and longer args that start with the token's, which xUDT accepts
205
+ * and anyone can create with 0 units) are planted too, and swept the same way
206
+ * when an owner sweep can spend them (plain xUDT, no flags; review W6 M1).
207
+ *
208
+ * Bounded: one call keeps at most `4 * MAX_SUBSCRIPTION_CELLS` cells of the
209
+ * kind, reads at most twice as many listed cells for each of the two scans,
210
+ * and looks each creating transaction up once. `more` says cells were left
211
+ * unread: call again once this sweep commits. Refused when none of the cells read is the
212
+ * subscription's own: cancel instead. `nothingFound` when nothing is planted.
213
+ */
214
+ export declare function clearPlantedCells(subscriber: ccc.Signer, deployment: SessionLockDeployment, sub: Subscription): Promise<{
215
+ txHash?: ccc.Hex;
216
+ swept: number;
217
+ refunded: bigint;
218
+ refundedTokens?: bigint;
219
+ more: boolean;
220
+ nothingFound?: true;
221
+ }>;
135
222
  export type SubscriptionCheck = {
136
223
  ok: true;
137
224
  subscription: Subscription;
@@ -181,7 +268,10 @@ export declare function verifySubscription(client: ccc.Client, deployment: Sessi
181
268
  token?: ccc.ScriptLike;
182
269
  }): Promise<SubscriptionCheck>;
183
270
  export interface SubscriptionStatus {
184
- /** The block until which the subscriber has paid, from the newest paying cell; undefined when no cell is left. */
271
+ /**
272
+ * The block until which the subscriber has paid, from the newest paying cell or
273
+ * a charge that took the whole balance; undefined when neither is left.
274
+ */
185
275
  paidThroughBlock: bigint | undefined;
186
276
  tip: bigint;
187
277
  /** Whether the tip is within the paid period. */
@@ -203,6 +293,11 @@ export interface SubscriptionStatus {
203
293
  * Each must have been created by a payment, so a cell re-created by the
204
294
  * subscriber to reset its age counts for nothing. Cancelling ends access at once.
205
295
  * A token subscription (`sub.token`) counts payments in its token, as `verifySubscription` does.
296
+ *
297
+ * Throws a `SessionPolicyError` of reason "too-many-cells" when over
298
+ * `MAX_SUBSCRIPTION_CELLS` cells (or recent transactions) sit under the lock:
299
+ * not a verdict on the subscription, so not an `ok: false`. A server should
300
+ * answer it as a conflict the subscriber can clear by cancelling and opening again.
206
301
  */
207
302
  export declare function subscriptionStatus(client: ccc.Client, deployment: SessionLockDeployment, sub: Subscription, proof: {
208
303
  openTx: ccc.HexLike;
@@ -219,3 +314,4 @@ export declare function subscriptionStatus(client: ccc.Client, deployment: Sessi
219
314
  * `sample` blocks. Block time varies; round up when a period means "no more often than".
220
315
  */
221
316
  export declare function blocksForSeconds(client: ccc.Client, seconds: number, sample?: bigint): Promise<bigint>;
317
+ export {};