ckb-session-kit 2.0.0 → 2.2.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 +93 -0
- package/README.md +863 -718
- package/dist/confirmations.d.ts +46 -0
- package/dist/confirmations.js +58 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/onchain.d.ts +131 -2
- package/dist/onchain.js +307 -2
- package/dist/paywall.d.ts +11 -1
- package/dist/paywall.js +15 -0
- package/dist/session.d.ts +7 -2
- package/dist/subscription.d.ts +34 -3
- package/dist/subscription.js +54 -6
- package/package.json +3 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ccc } from "@ckb-ccc/core";
|
|
2
|
+
/**
|
|
3
|
+
* Confirmation depth for the checks that accept a payment as proof of access
|
|
4
|
+
* (`verifyAccess`, `verifySubscription`, `subscriptionStatus`).
|
|
5
|
+
*
|
|
6
|
+
* Counting: a transaction in block `b` has `tip - b + 1` confirmations when the
|
|
7
|
+
* tip is block `tip`. The block that includes it is the first confirmation, as
|
|
8
|
+
* in the usual (Bitcoin) count; a payment in the tip block has 1.
|
|
9
|
+
* `minConfirmations` 0 (the default) checks no depth at all: committed is
|
|
10
|
+
* enough, exactly as before the option existed, and nothing more is fetched.
|
|
11
|
+
* 1 is the same depth as 0 plus the main-chain check below.
|
|
12
|
+
*
|
|
13
|
+
* A payment with enough confirmations must also still be on the main chain: the
|
|
14
|
+
* node's header at its block number must be the block the node said holds it
|
|
15
|
+
* (read without CCC's cache). Otherwise it is not counted (`"reorged"`).
|
|
16
|
+
*/
|
|
17
|
+
/** The largest `minConfirmations` accepted: about a day of CKB blocks. Anything past it is taken for a mistake. */
|
|
18
|
+
export declare const MAX_MIN_CONFIRMATIONS = 10000;
|
|
19
|
+
/** Why a payment that is otherwise valid does not count yet, or no longer counts. */
|
|
20
|
+
export type ConfirmationFault = {
|
|
21
|
+
ok: false;
|
|
22
|
+
/** Committed, but not yet `minConfirmations` deep: ask again later. */
|
|
23
|
+
code: "unconfirmed";
|
|
24
|
+
reason: string;
|
|
25
|
+
/** `tip - block + 1`, or 0 when the node gave no block (or a block past the tip read). */
|
|
26
|
+
confirmations: bigint;
|
|
27
|
+
minConfirmations: bigint;
|
|
28
|
+
} | {
|
|
29
|
+
ok: false;
|
|
30
|
+
/** Its block is no longer on the main chain: not counted. */
|
|
31
|
+
code: "reorged";
|
|
32
|
+
reason: string;
|
|
33
|
+
};
|
|
34
|
+
/** `minConfirmations` checked: a non-negative integer up to `MAX_MIN_CONFIRMATIONS`; undefined is 0. */
|
|
35
|
+
export declare function minConfirmationsFrom(value: unknown): bigint;
|
|
36
|
+
/** Confirmations of a transaction in block `block` with the tip at `tip`: `tip - block + 1`, never below 0. */
|
|
37
|
+
export declare const confirmationsAt: (tip: bigint, block: bigint) => bigint;
|
|
38
|
+
/**
|
|
39
|
+
* Why the committed transaction `res` (from `getTransactionNoCache`) does not
|
|
40
|
+
* count at `minConfirmations` with the tip at `tip`; undefined when it does.
|
|
41
|
+
* `what` names it in the reason ("payment", "the opening", …).
|
|
42
|
+
*/
|
|
43
|
+
export declare function confirmationFault(client: ccc.Client, tip: bigint, res: {
|
|
44
|
+
blockNumber?: ccc.NumLike | null;
|
|
45
|
+
blockHash?: ccc.HexLike | null;
|
|
46
|
+
}, minConfirmations: bigint, what: string): Promise<ConfirmationFault | undefined>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ccc } from "@ckb-ccc/core";
|
|
2
|
+
/**
|
|
3
|
+
* Confirmation depth for the checks that accept a payment as proof of access
|
|
4
|
+
* (`verifyAccess`, `verifySubscription`, `subscriptionStatus`).
|
|
5
|
+
*
|
|
6
|
+
* Counting: a transaction in block `b` has `tip - b + 1` confirmations when the
|
|
7
|
+
* tip is block `tip`. The block that includes it is the first confirmation, as
|
|
8
|
+
* in the usual (Bitcoin) count; a payment in the tip block has 1.
|
|
9
|
+
* `minConfirmations` 0 (the default) checks no depth at all: committed is
|
|
10
|
+
* enough, exactly as before the option existed, and nothing more is fetched.
|
|
11
|
+
* 1 is the same depth as 0 plus the main-chain check below.
|
|
12
|
+
*
|
|
13
|
+
* A payment with enough confirmations must also still be on the main chain: the
|
|
14
|
+
* node's header at its block number must be the block the node said holds it
|
|
15
|
+
* (read without CCC's cache). Otherwise it is not counted (`"reorged"`).
|
|
16
|
+
*/
|
|
17
|
+
/** The largest `minConfirmations` accepted: about a day of CKB blocks. Anything past it is taken for a mistake. */
|
|
18
|
+
export const MAX_MIN_CONFIRMATIONS = 10_000;
|
|
19
|
+
/** `minConfirmations` checked: a non-negative integer up to `MAX_MIN_CONFIRMATIONS`; undefined is 0. */
|
|
20
|
+
export function minConfirmationsFrom(value) {
|
|
21
|
+
if (value === undefined)
|
|
22
|
+
return 0n;
|
|
23
|
+
const ok = (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= MAX_MIN_CONFIRMATIONS) ||
|
|
24
|
+
(typeof value === "bigint" && value >= 0n && value <= BigInt(MAX_MIN_CONFIRMATIONS));
|
|
25
|
+
if (!ok) {
|
|
26
|
+
throw new RangeError(`minConfirmations must be an integer from 0 to ${MAX_MIN_CONFIRMATIONS}, got ${String(value)}`);
|
|
27
|
+
}
|
|
28
|
+
return BigInt(value);
|
|
29
|
+
}
|
|
30
|
+
/** Confirmations of a transaction in block `block` with the tip at `tip`: `tip - block + 1`, never below 0. */
|
|
31
|
+
export const confirmationsAt = (tip, block) => (tip >= block ? tip - block + 1n : 0n);
|
|
32
|
+
/**
|
|
33
|
+
* Why the committed transaction `res` (from `getTransactionNoCache`) does not
|
|
34
|
+
* count at `minConfirmations` with the tip at `tip`; undefined when it does.
|
|
35
|
+
* `what` names it in the reason ("payment", "the opening", …).
|
|
36
|
+
*/
|
|
37
|
+
export async function confirmationFault(client, tip, res, minConfirmations, what) {
|
|
38
|
+
if (minConfirmations === 0n)
|
|
39
|
+
return undefined;
|
|
40
|
+
const known = res.blockNumber !== undefined && res.blockNumber !== null && res.blockHash !== undefined && res.blockHash !== null;
|
|
41
|
+
const block = known ? ccc.numFrom(res.blockNumber) : undefined;
|
|
42
|
+
const confirmations = block === undefined ? 0n : confirmationsAt(tip, block);
|
|
43
|
+
if (confirmations < minConfirmations) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
code: "unconfirmed",
|
|
47
|
+
reason: `${what} has ${confirmations} of ${minConfirmations} confirmations`,
|
|
48
|
+
confirmations,
|
|
49
|
+
minConfirmations,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// The node's main chain now, not CCC's header cache.
|
|
53
|
+
const header = await client.getHeaderByNumberNoCache(block);
|
|
54
|
+
if (!header || header.hash !== ccc.hexFrom(res.blockHash)) {
|
|
55
|
+
return { ok: false, code: "reorged", reason: `${what}'s block is no longer on the main chain` };
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,11 +6,13 @@ export { ScopeError, sendInScope, sessionAddress, sessionSigner } from "./transf
|
|
|
6
6
|
export { relativeBlocksSince, SESSION_LOCK_ERRORS, sessionLockArgs, sessionLockCellDep, sessionLockErrorFrom, sessionLockScript, TESTNET_DEPLOYMENT, TESTNET_LEGACY_DEPLOYMENTS, } from "./lock.js";
|
|
7
7
|
export type { SessionLockDeployment, SessionLockParams } from "./lock.js";
|
|
8
8
|
export { assertCurrentDeployment } from "./deployment.js";
|
|
9
|
-
export { assertPlainXudt, assertSigningLock, closeSession, createPayoutCells, DEFAULT_RETRY, findOwnedSessionCells, findSessionCells, keyLockOf, matureCells, MAX_PAYOUT_CELLS, MAX_POOL_CHECKS, onchainRecipient, openSession, pickPayoutCell, poolConflict, recoverSessions, SendOutcomeUnknownError, SessionBalanceError, sessionLockOf, spendInSession, tokenAmount, } from "./onchain.js";
|
|
10
|
-
export type { OnChainBinding, RetryOptions, SessionBalanceReason, SessionCells, SessionWarning, SigningLockOptions, } from "./onchain.js";
|
|
9
|
+
export { assertPlainXudt, assertSigningLock, checkOwnerLockBuild, closeSession, createPayoutCells, DEFAULT_FUND_FEE_RATE, DEFAULT_RETRY, deadInputs, findOwnedSessionCells, findSessionCells, fundInSession, keyLockOf, matureCells, MAX_PAYOUT_CELLS, MAX_POOL_CHECKS, onchainRecipient, openSession, pickPayoutCell, poolConflict, recoverSessions, REVIEWED_OWNER_LOCK_BUILDS, SendOutcomeUnknownError, sendWithSession, SessionBalanceError, sessionLockOf, spendInSession, tokenAmount, } from "./onchain.js";
|
|
10
|
+
export type { FundedTransaction, FundOptions, OnChainBinding, OwnerLockBuild, RetryOptions, SendWithSessionOptions, SessionBalanceReason, SessionCells, SessionWarning, SigningLockOptions, } from "./onchain.js";
|
|
11
11
|
export { blocksForSeconds, cancelSubscription, chargeSubscription, clearPlantedCells, findSubscriptionCells, MAX_SUBSCRIPTION_CELLS, openSubscription, subscriptionLock, subscriptionMemo, subscriptionStatus, verifySubscription, } from "./subscription.js";
|
|
12
12
|
export type { Subscription, SubscriptionCheck, SubscriptionStatus } from "./subscription.js";
|
|
13
13
|
export { paymentMemo, signAccess, verifyAccess } from "./paywall.js";
|
|
14
14
|
export type { AccessCheck, AccessProof } from "./paywall.js";
|
|
15
|
+
export { MAX_MIN_CONFIRMATIONS } from "./confirmations.js";
|
|
16
|
+
export type { ConfirmationFault } from "./confirmations.js";
|
|
15
17
|
export { applyPlacements, checkPlacement, decodePlacement, encodePlacement, PLACE_BOARD, placementMemo, placementPrice, PlaceMemoError, } from "./place.js";
|
|
16
18
|
export type { BoardState, Pixel, PlaceBoard, PlacedPixel, Placement, PlacementCheck } from "./place.js";
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,8 @@ export { deserializeSession, indexedDbStore, memoryStore, serializeSession, Sess
|
|
|
3
3
|
export { ScopeError, sendInScope, sessionAddress, sessionSigner } from "./transfer.js";
|
|
4
4
|
export { relativeBlocksSince, SESSION_LOCK_ERRORS, sessionLockArgs, sessionLockCellDep, sessionLockErrorFrom, sessionLockScript, TESTNET_DEPLOYMENT, TESTNET_LEGACY_DEPLOYMENTS, } from "./lock.js";
|
|
5
5
|
export { assertCurrentDeployment } from "./deployment.js";
|
|
6
|
-
export { assertPlainXudt, assertSigningLock, closeSession, createPayoutCells, DEFAULT_RETRY, findOwnedSessionCells, findSessionCells, keyLockOf, matureCells, MAX_PAYOUT_CELLS, MAX_POOL_CHECKS, onchainRecipient, openSession, pickPayoutCell, poolConflict, recoverSessions, SendOutcomeUnknownError, SessionBalanceError, sessionLockOf, spendInSession, tokenAmount, } from "./onchain.js";
|
|
6
|
+
export { assertPlainXudt, assertSigningLock, checkOwnerLockBuild, closeSession, createPayoutCells, DEFAULT_FUND_FEE_RATE, DEFAULT_RETRY, deadInputs, findOwnedSessionCells, findSessionCells, fundInSession, keyLockOf, matureCells, MAX_PAYOUT_CELLS, MAX_POOL_CHECKS, onchainRecipient, openSession, pickPayoutCell, poolConflict, recoverSessions, REVIEWED_OWNER_LOCK_BUILDS, SendOutcomeUnknownError, sendWithSession, SessionBalanceError, sessionLockOf, spendInSession, tokenAmount, } from "./onchain.js";
|
|
7
7
|
export { blocksForSeconds, cancelSubscription, chargeSubscription, clearPlantedCells, findSubscriptionCells, MAX_SUBSCRIPTION_CELLS, openSubscription, subscriptionLock, subscriptionMemo, subscriptionStatus, verifySubscription, } from "./subscription.js";
|
|
8
8
|
export { paymentMemo, signAccess, verifyAccess } from "./paywall.js";
|
|
9
|
+
export { MAX_MIN_CONFIRMATIONS } from "./confirmations.js";
|
|
9
10
|
export { applyPlacements, checkPlacement, decodePlacement, encodePlacement, PLACE_BOARD, placementMemo, placementPrice, PlaceMemoError, } from "./place.js";
|
package/dist/onchain.d.ts
CHANGED
|
@@ -60,10 +60,18 @@ export declare class SessionBalanceError extends Error {
|
|
|
60
60
|
* spend is held to `maxPerTx`, but the change of one spend can be spent again in
|
|
61
61
|
* the next block (with 1, a block later), so a leaked key can repeat spends until the
|
|
62
62
|
* session is empty.
|
|
63
|
+
* - `owner-lock-build-unreviewed`: the owner's lock is Omnilock, JoyID or the
|
|
64
|
+
* Nostr lock under its type id, and its live code cell is not a build listed
|
|
65
|
+
* in `REVIEWED_OWNER_LOCK_BUILDS` (`dataHash` is what it runs now). The
|
|
66
|
+
* deployer may have upgraded it; the opening is not refused.
|
|
67
|
+
* - `owner-lock-build-unresolved`: the same locks, but the code cell could not be
|
|
68
|
+
* read, so the build was not checked.
|
|
63
69
|
*/
|
|
64
70
|
export interface SessionWarning {
|
|
65
|
-
code: "no-rate-limit";
|
|
71
|
+
code: "no-rate-limit" | "owner-lock-build-unreviewed" | "owner-lock-build-unresolved";
|
|
66
72
|
message: string;
|
|
73
|
+
/** With `"owner-lock-build-unreviewed"`: the data hash of the owner lock's live code cell. */
|
|
74
|
+
dataHash?: ccc.Hex;
|
|
67
75
|
}
|
|
68
76
|
/**
|
|
69
77
|
* The smallest key cell that can still be returned to the owner: a cell under the
|
|
@@ -259,6 +267,40 @@ export type SigningRole = {
|
|
|
259
267
|
};
|
|
260
268
|
/** `assertSigningLock` for any role: a subscription's charger lock is checked the same way (review M2), with its own opt-in. */
|
|
261
269
|
export declare function checkSigningLock(lock: ccc.Script, client: ccc.Client, allowUnreviewed: boolean, role: SigningRole): Promise<void>;
|
|
270
|
+
/** A build of a type-id owner lock: the data hash (ckb blake2b) of its code cell. */
|
|
271
|
+
export type OwnerLockBuild = {
|
|
272
|
+
lock: "Omnilock" | "JoyID" | "Nostr lock";
|
|
273
|
+
network: "mainnet" | "testnet";
|
|
274
|
+
/** The type-id code hash owner locks reference (hash_type `type`). */
|
|
275
|
+
codeHash: ccc.Hex;
|
|
276
|
+
/** `ccc.hashCkb` of the code cell's data. */
|
|
277
|
+
dataHash: ccc.Hex;
|
|
278
|
+
/** What was checked, and when. */
|
|
279
|
+
review: string;
|
|
280
|
+
};
|
|
281
|
+
/**
|
|
282
|
+
* The builds of the type-id owner locks this kit has looked at. An owner lock
|
|
283
|
+
* hash binds the type id, not the binary, so the deployer can replace the code
|
|
284
|
+
* under every lock at once. `openSession` and `openSubscription` read the live
|
|
285
|
+
* code cell and warn (`"owner-lock-build-unreviewed"`) when its data hash is not
|
|
286
|
+
* listed here; they do not refuse. Data hashes read from the live code cells
|
|
287
|
+
* (resolved by type id, as CCC resolves them) on 7 Nov 2026.
|
|
288
|
+
*/
|
|
289
|
+
export declare const REVIEWED_OWNER_LOCK_BUILDS: readonly OwnerLockBuild[];
|
|
290
|
+
/**
|
|
291
|
+
* For an owner lock that is Omnilock, JoyID or the Nostr lock referenced by type
|
|
292
|
+
* id: reads its live code cell and returns a warning unless the cell's data hash
|
|
293
|
+
* is in `reviewed` (default `REVIEWED_OWNER_LOCK_BUILDS`). `undefined` for every other lock (secp256k1,
|
|
294
|
+
* multisig, and any lock referenced by data hash, which already binds the binary).
|
|
295
|
+
*
|
|
296
|
+
* Never throws: if the code cell cannot be read (RPC error, or no live cell),
|
|
297
|
+
* the warning is `"owner-lock-build-unresolved"`. The pin is advisory, so a
|
|
298
|
+
* failed read cannot be stricter than an unknown build; and a client that cannot
|
|
299
|
+
* read cells will fail later in the opening anyway.
|
|
300
|
+
*/
|
|
301
|
+
export declare function checkOwnerLockBuild(lockLike: ccc.ScriptLike, client: ccc.Client, reviewed?: readonly OwnerLockBuild[]): Promise<SessionWarning | undefined>;
|
|
302
|
+
/** Reports `checkOwnerLockBuild`'s warning, if any, to `onWarning` or `console.warn` (internal; not exported from the package). */
|
|
303
|
+
export declare function warnOwnerLockBuild(lock: ccc.Script, client: ccc.Client, onWarning?: (warning: SessionWarning) => void | Promise<void>): Promise<void>;
|
|
262
304
|
/**
|
|
263
305
|
* Owner funds the session: one transaction, one signature from `owner`.
|
|
264
306
|
* The on-chain recipient comes from `session.policy.scope.recipients`.
|
|
@@ -351,12 +393,99 @@ type SpendRequest = SpendCommon & ({
|
|
|
351
393
|
token: bigint;
|
|
352
394
|
amount?: undefined;
|
|
353
395
|
});
|
|
396
|
+
/**
|
|
397
|
+
* The fee rate `fundInSession` and `sendWithSession` use when none is given, in
|
|
398
|
+
* shannons per 1000 bytes (twice the usual testnet minimum). `sendWithSession` keeps the
|
|
399
|
+
* same rate on every attempt (see there).
|
|
400
|
+
*/
|
|
401
|
+
export declare const DEFAULT_FUND_FEE_RATE = 2000n;
|
|
402
|
+
/** Options for {@link fundInSession}. */
|
|
403
|
+
export interface FundOptions {
|
|
404
|
+
/** Shannons per 1000 bytes (default `DEFAULT_FUND_FEE_RATE`). The key cell pays it. */
|
|
405
|
+
feeRate?: ccc.NumLike;
|
|
406
|
+
/**
|
|
407
|
+
* The app's own policy on the finished transaction (which outputs it may hold),
|
|
408
|
+
* run last, before anything is signed: throw to refuse it. It guards against
|
|
409
|
+
* app bugs; a leaked key is bounded by the lock, not by this.
|
|
410
|
+
*/
|
|
411
|
+
check?: (tx: ccc.Transaction) => void | Promise<void>;
|
|
412
|
+
now?: Date;
|
|
413
|
+
/** Out points (as `ccc.hexFrom(outPoint.toBytes())`) of session or key cells not to take. */
|
|
414
|
+
avoid?: ReadonlySet<string>;
|
|
415
|
+
}
|
|
416
|
+
/** What {@link fundInSession} returns. */
|
|
417
|
+
export interface FundedTransaction {
|
|
418
|
+
/** The app's transaction with the session's inputs and outputs added, unsigned. */
|
|
419
|
+
tx: ccc.Transaction;
|
|
420
|
+
/** Shannons leaving the session cells (session inputs less change): what the lock holds to `maxPerTx`. */
|
|
421
|
+
outflow: bigint;
|
|
422
|
+
/** The inputs the kit added: session cells, then the key cell (and any key cell CCC added for the fee). */
|
|
423
|
+
sessionInputs: ccc.OutPoint[];
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Funds a transaction the app built (its own inputs, typed outputs, cell deps
|
|
427
|
+
* and witnesses) from the session: adds session cells as inputs, at most one
|
|
428
|
+
* plain change cell under the session lock, and the key cell, which comes back
|
|
429
|
+
* as the last output and pays the fee. Signs nothing; `sendWithSession` signs
|
|
430
|
+
* and sends. The checks follow the order the session lock makes them, so a
|
|
431
|
+
* transaction this returns is one the lock accepts:
|
|
432
|
+
*
|
|
433
|
+
* 1. The session is active, and has no on-chain recipient: a recipient-scoped
|
|
434
|
+
* session can only pay that recipient and the owner, never a typed output
|
|
435
|
+
* (lock errors 13 and 18), so it is refused (`SessionPolicyError`,
|
|
436
|
+
* "session-has-recipient").
|
|
437
|
+
* 2. No input of the app's is under the owner's lock: the lock would run in owner
|
|
438
|
+
* mode, and the wallet would have to sign ("owner-lock-input"). None is under
|
|
439
|
+
* the session lock or the key's lock, and no output is either: the kit adds
|
|
440
|
+
* those ("app-tx-shape").
|
|
441
|
+
* 3. Need = the outputs' capacity less the app inputs'. Over `maxPerTx`, a
|
|
442
|
+
* `ScopeError` before anything is looked up. Session cells are taken (plain
|
|
443
|
+
* ones only, mature ones under a rate limit, with `since` set), and change
|
|
444
|
+
* is either none or at least a session cell's size (121 CKB for 80-byte args).
|
|
445
|
+
* 4. The key cell: the largest, in last, out last; the fee (`feeRate`) comes out
|
|
446
|
+
* of it, so the fee never counts as outflow.
|
|
447
|
+
* 5. outflow = session inputs less change, checked against `maxPerTx` again;
|
|
448
|
+
* then `opts.check`.
|
|
449
|
+
*
|
|
450
|
+
* The app's inputs, witnesses and cell deps stay where they are; the kit's
|
|
451
|
+
* inputs go after them. `tx` itself is not changed.
|
|
452
|
+
*/
|
|
453
|
+
export declare function fundInSession(session: Session, client: ccc.Client, deployment: SessionLockDeployment, binding: OnChainBinding, txLike: ccc.TransactionLike, opts?: FundOptions): Promise<FundedTransaction>;
|
|
454
|
+
/** Options for {@link sendWithSession}. */
|
|
455
|
+
export interface SendWithSessionOptions extends Omit<FundOptions, "avoid"> {
|
|
456
|
+
/** Runs with the signed transaction's hash before it is sent, on every attempt (see `signAndSend`). */
|
|
457
|
+
beforeSend?: (txHash: ccc.Hex) => Promise<void>;
|
|
458
|
+
/** Attempts and backoff for a refusal naming one of the session's own cells (default `DEFAULT_RETRY`). */
|
|
459
|
+
retry?: RetryOptions;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Builds the app's transaction (`build`), funds it from the session
|
|
463
|
+
* (`fundInSession`), signs it with the session key alone and sends it. No
|
|
464
|
+
* wallet is involved: the key signs only its own cell, and the session lock
|
|
465
|
+
* checks no signature. Returns the transaction hash.
|
|
466
|
+
*
|
|
467
|
+
* Retries only when the node refuses the transaction for one of the session's
|
|
468
|
+
* own cells (`Dead` or `Unknown` naming a session or key cell this attempt
|
|
469
|
+
* added: another tab spent it). Then the client's cache is cleared, that cell
|
|
470
|
+
* is left out, and `build` runs again, with the backoff of `rebuildOnDeadInputs`.
|
|
471
|
+
*
|
|
472
|
+
* Any other refusal is thrown unchanged, for the app's own loop: a dead input of
|
|
473
|
+
* the app's (another user took the cell the app built on, such as a
|
|
474
|
+
* crowdfunding campaign cell; `deadInputs(e)` is true) or an RBF refusal
|
|
475
|
+
* (`poolConflict(e)`), which names no cell. The app waits for its cell to move
|
|
476
|
+
* and calls again. A send whose outcome is unknown is never retried.
|
|
477
|
+
*
|
|
478
|
+
* The fee rate is the same on every attempt (`feeRate`, default
|
|
479
|
+
* `DEFAULT_FUND_FEE_RATE`): a retry at a higher rate could replace (RBF)
|
|
480
|
+
* another user's pending transaction on the same app cell.
|
|
481
|
+
*/
|
|
482
|
+
export declare function sendWithSession(session: Session, client: ccc.Client, deployment: SessionLockDeployment, binding: OnChainBinding, build: () => Promise<ccc.TransactionLike>, opts?: SendWithSessionOptions): Promise<ccc.Hex>;
|
|
354
483
|
/**
|
|
355
484
|
* `completeFeeChangeToOutput` into the last output, with CCC's "Insufficient CKB"
|
|
356
485
|
* (the paying cell holds its floor but not the fee too) as a
|
|
357
486
|
* `SessionBalanceError` with reason `no-fee-cell` (audit round 2, 31 Oct).
|
|
358
487
|
*/
|
|
359
|
-
export declare function completeFeeOrNoFeeCell(tx: ccc.Transaction, signer: ccc.Signer, message: string): Promise<void>;
|
|
488
|
+
export declare function completeFeeOrNoFeeCell(tx: ccc.Transaction, signer: ccc.Signer, message: string, feeRate?: ccc.NumLike): Promise<void>;
|
|
360
489
|
/** The cells created at least `blocks` blocks before the tip: those a relative `since` of `blocks` can spend. */
|
|
361
490
|
export declare function matureCells(client: ccc.Client, cells: ccc.Cell[], blocks: bigint): Promise<ccc.Cell[]>;
|
|
362
491
|
/**
|
package/dist/onchain.js
CHANGED
|
@@ -569,6 +569,130 @@ export async function checkSigningLock(lock, client, allowUnreviewed, role) {
|
|
|
569
569
|
return;
|
|
570
570
|
return unreviewed("is not one this kit has reviewed: it may open without the owner's signature");
|
|
571
571
|
}
|
|
572
|
+
/**
|
|
573
|
+
* The builds of the type-id owner locks this kit has looked at. An owner lock
|
|
574
|
+
* hash binds the type id, not the binary, so the deployer can replace the code
|
|
575
|
+
* under every lock at once. `openSession` and `openSubscription` read the live
|
|
576
|
+
* code cell and warn (`"owner-lock-build-unreviewed"`) when its data hash is not
|
|
577
|
+
* listed here; they do not refuse. Data hashes read from the live code cells
|
|
578
|
+
* (resolved by type id, as CCC resolves them) on 7 Nov 2026.
|
|
579
|
+
*/
|
|
580
|
+
export const REVIEWED_OWNER_LOCK_BUILDS = Object.freeze([
|
|
581
|
+
{
|
|
582
|
+
lock: "Omnilock",
|
|
583
|
+
network: "mainnet",
|
|
584
|
+
codeHash: "0x9b819793a64463aed77c615d6cb226eea5487ccfc0783043a587254cda2b6f26",
|
|
585
|
+
dataHash: "0x768f306681da232ceb0b94f436c5f813377179762a831c5ad8797bd4fd2d118d",
|
|
586
|
+
review: "source: nervosnetwork/omnilock cd764d7, whose tests pin this hash; reviewed 5 Nov 2026",
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
lock: "JoyID",
|
|
590
|
+
network: "mainnet",
|
|
591
|
+
codeHash: "0xd00c84f0ec8fd441c38bc3f87a371f547190f2fcff88e642bc5bf54b9e318323",
|
|
592
|
+
dataHash: "0x4af7d832e7c328aa4ad6f2deb669743446aea7b9a2f0c25646897501ac4f1ebe",
|
|
593
|
+
review: "closed source: documentation and a string scan of this binary only; reviewed 5 Nov 2026",
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
lock: "Nostr lock",
|
|
597
|
+
network: "mainnet",
|
|
598
|
+
codeHash: "0x641a89ad2f77721b803cd50d01351c1f308444072d5fa20088567196c0574c68",
|
|
599
|
+
dataHash: "0x2effea19ff3024b5b0fb4c0c59c379e7610110e7e3fe7ae003247b1115e59795",
|
|
600
|
+
review: "source: cryptape/nostr-binding eef134e (strings consistent, not rebuilt); reviewed 5 Nov 2026",
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
lock: "Omnilock",
|
|
604
|
+
network: "testnet",
|
|
605
|
+
codeHash: "0xf329effd1c475a2978453c8600e1eaf0bc2087ee093c3ee64cc96ec6847752cb",
|
|
606
|
+
dataHash: "0x6b29b6f10346b43c540e53806ba88ba0fe3a0c3a29d7448ef555840c8f8318fa",
|
|
607
|
+
review: "the build live on testnet when read on 7 Nov 2026; its source is not identified (testnet funds only)",
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
lock: "JoyID",
|
|
611
|
+
network: "testnet",
|
|
612
|
+
codeHash: "0xd23761b364210735c19c60561d213fb3beae2fd6172743719eff6920e020baac",
|
|
613
|
+
dataHash: "0xd2c0f2f621a337007e046f0ed65f8fe29112c32a00ea1e43c2f8059fd42ca6d7",
|
|
614
|
+
review: "the build live on testnet when read on 7 Nov 2026, a different binary from mainnet; not examined, documentation review only",
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
lock: "Nostr lock",
|
|
618
|
+
network: "testnet",
|
|
619
|
+
codeHash: "0x6ae5ee0cb887b2df5a9a18137315b9bdc55be8d52637b2de0624092d5f0c91d5",
|
|
620
|
+
dataHash: "0x2effea19ff3024b5b0fb4c0c59c379e7610110e7e3fe7ae003247b1115e59795",
|
|
621
|
+
review: "the same binary as the mainnet Nostr lock",
|
|
622
|
+
},
|
|
623
|
+
]);
|
|
624
|
+
const BUILD_PINNED = [
|
|
625
|
+
[ccc.KnownScript.OmniLock, "Omnilock"],
|
|
626
|
+
[ccc.KnownScript.JoyId, "JoyID"],
|
|
627
|
+
[ccc.KnownScript.NostrLock, "Nostr lock"],
|
|
628
|
+
];
|
|
629
|
+
/**
|
|
630
|
+
* For an owner lock that is Omnilock, JoyID or the Nostr lock referenced by type
|
|
631
|
+
* id: reads its live code cell and returns a warning unless the cell's data hash
|
|
632
|
+
* is in `reviewed` (default `REVIEWED_OWNER_LOCK_BUILDS`). `undefined` for every other lock (secp256k1,
|
|
633
|
+
* multisig, and any lock referenced by data hash, which already binds the binary).
|
|
634
|
+
*
|
|
635
|
+
* Never throws: if the code cell cannot be read (RPC error, or no live cell),
|
|
636
|
+
* the warning is `"owner-lock-build-unresolved"`. The pin is advisory, so a
|
|
637
|
+
* failed read cannot be stricter than an unknown build; and a client that cannot
|
|
638
|
+
* read cells will fail later in the opening anyway.
|
|
639
|
+
*/
|
|
640
|
+
export async function checkOwnerLockBuild(lockLike, client, reviewed = REVIEWED_OWNER_LOCK_BUILDS) {
|
|
641
|
+
const lock = ccc.Script.from(lockLike);
|
|
642
|
+
if (lock.hashType !== "type")
|
|
643
|
+
return undefined;
|
|
644
|
+
let found;
|
|
645
|
+
for (const [known, name] of BUILD_PINNED) {
|
|
646
|
+
try {
|
|
647
|
+
const info = await client.getKnownScript(known);
|
|
648
|
+
if (info.codeHash === lock.codeHash && info.hashType === "type")
|
|
649
|
+
found = { info, name };
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
// not deployed on this network
|
|
653
|
+
}
|
|
654
|
+
if (found)
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
if (!found)
|
|
658
|
+
return undefined;
|
|
659
|
+
const { info, name } = found;
|
|
660
|
+
const whose = `the owner's lock (${name}, type id ${lock.codeHash})`;
|
|
661
|
+
let dataHash;
|
|
662
|
+
try {
|
|
663
|
+
const type = info.cellDeps.map((d) => d.type).find((t) => t && t.hash() === lock.codeHash);
|
|
664
|
+
const cell = type && (await client.findSingletonCellByType(type, true));
|
|
665
|
+
if (cell && cell.cellOutput.type?.hash() === lock.codeHash)
|
|
666
|
+
dataHash = ccc.hashCkb(cell.outputData);
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
dataHash = undefined;
|
|
670
|
+
}
|
|
671
|
+
if (!dataHash) {
|
|
672
|
+
return {
|
|
673
|
+
code: "owner-lock-build-unresolved",
|
|
674
|
+
message: `${whose}: its live code cell could not be read, so its build was not checked against the reviewed builds`,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
if (reviewed.some((b) => b.codeHash === lock.codeHash && b.dataHash === dataHash))
|
|
678
|
+
return undefined;
|
|
679
|
+
return {
|
|
680
|
+
code: "owner-lock-build-unreviewed",
|
|
681
|
+
dataHash,
|
|
682
|
+
message: `${whose} now runs code with data hash ${dataHash}, not a build this kit has reviewed: ` +
|
|
683
|
+
"the deployer may have upgraded it. Check that every way to unlock it still signs the whole transaction",
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
/** Reports `checkOwnerLockBuild`'s warning, if any, to `onWarning` or `console.warn` (internal; not exported from the package). */
|
|
687
|
+
export async function warnOwnerLockBuild(lock, client, onWarning) {
|
|
688
|
+
const w = await checkOwnerLockBuild(lock, client);
|
|
689
|
+
if (!w)
|
|
690
|
+
return;
|
|
691
|
+
if (onWarning)
|
|
692
|
+
await onWarning(w);
|
|
693
|
+
else
|
|
694
|
+
console.warn(`ckb-session-kit: ${w.message}`);
|
|
695
|
+
}
|
|
572
696
|
/**
|
|
573
697
|
* Owner funds the session: one transaction, one signature from `owner`.
|
|
574
698
|
* The on-chain recipient comes from `session.policy.scope.recipients`.
|
|
@@ -595,6 +719,7 @@ export async function openSession(owner, session, deployment, opts) {
|
|
|
595
719
|
relativeBlocksSince(opts.minInterval); // a spend must be able to meet it
|
|
596
720
|
const ownerLock = (await owner.getRecommendedAddressObj()).script;
|
|
597
721
|
await assertSigningLock(ownerLock, client, opts);
|
|
722
|
+
await warnOwnerLockBuild(ownerLock, client, opts.onWarning);
|
|
598
723
|
const floor = keyCellFloor(ownerLock);
|
|
599
724
|
const feeBudget = opts.feeBudget ?? floor + FEE_MARGIN;
|
|
600
725
|
if (feeBudget <= floor) {
|
|
@@ -827,14 +952,194 @@ async function spendOnce(session, client, deployment, binding, request, now, avo
|
|
|
827
952
|
}
|
|
828
953
|
return signAndSend(sessionSigner(session, client), tx, request.beforeSend);
|
|
829
954
|
}
|
|
955
|
+
/**
|
|
956
|
+
* The fee rate `fundInSession` and `sendWithSession` use when none is given, in
|
|
957
|
+
* shannons per 1000 bytes (twice the usual testnet minimum). `sendWithSession` keeps the
|
|
958
|
+
* same rate on every attempt (see there).
|
|
959
|
+
*/
|
|
960
|
+
export const DEFAULT_FUND_FEE_RATE = 2000n;
|
|
961
|
+
/**
|
|
962
|
+
* Funds a transaction the app built (its own inputs, typed outputs, cell deps
|
|
963
|
+
* and witnesses) from the session: adds session cells as inputs, at most one
|
|
964
|
+
* plain change cell under the session lock, and the key cell, which comes back
|
|
965
|
+
* as the last output and pays the fee. Signs nothing; `sendWithSession` signs
|
|
966
|
+
* and sends. The checks follow the order the session lock makes them, so a
|
|
967
|
+
* transaction this returns is one the lock accepts:
|
|
968
|
+
*
|
|
969
|
+
* 1. The session is active, and has no on-chain recipient: a recipient-scoped
|
|
970
|
+
* session can only pay that recipient and the owner, never a typed output
|
|
971
|
+
* (lock errors 13 and 18), so it is refused (`SessionPolicyError`,
|
|
972
|
+
* "session-has-recipient").
|
|
973
|
+
* 2. No input of the app's is under the owner's lock: the lock would run in owner
|
|
974
|
+
* mode, and the wallet would have to sign ("owner-lock-input"). None is under
|
|
975
|
+
* the session lock or the key's lock, and no output is either: the kit adds
|
|
976
|
+
* those ("app-tx-shape").
|
|
977
|
+
* 3. Need = the outputs' capacity less the app inputs'. Over `maxPerTx`, a
|
|
978
|
+
* `ScopeError` before anything is looked up. Session cells are taken (plain
|
|
979
|
+
* ones only, mature ones under a rate limit, with `since` set), and change
|
|
980
|
+
* is either none or at least a session cell's size (121 CKB for 80-byte args).
|
|
981
|
+
* 4. The key cell: the largest, in last, out last; the fee (`feeRate`) comes out
|
|
982
|
+
* of it, so the fee never counts as outflow.
|
|
983
|
+
* 5. outflow = session inputs less change, checked against `maxPerTx` again;
|
|
984
|
+
* then `opts.check`.
|
|
985
|
+
*
|
|
986
|
+
* The app's inputs, witnesses and cell deps stay where they are; the kit's
|
|
987
|
+
* inputs go after them. `tx` itself is not changed.
|
|
988
|
+
*/
|
|
989
|
+
export async function fundInSession(session, client, deployment, binding, txLike, opts = {}) {
|
|
990
|
+
const now = opts.now ?? new Date();
|
|
991
|
+
if (!isActive(session, now))
|
|
992
|
+
throw new ScopeError("session expired");
|
|
993
|
+
if (binding.recipientLockHash) {
|
|
994
|
+
throw new SessionPolicyError("an app transaction with typed outputs to other locks needs a session without a recipient: " +
|
|
995
|
+
"this session's lock only lets it pay its recipient and the owner, in plain cells", "session-has-recipient");
|
|
996
|
+
}
|
|
997
|
+
const { maxPerTx } = session.policy.scope;
|
|
998
|
+
const tx = ccc.Transaction.from(txLike).clone();
|
|
999
|
+
const appInputs = tx.inputs.length;
|
|
1000
|
+
const state = await findSessionCells(session, client, deployment, binding);
|
|
1001
|
+
const ownerHash = ccc.Script.from(binding.ownerLock).hash();
|
|
1002
|
+
const ownHashes = [state.lock.hash(), state.keyLock.hash()];
|
|
1003
|
+
for (const [i, input] of tx.inputs.entries()) {
|
|
1004
|
+
await input.completeExtraInfos(client);
|
|
1005
|
+
const lockHash = input.cellOutput?.lock.hash();
|
|
1006
|
+
if (lockHash === ownerHash) {
|
|
1007
|
+
throw new SessionPolicyError(`input ${i} is under the session owner's lock: the session lock would run in owner mode and ` +
|
|
1008
|
+
"the wallet would have to sign; fund this transaction from the wallet instead", "owner-lock-input");
|
|
1009
|
+
}
|
|
1010
|
+
if (lockHash && ownHashes.includes(lockHash)) {
|
|
1011
|
+
throw new SessionPolicyError(`input ${i} is a session or key cell: fundInSession adds those itself`, "app-tx-shape");
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
for (const [i, output] of tx.outputs.entries()) {
|
|
1015
|
+
if (ownHashes.includes(output.lock.hash())) {
|
|
1016
|
+
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");
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
const need = tx.getOutputsCapacity() - (await tx.getInputsCapacity(client));
|
|
1020
|
+
if (need < 0n) {
|
|
1021
|
+
throw new SessionPolicyError(`the app transaction's inputs exceed its outputs by ${ccc.fixedPointToString(-need)} CKB: ` +
|
|
1022
|
+
"give the surplus an output, or it would go to the key cell", "app-tx-shape");
|
|
1023
|
+
}
|
|
1024
|
+
if (need > maxPerTx) {
|
|
1025
|
+
throw new ScopeError(`the transaction needs ${ccc.fixedPointToString(need)} CKB from the session, over maxPerTx ` +
|
|
1026
|
+
`(${ccc.fixedPointToString(maxPerTx)} CKB)`);
|
|
1027
|
+
}
|
|
1028
|
+
const avoid = opts.avoid ?? new Set();
|
|
1029
|
+
const free = (c) => !avoid.has(outPointKey(c.outPoint));
|
|
1030
|
+
const keyCells = state.keyCells.filter(free);
|
|
1031
|
+
if (!keyCells.length)
|
|
1032
|
+
throw new SessionBalanceError("no key cell left to pay fees", "no-fee-cell");
|
|
1033
|
+
// Plain cells only: in a token session the tokens stay where they are.
|
|
1034
|
+
const plain = state.sessionCells.filter((c) => !c.cellOutput.type && free(c));
|
|
1035
|
+
const usable = need > 0n && binding.minInterval > 0n ? await matureCells(client, plain, binding.minInterval) : plain;
|
|
1036
|
+
if (need > 0n && plain.length && !usable.length) {
|
|
1037
|
+
throw new SessionBalanceError(`rate limited: no session cell is ${binding.minInterval} blocks old yet`);
|
|
1038
|
+
}
|
|
1039
|
+
// Change is none, or a whole session cell: take one more cell rather than leave a sliver.
|
|
1040
|
+
const minChange = ccc.CellOutput.from({ lock: state.lock }, "0x").capacity;
|
|
1041
|
+
const taken = [];
|
|
1042
|
+
let ckbIn = 0n;
|
|
1043
|
+
for (const cell of usable) {
|
|
1044
|
+
if (ckbIn >= need && (ckbIn === need || ckbIn - need >= minChange))
|
|
1045
|
+
break;
|
|
1046
|
+
taken.push(cell);
|
|
1047
|
+
ckbIn += cell.cellOutput.capacity;
|
|
1048
|
+
}
|
|
1049
|
+
const balance = plain.reduce((s, c) => s + c.cellOutput.capacity, 0n);
|
|
1050
|
+
if (ckbIn < need) {
|
|
1051
|
+
throw new SessionBalanceError(`session balance ${ccc.fixedPointToString(balance)} CKB is too low: the transaction needs ${ccc.fixedPointToString(need)} CKB`);
|
|
1052
|
+
}
|
|
1053
|
+
const change = ckbIn - need;
|
|
1054
|
+
if (change > 0n && change < minChange) {
|
|
1055
|
+
throw new SessionBalanceError(`the session would keep ${ccc.fixedPointToString(change)} CKB, below the ` +
|
|
1056
|
+
`${ccc.fixedPointToString(minChange)} CKB a session cell needs; spend all of it or less`);
|
|
1057
|
+
}
|
|
1058
|
+
const since = binding.minInterval > 0n ? relativeBlocksSince(binding.minInterval) : 0n;
|
|
1059
|
+
tx.inputs.push(...taken.map((c) => ccc.CellInput.from({ previousOutput: c.outPoint, since })));
|
|
1060
|
+
if (taken.length)
|
|
1061
|
+
tx.addCellDeps(sessionLockCellDep(deployment));
|
|
1062
|
+
if (change > 0n)
|
|
1063
|
+
tx.addOutput({ lock: state.lock, capacity: change }, "0x");
|
|
1064
|
+
// The key cell authorises the spend, comes back last and pays the fee (as in spendOnce).
|
|
1065
|
+
tx.inputs.push(ccc.CellInput.from({ previousOutput: largestCell(keyCells).outPoint }));
|
|
1066
|
+
tx.addOutput({ lock: state.keyLock, capacity: 0n }, "0x");
|
|
1067
|
+
await completeFeeOrNoFeeCell(tx, sessionSigner(session, client), "the key cell cannot cover the fee; top it up", opts.feeRate ?? DEFAULT_FUND_FEE_RATE);
|
|
1068
|
+
if (tx.outputs[tx.outputs.length - 1].capacity < keyCellFloor(binding.ownerLock)) {
|
|
1069
|
+
throw new SessionBalanceError("the key cell cannot cover another fee; top it up", "no-fee-cell");
|
|
1070
|
+
}
|
|
1071
|
+
// The lock's own rules on the result (1: outflow; 3 and 5: one plain change cell).
|
|
1072
|
+
const outflow = taken.reduce((s, c) => s + c.cellOutput.capacity, 0n) - change;
|
|
1073
|
+
if (outflow > maxPerTx) {
|
|
1074
|
+
throw new ScopeError(`outflow ${ccc.fixedPointToString(outflow)} CKB exceeds maxPerTx (${ccc.fixedPointToString(maxPerTx)} CKB)`);
|
|
1075
|
+
}
|
|
1076
|
+
const changeCells = tx.outputs.filter((o) => o.lock.eq(state.lock));
|
|
1077
|
+
if (changeCells.length > 1 || changeCells.some((o) => o.type)) {
|
|
1078
|
+
throw new SessionPolicyError("the session lock allows one plain change cell", "app-tx-shape");
|
|
1079
|
+
}
|
|
1080
|
+
await opts.check?.(tx);
|
|
1081
|
+
return { tx, outflow, sessionInputs: tx.inputs.slice(appInputs).map((i) => i.previousOutput) };
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Builds the app's transaction (`build`), funds it from the session
|
|
1085
|
+
* (`fundInSession`), signs it with the session key alone and sends it. No
|
|
1086
|
+
* wallet is involved: the key signs only its own cell, and the session lock
|
|
1087
|
+
* checks no signature. Returns the transaction hash.
|
|
1088
|
+
*
|
|
1089
|
+
* Retries only when the node refuses the transaction for one of the session's
|
|
1090
|
+
* own cells (`Dead` or `Unknown` naming a session or key cell this attempt
|
|
1091
|
+
* added: another tab spent it). Then the client's cache is cleared, that cell
|
|
1092
|
+
* is left out, and `build` runs again, with the backoff of `rebuildOnDeadInputs`.
|
|
1093
|
+
*
|
|
1094
|
+
* Any other refusal is thrown unchanged, for the app's own loop: a dead input of
|
|
1095
|
+
* the app's (another user took the cell the app built on, such as a
|
|
1096
|
+
* crowdfunding campaign cell; `deadInputs(e)` is true) or an RBF refusal
|
|
1097
|
+
* (`poolConflict(e)`), which names no cell. The app waits for its cell to move
|
|
1098
|
+
* and calls again. A send whose outcome is unknown is never retried.
|
|
1099
|
+
*
|
|
1100
|
+
* The fee rate is the same on every attempt (`feeRate`, default
|
|
1101
|
+
* `DEFAULT_FUND_FEE_RATE`): a retry at a higher rate could replace (RBF)
|
|
1102
|
+
* another user's pending transaction on the same app cell.
|
|
1103
|
+
*/
|
|
1104
|
+
export async function sendWithSession(session, client, deployment, binding, build, opts = {}) {
|
|
1105
|
+
const attempts = Math.max(1, Math.floor(opts.retry?.attempts ?? DEFAULT_RETRY.attempts));
|
|
1106
|
+
const delay = Math.max(0, opts.retry?.delayMs ?? DEFAULT_RETRY.delayMs);
|
|
1107
|
+
const feeRate = opts.feeRate ?? DEFAULT_FUND_FEE_RATE; // fixed for every attempt
|
|
1108
|
+
const avoid = new Set();
|
|
1109
|
+
for (let attempt = 1;; attempt++) {
|
|
1110
|
+
let ours = new Set();
|
|
1111
|
+
try {
|
|
1112
|
+
const funded = await fundInSession(session, client, deployment, binding, await build(), {
|
|
1113
|
+
feeRate,
|
|
1114
|
+
check: opts.check,
|
|
1115
|
+
now: opts.now,
|
|
1116
|
+
avoid,
|
|
1117
|
+
});
|
|
1118
|
+
ours = new Set(funded.sessionInputs.map(outPointKey));
|
|
1119
|
+
return await signAndSend(sessionSigner(session, client), funded.tx, opts.beforeSend);
|
|
1120
|
+
}
|
|
1121
|
+
catch (e) {
|
|
1122
|
+
const named = e instanceof SendOutcomeUnknownError ? undefined : rejectedOutPoint(e);
|
|
1123
|
+
if (!named || !ours.has(named) || attempt >= attempts)
|
|
1124
|
+
throw e;
|
|
1125
|
+
avoid.add(named);
|
|
1126
|
+
await client.cache?.clear();
|
|
1127
|
+
const wait = delay * 2 ** (attempt - 1) * (0.5 + Math.random());
|
|
1128
|
+
if (wait > 0)
|
|
1129
|
+
await sleep(wait);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
830
1133
|
/**
|
|
831
1134
|
* `completeFeeChangeToOutput` into the last output, with CCC's "Insufficient CKB"
|
|
832
1135
|
* (the paying cell holds its floor but not the fee too) as a
|
|
833
1136
|
* `SessionBalanceError` with reason `no-fee-cell` (audit round 2, 31 Oct).
|
|
834
1137
|
*/
|
|
835
|
-
export async function completeFeeOrNoFeeCell(tx, signer, message) {
|
|
1138
|
+
export async function completeFeeOrNoFeeCell(tx, signer, message, feeRate) {
|
|
836
1139
|
try {
|
|
837
|
-
await
|
|
1140
|
+
await (feeRate === undefined
|
|
1141
|
+
? tx.completeFeeChangeToOutput(signer, tx.outputs.length - 1)
|
|
1142
|
+
: tx.completeFeeChangeToOutput(signer, tx.outputs.length - 1, feeRate));
|
|
838
1143
|
}
|
|
839
1144
|
catch (e) {
|
|
840
1145
|
// By class, or by message where a second copy of CCC threw it (a bundler's CJS and ESM builds).
|