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 +61 -0
- package/README.md +70 -4
- package/dist/confirmations.d.ts +46 -0
- package/dist/confirmations.js +58 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/onchain.d.ts +43 -1
- package/dist/onchain.js +125 -0
- package/dist/paywall.d.ts +11 -1
- package/dist/paywall.js +15 -0
- package/dist/subscription.d.ts +34 -3
- package/dist/subscription.js +54 -6
- package/package.json +1 -1
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.
|
|
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
|
|
720
|
-
|
|
721
|
-
|
|
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
|
}
|
package/dist/subscription.d.ts
CHANGED
|
@@ -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".
|
package/dist/subscription.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|