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/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
  }
package/dist/session.d.ts CHANGED
@@ -50,9 +50,14 @@ export interface CreateOptions {
50
50
  * Why a policy error is not about the request itself: "payto-not-anyone-can-pay",
51
51
  * the payer's own setup (a charge can never top such a `payTo` up); or
52
52
  * "too-many-cells", more cells under a subscription's lock than a status or a
53
- * charge reads (`MAX_SUBSCRIPTION_CELLS`).
53
+ * charge reads (`MAX_SUBSCRIPTION_CELLS`). From `fundInSession`:
54
+ * "session-has-recipient", the session has an on-chain recipient, so it cannot
55
+ * fund outputs to other locks; "owner-lock-input", the app transaction spends a
56
+ * cell under the owner's lock, which would switch the session lock to owner
57
+ * mode; "app-tx-shape", the app transaction already holds cells only the kit
58
+ * may add (session or key cells), or its inputs exceed its outputs.
54
59
  */
55
- export type SessionPolicyReason = "payto-not-anyone-can-pay" | "too-many-cells" | "owner-lock-unsafe" | "owner-lock-unreviewed";
60
+ export type SessionPolicyReason = "payto-not-anyone-can-pay" | "too-many-cells" | "owner-lock-unsafe" | "owner-lock-unreviewed" | "session-has-recipient" | "owner-lock-input" | "app-tx-shape";
56
61
  export declare class SessionPolicyError extends Error {
57
62
  readonly reason?: SessionPolicyReason;
58
63
  constructor(message: string, reason?: SessionPolicyReason);
@@ -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.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Browser-held self-custody sessions for CKB applications",
5
5
  "keywords": [
6
6
  "ckb",
@@ -57,6 +57,8 @@
57
57
  "smoke:contention": "tsx scripts/testnet-contention.ts",
58
58
  "smoke:clear-planted": "tsx scripts/testnet-clear-planted.ts",
59
59
  "smoke:owner-locks": "tsx scripts/testnet-owner-locks.ts",
60
+ "smoke:fund-pledge": "tsx scripts/testnet-fund-pledge.ts",
61
+ "smoke:pool-lag": "tsx scripts/testnet-pool-lag.ts",
60
62
  "matrix:wallets": "tsx scripts/wallet-matrix.ts",
61
63
  "prepack": "npm run build",
62
64
  "pack:check": "node scripts/pack-check.mjs"