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.
@@ -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 tx.completeFeeChangeToOutput(signer, tx.outputs.length - 1);
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).