ckb-session-kit 2.1.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 CHANGED
@@ -3,6 +3,67 @@
3
3
  This project follows [semantic versioning](https://semver.org/). The session lock is
4
4
  deployed on CKB **testnet only**; there is no mainnet deployment yet.
5
5
 
6
+ ## 2.2.0 (2026-11-07)
7
+
8
+ ### Added
9
+ - `minConfirmations` on `verifyAccess`, `verifySubscription` and (as a new
10
+ fifth argument, `{ minConfirmations }`) `subscriptionStatus` (review, tracker
11
+ #47 point 5). Default 0: committed is enough, exactly as before, with no
12
+ extra RPC call. Confirmations are `tip - block + 1` (the payment's own block
13
+ is the first). A payment valid in every other way but short of the depth
14
+ gives `{ ok: false, code: "unconfirmed", reason, confirmations,
15
+ minConfirmations }`; one whose block is no longer on the main chain (the
16
+ node's header at that height, read without cache, has another hash) gives
17
+ `code: "reorged"`. The tip is read once per call. In `subscriptionStatus` an
18
+ opening short of the depth is refused that way, and a charge short of it does
19
+ not renew yet: it shows as `status.unconfirmedThroughBlock`.
20
+ - `MAX_MIN_CONFIRMATIONS` (10000) and the `ConfirmationFault` type. A
21
+ `minConfirmations` that is negative, fractional, not a number or bigint, or
22
+ past the maximum throws a `RangeError` before any RPC call.
23
+ - Build pinning for type-id owner locks (review, tracker #47 point 2).
24
+ `openSession` and `openSubscription` read the live code cell of an Omnilock,
25
+ JoyID or Nostr owner lock referenced by type id and compare `ccc.hashCkb` of
26
+ its data with the new `REVIEWED_OWNER_LOCK_BUILDS` (type `OwnerLockBuild`):
27
+ mainnet Omnilock `0x768f3066…118d`, JoyID `0x4af7d832…1ebe`, Nostr lock
28
+ `0x2effea19…9795`, and the builds deployed on testnet. A build not listed
29
+ raises a `SessionWarning` with code `"owner-lock-build-unreviewed"` and the
30
+ observed `dataHash`; a code cell that cannot be read raises
31
+ `"owner-lock-build-unresolved"`. Neither refuses: throw from `onWarning` to
32
+ stop. One indexer query per opening for these three locks; none for others.
33
+ `checkOwnerLockBuild(lock, client, reviewed?)` runs the check alone.
34
+ `openSubscription` takes `onWarning` too.
35
+
36
+ ### Changed
37
+ - `AccessCheck`, `SubscriptionCheck` and `subscriptionStatus`'s result add the
38
+ `ConfirmationFault` variant; the plain `{ ok: false, reason }` variant has
39
+ `code?: undefined`, so `r.code === "unconfirmed"` narrows. Callers that only
40
+ read `ok` and `reason` need no change.
41
+
42
+ ### Docs
43
+ - `docs/design/owner-locks.md` (review, tracker #47 point 2): every unlocking
44
+ path of each owner lock `assertSigningLock` accepts, what it signs, and
45
+ whether it is accepted, from the deployed code (Omnilock `cd764d7`, the
46
+ genesis secp256k1 and multisig scripts, nostr-lock `eef134e`; JoyID from its
47
+ documentation only). No accepted path signs less than the whole transaction;
48
+ the mainnet Omnilock has no CoBuild/open-transaction code. README, "Which
49
+ wallets can own a session": never sign a partial transaction with a lock that
50
+ owns sessions, and type-id upgrades of Omnilock, JoyID and the Nostr lock
51
+ apply to open sessions.
52
+ - `docs/design/subscriptions.md`, "How a payment history is reconstructed"
53
+ (review, tracker #47 point 4): which transactions renew a period (the proven
54
+ opening, or a charge, and either must pay `payTo` the prices of the cells it
55
+ creates), why cells a third party creates under the same lock add no period,
56
+ how the scan and history budgets refuse rather than answer from a partial
57
+ read, and what a forced refusal costs and how it is cleared, with file:line
58
+ and test references. New test: a stranger's transaction that pays `payTo`
59
+ and carries the account memo in its own witness is refused as an opening.
60
+ - README, "If the page is compromised" (tracker #47 point 5): the session key
61
+ is raw hex in JavaScript memory and IndexedDB, so same-origin script can read
62
+ it and sign within the lock's scope; what the lock still bounds on chain, what
63
+ browser expiry does not, and what an app should do (CSP, small budgets,
64
+ recipient and rate limit, server-side checks with `minConfirmations`, sweep
65
+ on suspicion).
66
+
6
67
  ## 2.1.0 (2026-11-05)
7
68
 
8
69
  ### Added
package/README.md CHANGED
@@ -44,7 +44,7 @@ The problem is not specific to that application.
44
44
  npm install ckb-session-kit
45
45
  ```
46
46
 
47
- [ckb-session-kit on npm](https://www.npmjs.com/package/ckb-session-kit) (v2.1.0, ESM, Node 20 or later
47
+ [ckb-session-kit on npm](https://www.npmjs.com/package/ckb-session-kit) (v2.2.0, ESM, Node 20 or later
48
48
  and browsers). The API is in [docs/API.md](docs/API.md); the walkthrough in
49
49
  [docs/WALKTHROUGH.md](docs/WALKTHROUGH.md).
50
50
 
@@ -514,6 +514,28 @@ await assertSigningLock(ownerLock, owner.client); // the kit's own check; openSe
514
514
  await openSession(owner, session, deployment, { budget });
515
515
  ```
516
516
 
517
+ Owner-mode safety rests on the deployed code of the owner's lock, not only on
518
+ this kit. Every unlocking path of each accepted lock, and what it signs, is
519
+ listed in [docs/design/owner-locks.md](docs/design/owner-locks.md): in the
520
+ accepted configurations every path signs the whole transaction, and the mainnet
521
+ Omnilock build has no CoBuild or open-transaction mode. Two things follow:
522
+
523
+ - **Never sign an open or partial transaction with a lock that owns sessions.**
524
+ Anyone holding that piece could spend your sessions with it.
525
+ - **Omnilock, JoyID and the Nostr lock are deployed under type ids**, which
526
+ their deployers can upgrade. The review covers the code deployed on 5 Nov
527
+ 2026; an upgrade that added a partial-signing mode would also apply to
528
+ sessions that are already open.
529
+
530
+ `openSession` and `openSubscription` read the live code cell of an Omnilock,
531
+ JoyID or Nostr owner lock and compare its data hash with
532
+ `REVIEWED_OWNER_LOCK_BUILDS`, the builds reviewed above (mainnet and testnet).
533
+ A build not in that list is reported as a warning (`"owner-lock-build-unreviewed"`,
534
+ with the observed hash, to `onWarning` or `console.warn`), not refused, so a
535
+ vendor upgrade does not stop sessions from opening; if the code cell cannot be
536
+ read, the warning is `"owner-lock-build-unresolved"`. This covers new sessions
537
+ only: one already open follows the type id.
538
+
517
539
  ## The on-chain session lock
518
540
 
519
541
  [`contracts/session-lock`](contracts/session-lock/src/main.rs) is a CKB lock script in
@@ -614,6 +636,44 @@ session](#which-wallets-can-own-a-session).
614
636
  A session-lock cell occupies 121 CKB (153 CKB with a recipient), so its balance
615
637
  and its change must stay above that.
616
638
 
639
+ ### If the page is compromised
640
+
641
+ The session key is a raw secp256k1 private key, held as a hex string in
642
+ JavaScript memory (`Session.privateKey`) and saved as such in IndexedDB by
643
+ `indexedDbStore` (or kept in memory by `memoryStore`). Web Crypto has no
644
+ secp256k1 keys, so it cannot be held as a non-extractable key. Any script
645
+ running on the same origin (an XSS bug, a compromised dependency, an injected
646
+ script) can read it, copy it off the device, and sign with it. Treat that as a
647
+ leaked key: everything in [If a session key leaks](#if-a-session-key-leaks)
648
+ applies. Such a script can also ask the connected wallet to sign; the wallet
649
+ shows that prompt to the user.
650
+
651
+ What still holds, because the lock enforces it on chain and not the page:
652
+
653
+ - at most `max_per_tx` shannons leave the session per transaction, fees
654
+ included (and, in a token session, at most its token limit);
655
+ - with a recipient, only to that recipient (or to the miner as fee); the key
656
+ cell may not grow;
657
+ - with `min_interval`, once per interval per session cell;
658
+ - the session's balance and the 62–64 CKB key cell are the most it can lose;
659
+ the owner's other cells are out of reach, since owner mode needs the owner's
660
+ own signature;
661
+ - the owner can sweep at any time with `closeSession` or `recoverSessions`.
662
+
663
+ What does not help: browser expiry (`expiresAt`) and clearing the store revoke
664
+ nothing on chain; a copied key stays valid until the owner's sweep is confirmed.
665
+
666
+ What an app should do:
667
+
668
+ - serve a strict Content Security Policy and keep third-party scripts off the
669
+ origin that holds sessions (the demo's headers are in `demo/next.config.ts`);
670
+ - keep budgets small, and set a recipient and `min_interval` where the app has
671
+ one payee;
672
+ - check paid access on the server, from the chain (`verifyAccess`,
673
+ `subscriptionStatus`), never from what the page says, and pass
674
+ `minConfirmations` for anything valuable;
675
+ - sweep at once on any suspicion, and sweep again if a spend raced it.
676
+
617
677
  ### Enforced by the real network
618
678
 
619
679
  Deployed on testnet: code hash `0xab419466…4c08ce` (`data2`, [deploy tx](https://testnet.explorer.nervos.org/transaction/0xf3098bdb9a7bb1c500990be0f1159b7e66f507fef6b7a4f904046024284877f2)),
@@ -716,9 +776,15 @@ What has and has not been shown on testnet is in
716
776
  lost key.
717
777
  - **An owner sweep can be raced.** A key holder can replace a pending sweep by
718
778
  paying a higher fee. Sweep early and sweep again.
719
- - **Paid access is checked at zero confirmations.** `verifyAccess` accepts a
720
- payment once it is committed in a block; a reorg could undo it after the
721
- content was released. Fine for articles and pixels, not for anything costly.
779
+ - **Paid access needs no confirmations unless you ask.** By default
780
+ `verifyAccess`, `verifySubscription` and `subscriptionStatus` accept a payment
781
+ once it is committed in a block; a reorg could undo it after the content was
782
+ released. Fine for articles and pixels. For anything costly, pass
783
+ `minConfirmations` (e.g. `verifyAccess(client, proof, { ...expect,
784
+ minConfirmations: 6 })`): the payment's own block counts as the first
785
+ confirmation, a payment short of the depth comes back as `code:
786
+ "unconfirmed"` (ask again later; it is not unpaid), and one whose block left
787
+ the main chain as `code: "reorged"`. See docs/API.md, "Confirmation depth".
722
788
  - **An access proof does not expire.** It is a bearer token for one article:
723
789
  whoever holds the signed proof can fetch that article again. It cannot be
724
790
  used for another article or by another payment.
@@ -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_FUND_FEE_RATE, DEFAULT_RETRY, deadInputs, findOwnedSessionCells, findSessionCells, fundInSession, keyLockOf, matureCells, MAX_PAYOUT_CELLS, MAX_POOL_CHECKS, onchainRecipient, openSession, pickPayoutCell, poolConflict, recoverSessions, SendOutcomeUnknownError, sendWithSession, SessionBalanceError, sessionLockOf, spendInSession, tokenAmount, } from "./onchain.js";
10
- export type { FundedTransaction, FundOptions, OnChainBinding, RetryOptions, SendWithSessionOptions, 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_FUND_FEE_RATE, DEFAULT_RETRY, deadInputs, findOwnedSessionCells, findSessionCells, fundInSession, keyLockOf, matureCells, MAX_PAYOUT_CELLS, MAX_POOL_CHECKS, onchainRecipient, openSession, pickPayoutCell, poolConflict, recoverSessions, SendOutcomeUnknownError, sendWithSession, 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`.
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) {
package/dist/paywall.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ccc } from "@ckb-ccc/core";
2
2
  import type { Session } from "./session.js";
3
+ import { type ConfirmationFault } from "./confirmations.js";
3
4
  /** The memo for a payment: a short UTF-8 label, e.g. `read:article-1`. */
4
5
  export declare function paymentMemo(label: string): ccc.Hex;
5
6
  /** Whether `lock` runs the standard secp256k1-blake160 sighash_all code (`code`: that script on this network). */
@@ -46,15 +47,24 @@ export type AccessCheck = {
46
47
  } | {
47
48
  ok: false;
48
49
  reason: string;
49
- };
50
+ code?: undefined;
51
+ } | ConfirmationFault;
50
52
  /**
51
53
  * The server's side. `to` is the recipient that must have been paid, as a lock
52
54
  * script; `minAmount` in shannons. With `token` (an xUDT type script), the
53
55
  * payment is counted in that token's units instead, and `minAmount` is in units.
56
+ *
57
+ * With `minConfirmations` (default 0: committed is enough), a payment that is
58
+ * valid in every other way but fewer than that many blocks deep (counting its
59
+ * own block as 1) gives `code: "unconfirmed"` with its `confirmations`, and one
60
+ * whose block left the main chain `code: "reorged"` (src/confirmations.ts).
61
+ * Throws a `RangeError` for a `minConfirmations` that is not an integer from 0
62
+ * to `MAX_MIN_CONFIRMATIONS`.
54
63
  */
55
64
  export declare function verifyAccess(client: ccc.Client, proof: AccessProof, expect: {
56
65
  to: ccc.ScriptLike;
57
66
  minAmount: bigint;
58
67
  memo: ccc.HexLike;
59
68
  token?: ccc.ScriptLike;
69
+ minConfirmations?: number | bigint;
60
70
  }): Promise<AccessCheck>;
package/dist/paywall.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ccc } from "@ckb-ccc/core";
2
2
  import { tokenAmount } from "./onchain.js";
3
3
  import { sessionSigner } from "./transfer.js";
4
+ import { confirmationFault, minConfirmationsFrom } from "./confirmations.js";
4
5
  /**
5
6
  * Paid access, checked against the chain with no database.
6
7
  *
@@ -88,8 +89,16 @@ export async function signAccess(session, txHash, memo) {
88
89
  * The server's side. `to` is the recipient that must have been paid, as a lock
89
90
  * script; `minAmount` in shannons. With `token` (an xUDT type script), the
90
91
  * payment is counted in that token's units instead, and `minAmount` is in units.
92
+ *
93
+ * With `minConfirmations` (default 0: committed is enough), a payment that is
94
+ * valid in every other way but fewer than that many blocks deep (counting its
95
+ * own block as 1) gives `code: "unconfirmed"` with its `confirmations`, and one
96
+ * whose block left the main chain `code: "reorged"` (src/confirmations.ts).
97
+ * Throws a `RangeError` for a `minConfirmations` that is not an integer from 0
98
+ * to `MAX_MIN_CONFIRMATIONS`.
91
99
  */
92
100
  export async function verifyAccess(client, proof, expect) {
101
+ const minConfirmations = minConfirmationsFrom(expect.minConfirmations);
93
102
  if (ccc.hexFrom(proof.memo) !== ccc.hexFrom(expect.memo))
94
103
  return { ok: false, reason: "memo does not match" };
95
104
  // Always the CKB secp256k1 scheme: a sign type chosen by the caller is not trusted.
@@ -171,5 +180,11 @@ export async function verifyAccess(client, proof, expect) {
171
180
  const shown = token ? `${paid} units` : `${ccc.fixedPointToString(paid)} CKB`;
172
181
  return { ok: false, reason: `paid ${shown}, below the price` };
173
182
  }
183
+ // Depth last: "unconfirmed" then means the payment is good, only too recent.
184
+ if (minConfirmations > 0n) {
185
+ const fault = await confirmationFault(client, await client.getTip(), res, minConfirmations, "payment");
186
+ if (fault)
187
+ return fault;
188
+ }
174
189
  return { ok: true, paid };
175
190
  }
@@ -1,6 +1,7 @@
1
1
  import { ccc } from "@ckb-ccc/core";
2
2
  import { type SessionLockDeployment } from "./lock.js";
3
- import { type SigningLockOptions, type RetryOptions } from "./onchain.js";
3
+ import { type SigningLockOptions, type SessionWarning, type RetryOptions } from "./onchain.js";
4
+ import { type ConfirmationFault } from "./confirmations.js";
4
5
  /**
5
6
  * Subscriptions (docs/design/subscriptions.md): the same session lock, with the
6
7
  * roles moved. The subscriber is the owner; the creator's charger key is the
@@ -126,6 +127,12 @@ export declare function openSubscription(subscriber: ccc.Signer, deployment: Ses
126
127
  beforeSend?: (sub: Subscription) => Promise<void>;
127
128
  /** Let an unreviewed charger lock through, with a warning (`allowUnreviewedOwnerLock` covers the subscriber's own lock only). */
128
129
  allowUnreviewedChargerLock?: boolean;
130
+ /**
131
+ * Called before anything is signed with a `SessionWarning` about the
132
+ * subscriber's lock (`"owner-lock-build-unreviewed"` or
133
+ * `"owner-lock-build-unresolved"`); throw to stop. Without it, `console.warn`.
134
+ */
135
+ onWarning?: (warning: SessionWarning) => void | Promise<void>;
129
136
  } & SigningLockOptions): Promise<{
130
137
  txHash: ccc.Hex;
131
138
  subscription: Subscription;
@@ -233,7 +240,8 @@ export type SubscriptionCheck = {
233
240
  } | {
234
241
  ok: false;
235
242
  reason: string;
236
- };
243
+ code?: undefined;
244
+ } | ConfirmationFault;
237
245
  /**
238
246
  * The creator checks a subscription from the chain alone. Nothing in the request
239
247
  * is trusted but the out point. The cell at `outPoint` must be live, under this
@@ -257,6 +265,13 @@ export type SubscriptionCheck = {
257
265
  * type hash, `price` in token units as `max_per_tx_token`); `minBalance` is in token
258
266
  * units, and the payment counted is token units into `payTo`'s cells of the token.
259
267
  * Without `token`, a 160-byte cell is refused, and the reverse.
268
+ *
269
+ * With `minConfirmations` (default 0: committed is enough), the creating
270
+ * transaction must be that many blocks deep, its own block counting as 1: a cell
271
+ * valid in every other way gives `code: "unconfirmed"` until then, and
272
+ * `code: "reorged"` if that block left the main chain (src/confirmations.ts).
273
+ * Throws a `RangeError` for a `minConfirmations` that is not an integer from 0
274
+ * to `MAX_MIN_CONFIRMATIONS`.
260
275
  */
261
276
  export declare function verifySubscription(client: ccc.Client, deployment: SessionLockDeployment, outPoint: ccc.OutPointLike, expected: {
262
277
  charger: string;
@@ -266,6 +281,7 @@ export declare function verifySubscription(client: ccc.Client, deployment: Sessi
266
281
  minBalance?: bigint;
267
282
  account?: string;
268
283
  token?: ccc.ScriptLike;
284
+ minConfirmations?: number | bigint;
269
285
  }): Promise<SubscriptionCheck>;
270
286
  export interface SubscriptionStatus {
271
287
  /**
@@ -283,6 +299,11 @@ export interface SubscriptionStatus {
283
299
  chargesLeft: bigint;
284
300
  /** Blocks until the creator may charge again (0 = now). */
285
301
  nextChargeIn: bigint | undefined;
302
+ /**
303
+ * With `minConfirmations`: the block a charge still short of that depth will
304
+ * pay through once it has it, when that is past `paidThroughBlock`. Not access yet.
305
+ */
306
+ unconfirmedThroughBlock?: bigint;
286
307
  }
287
308
  /**
288
309
  * Where a subscription stands, for a creator's server that keeps no database:
@@ -298,17 +319,27 @@ export interface SubscriptionStatus {
298
319
  * `MAX_SUBSCRIPTION_CELLS` cells (or recent transactions) sit under the lock:
299
320
  * not a verdict on the subscription, so not an `ok: false`. A server should
300
321
  * answer it as a conflict the subscriber can clear by cancelling and opening again.
322
+ *
323
+ * With `minConfirmations` (default 0: committed is enough), only payments that
324
+ * many blocks deep count, their own block counting as 1, and still on the main
325
+ * chain. An opening short of it gives `code: "unconfirmed"` (or `"reorged"`); a
326
+ * charge short of it does not extend `paidThroughBlock` yet, and shows in
327
+ * `unconfirmedThroughBlock`. The tip is read once. Throws a `RangeError` for a
328
+ * `minConfirmations` that is not an integer from 0 to `MAX_MIN_CONFIRMATIONS`.
301
329
  */
302
330
  export declare function subscriptionStatus(client: ccc.Client, deployment: SessionLockDeployment, sub: Subscription, proof: {
303
331
  openTx: ccc.HexLike;
304
332
  account: string;
333
+ }, options?: {
334
+ minConfirmations?: number | bigint;
305
335
  }): Promise<{
306
336
  ok: true;
307
337
  status: SubscriptionStatus;
308
338
  } | {
309
339
  ok: false;
310
340
  reason: string;
311
- }>;
341
+ code?: undefined;
342
+ } | ConfirmationFault>;
312
343
  /**
313
344
  * About how many blocks `seconds` is, from the average block time over the last
314
345
  * `sample` blocks. Block time varies; round up when a period means "no more often than".
@@ -1,8 +1,9 @@
1
1
  import { ccc } from "@ckb-ccc/core";
2
2
  import { relativeBlocksSince, sessionLockCellDep, sessionLockScript, } from "./lock.js";
3
- import { addTopUp, assertPlainXudt, assertSigningLock, checkSigningLock, isAnyoneCanPay, keyCellFloor, ownerSweep, ownOrigin, plainXudtFault, randomPick, completeFeeOrNoFeeCell, rebuildOnDeadInputs, SessionBalanceError, signAndSend, tokenAmount, tokenData, withoutDeadCached, withinU128, } from "./onchain.js";
3
+ import { addTopUp, assertPlainXudt, assertSigningLock, checkSigningLock, isAnyoneCanPay, keyCellFloor, ownerSweep, ownOrigin, plainXudtFault, randomPick, completeFeeOrNoFeeCell, rebuildOnDeadInputs, SessionBalanceError, signAndSend, tokenAmount, tokenData, warnOwnerLockBuild, withoutDeadCached, withinU128, } from "./onchain.js";
4
4
  import { assertCurrentDeployment } from "./deployment.js";
5
5
  import { SessionPolicyError } from "./session.js";
6
+ import { confirmationFault, minConfirmationsFrom } from "./confirmations.js";
6
7
  const tokenOf = (sub) => sub.token && ccc.Script.from(sub.token.type);
7
8
  export function subscriptionLock(deployment, sub) {
8
9
  const token = tokenOf(sub);
@@ -297,6 +298,7 @@ export async function openSubscription(subscriber, deployment, opts) {
297
298
  }
298
299
  const subscriberLock = (await subscriber.getRecommendedAddressObj()).script;
299
300
  await assertSigningLock(subscriberLock, client, opts);
301
+ await warnOwnerLockBuild(subscriberLock, client, opts.onWarning);
300
302
  const [chargerLock, payToLock] = await Promise.all([opts.charger, opts.payTo].map(async (a) => (await ccc.Address.fromString(a, client)).script));
301
303
  // A charger lock that signs nothing would let anyone trigger charges (review M2). Its own
302
304
  // opt-in: opting in for the subscriber's wallet does not waive review of the creator's lock (review W6 I1).
@@ -394,7 +396,9 @@ export async function findSubscriptionCells(client, deployment, sub) {
394
396
  * cell's creating transaction and age (undefined while not committed), so a
395
397
  * charge or a status fetches each of them once (review, 28 Oct).
396
398
  */
397
- async function subscriptionState(client, deployment, sub) {
399
+ async function subscriptionState(client, deployment, sub,
400
+ /** The tip, when the caller already read it (one tip per call). */
401
+ knownTip) {
398
402
  const lock = subscriptionLock(deployment, sub);
399
403
  const token = tokenOf(sub);
400
404
  const cells = await subscriptionCells(client, lock, token, MAX_SUBSCRIPTION_CELLS);
@@ -407,7 +411,7 @@ async function subscriptionState(client, deployment, sub) {
407
411
  byHash.set(h, client.getTransactionNoCache(h)); // the node's answer, not the cache's
408
412
  return byHash.get(h);
409
413
  };
410
- const [tip, created] = await Promise.all([client.getTip(), Promise.all(cells.map((c) => creating(c.outPoint.txHash)))]);
414
+ const [tip, created] = await Promise.all([knownTip ?? client.getTip(), Promise.all(cells.map((c) => creating(c.outPoint.txHash)))]);
411
415
  const ages = created.map((res) => {
412
416
  const born = res?.blockNumber;
413
417
  return born === undefined || born === null ? undefined : tip - ccc.numFrom(born);
@@ -712,8 +716,16 @@ function chargesLeftOf(sub, lock, balance, tokenBalance) {
712
716
  * type hash, `price` in token units as `max_per_tx_token`); `minBalance` is in token
713
717
  * units, and the payment counted is token units into `payTo`'s cells of the token.
714
718
  * Without `token`, a 160-byte cell is refused, and the reverse.
719
+ *
720
+ * With `minConfirmations` (default 0: committed is enough), the creating
721
+ * transaction must be that many blocks deep, its own block counting as 1: a cell
722
+ * valid in every other way gives `code: "unconfirmed"` until then, and
723
+ * `code: "reorged"` if that block left the main chain (src/confirmations.ts).
724
+ * Throws a `RangeError` for a `minConfirmations` that is not an integer from 0
725
+ * to `MAX_MIN_CONFIRMATIONS`.
715
726
  */
716
727
  export async function verifySubscription(client, deployment, outPoint, expected) {
728
+ const minConfirmations = minConfirmationsFrom(expected.minConfirmations);
717
729
  const cell = await client.getCellLive(outPoint, true, false);
718
730
  if (!cell)
719
731
  return { ok: false, reason: "no live cell at that out point" };
@@ -795,6 +807,12 @@ export async function verifySubscription(client, deployment, outPoint, expected)
795
807
  if (expected.account !== undefined && !commitsToAccount(created, sub, expected.account)) {
796
808
  return { ok: false, reason: "the opening does not commit to this account" };
797
809
  }
810
+ // Depth last: "unconfirmed" then means the cell is good, only too recent.
811
+ if (minConfirmations > 0n) {
812
+ const fault = await confirmationFault(client, await client.getTip(), { blockNumber: created.block, blockHash: created.blockHash }, minConfirmations, "the transaction that created the cell");
813
+ if (fault)
814
+ return fault;
815
+ }
798
816
  return {
799
817
  ok: true,
800
818
  subscription: sub,
@@ -864,7 +882,7 @@ fetched) {
864
882
  const paid = out - amount(toPayTo(inputs.map((c) => ({ cellOutput: c.cellOutput, data: c.outputData }))));
865
883
  if (paid < owed)
866
884
  return notPaid;
867
- return { tx, block: ccc.numFrom(created.blockNumber), inputs };
885
+ return { tx, block: ccc.numFrom(created.blockNumber), blockHash: created.blockHash, inputs };
868
886
  }
869
887
  /** Whether `p` carries `subscriptionMemo(account)` in the witness of an input under the subscriber's lock. */
870
888
  function commitsToAccount(p, sub, account) {
@@ -885,8 +903,16 @@ function commitsToAccount(p, sub, account) {
885
903
  * `MAX_SUBSCRIPTION_CELLS` cells (or recent transactions) sit under the lock:
886
904
  * not a verdict on the subscription, so not an `ok: false`. A server should
887
905
  * answer it as a conflict the subscriber can clear by cancelling and opening again.
906
+ *
907
+ * With `minConfirmations` (default 0: committed is enough), only payments that
908
+ * many blocks deep count, their own block counting as 1, and still on the main
909
+ * chain. An opening short of it gives `code: "unconfirmed"` (or `"reorged"`); a
910
+ * charge short of it does not extend `paidThroughBlock` yet, and shows in
911
+ * `unconfirmedThroughBlock`. The tip is read once. Throws a `RangeError` for a
912
+ * `minConfirmations` that is not an integer from 0 to `MAX_MIN_CONFIRMATIONS`.
888
913
  */
889
- export async function subscriptionStatus(client, deployment, sub, proof) {
914
+ export async function subscriptionStatus(client, deployment, sub, proof, options = {}) {
915
+ const minConfirmations = minConfirmationsFrom(options.minConfirmations);
890
916
  try {
891
917
  assertTerms(sub);
892
918
  const token = tokenOf(sub);
@@ -912,7 +938,15 @@ export async function subscriptionStatus(client, deployment, sub, proof) {
912
938
  return { ok: false, reason: "the opening created no cell under this subscription's lock" };
913
939
  if (!commitsToAccount(opening, sub, proof.account))
914
940
  return { ok: false, reason: "the opening does not commit to this account" };
915
- const { cells, balance, tokenBalance, nextChargeIn, tip, created, spent, ownerAfter } = await subscriptionState(client, deployment, sub);
941
+ // One tip for the whole call: the opening's depth, each charge's, and `active`.
942
+ const knownTip = minConfirmations > 0n ? await client.getTip() : undefined;
943
+ const depthFault = (p, what) => knownTip === undefined
944
+ ? Promise.resolve(undefined)
945
+ : confirmationFault(client, knownTip, { blockNumber: p.block, blockHash: p.blockHash }, minConfirmations, what);
946
+ const openingFault = await depthFault(opening, "the opening");
947
+ if (openingFault)
948
+ return openingFault;
949
+ const { cells, balance, tokenBalance, nextChargeIn, tip, created, spent, ownerAfter } = await subscriptionState(client, deployment, sub, knownTip);
916
950
  let paidThroughBlock;
917
951
  const renews = (p, txHash) =>
918
952
  // Only the proven opening or a charge renews; another payment to payTo that
@@ -924,14 +958,27 @@ export async function subscriptionStatus(client, deployment, sub, proof) {
924
958
  if (spent && !ownerAfter && !paying.some((c) => c.txHash === spent.txHash))
925
959
  paying.push({ txHash: spent.txHash, fetched: spent.fetched });
926
960
  const payments = await Promise.all(paying.map((c) => payment(c.txHash, c.fetched)));
961
+ let unconfirmedThroughBlock;
927
962
  for (const [i, { txHash }] of paying.entries()) {
928
963
  const p = payments[i];
929
964
  if (!renews(p, txHash))
930
965
  continue;
931
966
  const through = p.block + sub.periodBlocks;
967
+ // The opening's depth was checked above; a charge's is checked here.
968
+ const fault = txHash === openHash ? undefined : await depthFault(p, "a charge");
969
+ if (fault) {
970
+ // Too recent: it counts later. Reorged: it never does.
971
+ if (fault.code === "unconfirmed" && (unconfirmedThroughBlock === undefined || through > unconfirmedThroughBlock)) {
972
+ unconfirmedThroughBlock = through;
973
+ }
974
+ continue;
975
+ }
932
976
  if (paidThroughBlock === undefined || through > paidThroughBlock)
933
977
  paidThroughBlock = through;
934
978
  }
979
+ if (unconfirmedThroughBlock !== undefined && paidThroughBlock !== undefined && unconfirmedThroughBlock <= paidThroughBlock) {
980
+ unconfirmedThroughBlock = undefined;
981
+ }
935
982
  return {
936
983
  ok: true,
937
984
  status: {
@@ -942,6 +989,7 @@ export async function subscriptionStatus(client, deployment, sub, proof) {
942
989
  ...(sub.token && { tokenBalance }),
943
990
  chargesLeft: chargesLeftOf(sub, lock, balance, tokenBalance),
944
991
  nextChargeIn,
992
+ ...(unconfirmedThroughBlock !== undefined && { unconfirmedThroughBlock }),
945
993
  },
946
994
  };
947
995
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ckb-session-kit",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Browser-held self-custody sessions for CKB applications",
5
5
  "keywords": [
6
6
  "ckb",