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.
- package/CHANGELOG.md +337 -0
- package/LICENSE +21 -0
- package/README.md +718 -3
- package/dist/deployment.d.ts +21 -0
- package/dist/deployment.js +92 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +9 -0
- package/dist/lock.d.ts +70 -0
- package/dist/lock.js +106 -0
- package/dist/onchain.d.ts +451 -0
- package/dist/onchain.js +1150 -0
- package/dist/paywall.d.ts +60 -0
- package/dist/paywall.js +175 -0
- package/dist/place.d.ts +113 -0
- package/dist/place.js +155 -0
- package/dist/session.d.ts +71 -0
- package/dist/session.js +57 -0
- package/dist/store.d.ts +27 -0
- package/dist/store.js +192 -0
- package/dist/subscription.d.ts +317 -0
- package/dist/subscription.js +959 -0
- package/dist/transfer.d.ts +19 -0
- package/dist/transfer.js +33 -0
- package/package.json +80 -6
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
import { ccc } from "@ckb-ccc/core";
|
|
2
|
+
import { relativeBlocksSince, sessionLockCellDep, sessionLockScript, } from "./lock.js";
|
|
3
|
+
import { addTopUp, assertPlainXudt, assertSigningLock, checkSigningLock, isAnyoneCanPay, keyCellFloor, ownerSweep, ownOrigin, plainXudtFault, randomPick, completeFeeOrNoFeeCell, rebuildOnDeadInputs, SessionBalanceError, signAndSend, tokenAmount, tokenData, withoutDeadCached, withinU128, } from "./onchain.js";
|
|
4
|
+
import { assertCurrentDeployment } from "./deployment.js";
|
|
5
|
+
import { SessionPolicyError } from "./session.js";
|
|
6
|
+
const tokenOf = (sub) => sub.token && ccc.Script.from(sub.token.type);
|
|
7
|
+
export function subscriptionLock(deployment, sub) {
|
|
8
|
+
const token = tokenOf(sub);
|
|
9
|
+
return sessionLockScript(deployment, {
|
|
10
|
+
ownerLockHash: sub.subscriberLockHash,
|
|
11
|
+
sessionLockHash: sub.chargerLockHash,
|
|
12
|
+
// In a token subscription no CKB may leave: the charger pays the fee from its own cell.
|
|
13
|
+
maxPerTx: token ? 0n : sub.price,
|
|
14
|
+
minInterval: sub.periodBlocks,
|
|
15
|
+
...(token && { token: { typeHash: token.hash(), maxPerTx: sub.price } }),
|
|
16
|
+
recipientLockHash: sub.payToLockHash,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** Throws unless the three roles and the numbers make a subscription the lock can enforce as meant. */
|
|
20
|
+
function assertTerms(sub) {
|
|
21
|
+
if (sub.price <= 0n)
|
|
22
|
+
throw new SessionPolicyError("price must be positive");
|
|
23
|
+
// min_interval 0 means no rate limit at all: the charger could take the price every block.
|
|
24
|
+
if (sub.periodBlocks <= 0n)
|
|
25
|
+
throw new SessionPolicyError("periodBlocks must be positive");
|
|
26
|
+
relativeBlocksSince(sub.periodBlocks);
|
|
27
|
+
if (sub.payToLockHash === sub.chargerLockHash) {
|
|
28
|
+
// With a recipient set, outputs under the key's lock may not grow (error 17).
|
|
29
|
+
throw new SessionPolicyError("payTo must differ from the charger: a charge to the charger's own lock is refused (error 17)");
|
|
30
|
+
}
|
|
31
|
+
if (sub.chargerLockHash === sub.subscriberLockHash)
|
|
32
|
+
throw new SessionPolicyError("the charger must not be the subscriber");
|
|
33
|
+
if (sub.payToLockHash === sub.subscriberLockHash)
|
|
34
|
+
throw new SessionPolicyError("payTo must not be the subscriber");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The memo an opening commits to, binding the subscription to one account at the
|
|
38
|
+
* creator: a hash, so the account id never appears on-chain and can double as a
|
|
39
|
+
* secret the subscriber's browser presents to the creator.
|
|
40
|
+
*/
|
|
41
|
+
export function subscriptionMemo(account) {
|
|
42
|
+
return ccc.hashCkb(ccc.bytesFrom(`ckb-session-kit subscribe ${account}`, "utf8"));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Plain (untyped) cells under a subscription's lock, with or without data: the
|
|
46
|
+
* charger may write data into a change cell, and such a cell must still be found,
|
|
47
|
+
* charged and swept (review L2).
|
|
48
|
+
*
|
|
49
|
+
* In a token subscription, the cells typed with exactly its token and holding at
|
|
50
|
+
* least the 16-byte amount (the lock keeps a charge's change at exactly 16).
|
|
51
|
+
* Plain CKB sent to the same lock holds no tokens and cannot pay a price; it is
|
|
52
|
+
* left to `recoverSessions`. Cells whose tokens would overflow a u128 are left out
|
|
53
|
+
* (`withinU128`).
|
|
54
|
+
*
|
|
55
|
+
* The indexer filters by kind (`kindFilter`: no type script; or exactly the
|
|
56
|
+
* token's type script), so cells of any other kind are never listed and never
|
|
57
|
+
* count toward `cap`: anyone can create them, they cannot pay a price, and one
|
|
58
|
+
* with a type script that refuses to be spent could never be cleared (mentor
|
|
59
|
+
* review, 2 Nov). Only cells that are really of the kind count toward `cap`
|
|
60
|
+
* (review W6 M1), and the listing itself is read at most `RAW_READS * cap`
|
|
61
|
+
* deep, for an indexer that would list more than the filter asks.
|
|
62
|
+
*/
|
|
63
|
+
async function subscriptionCells(client, lock, token,
|
|
64
|
+
/** Refuse ("too-many-cells") once more than this many cells of this kind sit under the lock. */
|
|
65
|
+
cap) {
|
|
66
|
+
const cells = [];
|
|
67
|
+
let read = 0;
|
|
68
|
+
for await (const cell of client.findCells({ script: lock, scriptType: "lock", scriptSearchMode: "exact", filter: kindFilter(token), withData: true })) {
|
|
69
|
+
if (cap !== undefined && ++read > RAW_READS * cap)
|
|
70
|
+
throw tooManyCells(cap);
|
|
71
|
+
if (!ofKind(cell, token))
|
|
72
|
+
continue;
|
|
73
|
+
if (cap !== undefined && cells.length >= cap)
|
|
74
|
+
throw tooManyCells(cap);
|
|
75
|
+
cells.push(cell);
|
|
76
|
+
}
|
|
77
|
+
// Cells only CCC's cache still lists, spent since by another client, would make a charge or a cancel fail.
|
|
78
|
+
const live = await withoutDeadCached(client, lock, cells);
|
|
79
|
+
// A planted cell of 2^128-1 free tokens would block charges and the cancel; the
|
|
80
|
+
// subscriber's and the charger's own cells are always kept.
|
|
81
|
+
return token ? withinU128(client, live) : live;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The indexer filter for a subscription's own kind of cell under its lock: no
|
|
85
|
+
* type script, or exactly its token. The indexer matches a filter script by args
|
|
86
|
+
* prefix, and xUDT with a zero flags word ignores bytes after it, so anyone can
|
|
87
|
+
* create zero-amount cells of `xUDT(token.args ++ anything)` (review W6 M1);
|
|
88
|
+
* the length range (33 bytes plus the args, as the indexer and CCC count a
|
|
89
|
+
* script) leaves only the token itself, as CCC's `completeInputsByUdt` does.
|
|
90
|
+
*/
|
|
91
|
+
const kindFilter = (token) => token
|
|
92
|
+
? { script: token, scriptLenRange: [token.occupiedSize, token.occupiedSize + 1] }
|
|
93
|
+
: { scriptLenRange: [0, 1] };
|
|
94
|
+
/** Whether `cell` is of the subscription's kind: untyped, or typed with exactly `token` and holding its amount. */
|
|
95
|
+
const ofKind = (cell, token) => token ? cell.cellOutput.type?.eq(token) === true && ccc.bytesFrom(cell.outputData).length >= 16 : !cell.cellOutput.type;
|
|
96
|
+
/** Listed cells read per cell a scan may keep: only an indexer that ignored the filter would list other ones. */
|
|
97
|
+
const RAW_READS = 8;
|
|
98
|
+
/**
|
|
99
|
+
* Look-alikes of a token subscription's token under its lock (review W6 M1):
|
|
100
|
+
* cells typed with the token's code and args that start with the token's args
|
|
101
|
+
* but are longer. The subscription never makes them, so every one was planted.
|
|
102
|
+
* Only those an owner sweep can spend are kept: a plain xUDT (zero flags, so no
|
|
103
|
+
* extension script that could refuse the spend) holding an amount. At most
|
|
104
|
+
* `budget` listed cells are read when given; `more` says some were left unread.
|
|
105
|
+
*/
|
|
106
|
+
async function lookalikeCells(client, lock, token, budget) {
|
|
107
|
+
const xudt = await client.getKnownScript(ccc.KnownScript.XUdt);
|
|
108
|
+
const cells = [];
|
|
109
|
+
let read = 0;
|
|
110
|
+
let more = false;
|
|
111
|
+
for await (const cell of client.findCells({ script: lock, scriptType: "lock", scriptSearchMode: "exact", filter: { script: token }, withData: true })) {
|
|
112
|
+
if (budget !== undefined && ++read > budget) {
|
|
113
|
+
more = true;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
const type = cell.cellOutput.type;
|
|
117
|
+
if (!type || type.eq(token) || !type.args.startsWith(token.args) || type.codeHash !== token.codeHash || type.hashType !== token.hashType)
|
|
118
|
+
continue;
|
|
119
|
+
if (plainXudtFault(type, xudt) !== undefined || ccc.bytesFrom(cell.outputData).length < 16)
|
|
120
|
+
continue;
|
|
121
|
+
cells.push(cell);
|
|
122
|
+
}
|
|
123
|
+
return { cells: await withinU128(client, await withoutDeadCached(client, lock, cells)), more };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The most cells under one subscription's lock that a status or a charge reads
|
|
127
|
+
* (security audit, 30 Oct). Anyone can create cells under any lock, and each
|
|
128
|
+
* cell read costs RPC calls (its creating transaction, and that transaction's
|
|
129
|
+
* inputs), so without a bound one request could be made to cost thousands. A
|
|
130
|
+
* subscription in use holds one cell, or a few after top-ups; 16 is far above that.
|
|
131
|
+
*
|
|
132
|
+
* Beyond it, a status or a charge is refused with a `SessionPolicyError` of
|
|
133
|
+
* reason "too-many-cells", never answered from part of the cells: the cells
|
|
134
|
+
* left unread could hold the newest charge (so access would be denied as
|
|
135
|
+
* unpaid, with a wrong balance) or a mature cell (so a charge would leave it free
|
|
136
|
+
* to pay a second price in the same period). Refusing grants nothing and charges
|
|
137
|
+
* nothing. Only cells of the subscription's own kind count (plain CKB, or its
|
|
138
|
+
* token). The subscriber clears cells planted among them with
|
|
139
|
+
* `clearPlantedCells`, which leaves the subscription's own cells as they are,
|
|
140
|
+
* or with `cancelSubscription`.
|
|
141
|
+
*/
|
|
142
|
+
export const MAX_SUBSCRIPTION_CELLS = 16;
|
|
143
|
+
/**
|
|
144
|
+
* The most transactions that spent a cell under the lock that `lastCharge` reads
|
|
145
|
+
* in the recent history (each is fetched, with its inputs), on the same grounds.
|
|
146
|
+
* Only the subscriber and the charger can make those.
|
|
147
|
+
*/
|
|
148
|
+
const MAX_HISTORY = 4 * MAX_SUBSCRIPTION_CELLS;
|
|
149
|
+
/**
|
|
150
|
+
* The most transactions that only created cells under the lock that `lastCharge`
|
|
151
|
+
* pages past in the recent history (review W6 M2). Anyone can make those, but
|
|
152
|
+
* they are skipped without a fetch, so only the indexer's pages cost anything
|
|
153
|
+
* (`HISTORY_PAGE` entries each). Past it, a status or a charge is refused
|
|
154
|
+
* rather than stopping short of the last charge: blocking one period this way
|
|
155
|
+
* takes over 1024 transactions within it, each locking up at least a cell's
|
|
156
|
+
* size (153 CKB) under the subscription's lock.
|
|
157
|
+
*/
|
|
158
|
+
export const MAX_CREATED_HISTORY = 64 * MAX_SUBSCRIPTION_CELLS;
|
|
159
|
+
/** Entries per indexer page when reading a lock's history. */
|
|
160
|
+
const HISTORY_PAGE = 128;
|
|
161
|
+
/** The most inputs a creating transaction may have before its inputs are looked up (each is an RPC call). */
|
|
162
|
+
const MAX_PAYMENT_INPUTS = 32;
|
|
163
|
+
/** The charger's cells read when picking one to pay a charge's fee: any of them will do. */
|
|
164
|
+
const FEE_CELL_SCAN = 4 * MAX_SUBSCRIPTION_CELLS;
|
|
165
|
+
const tooManyTransactions = (cap) => new SessionPolicyError(`over ${cap} recent transactions under this subscription's lock: refused rather than read in part; ` +
|
|
166
|
+
"they leave the window a period after they committed, or the subscriber can cancel", "too-many-cells");
|
|
167
|
+
const tooManyCells = (cap) => new SessionPolicyError(`over ${cap} cells or recent transactions under this subscription's lock: refused rather than read in part; ` +
|
|
168
|
+
"the subscriber can clear the planted cells (clearPlantedCells), or cancel", "too-many-cells");
|
|
169
|
+
/** The token units in token cells (cells typed with `token`, with at least 16 data bytes). */
|
|
170
|
+
function tokensIn(cells, token) {
|
|
171
|
+
return cells.reduce((s, c) => (c.type?.eq(token) && ccc.bytesFrom(c.data).length >= 16 ? s + tokenAmount(c.data) : s), 0n);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Whether transaction `txHash` (which spent a cell under a subscription's lock)
|
|
175
|
+
* is a charge: one of its inputs is under the charger's lock. Only the charger
|
|
176
|
+
* (session mode) and the subscriber (owner mode) can spend those cells. When it
|
|
177
|
+
* cannot be told (the transaction or an input not found, too many inputs), it
|
|
178
|
+
* counts as a charge: that can only delay the next charge, never allow a second
|
|
179
|
+
* one in a period. The last input is looked at first: a charge puts the
|
|
180
|
+
* charger's cell there.
|
|
181
|
+
*/
|
|
182
|
+
async function isCharge(client, txHash, chargerLockHash) {
|
|
183
|
+
const fetched = await client.getTransactionNoCache(txHash);
|
|
184
|
+
const inputs = fetched?.transaction.inputs ?? [];
|
|
185
|
+
if (!inputs.length || inputs.length > MAX_PAYMENT_INPUTS)
|
|
186
|
+
return { charge: true, fetched };
|
|
187
|
+
const byCharger = (c) => !c || c.cellOutput.lock.hash() === chargerLockHash;
|
|
188
|
+
const last = await client.getCell(inputs[inputs.length - 1].previousOutput);
|
|
189
|
+
if (byCharger(last))
|
|
190
|
+
return { charge: true, fetched };
|
|
191
|
+
const rest = await Promise.all(inputs.slice(0, -1).map((i) => client.getCell(i.previousOutput)));
|
|
192
|
+
return { charge: rest.some(byCharger), fetched, inputs: [...rest, last] };
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The memo `clearPlantedCells` puts in the witness (`input_type`) of the
|
|
196
|
+
* subscriber's own input, under the subscriber's signature: a status tells its
|
|
197
|
+
* cleanup from a cancel by it, so a cleanup does not end a paid period (review
|
|
198
|
+
* W6 L2). Only the subscriber can sign it; a subscriber who marks a cancel
|
|
199
|
+
* with it keeps access only through a period already paid for.
|
|
200
|
+
*/
|
|
201
|
+
export const CLEAR_PLANTED_MEMO = ccc.hashCkb(ccc.bytesFrom("ckb-session-kit clear-planted", "utf8"));
|
|
202
|
+
/** Whether a subscriber's spend `fetched` (with its `inputs`) is marked as a `clearPlantedCells` cleanup. */
|
|
203
|
+
function isCleanup(fetched, inputs, subscriberLockHash) {
|
|
204
|
+
const tx = fetched?.transaction;
|
|
205
|
+
if (!tx || !inputs)
|
|
206
|
+
return false;
|
|
207
|
+
return inputs.some((c, i) => {
|
|
208
|
+
if (c?.cellOutput.lock.hash() !== subscriberLockHash)
|
|
209
|
+
return false;
|
|
210
|
+
try {
|
|
211
|
+
return tx.getWitnessArgsAt(i)?.inputType === CLEAR_PLANTED_MEMO;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return false; // not a WitnessArgs
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The newest charge (a spend of a cell under `lock` by the charger), with its
|
|
220
|
+
* age in blocks, looking back under `window` blocks (undefined if none), from
|
|
221
|
+
* the indexer's history of the lock: a charge that took the whole balance leaves
|
|
222
|
+
* no change cell to tell it by (30 Oct). `ownerAfter`: the subscriber spent
|
|
223
|
+
* cells under the lock after it, other than in a cleanup marked with
|
|
224
|
+
* `CLEAR_PLANTED_MEMO` (a cancel, or any other owner-mode spend).
|
|
225
|
+
* Transactions that only created cells under the lock (anyone can) are skipped
|
|
226
|
+
* without being fetched, and counted under their own, larger budget
|
|
227
|
+
* (`MAX_CREATED_HISTORY`); only spends count toward `MAX_HISTORY`, and an
|
|
228
|
+
* entry older than the window is never counted (review W6 M2). Past either
|
|
229
|
+
* budget it refuses: stopping short could miss the last charge. A subscriber's
|
|
230
|
+
* spend does not date the last charge: a cleanup must not delay the next one
|
|
231
|
+
* (mentor review, 2 Nov).
|
|
232
|
+
*/
|
|
233
|
+
async function lastCharge(client, lock, sub, tip, window) {
|
|
234
|
+
let spends = 0;
|
|
235
|
+
let created = 0;
|
|
236
|
+
let ownerAfter = false;
|
|
237
|
+
for await (const t of client.findTransactionsByLock(lock, null, true, "desc", HISTORY_PAGE)) {
|
|
238
|
+
const born = ccc.numFrom(t.blockNumber);
|
|
239
|
+
const age = tip > born ? tip - born : 0n; // the indexer may be a block past the tip read
|
|
240
|
+
if (age >= window)
|
|
241
|
+
break;
|
|
242
|
+
// Stopping short could miss the last charge and let a second one into the period: refuse instead.
|
|
243
|
+
if (!t.cells.some((c) => c.isInput)) {
|
|
244
|
+
if (++created > MAX_CREATED_HISTORY)
|
|
245
|
+
throw tooManyTransactions(MAX_CREATED_HISTORY);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (++spends > MAX_HISTORY)
|
|
249
|
+
throw tooManyTransactions(MAX_HISTORY);
|
|
250
|
+
const { charge, fetched, inputs } = await isCharge(client, t.txHash, sub.chargerLockHash);
|
|
251
|
+
if (charge)
|
|
252
|
+
return { charge: { txHash: t.txHash, age, fetched }, ownerAfter };
|
|
253
|
+
if (!isCleanup(fetched, inputs, sub.subscriberLockHash))
|
|
254
|
+
ownerAfter = true;
|
|
255
|
+
}
|
|
256
|
+
return { ownerAfter };
|
|
257
|
+
}
|
|
258
|
+
/** Whether `p` spends a cell under `lockHash`: who took part in the transaction. */
|
|
259
|
+
const spends = (p, lockHash) => p.inputs.some((c) => c?.cellOutput.lock.hash() === lockHash);
|
|
260
|
+
const NO_TOKEN_TARGET = "payTo has no cell of this token to top up: create one (any amount, even 0) first";
|
|
261
|
+
/**
|
|
262
|
+
* The subscriber opens a subscription, in one transaction and one signature: the
|
|
263
|
+
* first period's price goes to `payTo` at once (a new cell cannot be charged for a
|
|
264
|
+
* whole period, so otherwise the first period would be free; review H1), and one
|
|
265
|
+
* cell holding `budget` shannons goes under the session lock. No key cell: the
|
|
266
|
+
* creator already owns cells under its charger key. `budget` of the cell's own
|
|
267
|
+
* size (153 CKB) plus n prices pays n more periods; what is left comes back on
|
|
268
|
+
* cancel. With a price of 153 CKB or more, the last 153 can be charged too.
|
|
269
|
+
*
|
|
270
|
+
* `account` (the subscriber's id at the creator) is committed in the witness of
|
|
271
|
+
* the subscriber's own input, which the subscriber's signature covers; the
|
|
272
|
+
* creator checks it with `verifySubscription`, so nobody else can present this
|
|
273
|
+
* subscription as theirs (review H3).
|
|
274
|
+
*
|
|
275
|
+
* `payTo` should be an anyone-can-pay address with a live cell, so a charge of
|
|
276
|
+
* any size tops it up; otherwise each charge creates a new cell and the price must
|
|
277
|
+
* be at least that cell's size (61 CKB for a plain address). Keep a plain cell in
|
|
278
|
+
* the wallet: cancelling needs one to prove ownership.
|
|
279
|
+
*
|
|
280
|
+
* With `token`, the subscription is paid in that plain xUDT and `price` is in
|
|
281
|
+
* token units: the cell holds `token.amount` units plus its own size in CKB
|
|
282
|
+
* (282 CKB; `budget` may add more, which stays until cancel), and the first price
|
|
283
|
+
* tops up `payTo`'s cell of the token. `payTo` must then be anyone-can-pay and
|
|
284
|
+
* already hold a cell of the token: no CKB may leave a token subscription, so a
|
|
285
|
+
* charge cannot pay for a new token cell.
|
|
286
|
+
*/
|
|
287
|
+
export async function openSubscription(subscriber, deployment, opts) {
|
|
288
|
+
const client = subscriber.client;
|
|
289
|
+
const token = opts.token && ccc.Script.from(opts.token.type);
|
|
290
|
+
if (token && opts.token) {
|
|
291
|
+
await assertPlainXudt(token, client);
|
|
292
|
+
if (opts.token.amount < 0n)
|
|
293
|
+
throw new SessionBalanceError("token amount must not be negative");
|
|
294
|
+
}
|
|
295
|
+
else if (opts.budget === undefined) {
|
|
296
|
+
throw new SessionBalanceError("budget is required: the shannons the subscription cell holds");
|
|
297
|
+
}
|
|
298
|
+
const subscriberLock = (await subscriber.getRecommendedAddressObj()).script;
|
|
299
|
+
await assertSigningLock(subscriberLock, client, opts);
|
|
300
|
+
const [chargerLock, payToLock] = await Promise.all([opts.charger, opts.payTo].map(async (a) => (await ccc.Address.fromString(a, client)).script));
|
|
301
|
+
// A charger lock that signs nothing would let anyone trigger charges (review M2). Its own
|
|
302
|
+
// opt-in: opting in for the subscriber's wallet does not waive review of the creator's lock (review W6 I1).
|
|
303
|
+
await checkSigningLock(chargerLock, client, !!opts.allowUnreviewedChargerLock, {
|
|
304
|
+
whose: "the charger's lock",
|
|
305
|
+
can: "charge a subscription",
|
|
306
|
+
optIn: "allowUnreviewedChargerLock",
|
|
307
|
+
});
|
|
308
|
+
const sub = {
|
|
309
|
+
subscriberLockHash: subscriberLock.hash(),
|
|
310
|
+
chargerLockHash: chargerLock.hash(),
|
|
311
|
+
payToLockHash: payToLock.hash(),
|
|
312
|
+
price: opts.price,
|
|
313
|
+
periodBlocks: opts.periodBlocks,
|
|
314
|
+
...(token && { token: { type: { codeHash: token.codeHash, hashType: token.hashType, args: token.args } } }),
|
|
315
|
+
};
|
|
316
|
+
assertTerms(sub);
|
|
317
|
+
const acp = await isAnyoneCanPay(payToLock, client);
|
|
318
|
+
const newCell = ccc.CellOutput.from({ lock: payToLock }, "0x").capacity;
|
|
319
|
+
if (token && !acp) {
|
|
320
|
+
throw new SessionPolicyError("a token subscription pays into payTo's anyone-can-pay cell of the token: no CKB may leave it to make a new one");
|
|
321
|
+
}
|
|
322
|
+
if (!token && opts.price < newCell && !acp) {
|
|
323
|
+
throw new SessionPolicyError(`each charge creates a cell for payTo, which needs ${ccc.fixedPointToString(newCell)} CKB: ` +
|
|
324
|
+
"raise the price or use an anyone-can-pay address");
|
|
325
|
+
}
|
|
326
|
+
// Not a legacy lock (the 8 Oct code lacks errors 16-18, review M5), and its code
|
|
327
|
+
// is live on this network (audit round 2, 31 Oct).
|
|
328
|
+
await assertCurrentDeployment(client, deployment);
|
|
329
|
+
const lock = subscriptionLock(deployment, sub);
|
|
330
|
+
// Two live cells under the same lock are one group with two rate-limited cells:
|
|
331
|
+
// two charges per period (review M1).
|
|
332
|
+
if ((await subscriptionCells(client, lock, token)).length) {
|
|
333
|
+
throw new SessionPolicyError("a subscription with exactly these terms is already open: cancel it first");
|
|
334
|
+
}
|
|
335
|
+
const tx = ccc.Transaction.from({
|
|
336
|
+
outputs: [{ lock, capacity: opts.budget ?? 0n, ...(token && { type: token }) }],
|
|
337
|
+
outputsData: [token && opts.token ? tokenData(opts.token.amount) : "0x"],
|
|
338
|
+
});
|
|
339
|
+
// CCC raises an output to its occupied capacity; a budget below that is a mistake.
|
|
340
|
+
if (opts.budget !== undefined && tx.outputs[0].capacity !== opts.budget) {
|
|
341
|
+
throw new SessionBalanceError(`budget must be at least ${ccc.fixedPointToString(tx.outputs[0].capacity)} CKB`);
|
|
342
|
+
}
|
|
343
|
+
// The first period, paid now.
|
|
344
|
+
if (token) {
|
|
345
|
+
if (!(await addTopUp(tx, client, payToLock, { token, units: opts.price })))
|
|
346
|
+
throw new SessionBalanceError(NO_TOKEN_TARGET, "no-payout-cell");
|
|
347
|
+
await tx.completeInputsByUdt(subscriber, token);
|
|
348
|
+
const extra = (await tx.getInputsUdtBalance(client, token)) - tx.getOutputsUdtBalance(token);
|
|
349
|
+
if (extra > 0n)
|
|
350
|
+
tx.addOutput({ lock: subscriberLock, type: token }, tokenData(extra));
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
// A random cell, so openings at once rarely compete for one (review, 28 Oct).
|
|
354
|
+
if (!acp || !(await addTopUp(tx, client, payToLock, { ckb: opts.price }))) {
|
|
355
|
+
tx.addOutput({ lock: payToLock, capacity: opts.price });
|
|
356
|
+
if (tx.outputs[1].capacity !== opts.price) {
|
|
357
|
+
throw new SessionBalanceError(`payTo has no cell to top up, and a new one needs ${ccc.fixedPointToString(newCell)} CKB`, "no-payout-cell");
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
await tx.completeInputsByCapacity(subscriber);
|
|
362
|
+
if (opts.account !== undefined) {
|
|
363
|
+
let mine = -1;
|
|
364
|
+
for (let i = 0; i < tx.inputs.length && mine < 0; i++) {
|
|
365
|
+
await tx.inputs[i].completeExtraInfos(client);
|
|
366
|
+
if (tx.inputs[i].cellOutput?.lock.eq(subscriberLock))
|
|
367
|
+
mine = i;
|
|
368
|
+
}
|
|
369
|
+
if (mine < 0)
|
|
370
|
+
throw new SessionBalanceError("no input of the subscriber to carry the account memo");
|
|
371
|
+
tx.setWitnessArgsAt(mine, { inputType: subscriptionMemo(opts.account) });
|
|
372
|
+
}
|
|
373
|
+
await tx.completeFeeBy(subscriber);
|
|
374
|
+
let funded = sub;
|
|
375
|
+
const txHash = await signAndSend(subscriber, tx, async (hash) => {
|
|
376
|
+
funded = { ...sub, openTx: hash };
|
|
377
|
+
await opts.beforeSend?.(funded);
|
|
378
|
+
});
|
|
379
|
+
return { txHash, subscription: funded };
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* The live cells of a subscription, their balance (shannons) and token balance
|
|
383
|
+
* (units; 0 without a token), and how many blocks until one can be charged: once
|
|
384
|
+
* a cell is a period old and the last charge (the newest transaction that spent
|
|
385
|
+
* a cell under the lock) is too. Throws a `SessionPolicyError` ("too-many-cells")
|
|
386
|
+
* with over `MAX_SUBSCRIPTION_CELLS` cells under the lock.
|
|
387
|
+
*/
|
|
388
|
+
export async function findSubscriptionCells(client, deployment, sub) {
|
|
389
|
+
const { lock, cells, balance, tokenBalance, nextChargeIn } = await subscriptionState(client, deployment, sub);
|
|
390
|
+
return { lock, cells, balance, tokenBalance, nextChargeIn };
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* `findSubscriptionCells`, plus what it looked up on the way: the tip, and each
|
|
394
|
+
* cell's creating transaction and age (undefined while not committed), so a
|
|
395
|
+
* charge or a status fetches each of them once (review, 28 Oct).
|
|
396
|
+
*/
|
|
397
|
+
async function subscriptionState(client, deployment, sub) {
|
|
398
|
+
const lock = subscriptionLock(deployment, sub);
|
|
399
|
+
const token = tokenOf(sub);
|
|
400
|
+
const cells = await subscriptionCells(client, lock, token, MAX_SUBSCRIPTION_CELLS);
|
|
401
|
+
const balance = cells.reduce((s, c) => s + c.cellOutput.capacity, 0n);
|
|
402
|
+
const tokenBalance = token ? tokensIn(cells.map((c) => ({ type: c.cellOutput.type, data: c.outputData })), token) : 0n;
|
|
403
|
+
// Cells made by one transaction share one lookup.
|
|
404
|
+
const byHash = new Map();
|
|
405
|
+
const creating = (h) => {
|
|
406
|
+
if (!byHash.has(h))
|
|
407
|
+
byHash.set(h, client.getTransactionNoCache(h)); // the node's answer, not the cache's
|
|
408
|
+
return byHash.get(h);
|
|
409
|
+
};
|
|
410
|
+
const [tip, created] = await Promise.all([client.getTip(), Promise.all(cells.map((c) => creating(c.outPoint.txHash)))]);
|
|
411
|
+
const ages = created.map((res) => {
|
|
412
|
+
const born = res?.blockNumber;
|
|
413
|
+
return born === undefined || born === null ? undefined : tip - ccc.numFrom(born);
|
|
414
|
+
});
|
|
415
|
+
let nextChargeIn;
|
|
416
|
+
for (const age of ages) {
|
|
417
|
+
if (age === undefined)
|
|
418
|
+
continue; // not committed yet
|
|
419
|
+
const wait = age >= sub.periodBlocks ? 0n : sub.periodBlocks - age;
|
|
420
|
+
if (nextChargeIn === undefined || wait < nextChargeIn)
|
|
421
|
+
nextChargeIn = wait;
|
|
422
|
+
}
|
|
423
|
+
// The rate limit is per cell, so a cell older than the last charge (a top-up)
|
|
424
|
+
// could pay a second price in the same period: wait out the newest charge as
|
|
425
|
+
// well (review, 28 Oct). It is read from the lock's history, not from its change
|
|
426
|
+
// cell, which a charge of the whole balance does not leave (30 Oct). Only the
|
|
427
|
+
// charger's spends count: the subscriber's own (clearing planted cells) leave
|
|
428
|
+
// the subscription's cells as they were (mentor review, 2 Nov), and a cancel
|
|
429
|
+
// takes every cell, so whatever comes after it is younger than it anyway.
|
|
430
|
+
// One block more than a period: a status counts the period's last block as paid.
|
|
431
|
+
const { charge: spent, ownerAfter } = await lastCharge(client, lock, sub, tip, sub.periodBlocks + 1n);
|
|
432
|
+
if (nextChargeIn !== undefined && spent && sub.periodBlocks - spent.age > nextChargeIn)
|
|
433
|
+
nextChargeIn = sub.periodBlocks - spent.age;
|
|
434
|
+
return { lock, cells, balance, tokenBalance, nextChargeIn, tip, created, ages, spent, ownerAfter };
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* The creator's charger key takes one price from the subscription and pays it to
|
|
438
|
+
* `payTo` (topping up its anyone-can-pay cell with `topUp`). The fee comes from
|
|
439
|
+
* one of the charger's own plain cells, which goes back to it smaller. Throws a
|
|
440
|
+
* `SessionBalanceError` while no cell is a full period old or the last charge is
|
|
441
|
+
* under one, or once what is left cannot cover a price and still leave a cell behind.
|
|
442
|
+
*
|
|
443
|
+
* In a token subscription the price is in token units and always tops up
|
|
444
|
+
* `payTo`'s cell of the token (`topUp` is implied). The change keeps every
|
|
445
|
+
* shannon that came in (no CKB may leave) and the tokens less one price, in
|
|
446
|
+
* exactly 16 data bytes.
|
|
447
|
+
*
|
|
448
|
+
* With `topUp` (or a token), `payTo`'s cell is a confirmed one that no
|
|
449
|
+
* transaction in the node's pool spends, where the node can tell
|
|
450
|
+
* (`pickPayoutCell`). A charge the node rejects for a spent or unknown input is
|
|
451
|
+
* built again after clearing the client's cache, with another payout cell,
|
|
452
|
+
* after a short wait: up to `retry.attempts` in all (default 3), waiting
|
|
453
|
+
* `retry.delayMs` (default 1000) doubling each time (`rebuildOnDeadInputs`).
|
|
454
|
+
* `beforeSend` runs again with each new hash. A send whose outcome is unknown
|
|
455
|
+
* is never retried.
|
|
456
|
+
*/
|
|
457
|
+
export async function chargeSubscription(charger, deployment, sub, opts) {
|
|
458
|
+
// Built on a cell another instance or payer spent since: rebuilt from live cells (audit, 31 Oct; review, 2 Nov).
|
|
459
|
+
return rebuildOnDeadInputs(charger.client, (avoid) => chargeOnce(charger, deployment, sub, opts, avoid), opts.retry);
|
|
460
|
+
}
|
|
461
|
+
async function chargeOnce(charger, deployment, sub, opts, avoid) {
|
|
462
|
+
const client = charger.client;
|
|
463
|
+
assertTerms(sub);
|
|
464
|
+
const token = tokenOf(sub);
|
|
465
|
+
const chargerLock = (await charger.getRecommendedAddressObj()).script;
|
|
466
|
+
if (chargerLock.hash() !== sub.chargerLockHash)
|
|
467
|
+
throw new SessionPolicyError("this signer is not the subscription's charger");
|
|
468
|
+
const { script: payToLock } = await ccc.Address.fromString(opts.payTo, client);
|
|
469
|
+
if (payToLock.hash() !== sub.payToLockHash)
|
|
470
|
+
throw new SessionPolicyError("payTo is not the subscription's payout address");
|
|
471
|
+
const state = await subscriptionState(client, deployment, sub);
|
|
472
|
+
if (!state.cells.length)
|
|
473
|
+
throw new SessionBalanceError("no subscription cell: cancelled, or not committed yet");
|
|
474
|
+
if (state.nextChargeIn !== 0n) {
|
|
475
|
+
throw new SessionBalanceError(state.nextChargeIn === undefined
|
|
476
|
+
? "the subscription's cell is not committed yet"
|
|
477
|
+
: `not a full period yet: next charge in ${state.nextChargeIn} blocks`);
|
|
478
|
+
}
|
|
479
|
+
const tx = ccc.Transaction.from({ cellDeps: [sessionLockCellDep(deployment)] });
|
|
480
|
+
if (token) {
|
|
481
|
+
if (!(await isAnyoneCanPay(payToLock, client)))
|
|
482
|
+
throw new SessionPolicyError("a token subscription needs an anyone-can-pay payTo", "payto-not-anyone-can-pay");
|
|
483
|
+
if (!(await addTopUp(tx, client, payToLock, { token, units: sub.price }, avoid)))
|
|
484
|
+
throw new SessionBalanceError(NO_TOKEN_TARGET, "no-payout-cell");
|
|
485
|
+
}
|
|
486
|
+
else if (opts.topUp) {
|
|
487
|
+
if (!(await isAnyoneCanPay(payToLock, client)))
|
|
488
|
+
throw new SessionPolicyError("topUp needs an anyone-can-pay payTo", "payto-not-anyone-can-pay");
|
|
489
|
+
if (!(await addTopUp(tx, client, payToLock, { ckb: sub.price }, avoid))) {
|
|
490
|
+
throw new SessionBalanceError("payTo has no anyone-can-pay cell to top up", "no-payout-cell");
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
tx.addOutput({ lock: payToLock, capacity: sub.price });
|
|
495
|
+
if (tx.outputs[0].capacity !== sub.price) {
|
|
496
|
+
throw new SessionBalanceError(`a charge to a new cell must be at least ${ccc.fixedPointToString(tx.outputs[0].capacity)} CKB`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// Every mature cell, merged into one change: the rate limit is per cell, so
|
|
500
|
+
// leaving one out would let it pay another price in the same period (code review, 26 Oct).
|
|
501
|
+
const taken = state.cells.filter((_, i) => (state.ages[i] ?? -1n) >= sub.periodBlocks);
|
|
502
|
+
const ckbIn = taken.reduce((s, c) => s + c.cellOutput.capacity, 0n);
|
|
503
|
+
const since = relativeBlocksSince(sub.periodBlocks);
|
|
504
|
+
tx.inputs.unshift(...taken.map((c) => ccc.CellInput.from({ previousOutput: c.outPoint, since })));
|
|
505
|
+
if (token) {
|
|
506
|
+
const tokenIn = tokensIn(taken.map((c) => ({ type: c.cellOutput.type, data: c.outputData })), token);
|
|
507
|
+
if (tokenIn < sub.price) {
|
|
508
|
+
throw new SessionBalanceError(`the subscription holds ${tokenIn} token units: not enough for a price of ${sub.price}`);
|
|
509
|
+
}
|
|
510
|
+
// All the CKB back (max_per_tx is 0), the tokens less one price, in exactly 16 bytes.
|
|
511
|
+
tx.addOutput({ lock: state.lock, type: token, capacity: ckbIn }, tokenData(tokenIn - sub.price));
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
const change = ckbIn - sub.price;
|
|
515
|
+
const cellSize = ccc.CellOutput.from({ lock: state.lock }, "0x").capacity;
|
|
516
|
+
if (change < 0n || (change > 0n && change < cellSize)) {
|
|
517
|
+
throw new SessionBalanceError(`the subscription holds ${ccc.fixedPointToString(ckbIn)} CKB: not enough for a price of ` +
|
|
518
|
+
`${ccc.fixedPointToString(sub.price)} CKB and a ${ccc.fixedPointToString(cellSize)} CKB cell left behind`);
|
|
519
|
+
}
|
|
520
|
+
if (change > 0n)
|
|
521
|
+
tx.addOutput({ lock: state.lock, capacity: change });
|
|
522
|
+
}
|
|
523
|
+
// The charger's own cell authorises the charge and pays the fee; it comes back last, smaller.
|
|
524
|
+
// A random one, so charges of many subscriptions at once rarely collide (review, 28 Oct).
|
|
525
|
+
const floor = keyCellFloor(chargerLock);
|
|
526
|
+
const keyCell = randomPick(await feeCells(client, chargerLock, floor));
|
|
527
|
+
if (!keyCell)
|
|
528
|
+
throw new SessionBalanceError(`the charger needs a plain cell of at least ${ccc.fixedPointToString(floor + FEE_CELL_MARGIN)} CKB for fees`, "no-fee-cell");
|
|
529
|
+
tx.inputs.push(ccc.CellInput.from({ previousOutput: keyCell.outPoint }));
|
|
530
|
+
tx.addOutput({ lock: chargerLock, capacity: 0n });
|
|
531
|
+
await completeFeeOrNoFeeCell(tx, charger, `the charger's cell cannot cover the fee; send it a plain cell above ${ccc.fixedPointToString(floor + FEE_CELL_MARGIN)} CKB`);
|
|
532
|
+
return signAndSend(charger, tx, opts.beforeSend);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* What a charger's fee cell holds above the cell it comes back as, at least: far
|
|
536
|
+
* more than a charge's fee (about 0.00001 CKB at the minimum fee rate). A cell
|
|
537
|
+
* just above the floor would pass the filter and then fail inside CCC (round-2
|
|
538
|
+
* fuzz, 31 Oct).
|
|
539
|
+
*/
|
|
540
|
+
const FEE_CELL_MARGIN = 1000000n; // 0.01 CKB
|
|
541
|
+
/**
|
|
542
|
+
* Plain cells of the charger of at least `floor` plus `FEE_CELL_MARGIN`: at most `MAX_SUBSCRIPTION_CELLS` of
|
|
543
|
+
* them, from the first `FEE_CELL_SCAN` cells under its lock. Any one of them pays
|
|
544
|
+
* a fee, so there is no need to read every cell someone may have sent to the
|
|
545
|
+
* charger's address (security audit, 30 Oct).
|
|
546
|
+
*/
|
|
547
|
+
async function feeCells(client, lock, floor) {
|
|
548
|
+
const cells = [];
|
|
549
|
+
let seen = 0;
|
|
550
|
+
for await (const cell of client.findCellsByLock(lock, null, true)) {
|
|
551
|
+
if (++seen > FEE_CELL_SCAN || cells.length >= MAX_SUBSCRIPTION_CELLS)
|
|
552
|
+
break;
|
|
553
|
+
if (!cell.cellOutput.type && cell.outputData === "0x" && cell.cellOutput.capacity >= floor + FEE_CELL_MARGIN)
|
|
554
|
+
cells.push(cell);
|
|
555
|
+
}
|
|
556
|
+
// The change of this client's last charge, spent since by another instance, must not pay this one's fee.
|
|
557
|
+
return withoutDeadCached(client, lock, cells);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* The subscriber takes everything left back, in owner mode: no creator involved
|
|
561
|
+
* and no wait. `nothingFound` means no cell is left (already cancelled, or run dry).
|
|
562
|
+
* In a token subscription the tokens come back as one token cell (`refundedTokens`).
|
|
563
|
+
* Subscriptions are also found and swept by `recoverSessions`, since their args
|
|
564
|
+
* begin with the subscriber's lock hash like any session's.
|
|
565
|
+
*/
|
|
566
|
+
export async function cancelSubscription(subscriber, deployment, sub) {
|
|
567
|
+
const lock = (await subscriber.getRecommendedAddressObj()).script;
|
|
568
|
+
if (lock.hash() !== sub.subscriberLockHash)
|
|
569
|
+
throw new SessionPolicyError("this signer is not the subscriber");
|
|
570
|
+
// Every cell, without MAX_SUBSCRIPTION_CELLS: cancelling is how a subscriber
|
|
571
|
+
// clears cells planted under its lock, and the reads are its own.
|
|
572
|
+
const subLock = subscriptionLock(deployment, sub);
|
|
573
|
+
const token = tokenOf(sub);
|
|
574
|
+
const cells = await subscriptionCells(subscriber.client, subLock, token);
|
|
575
|
+
const balance = cells.reduce((s, c) => s + c.cellOutput.capacity, 0n);
|
|
576
|
+
const tokenBalance = token ? tokensIn(cells.map((c) => ({ type: c.cellOutput.type, data: c.outputData })), token) : 0n;
|
|
577
|
+
// A charger may leave a token subscription's last change untyped; its CKB is the subscriber's too (review, 27 Oct).
|
|
578
|
+
const untyped = token ? await subscriptionCells(subscriber.client, subLock) : [];
|
|
579
|
+
// Look-alikes of the token planted under the lock go back with the rest (review W6 M1).
|
|
580
|
+
const lookalikes = token ? (await lookalikeCells(subscriber.client, subLock, token)).cells : [];
|
|
581
|
+
const all = [...cells, ...untyped, ...lookalikes];
|
|
582
|
+
if (!all.length)
|
|
583
|
+
return { refunded: 0n, nothingFound: true };
|
|
584
|
+
const txHash = await ownerSweep(subscriber, deployment, all);
|
|
585
|
+
const refunded = all.reduce((s, c) => s + c.cellOutput.capacity, 0n);
|
|
586
|
+
return {
|
|
587
|
+
txHash,
|
|
588
|
+
refunded: token ? refunded : balance,
|
|
589
|
+
...(token && { refundedTokens: tokenBalance }),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
/** The most cells under a subscription's lock that one `clearPlantedCells` reads. */
|
|
593
|
+
const CLEAR_SCAN = 4 * MAX_SUBSCRIPTION_CELLS;
|
|
594
|
+
/**
|
|
595
|
+
* The most planted cells one `clearPlantedCells` transaction sweeps: with the
|
|
596
|
+
* subscriber's own input (and room for one more for the fee) it stays within
|
|
597
|
+
* `MAX_PAYMENT_INPUTS`, so a status can read it and never takes it for a
|
|
598
|
+
* charge, which would delay the next one (review W6 L3).
|
|
599
|
+
*/
|
|
600
|
+
const CLEAR_BATCH = MAX_PAYMENT_INPUTS - 2;
|
|
601
|
+
/**
|
|
602
|
+
* The subscriber clears cells a third party created under the subscription's
|
|
603
|
+
* lock, without cancelling (mentor review, 2 Nov). Anyone can create cells under
|
|
604
|
+
* any lock; past `MAX_SUBSCRIPTION_CELLS` of the subscription's kind, a status or
|
|
605
|
+
* a charge is refused ("too-many-cells").
|
|
606
|
+
*
|
|
607
|
+
* A cell is the subscription's own when its creating transaction is the
|
|
608
|
+
* opening (`sub.openTx`), or had an input under the subscriber's or the
|
|
609
|
+
* charger's lock: an opening, a charge, a top-up by the subscriber. Nobody else
|
|
610
|
+
* can sign for those. Every other cell of the subscription's kind is planted:
|
|
611
|
+
* one owner-mode transaction (one signature) sweeps them to the subscriber,
|
|
612
|
+
* who keeps their CKB and tokens. The subscription's own cells are not touched,
|
|
613
|
+
* so their age and the payments that made them still count: access and the
|
|
614
|
+
* charge schedule go on as before.
|
|
615
|
+
*
|
|
616
|
+
* In a token subscription, look-alikes of the token (cells typed with the
|
|
617
|
+
* token's code and longer args that start with the token's, which xUDT accepts
|
|
618
|
+
* and anyone can create with 0 units) are planted too, and swept the same way
|
|
619
|
+
* when an owner sweep can spend them (plain xUDT, no flags; review W6 M1).
|
|
620
|
+
*
|
|
621
|
+
* Bounded: one call keeps at most `4 * MAX_SUBSCRIPTION_CELLS` cells of the
|
|
622
|
+
* kind, reads at most twice as many listed cells for each of the two scans,
|
|
623
|
+
* and looks each creating transaction up once. `more` says cells were left
|
|
624
|
+
* unread: call again once this sweep commits. Refused when none of the cells read is the
|
|
625
|
+
* subscription's own: cancel instead. `nothingFound` when nothing is planted.
|
|
626
|
+
*/
|
|
627
|
+
export async function clearPlantedCells(subscriber, deployment, sub) {
|
|
628
|
+
const client = subscriber.client;
|
|
629
|
+
const lock = (await subscriber.getRecommendedAddressObj()).script;
|
|
630
|
+
if (lock.hash() !== sub.subscriberLockHash)
|
|
631
|
+
throw new SessionPolicyError("this signer is not the subscriber");
|
|
632
|
+
const subLock = subscriptionLock(deployment, sub);
|
|
633
|
+
const token = tokenOf(sub);
|
|
634
|
+
// At most CLEAR_SCAN cells of the kind, from at most twice as many listed (review W6 M1).
|
|
635
|
+
let cells = [];
|
|
636
|
+
let more = false;
|
|
637
|
+
let read = 0;
|
|
638
|
+
for await (const cell of client.findCells({ script: subLock, scriptType: "lock", scriptSearchMode: "exact", filter: kindFilter(token), withData: true })) {
|
|
639
|
+
if (cells.length >= CLEAR_SCAN || ++read > 2 * CLEAR_SCAN) {
|
|
640
|
+
more = true;
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
if (ofKind(cell, token))
|
|
644
|
+
cells.push(cell);
|
|
645
|
+
}
|
|
646
|
+
cells = await withoutDeadCached(client, subLock, cells);
|
|
647
|
+
if (token)
|
|
648
|
+
cells = await withinU128(client, cells);
|
|
649
|
+
// Look-alikes of the token are always planted; the scan reads past the token's own cells too.
|
|
650
|
+
const lookalikes = token ? await lookalikeCells(client, subLock, token, 2 * CLEAR_SCAN) : { cells: [], more: false };
|
|
651
|
+
if (lookalikes.more)
|
|
652
|
+
more = true;
|
|
653
|
+
// One look-up per creating transaction, however many cells it made.
|
|
654
|
+
const origin = new Map();
|
|
655
|
+
const own = (c) => {
|
|
656
|
+
const h = c.outPoint.txHash;
|
|
657
|
+
if (sub.openTx && h === ccc.hexFrom(sub.openTx))
|
|
658
|
+
return Promise.resolve(true);
|
|
659
|
+
if (!origin.has(h))
|
|
660
|
+
origin.set(h, ownOrigin(client, c));
|
|
661
|
+
return origin.get(h);
|
|
662
|
+
};
|
|
663
|
+
const mine = await Promise.all(cells.map(own));
|
|
664
|
+
const plantedOfKind = cells.filter((_, i) => !mine[i]);
|
|
665
|
+
const found = [...plantedOfKind, ...lookalikes.cells];
|
|
666
|
+
if (!found.length)
|
|
667
|
+
return { swept: 0, refunded: 0n, more, ...(!more && { nothingFound: true }) };
|
|
668
|
+
// Every cell read and none the subscription's own: it is gone (cancel instead). With
|
|
669
|
+
// cells left unread, its own may lie past them (review W6 L1), and sweeping planted ones is harmless.
|
|
670
|
+
if (!more && !mine.some(Boolean) && plantedOfKind.length) {
|
|
671
|
+
throw new SessionPolicyError("none of these cells is the subscription's own: cancel it instead (cancelSubscription)");
|
|
672
|
+
}
|
|
673
|
+
const planted = found.slice(0, CLEAR_BATCH);
|
|
674
|
+
if (found.length > planted.length)
|
|
675
|
+
more = true;
|
|
676
|
+
const txHash = await ownerSweep(subscriber, deployment, planted, { memo: CLEAR_PLANTED_MEMO });
|
|
677
|
+
return {
|
|
678
|
+
txHash,
|
|
679
|
+
swept: planted.length,
|
|
680
|
+
refunded: planted.reduce((s, c) => s + c.cellOutput.capacity, 0n),
|
|
681
|
+
...(token && { refundedTokens: tokensIn(planted.map((c) => ({ type: c.cellOutput.type, data: c.outputData })), token) }),
|
|
682
|
+
more,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
/** How many more prices a subscription's cells can pay. */
|
|
686
|
+
function chargesLeftOf(sub, lock, balance, tokenBalance) {
|
|
687
|
+
if (sub.token)
|
|
688
|
+
return tokenBalance / sub.price;
|
|
689
|
+
const cellSize = ccc.CellOutput.from({ lock }, "0x").capacity;
|
|
690
|
+
return balance >= cellSize + sub.price ? (balance - cellSize) / sub.price : 0n;
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* The creator checks a subscription from the chain alone. Nothing in the request
|
|
694
|
+
* is trusted but the out point. The cell at `outPoint` must be live, under this
|
|
695
|
+
* deployment of the session lock (never an older one), untyped and without data,
|
|
696
|
+
* with 112-byte args holding exactly these terms, and at least `minBalance`.
|
|
697
|
+
*
|
|
698
|
+
* And the transaction that created the cell must have paid `payTo` at least one
|
|
699
|
+
* price: the opening pays the first period, every charge pays one. A cell the
|
|
700
|
+
* subscriber re-created in owner mode to reset its age paid nothing, and is
|
|
701
|
+
* refused (review H2). So access is granted up to `paidThroughBlock`; check the
|
|
702
|
+
* current cell again (`findSubscriptionCells`) once that block has passed.
|
|
703
|
+
*
|
|
704
|
+
* With `account`, the creating transaction must also carry `subscriptionMemo(account)`
|
|
705
|
+
* in the witness of an input under the subscriber's lock, which only the
|
|
706
|
+
* subscriber's signature covers: someone else's subscription cannot be claimed
|
|
707
|
+
* (review H3). The memo is in the opening only, so check `account` once, when a
|
|
708
|
+
* subscription is registered, then keep the subscription by its lock.
|
|
709
|
+
*
|
|
710
|
+
* With `token` (a token subscription), the cell must instead be typed with exactly
|
|
711
|
+
* that token and hold its amount, with 160-byte args (`max_per_tx` 0, the token's
|
|
712
|
+
* type hash, `price` in token units as `max_per_tx_token`); `minBalance` is in token
|
|
713
|
+
* units, and the payment counted is token units into `payTo`'s cells of the token.
|
|
714
|
+
* Without `token`, a 160-byte cell is refused, and the reverse.
|
|
715
|
+
*/
|
|
716
|
+
export async function verifySubscription(client, deployment, outPoint, expected) {
|
|
717
|
+
const cell = await client.getCellLive(outPoint, true, false);
|
|
718
|
+
if (!cell)
|
|
719
|
+
return { ok: false, reason: "no live cell at that out point" };
|
|
720
|
+
const lock = cell.cellOutput.lock;
|
|
721
|
+
if (lock.codeHash !== ccc.hexFrom(deployment.codeHash) || lock.hashType !== ccc.hashTypeFrom(deployment.hashType)) {
|
|
722
|
+
return { ok: false, reason: "not under this deployment of the session lock" };
|
|
723
|
+
}
|
|
724
|
+
const token = expected.token && ccc.Script.from(expected.token);
|
|
725
|
+
const args = ccc.bytesFrom(lock.args);
|
|
726
|
+
if (token) {
|
|
727
|
+
if (!cell.cellOutput.type?.eq(token) || ccc.bytesFrom(cell.outputData).length < 16) {
|
|
728
|
+
return { ok: false, reason: "a token subscription cell is typed with the token and holds its amount" };
|
|
729
|
+
}
|
|
730
|
+
if (args.length !== 160)
|
|
731
|
+
return { ok: false, reason: "not a token subscription: args must be 160 bytes (token, with a payout address)" };
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
if (cell.cellOutput.type || cell.outputData !== "0x")
|
|
735
|
+
return { ok: false, reason: "a subscription cell carries no type and no data" };
|
|
736
|
+
if (args.length !== 112)
|
|
737
|
+
return { ok: false, reason: "not a subscription: args must be 112 bytes (CKB, with a payout address)" };
|
|
738
|
+
}
|
|
739
|
+
const [chargerLock, payToLock] = await Promise.all([expected.charger, expected.payTo].map(async (a) => (await ccc.Address.fromString(a, client)).script));
|
|
740
|
+
const sub = token
|
|
741
|
+
? {
|
|
742
|
+
subscriberLockHash: ccc.hexFrom(args.slice(0, 32)),
|
|
743
|
+
chargerLockHash: ccc.hexFrom(args.slice(32, 64)),
|
|
744
|
+
price: ccc.numLeFromBytes(args.slice(112, 128)),
|
|
745
|
+
periodBlocks: ccc.numLeFromBytes(args.slice(72, 80)),
|
|
746
|
+
payToLockHash: ccc.hexFrom(args.slice(128, 160)),
|
|
747
|
+
token: { type: { codeHash: token.codeHash, hashType: token.hashType, args: token.args } },
|
|
748
|
+
}
|
|
749
|
+
: {
|
|
750
|
+
subscriberLockHash: ccc.hexFrom(args.slice(0, 32)),
|
|
751
|
+
chargerLockHash: ccc.hexFrom(args.slice(32, 64)),
|
|
752
|
+
price: ccc.numLeFromBytes(args.slice(64, 72)),
|
|
753
|
+
periodBlocks: ccc.numLeFromBytes(args.slice(72, 80)),
|
|
754
|
+
payToLockHash: ccc.hexFrom(args.slice(80, 112)),
|
|
755
|
+
};
|
|
756
|
+
if (token) {
|
|
757
|
+
if (ccc.hexFrom(args.slice(80, 112)) !== token.hash())
|
|
758
|
+
return { ok: false, reason: "a different token" };
|
|
759
|
+
if (ccc.numLeFromBytes(args.slice(64, 72)) !== 0n)
|
|
760
|
+
return { ok: false, reason: "a token subscription must let no CKB out (max_per_tx 0)" };
|
|
761
|
+
}
|
|
762
|
+
if (sub.chargerLockHash !== chargerLock.hash())
|
|
763
|
+
return { ok: false, reason: "a different charger" };
|
|
764
|
+
if (sub.payToLockHash !== payToLock.hash())
|
|
765
|
+
return { ok: false, reason: "a different payout address" };
|
|
766
|
+
if (sub.price !== expected.price)
|
|
767
|
+
return { ok: false, reason: "a different price" };
|
|
768
|
+
if (sub.periodBlocks !== expected.periodBlocks)
|
|
769
|
+
return { ok: false, reason: "a different period" };
|
|
770
|
+
try {
|
|
771
|
+
assertTerms(sub);
|
|
772
|
+
if (token)
|
|
773
|
+
await assertPlainXudt(token, client);
|
|
774
|
+
}
|
|
775
|
+
catch (e) {
|
|
776
|
+
return { ok: false, reason: e instanceof Error ? e.message : String(e) };
|
|
777
|
+
}
|
|
778
|
+
const balance = cell.cellOutput.capacity;
|
|
779
|
+
const tokenBalance = token ? tokenAmount(cell.outputData) : 0n;
|
|
780
|
+
if (token) {
|
|
781
|
+
if (tokenBalance < (expected.minBalance ?? 0n))
|
|
782
|
+
return { ok: false, reason: `holds ${tokenBalance} token units, under the minimum` };
|
|
783
|
+
}
|
|
784
|
+
else if (balance < (expected.minBalance ?? ccc.CellOutput.from({ lock }, "0x").capacity)) {
|
|
785
|
+
return { ok: false, reason: `holds ${ccc.fixedPointToString(balance)} CKB, under the minimum` };
|
|
786
|
+
}
|
|
787
|
+
const created = await paymentOf(client, deployment, cell.outPoint.txHash, sub);
|
|
788
|
+
if (typeof created === "string")
|
|
789
|
+
return { ok: false, reason: created };
|
|
790
|
+
// An opening is signed by the subscriber, a charge by the charger. Any other
|
|
791
|
+
// payment to payTo (a paid read, say) must not count as a period too (review, 27 Oct).
|
|
792
|
+
if (!spends(created, sub.subscriberLockHash) && !spends(created, sub.chargerLockHash)) {
|
|
793
|
+
return { ok: false, reason: "the cell was created neither by the subscriber's opening nor by a charge" };
|
|
794
|
+
}
|
|
795
|
+
if (expected.account !== undefined && !commitsToAccount(created, sub, expected.account)) {
|
|
796
|
+
return { ok: false, reason: "the opening does not commit to this account" };
|
|
797
|
+
}
|
|
798
|
+
return {
|
|
799
|
+
ok: true,
|
|
800
|
+
subscription: sub,
|
|
801
|
+
balance,
|
|
802
|
+
...(token && { tokenBalance }),
|
|
803
|
+
chargesLeft: chargesLeftOf(sub, lock, balance, tokenBalance),
|
|
804
|
+
paidThroughBlock: created.block + sub.periodBlocks,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* The committed transaction `txHash`, if it paid `sub`'s payout address enough;
|
|
809
|
+
* otherwise why not. Enough is the sum of the prices of every subscription cell
|
|
810
|
+
* it creates for that payout address, not one price: otherwise one payment could
|
|
811
|
+
* open or renew many subscriptions at once (code review, 26 Oct). Only untyped
|
|
812
|
+
* cells count as paid, and an input that cannot be looked up fails the check.
|
|
813
|
+
*
|
|
814
|
+
* In a token subscription, the same in token units: the prices of every token
|
|
815
|
+
* subscription cell (160-byte args) for that payout address and token, against
|
|
816
|
+
* the units the payout address gained in cells typed with exactly that token
|
|
817
|
+
* and holding at least 16 data bytes. CKB and token payments never count for each other.
|
|
818
|
+
*/
|
|
819
|
+
async function paymentOf(client, deployment, txHash, sub,
|
|
820
|
+
/** `txHash`'s response, when already fetched. */
|
|
821
|
+
fetched) {
|
|
822
|
+
// The node's answer, never CCC's cache: a cached "committed" outlives a reorg (audit, 31 Oct).
|
|
823
|
+
const created = fetched ?? (await client.getTransactionNoCache(txHash));
|
|
824
|
+
if (!created || created.status !== "committed" || created.blockNumber === undefined || created.blockNumber === null) {
|
|
825
|
+
return "the transaction that created the cell is not committed";
|
|
826
|
+
}
|
|
827
|
+
const tx = created.transaction;
|
|
828
|
+
if (tx.inputs.length > MAX_PAYMENT_INPUTS)
|
|
829
|
+
return `the transaction has over ${MAX_PAYMENT_INPUTS} inputs`;
|
|
830
|
+
const codeHash = ccc.hexFrom(deployment.codeHash);
|
|
831
|
+
const hashType = ccc.hashTypeFrom(deployment.hashType);
|
|
832
|
+
const token = tokenOf(sub);
|
|
833
|
+
const tokenHash = token?.hash();
|
|
834
|
+
let owed = 0n;
|
|
835
|
+
for (const o of tx.outputs) {
|
|
836
|
+
const args = ccc.bytesFrom(o.lock.args);
|
|
837
|
+
if (o.lock.codeHash !== codeHash || o.lock.hashType !== hashType)
|
|
838
|
+
continue;
|
|
839
|
+
if (!token && args.length === 112 && ccc.hexFrom(args.slice(80, 112)) === sub.payToLockHash) {
|
|
840
|
+
owed += ccc.numLeFromBytes(args.slice(64, 72));
|
|
841
|
+
}
|
|
842
|
+
if (token &&
|
|
843
|
+
args.length === 160 &&
|
|
844
|
+
ccc.hexFrom(args.slice(80, 112)) === tokenHash &&
|
|
845
|
+
ccc.hexFrom(args.slice(128, 160)) === sub.payToLockHash) {
|
|
846
|
+
owed += ccc.numLeFromBytes(args.slice(112, 128));
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (owed < sub.price)
|
|
850
|
+
owed = sub.price;
|
|
851
|
+
const toPayTo = (cells) => cells.filter((c) => c.cellOutput.lock.hash() === sub.payToLockHash);
|
|
852
|
+
const amount = (cells) => token
|
|
853
|
+
? tokensIn(cells.map((c) => ({ type: c.cellOutput.type, data: c.data })), token)
|
|
854
|
+
: cells.reduce((s, c) => (!c.cellOutput.type ? s + c.cellOutput.capacity : s), 0n);
|
|
855
|
+
const out = amount(toPayTo(tx.outputs.map((cellOutput, i) => ({ cellOutput, data: tx.outputsData[i] ?? "0x" }))));
|
|
856
|
+
const notPaid = "the transaction that created the cell did not pay the price to payTo";
|
|
857
|
+
// What payTo gained is at most what its outputs hold: a transaction that falls
|
|
858
|
+
// short already is refused before its inputs are looked up (security audit, 30 Oct).
|
|
859
|
+
if (out < owed)
|
|
860
|
+
return notPaid;
|
|
861
|
+
const inputs = await Promise.all(tx.inputs.map((i) => client.getCell(i.previousOutput)));
|
|
862
|
+
if (inputs.some((c) => !c))
|
|
863
|
+
return "an input of the transaction could not be looked up";
|
|
864
|
+
const paid = out - amount(toPayTo(inputs.map((c) => ({ cellOutput: c.cellOutput, data: c.outputData }))));
|
|
865
|
+
if (paid < owed)
|
|
866
|
+
return notPaid;
|
|
867
|
+
return { tx, block: ccc.numFrom(created.blockNumber), inputs };
|
|
868
|
+
}
|
|
869
|
+
/** Whether `p` carries `subscriptionMemo(account)` in the witness of an input under the subscriber's lock. */
|
|
870
|
+
function commitsToAccount(p, sub, account) {
|
|
871
|
+
const memo = subscriptionMemo(account);
|
|
872
|
+
return p.inputs.some((c, i) => c?.cellOutput.lock.hash() === sub.subscriberLockHash && p.tx.getWitnessArgsAt(i)?.inputType === memo);
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Where a subscription stands, for a creator's server that keeps no database:
|
|
876
|
+
* the subscriber sends the terms' subscriber lock hash, the opening's hash and
|
|
877
|
+
* its account id on every request. The opening must have paid the first price
|
|
878
|
+
* into a cell under exactly this subscription's lock and committed to `account`
|
|
879
|
+
* under the subscriber's signature; then the live cells give the period paid for.
|
|
880
|
+
* Each must have been created by a payment, so a cell re-created by the
|
|
881
|
+
* subscriber to reset its age counts for nothing. Cancelling ends access at once.
|
|
882
|
+
* A token subscription (`sub.token`) counts payments in its token, as `verifySubscription` does.
|
|
883
|
+
*
|
|
884
|
+
* Throws a `SessionPolicyError` of reason "too-many-cells" when over
|
|
885
|
+
* `MAX_SUBSCRIPTION_CELLS` cells (or recent transactions) sit under the lock:
|
|
886
|
+
* not a verdict on the subscription, so not an `ok: false`. A server should
|
|
887
|
+
* answer it as a conflict the subscriber can clear by cancelling and opening again.
|
|
888
|
+
*/
|
|
889
|
+
export async function subscriptionStatus(client, deployment, sub, proof) {
|
|
890
|
+
try {
|
|
891
|
+
assertTerms(sub);
|
|
892
|
+
const token = tokenOf(sub);
|
|
893
|
+
if (token)
|
|
894
|
+
await assertPlainXudt(token, client);
|
|
895
|
+
}
|
|
896
|
+
catch (e) {
|
|
897
|
+
return { ok: false, reason: e instanceof Error ? e.message : String(e) };
|
|
898
|
+
}
|
|
899
|
+
const lock = subscriptionLock(deployment, sub);
|
|
900
|
+
// One check per transaction, however many cells it made (security audit, 30 Oct).
|
|
901
|
+
const checked = new Map();
|
|
902
|
+
const payment = (txHash, fetched) => {
|
|
903
|
+
if (!checked.has(txHash))
|
|
904
|
+
checked.set(txHash, paymentOf(client, deployment, txHash, sub, fetched));
|
|
905
|
+
return checked.get(txHash);
|
|
906
|
+
};
|
|
907
|
+
const openHash = ccc.hexFrom(proof.openTx);
|
|
908
|
+
const opening = await payment(openHash);
|
|
909
|
+
if (typeof opening === "string")
|
|
910
|
+
return { ok: false, reason: `opening: ${opening}` };
|
|
911
|
+
if (!opening.tx.outputs.some((o) => o.lock.eq(lock)))
|
|
912
|
+
return { ok: false, reason: "the opening created no cell under this subscription's lock" };
|
|
913
|
+
if (!commitsToAccount(opening, sub, proof.account))
|
|
914
|
+
return { ok: false, reason: "the opening does not commit to this account" };
|
|
915
|
+
const { cells, balance, tokenBalance, nextChargeIn, tip, created, spent, ownerAfter } = await subscriptionState(client, deployment, sub);
|
|
916
|
+
let paidThroughBlock;
|
|
917
|
+
const renews = (p, txHash) =>
|
|
918
|
+
// Only the proven opening or a charge renews; another payment to payTo that
|
|
919
|
+
// also creates a cell here (a paid read, say) is not a period (review, 27 Oct).
|
|
920
|
+
typeof p !== "string" && (txHash === openHash || spends(p, sub.chargerLockHash));
|
|
921
|
+
const paying = cells.map((c, i) => ({ txHash: c.outPoint.txHash, fetched: created[i] }));
|
|
922
|
+
// A charge of the whole balance leaves no cell, but still paid for its period
|
|
923
|
+
// (30 Oct), unless the subscriber spent cells after it: a cancel ends access at once.
|
|
924
|
+
if (spent && !ownerAfter && !paying.some((c) => c.txHash === spent.txHash))
|
|
925
|
+
paying.push({ txHash: spent.txHash, fetched: spent.fetched });
|
|
926
|
+
const payments = await Promise.all(paying.map((c) => payment(c.txHash, c.fetched)));
|
|
927
|
+
for (const [i, { txHash }] of paying.entries()) {
|
|
928
|
+
const p = payments[i];
|
|
929
|
+
if (!renews(p, txHash))
|
|
930
|
+
continue;
|
|
931
|
+
const through = p.block + sub.periodBlocks;
|
|
932
|
+
if (paidThroughBlock === undefined || through > paidThroughBlock)
|
|
933
|
+
paidThroughBlock = through;
|
|
934
|
+
}
|
|
935
|
+
return {
|
|
936
|
+
ok: true,
|
|
937
|
+
status: {
|
|
938
|
+
paidThroughBlock,
|
|
939
|
+
tip,
|
|
940
|
+
active: paidThroughBlock !== undefined && tip <= paidThroughBlock,
|
|
941
|
+
balance,
|
|
942
|
+
...(sub.token && { tokenBalance }),
|
|
943
|
+
chargesLeft: chargesLeftOf(sub, lock, balance, tokenBalance),
|
|
944
|
+
nextChargeIn,
|
|
945
|
+
},
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* About how many blocks `seconds` is, from the average block time over the last
|
|
950
|
+
* `sample` blocks. Block time varies; round up when a period means "no more often than".
|
|
951
|
+
*/
|
|
952
|
+
export async function blocksForSeconds(client, seconds, sample = 1000n) {
|
|
953
|
+
const tip = await client.getTipHeader();
|
|
954
|
+
const old = await client.getHeaderByNumber(tip.number > sample ? tip.number - sample : 0n);
|
|
955
|
+
if (!old || tip.number === old.number)
|
|
956
|
+
throw new Error("not enough blocks to estimate block time");
|
|
957
|
+
const msPerBlock = Number(tip.timestamp - old.timestamp) / Number(tip.number - old.number);
|
|
958
|
+
return BigInt(Math.ceil((seconds * 1000) / msPerBlock));
|
|
959
|
+
}
|