ckb-session-kit 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +337 -0
- package/LICENSE +21 -0
- package/README.md +718 -3
- package/dist/deployment.d.ts +21 -0
- package/dist/deployment.js +92 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +9 -0
- package/dist/lock.d.ts +70 -0
- package/dist/lock.js +106 -0
- package/dist/onchain.d.ts +451 -0
- package/dist/onchain.js +1150 -0
- package/dist/paywall.d.ts +60 -0
- package/dist/paywall.js +175 -0
- package/dist/place.d.ts +113 -0
- package/dist/place.js +155 -0
- package/dist/session.d.ts +71 -0
- package/dist/session.js +57 -0
- package/dist/store.d.ts +27 -0
- package/dist/store.js +192 -0
- package/dist/subscription.d.ts +317 -0
- package/dist/subscription.js +959 -0
- package/dist/transfer.d.ts +19 -0
- package/dist/transfer.js +33 -0
- package/package.json +80 -6
package/dist/onchain.js
ADDED
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
import { ccc } from "@ckb-ccc/core";
|
|
2
|
+
import { relativeBlocksSince, sessionLockCellDep, sessionLockScript, } from "./lock.js";
|
|
3
|
+
import { assertCurrentDeployment } from "./deployment.js";
|
|
4
|
+
import { checkRequest, isActive, SessionPolicyError } from "./session.js";
|
|
5
|
+
import { ScopeError, sessionSigner } from "./transfer.js";
|
|
6
|
+
export class SessionBalanceError extends Error {
|
|
7
|
+
reason;
|
|
8
|
+
constructor(message, reason) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "SessionBalanceError";
|
|
11
|
+
if (reason)
|
|
12
|
+
this.reason = reason;
|
|
13
|
+
}
|
|
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
|
+
}
|
|
31
|
+
const KEY_CELL_MIN = 61n * 100000000n;
|
|
32
|
+
/** What the key cell keeps above the cell it returns to the owner as, for fees. */
|
|
33
|
+
const FEE_MARGIN = 1n * 100000000n;
|
|
34
|
+
/**
|
|
35
|
+
* The smallest key cell that can still be returned to the owner: a cell under the
|
|
36
|
+
* owner's lock (63 CKB for JoyID or Omnilock, whose args are 22 bytes), and never
|
|
37
|
+
* below a key cell's own 61 CKB.
|
|
38
|
+
*/
|
|
39
|
+
export function keyCellFloor(ownerLock) {
|
|
40
|
+
const ownerCell = ccc.CellOutput.from({ lock: ownerLock }, "0x").capacity;
|
|
41
|
+
return ownerCell > KEY_CELL_MIN ? ownerCell : KEY_CELL_MIN;
|
|
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
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Signs `tx`, hands its final hash to `beforeSend`, then sends it. The hash is
|
|
60
|
+
* known before anything leaves, so a caller can record the payment or the key
|
|
61
|
+
* against it first. If the send throws but the node did take the transaction (a
|
|
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.
|
|
65
|
+
*/
|
|
66
|
+
export async function signAndSend(signer, tx, beforeSend) {
|
|
67
|
+
const signed = await signer.signTransaction(tx);
|
|
68
|
+
const hash = signed.hash();
|
|
69
|
+
await beforeSend?.(hash);
|
|
70
|
+
try {
|
|
71
|
+
return await signer.client.sendTransaction(signed);
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
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
|
+
}
|
|
82
|
+
if (known && ["pending", "proposed", "committed"].includes(known.status))
|
|
83
|
+
return hash;
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export async function keyLockOf(session, client) {
|
|
88
|
+
return (await sessionSigner(session, client).getRecommendedAddressObj()).script;
|
|
89
|
+
}
|
|
90
|
+
function params(session, keyLock, binding) {
|
|
91
|
+
return {
|
|
92
|
+
ownerLockHash: ccc.Script.from(binding.ownerLock).hash(),
|
|
93
|
+
sessionLockHash: keyLock.hash(),
|
|
94
|
+
maxPerTx: session.policy.scope.maxPerTx,
|
|
95
|
+
minInterval: binding.minInterval,
|
|
96
|
+
...(binding.recipientLockHash && { recipientLockHash: binding.recipientLockHash }),
|
|
97
|
+
...(binding.token && {
|
|
98
|
+
token: { typeHash: ccc.Script.from(binding.token.type).hash(), maxPerTx: binding.token.maxPerTx },
|
|
99
|
+
}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** The xUDT amount in a cell's data (RFC 0052: u128 LE in the first 16 bytes). */
|
|
103
|
+
export function tokenAmount(data) {
|
|
104
|
+
const bytes = ccc.bytesFrom(data);
|
|
105
|
+
if (bytes.length < 16)
|
|
106
|
+
throw new RangeError("not an xUDT cell: under 16 bytes of data");
|
|
107
|
+
return ccc.numLeFromBytes(bytes.slice(0, 16));
|
|
108
|
+
}
|
|
109
|
+
export const tokenData = (amount, rest = new Uint8Array()) => ccc.hexFrom(ccc.bytesConcat(ccc.numLeToBytes(amount, 16), rest));
|
|
110
|
+
/** A cell's data is exactly a 16-byte amount: what the session lock accepts as token change. */
|
|
111
|
+
export const plainTokenCell = (cell, type) => cell.cellOutput.type?.eq(type) === true && ccc.bytesFrom(cell.outputData).length === 16;
|
|
112
|
+
export async function sessionLockOf(session, client, deployment, binding) {
|
|
113
|
+
return sessionLockScript(deployment, params(session, await keyLockOf(session, client), binding));
|
|
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
|
+
}
|
|
284
|
+
/** Plain cells under `lock`; with `token`, also cells holding exactly that token. */
|
|
285
|
+
export async function collect(client, lock, token) {
|
|
286
|
+
const cells = [];
|
|
287
|
+
for await (const cell of client.findCellsByLock(lock, null, true)) {
|
|
288
|
+
if ((!cell.cellOutput.type && cell.outputData === "0x") || (token && plainTokenCell(cell, token)))
|
|
289
|
+
cells.push(cell);
|
|
290
|
+
}
|
|
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
|
+
}
|
|
409
|
+
}
|
|
410
|
+
export async function findSessionCells(session, client, deployment, binding) {
|
|
411
|
+
const keyLock = await keyLockOf(session, client);
|
|
412
|
+
const lock = sessionLockScript(deployment, params(session, keyLock, binding));
|
|
413
|
+
const token = binding.token && ccc.Script.from(binding.token.type);
|
|
414
|
+
const [found, keyCells] = await Promise.all([collect(client, lock, token), collect(client, keyLock)]);
|
|
415
|
+
const sessionCells = await withinU128(client, found);
|
|
416
|
+
const sum = (cells) => cells.reduce((s, c) => s + c.cellOutput.capacity, 0n);
|
|
417
|
+
const tokenBalance = sessionCells.reduce((s, c) => s + (c.cellOutput.type ? tokenAmount(c.outputData) : 0n), 0n);
|
|
418
|
+
return { lock, keyLock, sessionCells, keyCells, balance: sum(sessionCells), feeBalance: sum(keyCells), tokenBalance };
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* The recipient the session lock will enforce, taken from the session's own scope
|
|
422
|
+
* so the browser and the chain cannot disagree. The lock holds one recipient, so a
|
|
423
|
+
* scope listing several cannot be enforced on-chain and is refused.
|
|
424
|
+
*/
|
|
425
|
+
export function onchainRecipient(session) {
|
|
426
|
+
const { recipients } = session.policy.scope;
|
|
427
|
+
if (!recipients)
|
|
428
|
+
return undefined;
|
|
429
|
+
if (recipients.length !== 1) {
|
|
430
|
+
throw new SessionPolicyError(`the session lock enforces one recipient; scope.recipients lists ${recipients.length}`);
|
|
431
|
+
}
|
|
432
|
+
return recipients[0];
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
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).
|
|
481
|
+
*
|
|
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.
|
|
493
|
+
*
|
|
494
|
+
* The owner must also never sign a partial (open) transaction with this lock.
|
|
495
|
+
*/
|
|
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
|
+
};
|
|
513
|
+
const is = async (known) => {
|
|
514
|
+
try {
|
|
515
|
+
const s = await client.getKnownScript(known);
|
|
516
|
+
return s.codeHash === lock.codeHash && s.hashType === lock.hashType;
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
return false; // not deployed on this network
|
|
520
|
+
}
|
|
521
|
+
};
|
|
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`);
|
|
529
|
+
}
|
|
530
|
+
if (await is(ccc.KnownScript.AlwaysSuccess))
|
|
531
|
+
return unsafe("is always-success: it opens with no signature");
|
|
532
|
+
if (await is(ccc.KnownScript.OmniLock)) {
|
|
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)`);
|
|
545
|
+
}
|
|
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`);
|
|
548
|
+
}
|
|
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`);
|
|
551
|
+
}
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
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");
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Owner funds the session: one transaction, one signature from `owner`.
|
|
574
|
+
* The on-chain recipient comes from `session.policy.scope.recipients`.
|
|
575
|
+
*
|
|
576
|
+
* `beforeSend` runs once the transaction is signed and before it is sent: save the
|
|
577
|
+
* session there (`store.save({ ...session, onchain: binding })`). Saved after
|
|
578
|
+
* sending, a failed save would leave funds under a key that exists nowhere. The
|
|
579
|
+
* binding then carries `openTx`, the funding transaction's hash, which
|
|
580
|
+
* `closeSession` checks before reporting that nothing is left.
|
|
581
|
+
*
|
|
582
|
+
* The key cell (`feeBudget`) must be able to return to the owner at the end: by
|
|
583
|
+
* default it is the size of a cell under the owner's lock plus 1 CKB for fees.
|
|
584
|
+
*/
|
|
585
|
+
export async function openSession(owner, session, deployment, opts) {
|
|
586
|
+
const client = owner.client;
|
|
587
|
+
const token = opts.token && ccc.Script.from(opts.token.type);
|
|
588
|
+
if (token && opts.token) {
|
|
589
|
+
await assertPlainXudt(token, client);
|
|
590
|
+
if (opts.token.amount < 0n || opts.token.maxPerTx <= 0n) {
|
|
591
|
+
throw new SessionBalanceError("token amount must not be negative and token maxPerTx must be positive");
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (opts.minInterval !== undefined)
|
|
595
|
+
relativeBlocksSince(opts.minInterval); // a spend must be able to meet it
|
|
596
|
+
const ownerLock = (await owner.getRecommendedAddressObj()).script;
|
|
597
|
+
await assertSigningLock(ownerLock, client, opts);
|
|
598
|
+
const floor = keyCellFloor(ownerLock);
|
|
599
|
+
const feeBudget = opts.feeBudget ?? floor + FEE_MARGIN;
|
|
600
|
+
if (feeBudget <= floor) {
|
|
601
|
+
throw new SessionBalanceError(`feeBudget must be above ${ccc.fixedPointToString(floor)} CKB: the key cell pays fees and must still ` +
|
|
602
|
+
"be able to return to the owner's lock");
|
|
603
|
+
}
|
|
604
|
+
const recipient = onchainRecipient(session);
|
|
605
|
+
const recipientLockHash = recipient
|
|
606
|
+
? (await ccc.Address.fromString(recipient, client)).script.hash()
|
|
607
|
+
: undefined;
|
|
608
|
+
const binding = {
|
|
609
|
+
ownerLock: { codeHash: ownerLock.codeHash, hashType: ownerLock.hashType, args: ownerLock.args },
|
|
610
|
+
minInterval: opts.minInterval ?? 0n,
|
|
611
|
+
...(recipientLockHash && { recipientLockHash }),
|
|
612
|
+
...(token &&
|
|
613
|
+
opts.token && {
|
|
614
|
+
token: { type: { codeHash: token.codeHash, hashType: token.hashType, args: token.args }, maxPerTx: opts.token.maxPerTx },
|
|
615
|
+
}),
|
|
616
|
+
};
|
|
617
|
+
const keyLock = await keyLockOf(session, client);
|
|
618
|
+
if (recipientLockHash === keyLock.hash()) {
|
|
619
|
+
throw new SessionPolicyError("the recipient is the session key itself: it could then pay anyone");
|
|
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);
|
|
630
|
+
const lock = sessionLockScript(deployment, params(session, keyLock, binding));
|
|
631
|
+
const tx = ccc.Transaction.from({
|
|
632
|
+
outputs: [
|
|
633
|
+
{ lock, capacity: opts.budget, ...(token && { type: token }) },
|
|
634
|
+
{ lock: keyLock, capacity: feeBudget },
|
|
635
|
+
],
|
|
636
|
+
outputsData: [token && opts.token ? tokenData(opts.token.amount) : "0x", "0x"],
|
|
637
|
+
});
|
|
638
|
+
// CCC raises an output to its occupied capacity; a budget below that is a mistake.
|
|
639
|
+
if (tx.outputs[0].capacity !== opts.budget) {
|
|
640
|
+
throw new SessionBalanceError(`budget must be at least ${ccc.fixedPointToString(tx.outputs[0].capacity)} CKB`);
|
|
641
|
+
}
|
|
642
|
+
if (token) {
|
|
643
|
+
await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.XUdt);
|
|
644
|
+
await tx.completeInputsByUdt(owner, token);
|
|
645
|
+
const extra = (await tx.getInputsUdtBalance(client, token)) - tx.getOutputsUdtBalance(token);
|
|
646
|
+
if (extra > 0n)
|
|
647
|
+
tx.addOutput({ lock: ownerLock, type: token }, tokenData(extra));
|
|
648
|
+
}
|
|
649
|
+
await tx.completeInputsByCapacity(owner);
|
|
650
|
+
await tx.completeFeeBy(owner);
|
|
651
|
+
let funded = binding;
|
|
652
|
+
const txHash = await signAndSend(owner, tx, async (hash) => {
|
|
653
|
+
funded = { ...binding, openTx: hash };
|
|
654
|
+
await opts.beforeSend?.(funded);
|
|
655
|
+
});
|
|
656
|
+
return { txHash, binding: funded };
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Throws unless `type` is a plain xUDT (RFC 0052) on this network: the session
|
|
660
|
+
* lock reads amounts as xUDT stores them, and an xUDT with any flag set in its
|
|
661
|
+
* args either runs extension scripts the owner did not choose on every spend, or
|
|
662
|
+
* widens who may mint (owner mode by input or output type, code review 14 Oct).
|
|
663
|
+
*/
|
|
664
|
+
export async function assertPlainXudt(type, client) {
|
|
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) {
|
|
671
|
+
if (type.codeHash !== xudt.codeHash || type.hashType !== xudt.hashType) {
|
|
672
|
+
return "a token session holds an xUDT token; this type script is not xUDT";
|
|
673
|
+
}
|
|
674
|
+
const args = ccc.bytesFrom(type.args);
|
|
675
|
+
if (args.length < 32)
|
|
676
|
+
return "xUDT args must start with the issuer's 32-byte lock hash";
|
|
677
|
+
if (args.length >= 36 && ccc.numLeFromBytes(args.slice(32, 36)) !== 0n) {
|
|
678
|
+
return "xUDT tokens with flags (extension scripts, other owner modes) are not supported in a session";
|
|
679
|
+
}
|
|
680
|
+
return undefined;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* The session key pays `to`: `amount` shannons, or in a token session `token`
|
|
684
|
+
* units of its xUDT. The scope is checked here first, then the network checks it
|
|
685
|
+
* again in the session lock. No wallet is involved.
|
|
686
|
+
*
|
|
687
|
+
* `topUp: true` pays into an existing cell of `to` instead of creating one. `to`
|
|
688
|
+
* must be an anyone-can-pay address with at least one live cell to add to (plain
|
|
689
|
+
* for CKB, holding the session's token for `token`); that cell is consumed and
|
|
690
|
+
* recreated holding more, which the anyone-can-pay lock allows without the
|
|
691
|
+
* recipient's signature. A token paid without `topUp` goes in a new cell, whose
|
|
692
|
+
* capacity (about 142 CKB) the session also pays and must fit in `maxPerTx`.
|
|
693
|
+
*
|
|
694
|
+
* In a token session, tokens always stay in the change: a CKB payment from a
|
|
695
|
+
* cell holding tokens returns them, never burns them.
|
|
696
|
+
*
|
|
697
|
+
* `memo` is recorded in the witness of the session key's own input, which that
|
|
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.
|
|
711
|
+
*/
|
|
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) {
|
|
717
|
+
const tokenType = binding.token && ccc.Script.from(binding.token.type);
|
|
718
|
+
const tokenOut = request.token ?? 0n;
|
|
719
|
+
if (request.token !== undefined) {
|
|
720
|
+
if (!binding.token || !tokenType)
|
|
721
|
+
throw new ScopeError("this session holds no token");
|
|
722
|
+
if (!isActive(session, now))
|
|
723
|
+
throw new ScopeError("session expired");
|
|
724
|
+
if (tokenOut <= 0n)
|
|
725
|
+
throw new ScopeError("token amount must be positive");
|
|
726
|
+
if (tokenOut > binding.token.maxPerTx) {
|
|
727
|
+
throw new ScopeError(`token amount exceeds the session's token maxPerTx (${binding.token.maxPerTx})`);
|
|
728
|
+
}
|
|
729
|
+
const { recipients } = session.policy.scope;
|
|
730
|
+
if (recipients && !recipients.includes(request.to))
|
|
731
|
+
throw new ScopeError("recipient not in scope");
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
const check = checkRequest(session, { to: request.to, amount: request.amount }, now);
|
|
735
|
+
if (!check.ok)
|
|
736
|
+
throw new ScopeError(check.reason);
|
|
737
|
+
}
|
|
738
|
+
const state = await findSessionCells(session, client, deployment, binding);
|
|
739
|
+
const { script: toLock } = await ccc.Address.fromString(request.to, client);
|
|
740
|
+
if (binding.recipientLockHash && toLock.hash() !== binding.recipientLockHash) {
|
|
741
|
+
throw new ScopeError("recipient not in scope");
|
|
742
|
+
}
|
|
743
|
+
if (!state.keyCells.length)
|
|
744
|
+
throw new SessionBalanceError("no key cell left to pay fees", "no-fee-cell");
|
|
745
|
+
const since = binding.minInterval > 0n ? relativeBlocksSince(binding.minInterval) : 0n;
|
|
746
|
+
const tx = ccc.Transaction.from({ cellDeps: [sessionLockCellDep(deployment)] });
|
|
747
|
+
if (tokenType)
|
|
748
|
+
await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.XUdt);
|
|
749
|
+
// The payment: output 0, plus the recipient's cell when topping up.
|
|
750
|
+
let ckbOut;
|
|
751
|
+
if (request.topUp) {
|
|
752
|
+
if (!(await isAnyoneCanPay(toLock, client)))
|
|
753
|
+
throw new ScopeError("top-up needs an anyone-can-pay recipient");
|
|
754
|
+
// Payers at once compete for a cell: a confirmed one no pool transaction spends,
|
|
755
|
+
// at random among the recipient's (pickPayoutCell).
|
|
756
|
+
ckbOut = request.amount ?? 0n;
|
|
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
|
+
}
|
|
761
|
+
}
|
|
762
|
+
else if (request.token !== undefined) {
|
|
763
|
+
tx.addOutput({ lock: toLock, type: tokenType, capacity: 0n }, tokenData(tokenOut)); // CCC sizes it
|
|
764
|
+
ckbOut = tx.outputs[0].capacity;
|
|
765
|
+
if (ckbOut > session.policy.scope.maxPerTx) {
|
|
766
|
+
throw new ScopeError(`a new token cell needs ${ccc.fixedPointToString(ckbOut)} CKB, over maxPerTx; pay with topUp instead`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
ckbOut = request.amount;
|
|
771
|
+
tx.addOutput({ lock: toLock, capacity: ckbOut });
|
|
772
|
+
if (tx.outputs[0].capacity !== ckbOut) {
|
|
773
|
+
throw new SessionBalanceError(`a payment must be at least ${ccc.fixedPointToString(tx.outputs[0].capacity)} CKB`);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
// With a rate limit, only cells at least `minInterval` blocks old can be spent.
|
|
777
|
+
// Anyone can create a cell under this lock; a fresh one picked here would make
|
|
778
|
+
// the whole spend fail (code review, 14 Oct).
|
|
779
|
+
const usable = binding.minInterval > 0n ? await matureCells(client, state.sessionCells, binding.minInterval) : state.sessionCells;
|
|
780
|
+
if (state.sessionCells.length && !usable.length) {
|
|
781
|
+
throw new SessionBalanceError(`rate limited: no session cell is ${binding.minInterval} blocks old yet`);
|
|
782
|
+
}
|
|
783
|
+
// Take session cells until they cover both the shannons and the tokens.
|
|
784
|
+
const taken = [];
|
|
785
|
+
let ckbIn = 0n;
|
|
786
|
+
let tokenIn = 0n;
|
|
787
|
+
for (const cell of usable) {
|
|
788
|
+
if (ckbIn >= ckbOut && tokenIn >= tokenOut)
|
|
789
|
+
break;
|
|
790
|
+
if (ckbIn >= ckbOut && !cell.cellOutput.type)
|
|
791
|
+
continue; // only tokens still needed
|
|
792
|
+
taken.push(cell);
|
|
793
|
+
ckbIn += cell.cellOutput.capacity;
|
|
794
|
+
if (cell.cellOutput.type)
|
|
795
|
+
tokenIn += tokenAmount(cell.outputData);
|
|
796
|
+
}
|
|
797
|
+
if (ckbIn < ckbOut) {
|
|
798
|
+
throw new SessionBalanceError(`session balance ${ccc.fixedPointToString(state.balance)} CKB is too low`);
|
|
799
|
+
}
|
|
800
|
+
if (tokenIn < tokenOut)
|
|
801
|
+
throw new SessionBalanceError(`session token balance ${state.tokenBalance} is too low`);
|
|
802
|
+
tx.inputs.unshift(...taken.map((c) => ccc.CellInput.from({ previousOutput: c.outPoint, since })));
|
|
803
|
+
// One change cell (the lock allows no more); it keeps every token that came in and did not leave.
|
|
804
|
+
const change = ckbIn - ckbOut;
|
|
805
|
+
const keepsTokens = taken.some((c) => c.cellOutput.type);
|
|
806
|
+
if (keepsTokens || change > 0n) {
|
|
807
|
+
tx.addOutput({ lock: state.lock, capacity: change, ...(keepsTokens && { type: tokenType }) }, keepsTokens ? tokenData(tokenIn - tokenOut) : "0x");
|
|
808
|
+
const needed = tx.outputs[tx.outputs.length - 1].capacity;
|
|
809
|
+
if (needed !== change) {
|
|
810
|
+
throw new SessionBalanceError(`the session would keep ${ccc.fixedPointToString(change)} CKB, below the ` +
|
|
811
|
+
`${ccc.fixedPointToString(needed)} CKB a session cell needs; ` +
|
|
812
|
+
(keepsTokens ? "it still holds tokens, so spend less CKB" : "spend all of it or less"));
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
// The key cell authorises the spend, comes back as the last output and pays the fee.
|
|
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 }));
|
|
820
|
+
if (request.memo !== undefined)
|
|
821
|
+
tx.setWitnessArgsAt(tx.inputs.length - 1, { inputType: request.memo });
|
|
822
|
+
tx.addOutput({ lock: state.keyLock, capacity: 0n });
|
|
823
|
+
await completeFeeOrNoFeeCell(tx, sessionSigner(session, client), "the key cell cannot cover the fee; top it up");
|
|
824
|
+
// Below this, the key cell could no longer go back to the owner at the end.
|
|
825
|
+
if (tx.outputs[tx.outputs.length - 1].capacity < keyCellFloor(binding.ownerLock)) {
|
|
826
|
+
throw new SessionBalanceError("the key cell cannot cover another fee; top it up", "no-fee-cell");
|
|
827
|
+
}
|
|
828
|
+
return signAndSend(sessionSigner(session, client), tx, request.beforeSend);
|
|
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
|
+
}
|
|
856
|
+
/** The cells created at least `blocks` blocks before the tip: those a relative `since` of `blocks` can spend. */
|
|
857
|
+
export async function matureCells(client, cells, blocks) {
|
|
858
|
+
const tip = await client.getTip();
|
|
859
|
+
const born = await Promise.all(cells.map(async (c) => (await client.getTransaction(c.outPoint.txHash))?.blockNumber));
|
|
860
|
+
return cells.filter((_, i) => {
|
|
861
|
+
const b = born[i];
|
|
862
|
+
return b !== undefined && b !== null && tip - ccc.numFrom(b) >= blocks;
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Ends the session: the owner sweeps the session cells (one wallet signature),
|
|
867
|
+
* then the key cell returns to the owner (session key signs, no wallet).
|
|
868
|
+
*
|
|
869
|
+
* The sweep goes first because it is what matters: a key cell too small to pay
|
|
870
|
+
* for the owner's lock (some wallets' locks need more than 61 CKB) must not block
|
|
871
|
+
* it. A key-cell failure is reported in `keyError`; the key stays usable for a retry.
|
|
872
|
+
*
|
|
873
|
+
* `nothingFound` means no cell is left under the session or the key: the key can
|
|
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.
|
|
884
|
+
*/
|
|
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) {
|
|
916
|
+
const client = owner.client;
|
|
917
|
+
const ownerLock = ccc.Script.from(binding.ownerLock);
|
|
918
|
+
const state = await findSessionCells(session, client, deployment, binding);
|
|
919
|
+
const result = {};
|
|
920
|
+
if (!state.sessionCells.length && !state.keyCells.length) {
|
|
921
|
+
const open = binding.openTx && (await client.getTransaction(binding.openTx));
|
|
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`);
|
|
948
|
+
}
|
|
949
|
+
return { nothingFound: true };
|
|
950
|
+
}
|
|
951
|
+
if (state.sessionCells.length)
|
|
952
|
+
result.sweepTx = await ownerSweep(owner, deployment, state.sessionCells);
|
|
953
|
+
if (state.keyCells.length) {
|
|
954
|
+
try {
|
|
955
|
+
const tx = ccc.Transaction.from({
|
|
956
|
+
inputs: state.keyCells.map((c) => ({ previousOutput: c.outPoint })),
|
|
957
|
+
outputs: [{ lock: ownerLock, capacity: 0n }],
|
|
958
|
+
});
|
|
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);
|
|
963
|
+
}
|
|
964
|
+
catch (e) {
|
|
965
|
+
result.keyError = e instanceof Error ? e.message : String(e);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return result;
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Every live session cell this owner opened, found without any session key.
|
|
972
|
+
*
|
|
973
|
+
* Session-lock args begin with the owner's lock hash, so an indexer prefix search
|
|
974
|
+
* on the args finds them all. Only well-formed args (80 or 112 bytes; 128 or 160
|
|
975
|
+
* for a token session) are kept: a cell with malformed args would fail the lock
|
|
976
|
+
* even in owner mode, and anyone can create one with this owner's hash as a prefix
|
|
977
|
+
* to block a sweep. Likewise a typed cell is kept only if it holds the session's
|
|
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`).
|
|
981
|
+
*
|
|
982
|
+
* Pass several deployments (`[TESTNET_DEPLOYMENT, ...TESTNET_LEGACY_DEPLOYMENTS]`)
|
|
983
|
+
* to find sessions opened under earlier versions of the lock as well.
|
|
984
|
+
*/
|
|
985
|
+
export async function findOwnedSessionCells(owner, deployments) {
|
|
986
|
+
const ownerLock = (await owner.getRecommendedAddressObj()).script;
|
|
987
|
+
const cells = [];
|
|
988
|
+
let xudt; // looked up once, at the first typed cell
|
|
989
|
+
for (const deployment of [deployments].flat()) {
|
|
990
|
+
for await (const cell of owner.client.findCells({
|
|
991
|
+
script: { codeHash: deployment.codeHash, hashType: deployment.hashType, args: ownerLock.hash() },
|
|
992
|
+
scriptType: "lock",
|
|
993
|
+
scriptSearchMode: "prefix",
|
|
994
|
+
withData: true,
|
|
995
|
+
})) {
|
|
996
|
+
if (cell.cellOutput.type && !xudt)
|
|
997
|
+
xudt = await owner.client.getKnownScript(ccc.KnownScript.XUdt);
|
|
998
|
+
if (sweepable(cell, xudt))
|
|
999
|
+
cells.push(cell);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return withinU128(owner.client, cells);
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Device-loss recovery: sweeps every session this owner opened back to the owner,
|
|
1006
|
+
* in one transaction and one wallet signature. Needs nothing from the lost device.
|
|
1007
|
+
*
|
|
1008
|
+
* Anyone can create a cell under a session lock carrying this owner's hash and
|
|
1009
|
+
* their own key, and spend it before the sweep lands, which would make a single
|
|
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.
|
|
1014
|
+
*
|
|
1015
|
+
* The key cell (the fee budget) is under the session key's own lock and cannot be
|
|
1016
|
+
* recovered without that key; keep it small.
|
|
1017
|
+
*/
|
|
1018
|
+
export async function recoverSessions(owner, deployments) {
|
|
1019
|
+
const deployment = [deployments].flat();
|
|
1020
|
+
const cells = await findOwnedSessionCells(owner, deployment);
|
|
1021
|
+
const total = (cs) => cs.reduce((sum, c) => sum + c.cellOutput.capacity, 0n);
|
|
1022
|
+
if (!cells.length)
|
|
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;
|
|
1027
|
+
try {
|
|
1028
|
+
return { txHashes: [await ownerSweep(owner, deployment, cells)], recovered: total(cells), cells: cells.length, failed: 0 };
|
|
1029
|
+
}
|
|
1030
|
+
catch (e) {
|
|
1031
|
+
if (fixed(e))
|
|
1032
|
+
throw e;
|
|
1033
|
+
// Fall through: one session at a time.
|
|
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).
|
|
1038
|
+
const sessions = new Map();
|
|
1039
|
+
for (const c of cells) {
|
|
1040
|
+
const lockHash = c.cellOutput.lock.hash();
|
|
1041
|
+
sessions.set(lockHash, [...(sessions.get(lockHash) ?? []), c]);
|
|
1042
|
+
}
|
|
1043
|
+
const result = { txHashes: [], recovered: 0n, cells: 0, failed: 0 };
|
|
1044
|
+
for (const group of sessions.values()) {
|
|
1045
|
+
try {
|
|
1046
|
+
result.txHashes.push(await ownerSweep(owner, deployment, group));
|
|
1047
|
+
result.recovered += total(group);
|
|
1048
|
+
result.cells += group.length;
|
|
1049
|
+
}
|
|
1050
|
+
catch (e) {
|
|
1051
|
+
if (fixed(e))
|
|
1052
|
+
throw e;
|
|
1053
|
+
result.failed++;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
return result;
|
|
1057
|
+
}
|
|
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) {
|
|
1066
|
+
const args = ccc.bytesFrom(cell.cellOutput.lock.args);
|
|
1067
|
+
const type = cell.cellOutput.type;
|
|
1068
|
+
if (args.length === 80 || args.length === 112)
|
|
1069
|
+
return !type;
|
|
1070
|
+
if (args.length !== 128 && args.length !== 160)
|
|
1071
|
+
return false;
|
|
1072
|
+
if (!type)
|
|
1073
|
+
return true;
|
|
1074
|
+
return (!!xudt &&
|
|
1075
|
+
plainXudtFault(type, xudt) === undefined &&
|
|
1076
|
+
type.hash() === ccc.hexFrom(args.slice(80, 112)) &&
|
|
1077
|
+
ccc.bytesFrom(cell.outputData).length === 16);
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Spends `cells` in owner mode: everything returns to the owner. Tokens go back
|
|
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`).
|
|
1084
|
+
*/
|
|
1085
|
+
export async function ownerSweep(owner, deployments, cells, opts = {}) {
|
|
1086
|
+
// The code of every lock version these cells are under, and only those.
|
|
1087
|
+
const used = [deployments].flat().filter((d) => cells.some((c) => c.cellOutput.lock.codeHash === d.codeHash));
|
|
1088
|
+
const tx = ccc.Transaction.from({
|
|
1089
|
+
inputs: cells.map((c) => ({ previousOutput: c.outPoint })),
|
|
1090
|
+
outputs: [],
|
|
1091
|
+
cellDeps: used.map(sessionLockCellDep),
|
|
1092
|
+
});
|
|
1093
|
+
const ownerLock = (await owner.getRecommendedAddressObj()).script;
|
|
1094
|
+
const tokens = new Map();
|
|
1095
|
+
for (const c of cells) {
|
|
1096
|
+
const type = c.cellOutput.type;
|
|
1097
|
+
if (!type)
|
|
1098
|
+
continue;
|
|
1099
|
+
const held = tokens.get(type.hash()) ?? { type, amount: 0n };
|
|
1100
|
+
held.amount += tokenAmount(c.outputData);
|
|
1101
|
+
tokens.set(type.hash(), held);
|
|
1102
|
+
}
|
|
1103
|
+
for (const { type, amount } of tokens.values())
|
|
1104
|
+
tx.addOutput({ lock: ownerLock, type }, tokenData(amount));
|
|
1105
|
+
if (tokens.size)
|
|
1106
|
+
await tx.addCellDepsOfKnownScripts(owner.client, ccc.KnownScript.XUdt);
|
|
1107
|
+
// Owner mode needs an owner-locked input in the transaction.
|
|
1108
|
+
for await (const cell of owner.findCells({ scriptLenRange: [0, 1], outputDataLenRange: [0, 1] }, true)) {
|
|
1109
|
+
tx.inputs.push(ccc.CellInput.from({ previousOutput: cell.outPoint }));
|
|
1110
|
+
if (opts.memo)
|
|
1111
|
+
tx.setWitnessArgsAt(tx.inputs.length - 1, { inputType: opts.memo });
|
|
1112
|
+
await tx.completeFeeBy(owner);
|
|
1113
|
+
// A sweep the node took but whose reply was lost is still the sweep (review, 28 Oct).
|
|
1114
|
+
return signAndSend(owner, tx);
|
|
1115
|
+
}
|
|
1116
|
+
throw new SessionBalanceError("the owner needs one plain cell to prove ownership");
|
|
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
|
+
}
|