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