ckb-session-kit 1.0.0 → 2.1.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 +331 -0
- package/README.md +797 -558
- 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 +356 -30
- package/dist/onchain.js +855 -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 +14 -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 +15 -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).
|
|
108
441
|
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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).
|
|
114
481
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
482
|
+
* Known to unlock without the owner's signature, refused always (reason
|
|
483
|
+
* `"owner-lock-unsafe"`): anyone-can-pay, PW-Lock, always-success (by their
|
|
484
|
+
* type id, or by the data hash of their deployed binaries under any data
|
|
485
|
+
* hash_type), Omnilock in anyone-can-pay or administrator mode, Nostr lock in
|
|
486
|
+
* PoW mode (args[0] != 0). Another build of those locks (a binary with a
|
|
487
|
+
* different data hash) is not recognised: it is refused as unreviewed.
|
|
488
|
+
*
|
|
489
|
+
* Anything else (Omnilock auth 0xFC-0xFE, which hand the decision to another lock
|
|
490
|
+
* or to other code; the proxy locks; unknown locks; args the lock's code would
|
|
491
|
+
* not accept) is refused with reason `"owner-lock-unreviewed"`, unless
|
|
492
|
+
* `allowUnreviewedOwnerLock` is set: then it passes with a warning.
|
|
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");
|
|
132
526
|
}
|
|
527
|
+
if (free || (await is(ccc.KnownScript.AnyoneCanPay))) {
|
|
528
|
+
return unsafe(`is ${free?.what ?? "anyone-can-pay"}: it opens with no signature`);
|
|
529
|
+
}
|
|
530
|
+
if (await is(ccc.KnownScript.AlwaysSuccess))
|
|
531
|
+
return unsafe("is always-success: it opens with no signature");
|
|
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,226 @@ 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
|
+
* The fee rate `fundInSession` and `sendWithSession` use when none is given, in
|
|
832
|
+
* shannons per 1000 bytes (twice the usual testnet minimum). `sendWithSession` keeps the
|
|
833
|
+
* same rate on every attempt (see there).
|
|
834
|
+
*/
|
|
835
|
+
export const DEFAULT_FUND_FEE_RATE = 2000n;
|
|
836
|
+
/**
|
|
837
|
+
* Funds a transaction the app built (its own inputs, typed outputs, cell deps
|
|
838
|
+
* and witnesses) from the session: adds session cells as inputs, at most one
|
|
839
|
+
* plain change cell under the session lock, and the key cell, which comes back
|
|
840
|
+
* as the last output and pays the fee. Signs nothing; `sendWithSession` signs
|
|
841
|
+
* and sends. The checks follow the order the session lock makes them, so a
|
|
842
|
+
* transaction this returns is one the lock accepts:
|
|
843
|
+
*
|
|
844
|
+
* 1. The session is active, and has no on-chain recipient: a recipient-scoped
|
|
845
|
+
* session can only pay that recipient and the owner, never a typed output
|
|
846
|
+
* (lock errors 13 and 18), so it is refused (`SessionPolicyError`,
|
|
847
|
+
* "session-has-recipient").
|
|
848
|
+
* 2. No input of the app's is under the owner's lock: the lock would run in owner
|
|
849
|
+
* mode, and the wallet would have to sign ("owner-lock-input"). None is under
|
|
850
|
+
* the session lock or the key's lock, and no output is either: the kit adds
|
|
851
|
+
* those ("app-tx-shape").
|
|
852
|
+
* 3. Need = the outputs' capacity less the app inputs'. Over `maxPerTx`, a
|
|
853
|
+
* `ScopeError` before anything is looked up. Session cells are taken (plain
|
|
854
|
+
* ones only, mature ones under a rate limit, with `since` set), and change
|
|
855
|
+
* is either none or at least a session cell's size (121 CKB for 80-byte args).
|
|
856
|
+
* 4. The key cell: the largest, in last, out last; the fee (`feeRate`) comes out
|
|
857
|
+
* of it, so the fee never counts as outflow.
|
|
858
|
+
* 5. outflow = session inputs less change, checked against `maxPerTx` again;
|
|
859
|
+
* then `opts.check`.
|
|
860
|
+
*
|
|
861
|
+
* The app's inputs, witnesses and cell deps stay where they are; the kit's
|
|
862
|
+
* inputs go after them. `tx` itself is not changed.
|
|
863
|
+
*/
|
|
864
|
+
export async function fundInSession(session, client, deployment, binding, txLike, opts = {}) {
|
|
865
|
+
const now = opts.now ?? new Date();
|
|
866
|
+
if (!isActive(session, now))
|
|
867
|
+
throw new ScopeError("session expired");
|
|
868
|
+
if (binding.recipientLockHash) {
|
|
869
|
+
throw new SessionPolicyError("an app transaction with typed outputs to other locks needs a session without a recipient: " +
|
|
870
|
+
"this session's lock only lets it pay its recipient and the owner, in plain cells", "session-has-recipient");
|
|
871
|
+
}
|
|
872
|
+
const { maxPerTx } = session.policy.scope;
|
|
873
|
+
const tx = ccc.Transaction.from(txLike).clone();
|
|
874
|
+
const appInputs = tx.inputs.length;
|
|
875
|
+
const state = await findSessionCells(session, client, deployment, binding);
|
|
876
|
+
const ownerHash = ccc.Script.from(binding.ownerLock).hash();
|
|
877
|
+
const ownHashes = [state.lock.hash(), state.keyLock.hash()];
|
|
878
|
+
for (const [i, input] of tx.inputs.entries()) {
|
|
879
|
+
await input.completeExtraInfos(client);
|
|
880
|
+
const lockHash = input.cellOutput?.lock.hash();
|
|
881
|
+
if (lockHash === ownerHash) {
|
|
882
|
+
throw new SessionPolicyError(`input ${i} is under the session owner's lock: the session lock would run in owner mode and ` +
|
|
883
|
+
"the wallet would have to sign; fund this transaction from the wallet instead", "owner-lock-input");
|
|
884
|
+
}
|
|
885
|
+
if (lockHash && ownHashes.includes(lockHash)) {
|
|
886
|
+
throw new SessionPolicyError(`input ${i} is a session or key cell: fundInSession adds those itself`, "app-tx-shape");
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
for (const [i, output] of tx.outputs.entries()) {
|
|
890
|
+
if (ownHashes.includes(output.lock.hash())) {
|
|
891
|
+
throw new SessionPolicyError(`output ${i} is under the session lock or the key's lock: fundInSession adds the change and key cells itself`, "app-tx-shape");
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
const need = tx.getOutputsCapacity() - (await tx.getInputsCapacity(client));
|
|
895
|
+
if (need < 0n) {
|
|
896
|
+
throw new SessionPolicyError(`the app transaction's inputs exceed its outputs by ${ccc.fixedPointToString(-need)} CKB: ` +
|
|
897
|
+
"give the surplus an output, or it would go to the key cell", "app-tx-shape");
|
|
898
|
+
}
|
|
899
|
+
if (need > maxPerTx) {
|
|
900
|
+
throw new ScopeError(`the transaction needs ${ccc.fixedPointToString(need)} CKB from the session, over maxPerTx ` +
|
|
901
|
+
`(${ccc.fixedPointToString(maxPerTx)} CKB)`);
|
|
902
|
+
}
|
|
903
|
+
const avoid = opts.avoid ?? new Set();
|
|
904
|
+
const free = (c) => !avoid.has(outPointKey(c.outPoint));
|
|
905
|
+
const keyCells = state.keyCells.filter(free);
|
|
906
|
+
if (!keyCells.length)
|
|
907
|
+
throw new SessionBalanceError("no key cell left to pay fees", "no-fee-cell");
|
|
908
|
+
// Plain cells only: in a token session the tokens stay where they are.
|
|
909
|
+
const plain = state.sessionCells.filter((c) => !c.cellOutput.type && free(c));
|
|
910
|
+
const usable = need > 0n && binding.minInterval > 0n ? await matureCells(client, plain, binding.minInterval) : plain;
|
|
911
|
+
if (need > 0n && plain.length && !usable.length) {
|
|
912
|
+
throw new SessionBalanceError(`rate limited: no session cell is ${binding.minInterval} blocks old yet`);
|
|
913
|
+
}
|
|
914
|
+
// Change is none, or a whole session cell: take one more cell rather than leave a sliver.
|
|
915
|
+
const minChange = ccc.CellOutput.from({ lock: state.lock }, "0x").capacity;
|
|
916
|
+
const taken = [];
|
|
917
|
+
let ckbIn = 0n;
|
|
918
|
+
for (const cell of usable) {
|
|
919
|
+
if (ckbIn >= need && (ckbIn === need || ckbIn - need >= minChange))
|
|
920
|
+
break;
|
|
921
|
+
taken.push(cell);
|
|
922
|
+
ckbIn += cell.cellOutput.capacity;
|
|
923
|
+
}
|
|
924
|
+
const balance = plain.reduce((s, c) => s + c.cellOutput.capacity, 0n);
|
|
925
|
+
if (ckbIn < need) {
|
|
926
|
+
throw new SessionBalanceError(`session balance ${ccc.fixedPointToString(balance)} CKB is too low: the transaction needs ${ccc.fixedPointToString(need)} CKB`);
|
|
927
|
+
}
|
|
928
|
+
const change = ckbIn - need;
|
|
929
|
+
if (change > 0n && change < minChange) {
|
|
930
|
+
throw new SessionBalanceError(`the session would keep ${ccc.fixedPointToString(change)} CKB, below the ` +
|
|
931
|
+
`${ccc.fixedPointToString(minChange)} CKB a session cell needs; spend all of it or less`);
|
|
932
|
+
}
|
|
933
|
+
const since = binding.minInterval > 0n ? relativeBlocksSince(binding.minInterval) : 0n;
|
|
934
|
+
tx.inputs.push(...taken.map((c) => ccc.CellInput.from({ previousOutput: c.outPoint, since })));
|
|
935
|
+
if (taken.length)
|
|
936
|
+
tx.addCellDeps(sessionLockCellDep(deployment));
|
|
937
|
+
if (change > 0n)
|
|
938
|
+
tx.addOutput({ lock: state.lock, capacity: change }, "0x");
|
|
939
|
+
// The key cell authorises the spend, comes back last and pays the fee (as in spendOnce).
|
|
940
|
+
tx.inputs.push(ccc.CellInput.from({ previousOutput: largestCell(keyCells).outPoint }));
|
|
941
|
+
tx.addOutput({ lock: state.keyLock, capacity: 0n }, "0x");
|
|
942
|
+
await completeFeeOrNoFeeCell(tx, sessionSigner(session, client), "the key cell cannot cover the fee; top it up", opts.feeRate ?? DEFAULT_FUND_FEE_RATE);
|
|
943
|
+
if (tx.outputs[tx.outputs.length - 1].capacity < keyCellFloor(binding.ownerLock)) {
|
|
944
|
+
throw new SessionBalanceError("the key cell cannot cover another fee; top it up", "no-fee-cell");
|
|
945
|
+
}
|
|
946
|
+
// The lock's own rules on the result (1: outflow; 3 and 5: one plain change cell).
|
|
947
|
+
const outflow = taken.reduce((s, c) => s + c.cellOutput.capacity, 0n) - change;
|
|
948
|
+
if (outflow > maxPerTx) {
|
|
949
|
+
throw new ScopeError(`outflow ${ccc.fixedPointToString(outflow)} CKB exceeds maxPerTx (${ccc.fixedPointToString(maxPerTx)} CKB)`);
|
|
950
|
+
}
|
|
951
|
+
const changeCells = tx.outputs.filter((o) => o.lock.eq(state.lock));
|
|
952
|
+
if (changeCells.length > 1 || changeCells.some((o) => o.type)) {
|
|
953
|
+
throw new SessionPolicyError("the session lock allows one plain change cell", "app-tx-shape");
|
|
954
|
+
}
|
|
955
|
+
await opts.check?.(tx);
|
|
956
|
+
return { tx, outflow, sessionInputs: tx.inputs.slice(appInputs).map((i) => i.previousOutput) };
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Builds the app's transaction (`build`), funds it from the session
|
|
960
|
+
* (`fundInSession`), signs it with the session key alone and sends it. No
|
|
961
|
+
* wallet is involved: the key signs only its own cell, and the session lock
|
|
962
|
+
* checks no signature. Returns the transaction hash.
|
|
963
|
+
*
|
|
964
|
+
* Retries only when the node refuses the transaction for one of the session's
|
|
965
|
+
* own cells (`Dead` or `Unknown` naming a session or key cell this attempt
|
|
966
|
+
* added: another tab spent it). Then the client's cache is cleared, that cell
|
|
967
|
+
* is left out, and `build` runs again, with the backoff of `rebuildOnDeadInputs`.
|
|
968
|
+
*
|
|
969
|
+
* Any other refusal is thrown unchanged, for the app's own loop: a dead input of
|
|
970
|
+
* the app's (another user took the cell the app built on, such as a
|
|
971
|
+
* crowdfunding campaign cell; `deadInputs(e)` is true) or an RBF refusal
|
|
972
|
+
* (`poolConflict(e)`), which names no cell. The app waits for its cell to move
|
|
973
|
+
* and calls again. A send whose outcome is unknown is never retried.
|
|
974
|
+
*
|
|
975
|
+
* The fee rate is the same on every attempt (`feeRate`, default
|
|
976
|
+
* `DEFAULT_FUND_FEE_RATE`): a retry at a higher rate could replace (RBF)
|
|
977
|
+
* another user's pending transaction on the same app cell.
|
|
978
|
+
*/
|
|
979
|
+
export async function sendWithSession(session, client, deployment, binding, build, opts = {}) {
|
|
980
|
+
const attempts = Math.max(1, Math.floor(opts.retry?.attempts ?? DEFAULT_RETRY.attempts));
|
|
981
|
+
const delay = Math.max(0, opts.retry?.delayMs ?? DEFAULT_RETRY.delayMs);
|
|
982
|
+
const feeRate = opts.feeRate ?? DEFAULT_FUND_FEE_RATE; // fixed for every attempt
|
|
983
|
+
const avoid = new Set();
|
|
984
|
+
for (let attempt = 1;; attempt++) {
|
|
985
|
+
let ours = new Set();
|
|
986
|
+
try {
|
|
987
|
+
const funded = await fundInSession(session, client, deployment, binding, await build(), {
|
|
988
|
+
feeRate,
|
|
989
|
+
check: opts.check,
|
|
990
|
+
now: opts.now,
|
|
991
|
+
avoid,
|
|
992
|
+
});
|
|
993
|
+
ours = new Set(funded.sessionInputs.map(outPointKey));
|
|
994
|
+
return await signAndSend(sessionSigner(session, client), funded.tx, opts.beforeSend);
|
|
995
|
+
}
|
|
996
|
+
catch (e) {
|
|
997
|
+
const named = e instanceof SendOutcomeUnknownError ? undefined : rejectedOutPoint(e);
|
|
998
|
+
if (!named || !ours.has(named) || attempt >= attempts)
|
|
999
|
+
throw e;
|
|
1000
|
+
avoid.add(named);
|
|
1001
|
+
await client.cache?.clear();
|
|
1002
|
+
const wait = delay * 2 ** (attempt - 1) * (0.5 + Math.random());
|
|
1003
|
+
if (wait > 0)
|
|
1004
|
+
await sleep(wait);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* `completeFeeChangeToOutput` into the last output, with CCC's "Insufficient CKB"
|
|
1010
|
+
* (the paying cell holds its floor but not the fee too) as a
|
|
1011
|
+
* `SessionBalanceError` with reason `no-fee-cell` (audit round 2, 31 Oct).
|
|
1012
|
+
*/
|
|
1013
|
+
export async function completeFeeOrNoFeeCell(tx, signer, message, feeRate) {
|
|
1014
|
+
try {
|
|
1015
|
+
await (feeRate === undefined
|
|
1016
|
+
? tx.completeFeeChangeToOutput(signer, tx.outputs.length - 1)
|
|
1017
|
+
: tx.completeFeeChangeToOutput(signer, tx.outputs.length - 1, feeRate));
|
|
1018
|
+
}
|
|
1019
|
+
catch (e) {
|
|
1020
|
+
// By class, or by message where a second copy of CCC threw it (a bundler's CJS and ESM builds).
|
|
1021
|
+
const short = e instanceof ccc.ErrorTransactionInsufficientCapacity || (e instanceof Error && /^Insufficient CKB/.test(e.message));
|
|
1022
|
+
if (short)
|
|
1023
|
+
throw new SessionBalanceError(message, "no-fee-cell");
|
|
1024
|
+
throw e;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
/** The largest of `cells` by capacity; on a tie, the lowest out point (tx hash, then index), whatever the indexer order. */
|
|
1028
|
+
function largestCell(cells) {
|
|
1029
|
+
const before = (x, y) => (x.txHash !== y.txHash ? x.txHash < y.txHash : x.index < y.index);
|
|
1030
|
+
return cells.reduce((best, c) => {
|
|
1031
|
+
const a = c.cellOutput.capacity;
|
|
1032
|
+
const b = best.cellOutput.capacity;
|
|
1033
|
+
return a > b || (a === b && before(c.outPoint, best.outPoint)) ? c : best;
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
393
1036
|
/** The cells created at least `blocks` blocks before the tip: those a relative `since` of `blocks` can spend. */
|
|
394
1037
|
export async function matureCells(client, cells, blocks) {
|
|
395
1038
|
const tip = await client.getTip();
|
|
@@ -408,19 +1051,80 @@ export async function matureCells(client, cells, blocks) {
|
|
|
408
1051
|
* it. A key-cell failure is reported in `keyError`; the key stays usable for a retry.
|
|
409
1052
|
*
|
|
410
1053
|
* `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
|
|
1054
|
+
* be forgotten. If the funding transaction (`binding.openTx`) is still in flight,
|
|
1055
|
+
* or committed with an output under the session or the key the node still holds
|
|
1056
|
+
* live (not indexed yet), this throws instead, so the key is never forgotten
|
|
1057
|
+
* before the funds it controls arrive. Once the funding is committed and every
|
|
1058
|
+
* one of those outputs is spent (a close whose reply was lost, another tab's
|
|
1059
|
+
* close), it is `nothingFound` (audit, 31 Oct).
|
|
1060
|
+
*
|
|
1061
|
+
* `owner` must be the wallet that opened the session (its lock is
|
|
1062
|
+
* `binding.ownerLock`): any other is refused with a `SessionPolicyError` before
|
|
1063
|
+
* it is asked to sign anything.
|
|
414
1064
|
*/
|
|
415
1065
|
export async function closeSession(owner, session, deployment, binding) {
|
|
1066
|
+
const client = owner.client;
|
|
1067
|
+
const ownerLock = ccc.Script.from(binding.ownerLock);
|
|
1068
|
+
// After a wallet switch, the connected wallet may not be the owner: it must not
|
|
1069
|
+
// be shown a sweep of someone else's session to itself (audit, 31 Oct).
|
|
1070
|
+
const signerLock = (await owner.getRecommendedAddressObj()).script;
|
|
1071
|
+
if (signerLock.hash() !== ownerLock.hash()) {
|
|
1072
|
+
throw new SessionPolicyError("this signer is not the session's owner: connect the wallet that opened it");
|
|
1073
|
+
}
|
|
1074
|
+
// Built on a cell another tab spent since: rebuilt once from live cells.
|
|
1075
|
+
return rebuildOnDeadInputs(client, () => closeOnce(owner, session, deployment, binding));
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Whether the node's indexer has reached the chain tip: false when it is behind,
|
|
1079
|
+
* undefined for a client with no JSON-RPC requestor to ask (tests, custom clients).
|
|
1080
|
+
*/
|
|
1081
|
+
async function indexerCaughtUp(client) {
|
|
1082
|
+
const requestor = client.requestor;
|
|
1083
|
+
if (!requestor)
|
|
1084
|
+
return undefined;
|
|
1085
|
+
try {
|
|
1086
|
+
const tip = (await requestor.request("get_indexer_tip", []));
|
|
1087
|
+
if (!tip?.block_number)
|
|
1088
|
+
return false;
|
|
1089
|
+
return BigInt(tip.block_number) >= (await client.getTip());
|
|
1090
|
+
}
|
|
1091
|
+
catch {
|
|
1092
|
+
return false; // cannot tell: keep the key
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
async function closeOnce(owner, session, deployment, binding) {
|
|
416
1096
|
const client = owner.client;
|
|
417
1097
|
const ownerLock = ccc.Script.from(binding.ownerLock);
|
|
418
1098
|
const state = await findSessionCells(session, client, deployment, binding);
|
|
419
1099
|
const result = {};
|
|
420
1100
|
if (!state.sessionCells.length && !state.keyCells.length) {
|
|
421
1101
|
const open = binding.openTx && (await client.getTransaction(binding.openTx));
|
|
422
|
-
if (open &&
|
|
423
|
-
|
|
1102
|
+
if (binding.openTx && open && open.status === "committed") {
|
|
1103
|
+
const openTx = binding.openTx;
|
|
1104
|
+
// Nothing indexed under the session: the indexer lags, or every output the
|
|
1105
|
+
// funding made under the session and the key is spent already. Ask the node.
|
|
1106
|
+
const ours = (open.transaction?.outputs ?? [])
|
|
1107
|
+
.map((o, index) => ({ o, index }))
|
|
1108
|
+
.filter(({ o }) => o.lock.eq(state.lock) || o.lock.eq(state.keyLock));
|
|
1109
|
+
if (!ours.length) {
|
|
1110
|
+
throw new SessionBalanceError("the funding transaction made no cell under this session: is it the session's deployment?");
|
|
1111
|
+
}
|
|
1112
|
+
const live = await Promise.all(ours.map(({ index }) => client.getCellLive({ txHash: openTx, index }, false, false)));
|
|
1113
|
+
if (live.every((c) => !c)) {
|
|
1114
|
+
// Spent, but a later spend's new key cell may not be indexed yet: trust the
|
|
1115
|
+
// empty search only once the indexer has reached the tip, and search again.
|
|
1116
|
+
if ((await indexerCaughtUp(client)) === false) {
|
|
1117
|
+
throw new SessionBalanceError("the indexer is behind the chain; close again in a minute");
|
|
1118
|
+
}
|
|
1119
|
+
const again = await findSessionCells(session, client, deployment, binding);
|
|
1120
|
+
if (!again.sessionCells.length && !again.keyCells.length)
|
|
1121
|
+
return { nothingFound: true };
|
|
1122
|
+
throw new SessionBalanceError("the session's cells were not indexed yet; close again");
|
|
1123
|
+
}
|
|
1124
|
+
throw new SessionBalanceError("the session's funding transaction is not indexed yet; close again in a minute");
|
|
1125
|
+
}
|
|
1126
|
+
if (open && (open.status === "pending" || open.status === "proposed")) {
|
|
1127
|
+
throw new SessionBalanceError(`the session's funding transaction is ${open.status}; close again in a minute`);
|
|
424
1128
|
}
|
|
425
1129
|
return { nothingFound: true };
|
|
426
1130
|
}
|
|
@@ -432,8 +1136,10 @@ export async function closeSession(owner, session, deployment, binding) {
|
|
|
432
1136
|
inputs: state.keyCells.map((c) => ({ previousOutput: c.outPoint })),
|
|
433
1137
|
outputs: [{ lock: ownerLock, capacity: 0n }],
|
|
434
1138
|
});
|
|
435
|
-
|
|
436
|
-
|
|
1139
|
+
const key = sessionSigner(session, client);
|
|
1140
|
+
await tx.completeFeeChangeToOutput(key, 0);
|
|
1141
|
+
// A key return the node took but whose reply was lost is still the key return.
|
|
1142
|
+
result.keyTx = await signAndSend(key, tx);
|
|
437
1143
|
}
|
|
438
1144
|
catch (e) {
|
|
439
1145
|
result.keyError = e instanceof Error ? e.message : String(e);
|
|
@@ -449,7 +1155,9 @@ export async function closeSession(owner, session, deployment, binding) {
|
|
|
449
1155
|
* for a token session) are kept: a cell with malformed args would fail the lock
|
|
450
1156
|
* even in owner mode, and anyone can create one with this owner's hash as a prefix
|
|
451
1157
|
* to block a sweep. Likewise a typed cell is kept only if it holds the session's
|
|
452
|
-
* own token in
|
|
1158
|
+
* own token, a plain xUDT, in exactly 16 bytes of data; anything else could make
|
|
1159
|
+
* the sweep fail. Cells
|
|
1160
|
+
* whose tokens would overflow a u128 together are left out (`withinU128`).
|
|
453
1161
|
*
|
|
454
1162
|
* Pass several deployments (`[TESTNET_DEPLOYMENT, ...TESTNET_LEGACY_DEPLOYMENTS]`)
|
|
455
1163
|
* to find sessions opened under earlier versions of the lock as well.
|
|
@@ -457,6 +1165,7 @@ export async function closeSession(owner, session, deployment, binding) {
|
|
|
457
1165
|
export async function findOwnedSessionCells(owner, deployments) {
|
|
458
1166
|
const ownerLock = (await owner.getRecommendedAddressObj()).script;
|
|
459
1167
|
const cells = [];
|
|
1168
|
+
let xudt; // looked up once, at the first typed cell
|
|
460
1169
|
for (const deployment of [deployments].flat()) {
|
|
461
1170
|
for await (const cell of owner.client.findCells({
|
|
462
1171
|
script: { codeHash: deployment.codeHash, hashType: deployment.hashType, args: ownerLock.hash() },
|
|
@@ -464,11 +1173,13 @@ export async function findOwnedSessionCells(owner, deployments) {
|
|
|
464
1173
|
scriptSearchMode: "prefix",
|
|
465
1174
|
withData: true,
|
|
466
1175
|
})) {
|
|
467
|
-
if (
|
|
1176
|
+
if (cell.cellOutput.type && !xudt)
|
|
1177
|
+
xudt = await owner.client.getKnownScript(ccc.KnownScript.XUdt);
|
|
1178
|
+
if (sweepable(cell, xudt))
|
|
468
1179
|
cells.push(cell);
|
|
469
1180
|
}
|
|
470
1181
|
}
|
|
471
|
-
return cells;
|
|
1182
|
+
return withinU128(owner.client, cells);
|
|
472
1183
|
}
|
|
473
1184
|
/**
|
|
474
1185
|
* Device-loss recovery: sweeps every session this owner opened back to the owner,
|
|
@@ -476,9 +1187,10 @@ export async function findOwnedSessionCells(owner, deployments) {
|
|
|
476
1187
|
*
|
|
477
1188
|
* Anyone can create a cell under a session lock carrying this owner's hash and
|
|
478
1189
|
* 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.
|
|
1190
|
+
* sweep fail. So if the one transaction is refused, each session (cells under one
|
|
1191
|
+
* session lock) is swept on its own, one signature each: a cell someone else can
|
|
1192
|
+
* spend fails alone, and `failed` counts the sessions not recovered. A fault no
|
|
1193
|
+
* retry can fix (the owner holds no plain cell to prove ownership) is thrown.
|
|
482
1194
|
*
|
|
483
1195
|
* The key cell (the fee budget) is under the session key's own lock and cannot be
|
|
484
1196
|
* recovered without that key; keep it small.
|
|
@@ -489,16 +1201,24 @@ export async function recoverSessions(owner, deployments) {
|
|
|
489
1201
|
const total = (cs) => cs.reduce((sum, c) => sum + c.cellOutput.capacity, 0n);
|
|
490
1202
|
if (!cells.length)
|
|
491
1203
|
return { txHashes: [], recovered: 0n, cells: 0, failed: 0 };
|
|
1204
|
+
// A fault no other set of cells can fix (no plain owner cell) is thrown, not
|
|
1205
|
+
// retried once per session (review, 28 Oct).
|
|
1206
|
+
const fixed = (e) => e instanceof SessionBalanceError || e instanceof SessionPolicyError;
|
|
492
1207
|
try {
|
|
493
1208
|
return { txHashes: [await ownerSweep(owner, deployment, cells)], recovered: total(cells), cells: cells.length, failed: 0 };
|
|
494
1209
|
}
|
|
495
|
-
catch {
|
|
1210
|
+
catch (e) {
|
|
1211
|
+
if (fixed(e))
|
|
1212
|
+
throw e;
|
|
496
1213
|
// Fall through: one session at a time.
|
|
497
1214
|
}
|
|
1215
|
+
// One group per session lock (its full hash), not per session key: anyone can
|
|
1216
|
+
// plant a cell carrying the victim's (public) key hash under other args, and a
|
|
1217
|
+
// group it joined would fail on every retry (audit, 30 Oct).
|
|
498
1218
|
const sessions = new Map();
|
|
499
1219
|
for (const c of cells) {
|
|
500
|
-
const
|
|
501
|
-
sessions.set(
|
|
1220
|
+
const lockHash = c.cellOutput.lock.hash();
|
|
1221
|
+
sessions.set(lockHash, [...(sessions.get(lockHash) ?? []), c]);
|
|
502
1222
|
}
|
|
503
1223
|
const result = { txHashes: [], recovered: 0n, cells: 0, failed: 0 };
|
|
504
1224
|
for (const group of sessions.values()) {
|
|
@@ -507,14 +1227,22 @@ export async function recoverSessions(owner, deployments) {
|
|
|
507
1227
|
result.recovered += total(group);
|
|
508
1228
|
result.cells += group.length;
|
|
509
1229
|
}
|
|
510
|
-
catch {
|
|
1230
|
+
catch (e) {
|
|
1231
|
+
if (fixed(e))
|
|
1232
|
+
throw e;
|
|
511
1233
|
result.failed++;
|
|
512
1234
|
}
|
|
513
1235
|
}
|
|
514
1236
|
return result;
|
|
515
1237
|
}
|
|
516
|
-
/**
|
|
517
|
-
|
|
1238
|
+
/**
|
|
1239
|
+
* A session cell an owner sweep can safely include. A typed cell must hold the
|
|
1240
|
+
* session's own token, a plain xUDT (`assertPlainXudt`'s rules, against the
|
|
1241
|
+
* network's xUDT script `xudt`), in exactly the 16-byte amount a session makes:
|
|
1242
|
+
* any other type script can refuse to be consumed, or needs code the sweep does
|
|
1243
|
+
* not bring (audit, 30 Oct).
|
|
1244
|
+
*/
|
|
1245
|
+
function sweepable(cell, xudt) {
|
|
518
1246
|
const args = ccc.bytesFrom(cell.cellOutput.lock.args);
|
|
519
1247
|
const type = cell.cellOutput.type;
|
|
520
1248
|
if (args.length === 80 || args.length === 112)
|
|
@@ -523,13 +1251,18 @@ function sweepable(cell) {
|
|
|
523
1251
|
return false;
|
|
524
1252
|
if (!type)
|
|
525
1253
|
return true;
|
|
526
|
-
return
|
|
1254
|
+
return (!!xudt &&
|
|
1255
|
+
plainXudtFault(type, xudt) === undefined &&
|
|
1256
|
+
type.hash() === ccc.hexFrom(args.slice(80, 112)) &&
|
|
1257
|
+
ccc.bytesFrom(cell.outputData).length === 16);
|
|
527
1258
|
}
|
|
528
1259
|
/**
|
|
529
1260
|
* Spends `cells` in owner mode: everything returns to the owner. Tokens go back
|
|
530
1261
|
* in one cell per token type (xUDT would let a sweep burn them otherwise).
|
|
1262
|
+
* `memo` goes in the witness (`input_type`) of the owner's own input, which the
|
|
1263
|
+
* owner's signature covers: what the sweep is for (`CLEAR_PLANTED_MEMO`).
|
|
531
1264
|
*/
|
|
532
|
-
export async function ownerSweep(owner, deployments, cells) {
|
|
1265
|
+
export async function ownerSweep(owner, deployments, cells, opts = {}) {
|
|
533
1266
|
// The code of every lock version these cells are under, and only those.
|
|
534
1267
|
const used = [deployments].flat().filter((d) => cells.some((c) => c.cellOutput.lock.codeHash === d.codeHash));
|
|
535
1268
|
const tx = ccc.Transaction.from({
|
|
@@ -554,8 +1287,44 @@ export async function ownerSweep(owner, deployments, cells) {
|
|
|
554
1287
|
// Owner mode needs an owner-locked input in the transaction.
|
|
555
1288
|
for await (const cell of owner.findCells({ scriptLenRange: [0, 1], outputDataLenRange: [0, 1] }, true)) {
|
|
556
1289
|
tx.inputs.push(ccc.CellInput.from({ previousOutput: cell.outPoint }));
|
|
1290
|
+
if (opts.memo)
|
|
1291
|
+
tx.setWitnessArgsAt(tx.inputs.length - 1, { inputType: opts.memo });
|
|
557
1292
|
await tx.completeFeeBy(owner);
|
|
558
|
-
|
|
1293
|
+
// A sweep the node took but whose reply was lost is still the sweep (review, 28 Oct).
|
|
1294
|
+
return signAndSend(owner, tx);
|
|
559
1295
|
}
|
|
560
1296
|
throw new SessionBalanceError("the owner needs one plain cell to prove ownership");
|
|
561
1297
|
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Gives an anyone-can-pay address (a creator's `payTo`) `count` more cells to be
|
|
1300
|
+
* topped up, paid by `funder`, in one transaction. Each top-up consumes one of
|
|
1301
|
+
* these cells, so readers paying at the same moment compete for them; with more
|
|
1302
|
+
* cells they rarely pick the same one (mentor review, 2 Nov). Anyone can be the
|
|
1303
|
+
* funder: the anyone-can-pay lock needs no signature to receive a new cell.
|
|
1304
|
+
*
|
|
1305
|
+
* Each cell is as small as a cell under `payTo` can be (61 CKB for a 20-byte
|
|
1306
|
+
* args address) unless `capacity` is given. With `token`, each is a cell of that
|
|
1307
|
+
* token holding 0 units (about 142 CKB), for token payments. `count` is 1 to
|
|
1308
|
+
* `MAX_PAYOUT_CELLS`: a payment reads no more than that many.
|
|
1309
|
+
*/
|
|
1310
|
+
export async function createPayoutCells(funder, payTo, opts) {
|
|
1311
|
+
const client = funder.client;
|
|
1312
|
+
if (!Number.isInteger(opts.count) || opts.count < 1 || opts.count > MAX_PAYOUT_CELLS) {
|
|
1313
|
+
throw new RangeError(`count must be 1 to ${MAX_PAYOUT_CELLS}`);
|
|
1314
|
+
}
|
|
1315
|
+
const { script: lock } = await ccc.Address.fromString(payTo, client);
|
|
1316
|
+
if (!(await isAnyoneCanPay(lock, client))) {
|
|
1317
|
+
throw new SessionPolicyError("payout cells go under an anyone-can-pay address", "payto-not-anyone-can-pay");
|
|
1318
|
+
}
|
|
1319
|
+
const token = opts.token && ccc.Script.from(opts.token);
|
|
1320
|
+
const tx = ccc.Transaction.from({ outputs: [], outputsData: [] });
|
|
1321
|
+
// CCC raises a capacity below what the cell occupies to that size.
|
|
1322
|
+
for (let i = 0; i < opts.count; i++) {
|
|
1323
|
+
tx.addOutput({ lock, capacity: opts.capacity ?? 0n, ...(token && { type: token }) }, token ? tokenData(0n) : "0x");
|
|
1324
|
+
}
|
|
1325
|
+
if (token)
|
|
1326
|
+
await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.XUdt);
|
|
1327
|
+
await tx.completeInputsByCapacity(funder);
|
|
1328
|
+
await tx.completeFeeBy(funder);
|
|
1329
|
+
return signAndSend(funder, tx, opts.beforeSend);
|
|
1330
|
+
}
|