ckb-session-kit 0.0.0-stage → 1.0.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/store.js ADDED
@@ -0,0 +1,181 @@
1
+ export class SessionStoreConflict extends Error {
2
+ constructor() {
3
+ super("another session is already stored here (opened in another tab?); reload the page");
4
+ this.name = "SessionStoreConflict";
5
+ }
6
+ }
7
+ const UNREADABLE = "the stored session is unreadable; it may still hold a funded key, so it is never overwritten";
8
+ /** The stored value's public key, without parsing the rest. */
9
+ function storedKey(raw) {
10
+ if (raw === undefined)
11
+ return undefined;
12
+ let key;
13
+ try {
14
+ key = JSON.parse(raw).publicKey;
15
+ }
16
+ catch {
17
+ throw new Error(UNREADABLE);
18
+ }
19
+ if (typeof key !== "string")
20
+ throw new Error(UNREADABLE);
21
+ return key;
22
+ }
23
+ export function serializeSession(s) {
24
+ const stored = {
25
+ v: 1,
26
+ privateKey: s.privateKey,
27
+ publicKey: s.publicKey,
28
+ expiresAt: s.policy.expiresAt.toISOString(),
29
+ maxPerTx: s.policy.scope.maxPerTx.toString(),
30
+ ...(s.policy.scope.recipients && { recipients: s.policy.scope.recipients }),
31
+ ...(s.onchain && {
32
+ onchain: {
33
+ ownerLock: s.onchain.ownerLock,
34
+ minInterval: s.onchain.minInterval.toString(),
35
+ ...(s.onchain.recipientLockHash && { recipientLockHash: s.onchain.recipientLockHash }),
36
+ ...(s.onchain.token && {
37
+ token: { type: s.onchain.token.type, maxPerTx: s.onchain.token.maxPerTx.toString() },
38
+ }),
39
+ ...(s.onchain.openTx && { openTx: s.onchain.openTx }),
40
+ },
41
+ }),
42
+ };
43
+ return JSON.stringify(stored);
44
+ }
45
+ export function deserializeSession(json) {
46
+ let d;
47
+ try {
48
+ d = JSON.parse(json);
49
+ }
50
+ catch {
51
+ throw new Error(UNREADABLE);
52
+ }
53
+ if (d.v !== 1)
54
+ throw new Error(`unsupported stored session version: ${String(d.v)}`);
55
+ return {
56
+ privateKey: d.privateKey,
57
+ publicKey: d.publicKey,
58
+ policy: {
59
+ expiresAt: new Date(d.expiresAt),
60
+ scope: {
61
+ maxPerTx: BigInt(d.maxPerTx),
62
+ ...(d.recipients && { recipients: d.recipients }),
63
+ },
64
+ },
65
+ ...(d.onchain && {
66
+ onchain: {
67
+ ownerLock: {
68
+ codeHash: d.onchain.ownerLock.codeHash,
69
+ hashType: d.onchain.ownerLock.hashType,
70
+ args: d.onchain.ownerLock.args,
71
+ },
72
+ minInterval: BigInt(d.onchain.minInterval),
73
+ ...(d.onchain.recipientLockHash && { recipientLockHash: d.onchain.recipientLockHash }),
74
+ ...(d.onchain.token && {
75
+ token: {
76
+ type: {
77
+ codeHash: d.onchain.token.type.codeHash,
78
+ hashType: d.onchain.token.type.hashType,
79
+ args: d.onchain.token.type.args,
80
+ },
81
+ maxPerTx: BigInt(d.onchain.token.maxPerTx),
82
+ },
83
+ }),
84
+ ...(d.onchain.openTx && { openTx: d.onchain.openTx }),
85
+ },
86
+ }),
87
+ };
88
+ }
89
+ export function memoryStore() {
90
+ let value;
91
+ return {
92
+ load: async () => (value === undefined ? undefined : deserializeSession(value)),
93
+ save: async (s) => {
94
+ const held = storedKey(value);
95
+ if (held !== undefined && held !== s.publicKey)
96
+ throw new SessionStoreConflict();
97
+ value = serializeSession(s);
98
+ },
99
+ clear: async (s) => {
100
+ if (storedKey(value) === s.publicKey)
101
+ value = undefined;
102
+ },
103
+ };
104
+ }
105
+ const DB = "ckb-session-kit";
106
+ const STORE = "sessions";
107
+ const KEY = "current";
108
+ function request(r) {
109
+ return new Promise((resolve, reject) => {
110
+ r.onsuccess = () => resolve(r.result);
111
+ r.onerror = () => reject(r.error);
112
+ });
113
+ }
114
+ async function open(idb) {
115
+ const r = idb.open(DB, 1);
116
+ r.onupgradeneeded = () => r.result.createObjectStore(STORE);
117
+ return request(r);
118
+ }
119
+ /**
120
+ * One session per `key`. Pages on the same origin that each run their own
121
+ * session (a wallet page and a reader page, say) pass different keys.
122
+ */
123
+ export function indexedDbStore(idb = globalThis.indexedDB, key = KEY) {
124
+ // Read and write in one readwrite transaction, so two tabs cannot interleave.
125
+ // It resolves only once the transaction has committed, durably: a save is what
126
+ // makes it safe to send funds to the key.
127
+ const update = async (decide, value) => {
128
+ const db = await open(idb);
129
+ try {
130
+ const tx = db.transaction(STORE, "readwrite", { durability: "strict" });
131
+ const done = new Promise((resolve, reject) => {
132
+ tx.oncomplete = () => resolve();
133
+ tx.onabort = () => reject(tx.error ?? new Error("session store transaction aborted"));
134
+ tx.onerror = () => reject(tx.error ?? new Error("session store transaction failed"));
135
+ });
136
+ done.catch(() => { }); // awaited below; a decide() error aborts it first
137
+ const store = tx.objectStore(STORE);
138
+ const raw = await request(store.get(key));
139
+ let action;
140
+ try {
141
+ action = decide(raw);
142
+ }
143
+ catch (e) {
144
+ tx.abort();
145
+ throw e;
146
+ }
147
+ if (action === "put")
148
+ await request(store.put(value, key));
149
+ if (action === "delete")
150
+ await request(store.delete(key));
151
+ await done;
152
+ }
153
+ finally {
154
+ db.close();
155
+ }
156
+ };
157
+ return {
158
+ load: async () => {
159
+ const db = await open(idb);
160
+ try {
161
+ const raw = await request(db.transaction(STORE, "readonly").objectStore(STORE).get(key));
162
+ return raw === undefined ? undefined : deserializeSession(raw);
163
+ }
164
+ finally {
165
+ db.close();
166
+ }
167
+ },
168
+ save: async (session) => {
169
+ const value = serializeSession(session);
170
+ await update((raw) => {
171
+ const held = storedKey(raw);
172
+ if (held !== undefined && held !== session.publicKey)
173
+ throw new SessionStoreConflict();
174
+ return "put";
175
+ }, value);
176
+ },
177
+ clear: async (session) => {
178
+ await update((raw) => (storedKey(raw) === session.publicKey ? "delete" : "keep"));
179
+ },
180
+ };
181
+ }
@@ -0,0 +1,221 @@
1
+ import { ccc } from "@ckb-ccc/core";
2
+ import { type SessionLockDeployment } from "./lock.js";
3
+ /**
4
+ * Subscriptions (docs/design/subscriptions.md): the same session lock, with the
5
+ * roles moved. The subscriber is the owner; the creator's charger key is the
6
+ * session key; `max_per_tx` is the price, `min_interval` the period, and the
7
+ * recipient the creator's payout address. So the network lets the charger take
8
+ * at most the price, at most once per period, only to the payout address, and
9
+ * the subscriber can sweep everything back alone at any time.
10
+ *
11
+ * - `openSubscription` — the subscriber funds one cell. One signature.
12
+ * - `chargeSubscription` — the creator's charger key takes the price, paying the
13
+ * fee from its own cell. No subscriber involved.
14
+ * - `cancelSubscription` — the subscriber sweeps the cell back. One signature.
15
+ * - `verifySubscription` — the creator checks a subscription it was told about.
16
+ *
17
+ * A `Subscription` holds no private key: both sides can store it as it is.
18
+ *
19
+ * A token subscription (`token` set) is paid in an xUDT token instead: the
20
+ * 160-byte token layout with `max_per_tx` 0, so no CKB ever leaves the cell, and
21
+ * the price as `max_per_tx_token`. Charges top up the payout address's
22
+ * anyone-can-pay cell of that token (docs/design/subscriptions.md, "Token subscriptions").
23
+ */
24
+ export interface Subscription {
25
+ subscriberLockHash: ccc.Hex;
26
+ chargerLockHash: ccc.Hex;
27
+ payToLockHash: ccc.Hex;
28
+ /** Per charge, in the subscription's asset: shannons, or token units in a token subscription. */
29
+ price: bigint;
30
+ /** Blocks between two charges of the same cell. */
31
+ periodBlocks: bigint;
32
+ /** A token subscription: the plain xUDT type script it is paid in. */
33
+ token?: {
34
+ type: {
35
+ codeHash: ccc.Hex;
36
+ hashType: ccc.HashType;
37
+ args: ccc.Hex;
38
+ };
39
+ };
40
+ /** The funding transaction, when this side opened it. */
41
+ openTx?: ccc.Hex;
42
+ }
43
+ export declare function subscriptionLock(deployment: SessionLockDeployment, sub: Subscription): ccc.Script;
44
+ /**
45
+ * The memo an opening commits to, binding the subscription to one account at the
46
+ * creator: a hash, so the account id never appears on-chain and can double as a
47
+ * secret the subscriber's browser presents to the creator.
48
+ */
49
+ export declare function subscriptionMemo(account: string): ccc.Hex;
50
+ /**
51
+ * The subscriber opens a subscription, in one transaction and one signature: the
52
+ * first period's price goes to `payTo` at once (a new cell cannot be charged for a
53
+ * whole period, so otherwise the first period would be free; review H1), and one
54
+ * cell holding `budget` shannons goes under the session lock. No key cell: the
55
+ * creator already owns cells under its charger key. `budget` of the cell's own
56
+ * size (153 CKB) plus n prices pays n more periods; what is left comes back on
57
+ * cancel. With a price of 153 CKB or more, the last 153 can be charged too.
58
+ *
59
+ * `account` (the subscriber's id at the creator) is committed in the witness of
60
+ * the subscriber's own input, which the subscriber's signature covers; the
61
+ * creator checks it with `verifySubscription`, so nobody else can present this
62
+ * subscription as theirs (review H3).
63
+ *
64
+ * `payTo` should be an anyone-can-pay address with a live cell, so a charge of
65
+ * any size tops it up; otherwise each charge creates a new cell and the price must
66
+ * be at least that cell's size (61 CKB for a plain address). Keep a plain cell in
67
+ * the wallet: cancelling needs one to prove ownership.
68
+ *
69
+ * With `token`, the subscription is paid in that plain xUDT and `price` is in
70
+ * token units: the cell holds `token.amount` units plus its own size in CKB
71
+ * (282 CKB; `budget` may add more, which stays until cancel), and the first price
72
+ * tops up `payTo`'s cell of the token. `payTo` must then be anyone-can-pay and
73
+ * already hold a cell of the token: no CKB may leave a token subscription, so a
74
+ * charge cannot pay for a new token cell.
75
+ */
76
+ export declare function openSubscription(subscriber: ccc.Signer, deployment: SessionLockDeployment, opts: {
77
+ charger: string;
78
+ payTo: string;
79
+ price: bigint;
80
+ periodBlocks: bigint;
81
+ /** Shannons in the subscription cell. Required without `token`; with one, defaults to the cell's own size. */
82
+ budget?: bigint;
83
+ /** A token subscription: the plain xUDT it is paid in, and the units put in the cell (beyond the first price). */
84
+ token?: {
85
+ type: ccc.ScriptLike;
86
+ amount: bigint;
87
+ };
88
+ account?: string;
89
+ beforeSend?: (sub: Subscription) => Promise<void>;
90
+ }): Promise<{
91
+ txHash: ccc.Hex;
92
+ subscription: Subscription;
93
+ }>;
94
+ /**
95
+ * The live cells of a subscription, their balance (shannons) and token balance
96
+ * (units; 0 without a token), and how many blocks until one can be charged.
97
+ */
98
+ export declare function findSubscriptionCells(client: ccc.Client, deployment: SessionLockDeployment, sub: Subscription): Promise<{
99
+ lock: ccc.Script;
100
+ cells: ccc.Cell[];
101
+ balance: bigint;
102
+ tokenBalance: bigint;
103
+ nextChargeIn: bigint | undefined;
104
+ }>;
105
+ /**
106
+ * The creator's charger key takes one price from the subscription and pays it to
107
+ * `payTo` (topping up its anyone-can-pay cell with `topUp`). The fee comes from
108
+ * one of the charger's own plain cells, which goes back to it smaller. Throws a
109
+ * `SessionBalanceError` while no cell is a full period old, or once what is left
110
+ * cannot cover a price and still leave a cell behind.
111
+ *
112
+ * In a token subscription the price is in token units and always tops up
113
+ * `payTo`'s cell of the token (`topUp` is implied). The change keeps every
114
+ * shannon that came in (no CKB may leave) and the tokens less one price, in
115
+ * exactly 16 data bytes.
116
+ */
117
+ export declare function chargeSubscription(charger: ccc.Signer, deployment: SessionLockDeployment, sub: Subscription, opts: {
118
+ payTo: string;
119
+ topUp?: boolean;
120
+ beforeSend?: (txHash: ccc.Hex) => Promise<void>;
121
+ }): Promise<ccc.Hex>;
122
+ /**
123
+ * The subscriber takes everything left back, in owner mode: no creator involved
124
+ * and no wait. `nothingFound` means no cell is left (already cancelled, or run dry).
125
+ * In a token subscription the tokens come back as one token cell (`refundedTokens`).
126
+ * Subscriptions are also found and swept by `recoverSessions`, since their args
127
+ * begin with the subscriber's lock hash like any session's.
128
+ */
129
+ export declare function cancelSubscription(subscriber: ccc.Signer, deployment: SessionLockDeployment, sub: Subscription): Promise<{
130
+ txHash?: ccc.Hex;
131
+ refunded: bigint;
132
+ refundedTokens?: bigint;
133
+ nothingFound?: true;
134
+ }>;
135
+ export type SubscriptionCheck = {
136
+ ok: true;
137
+ subscription: Subscription;
138
+ /** Shannons in the cell. */
139
+ balance: bigint;
140
+ /** Token units in the cell (token subscriptions only). */
141
+ tokenBalance?: bigint;
142
+ /** Charges the cell can still pay. */
143
+ chargesLeft: bigint;
144
+ /** The block until which the subscriber has paid: the block that created this cell, plus one period. */
145
+ paidThroughBlock: bigint;
146
+ } | {
147
+ ok: false;
148
+ reason: string;
149
+ };
150
+ /**
151
+ * The creator checks a subscription from the chain alone. Nothing in the request
152
+ * is trusted but the out point. The cell at `outPoint` must be live, under this
153
+ * deployment of the session lock (never an older one), untyped and without data,
154
+ * with 112-byte args holding exactly these terms, and at least `minBalance`.
155
+ *
156
+ * And the transaction that created the cell must have paid `payTo` at least one
157
+ * price: the opening pays the first period, every charge pays one. A cell the
158
+ * subscriber re-created in owner mode to reset its age paid nothing, and is
159
+ * refused (review H2). So access is granted up to `paidThroughBlock`; check the
160
+ * current cell again (`findSubscriptionCells`) once that block has passed.
161
+ *
162
+ * With `account`, the creating transaction must also carry `subscriptionMemo(account)`
163
+ * in the witness of an input under the subscriber's lock, which only the
164
+ * subscriber's signature covers: someone else's subscription cannot be claimed
165
+ * (review H3). The memo is in the opening only, so check `account` once, when a
166
+ * subscription is registered, then keep the subscription by its lock.
167
+ *
168
+ * With `token` (a token subscription), the cell must instead be typed with exactly
169
+ * that token and hold its amount, with 160-byte args (`max_per_tx` 0, the token's
170
+ * type hash, `price` in token units as `max_per_tx_token`); `minBalance` is in token
171
+ * units, and the payment counted is token units into `payTo`'s cells of the token.
172
+ * Without `token`, a 160-byte cell is refused, and the reverse.
173
+ */
174
+ export declare function verifySubscription(client: ccc.Client, deployment: SessionLockDeployment, outPoint: ccc.OutPointLike, expected: {
175
+ charger: string;
176
+ payTo: string;
177
+ price: bigint;
178
+ periodBlocks: bigint;
179
+ minBalance?: bigint;
180
+ account?: string;
181
+ token?: ccc.ScriptLike;
182
+ }): Promise<SubscriptionCheck>;
183
+ export interface SubscriptionStatus {
184
+ /** The block until which the subscriber has paid, from the newest paying cell; undefined when no cell is left. */
185
+ paidThroughBlock: bigint | undefined;
186
+ tip: bigint;
187
+ /** Whether the tip is within the paid period. */
188
+ active: boolean;
189
+ /** Shannons in the subscription's cells. */
190
+ balance: bigint;
191
+ /** Token units in the subscription's cells (token subscriptions only). */
192
+ tokenBalance?: bigint;
193
+ chargesLeft: bigint;
194
+ /** Blocks until the creator may charge again (0 = now). */
195
+ nextChargeIn: bigint | undefined;
196
+ }
197
+ /**
198
+ * Where a subscription stands, for a creator's server that keeps no database:
199
+ * the subscriber sends the terms' subscriber lock hash, the opening's hash and
200
+ * its account id on every request. The opening must have paid the first price
201
+ * into a cell under exactly this subscription's lock and committed to `account`
202
+ * under the subscriber's signature; then the live cells give the period paid for.
203
+ * Each must have been created by a payment, so a cell re-created by the
204
+ * subscriber to reset its age counts for nothing. Cancelling ends access at once.
205
+ * A token subscription (`sub.token`) counts payments in its token, as `verifySubscription` does.
206
+ */
207
+ export declare function subscriptionStatus(client: ccc.Client, deployment: SessionLockDeployment, sub: Subscription, proof: {
208
+ openTx: ccc.HexLike;
209
+ account: string;
210
+ }): Promise<{
211
+ ok: true;
212
+ status: SubscriptionStatus;
213
+ } | {
214
+ ok: false;
215
+ reason: string;
216
+ }>;
217
+ /**
218
+ * About how many blocks `seconds` is, from the average block time over the last
219
+ * `sample` blocks. Block time varies; round up when a period means "no more often than".
220
+ */
221
+ export declare function blocksForSeconds(client: ccc.Client, seconds: number, sample?: bigint): Promise<bigint>;