ckb-session-kit 1.0.0 → 2.1.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 +331 -0
- package/README.md +797 -558
- package/dist/deployment.d.ts +21 -0
- package/dist/deployment.js +92 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -2
- package/dist/onchain.d.ts +356 -30
- package/dist/onchain.js +855 -86
- package/dist/paywall.d.ts +29 -0
- package/dist/paywall.js +84 -25
- package/dist/place.d.ts +18 -9
- package/dist/place.js +32 -28
- package/dist/session.d.ts +14 -1
- package/dist/session.js +4 -1
- package/dist/store.d.ts +2 -1
- package/dist/store.js +17 -6
- package/dist/subscription.d.ts +103 -7
- package/dist/subscription.js +456 -119
- package/package.json +15 -3
package/dist/paywall.d.ts
CHANGED
|
@@ -2,6 +2,35 @@ import { ccc } from "@ckb-ccc/core";
|
|
|
2
2
|
import type { Session } from "./session.js";
|
|
3
3
|
/** The memo for a payment: a short UTF-8 label, e.g. `read:article-1`. */
|
|
4
4
|
export declare function paymentMemo(label: string): ccc.Hex;
|
|
5
|
+
/** Whether `lock` runs the standard secp256k1-blake160 sighash_all code (`code`: that script on this network). */
|
|
6
|
+
export declare const isSecp256k1Lock: (lock: ccc.Script, code?: {
|
|
7
|
+
codeHash: ccc.Hex;
|
|
8
|
+
hashType: ccc.HashType;
|
|
9
|
+
}) => boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Which witnesses of `tx` a signature covers, given the locks of its inputs in
|
|
12
|
+
* order: the first witness of each input lock group whose lock signs (`signs`),
|
|
13
|
+
* and every witness past the inputs, which a sighash_all signature also covers.
|
|
14
|
+
*
|
|
15
|
+
* Any other witness can be changed by whoever relays the transaction without
|
|
16
|
+
* changing its hash: a session cell's (the session lock reads no witness) or an
|
|
17
|
+
* anyone-can-pay cell's taking a top-up (audit, 30 Oct). A memo there is nobody's.
|
|
18
|
+
*/
|
|
19
|
+
export declare function signedWitnesses(tx: ccc.Transaction, inputLocks: ccc.ScriptLike[], signs: (lock: ccc.Script) => boolean): (index: number) => boolean;
|
|
20
|
+
/** Every witness carrying a memo (an `inputType`), with its index. */
|
|
21
|
+
export declare function memoWitnesses(tx: ccc.Transaction): {
|
|
22
|
+
index: number;
|
|
23
|
+
memo: ccc.Hex;
|
|
24
|
+
}[];
|
|
25
|
+
/**
|
|
26
|
+
* The one witness carrying a memo and its index; undefined with none or several.
|
|
27
|
+
* Only witnesses `counts` accepts are looked at (`signedWitnesses`: those a
|
|
28
|
+
* signature covers); by default, all of them.
|
|
29
|
+
*/
|
|
30
|
+
export declare function soleMemo(tx: ccc.Transaction, counts?: (index: number) => boolean): {
|
|
31
|
+
index: number;
|
|
32
|
+
memo: ccc.Hex;
|
|
33
|
+
} | undefined;
|
|
5
34
|
export interface AccessProof {
|
|
6
35
|
txHash: ccc.Hex;
|
|
7
36
|
memo: ccc.Hex;
|
package/dist/paywall.js
CHANGED
|
@@ -14,9 +14,11 @@ import { sessionSigner } from "./transfer.js";
|
|
|
14
14
|
* signed by the key that paid. Anyone can see the payment on-chain; only the
|
|
15
15
|
* payer can use it.
|
|
16
16
|
*
|
|
17
|
-
* One payment, one claim: a payment transaction may carry exactly one memo
|
|
18
|
-
* Otherwise one transaction could pay the
|
|
19
|
-
* several keys, each claiming the full amount
|
|
17
|
+
* One payment, one claim: a payment transaction may carry exactly one memo in
|
|
18
|
+
* the witnesses a signature covers. Otherwise one transaction could pay the
|
|
19
|
+
* creator once and carry memos from several keys, each claiming the full amount
|
|
20
|
+
* for a different article or reader. A memo in a witness nobody signed (the
|
|
21
|
+
* session cell's) does not count: whoever relays the payment could add one.
|
|
20
22
|
*/
|
|
21
23
|
/** A session payment has a handful of inputs; more is not one, and costs RPC calls to check. */
|
|
22
24
|
const MAX_PAYMENT_INPUTS = 16;
|
|
@@ -24,6 +26,55 @@ const MAX_PAYMENT_INPUTS = 16;
|
|
|
24
26
|
export function paymentMemo(label) {
|
|
25
27
|
return ccc.hexFrom(ccc.bytesFrom(label, "utf8"));
|
|
26
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* The standard secp256k1-blake160 sighash_all lock, the session key's own lock: the
|
|
31
|
+
* same code on mainnet and testnet. Its signature covers the first witness of its
|
|
32
|
+
* lock group (and the rest of the group, and every witness past the inputs).
|
|
33
|
+
*/
|
|
34
|
+
const SECP256K1_BLAKE160 = {
|
|
35
|
+
codeHash: "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8",
|
|
36
|
+
hashType: "type",
|
|
37
|
+
};
|
|
38
|
+
/** Whether `lock` runs the standard secp256k1-blake160 sighash_all code (`code`: that script on this network). */
|
|
39
|
+
export const isSecp256k1Lock = (lock, code = SECP256K1_BLAKE160) => lock.codeHash === code.codeHash && lock.hashType === code.hashType;
|
|
40
|
+
/**
|
|
41
|
+
* Which witnesses of `tx` a signature covers, given the locks of its inputs in
|
|
42
|
+
* order: the first witness of each input lock group whose lock signs (`signs`),
|
|
43
|
+
* and every witness past the inputs, which a sighash_all signature also covers.
|
|
44
|
+
*
|
|
45
|
+
* Any other witness can be changed by whoever relays the transaction without
|
|
46
|
+
* changing its hash: a session cell's (the session lock reads no witness) or an
|
|
47
|
+
* anyone-can-pay cell's taking a top-up (audit, 30 Oct). A memo there is nobody's.
|
|
48
|
+
*/
|
|
49
|
+
export function signedWitnesses(tx, inputLocks, signs) {
|
|
50
|
+
const locks = inputLocks.map((l) => ccc.Script.from(l));
|
|
51
|
+
return (i) => i >= tx.inputs.length || (i < locks.length && signs(locks[i]) && !locks.slice(0, i).some((l) => l.eq(locks[i])));
|
|
52
|
+
}
|
|
53
|
+
/** Every witness carrying a memo (an `inputType`), with its index. */
|
|
54
|
+
export function memoWitnesses(tx) {
|
|
55
|
+
const found = [];
|
|
56
|
+
for (let i = 0; i < tx.witnesses.length; i++) {
|
|
57
|
+
let inputType;
|
|
58
|
+
try {
|
|
59
|
+
inputType = tx.getWitnessArgsAt(i)?.inputType;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
continue; // not WitnessArgs: carries no memo
|
|
63
|
+
}
|
|
64
|
+
if (inputType !== undefined)
|
|
65
|
+
found.push({ index: i, memo: inputType });
|
|
66
|
+
}
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The one witness carrying a memo and its index; undefined with none or several.
|
|
71
|
+
* Only witnesses `counts` accepts are looked at (`signedWitnesses`: those a
|
|
72
|
+
* signature covers); by default, all of them.
|
|
73
|
+
*/
|
|
74
|
+
export function soleMemo(tx, counts = () => true) {
|
|
75
|
+
const found = memoWitnesses(tx).filter((m) => counts(m.index));
|
|
76
|
+
return found.length === 1 ? found[0] : undefined;
|
|
77
|
+
}
|
|
27
78
|
function accessMessage(txHash, memo) {
|
|
28
79
|
return `ckb-session-kit access ${ccc.hexFrom(txHash)} ${ccc.hexFrom(memo)}`;
|
|
29
80
|
}
|
|
@@ -51,7 +102,10 @@ export async function verifyAccess(client, proof, expect) {
|
|
|
51
102
|
}
|
|
52
103
|
if (!signed)
|
|
53
104
|
return { ok: false, reason: "request not signed by the given key" };
|
|
54
|
-
|
|
105
|
+
// Not getTransaction: when the node no longer knows a transaction (reorged out,
|
|
106
|
+
// replaced by a conflicting spend), it answers from CCC's cache, where a payment
|
|
107
|
+
// once seen committed stays committed (audit, 31 Oct).
|
|
108
|
+
const res = await client.getTransactionNoCache(proof.txHash);
|
|
55
109
|
if (!res)
|
|
56
110
|
return { ok: false, reason: "payment not found" };
|
|
57
111
|
if (res.status !== "committed")
|
|
@@ -60,39 +114,38 @@ export async function verifyAccess(client, proof, expect) {
|
|
|
60
114
|
// Cheap checks first, before resolving any input over RPC.
|
|
61
115
|
if (tx.inputs.length > MAX_PAYMENT_INPUTS)
|
|
62
116
|
return { ok: false, reason: "not a session payment: too many inputs" };
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
for (let i = 0; i < tx.witnesses.length; i++) {
|
|
66
|
-
let inputType;
|
|
67
|
-
try {
|
|
68
|
-
inputType = tx.getWitnessArgsAt(i)?.inputType;
|
|
69
|
-
}
|
|
70
|
-
catch {
|
|
71
|
-
continue; // not WitnessArgs: carries no memo
|
|
72
|
-
}
|
|
73
|
-
if (inputType !== undefined) {
|
|
74
|
-
memos++;
|
|
75
|
-
memoAt = i;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
if (memos !== 1)
|
|
117
|
+
const memos = memoWitnesses(tx);
|
|
118
|
+
if (!memos.length)
|
|
79
119
|
return { ok: false, reason: "a payment must carry exactly one memo" };
|
|
80
120
|
// The payer's lock: the standard secp256k1 lock of the key that signed the request.
|
|
81
121
|
const payerLock = (await new ccc.SignerCkbPublicKey(client, proof.publicKey).getAddressObjSecp256k1()).script;
|
|
82
|
-
//
|
|
122
|
+
// Before all of them, only the inputs whose witness carries a memo, until one is
|
|
123
|
+
// the payer's (usually the first): anyone can sign a request for someone else's
|
|
83
124
|
// public payment, and must not get to make the server resolve every input.
|
|
84
|
-
const
|
|
85
|
-
|
|
125
|
+
const known = new Map();
|
|
126
|
+
let paidByKey = false;
|
|
127
|
+
for (const { index } of memos) {
|
|
128
|
+
if (index >= tx.inputs.length)
|
|
129
|
+
continue;
|
|
130
|
+
const cell = await client.getCell(tx.inputs[index].previousOutput);
|
|
131
|
+
known.set(index, cell);
|
|
132
|
+
if (cell?.cellOutput.lock.eq(payerLock)) {
|
|
133
|
+
paidByKey = true;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!paidByKey)
|
|
86
138
|
return { ok: false, reason: "the signing key did not pay this transaction" };
|
|
87
139
|
const to = ccc.Script.from(expect.to);
|
|
88
140
|
const token = expect.token && ccc.Script.from(expect.token);
|
|
89
141
|
// What a cell gives the recipient: its capacity, or with a token its units of that exact token
|
|
90
142
|
// (a committed xUDT cell always holds the 16-byte amount).
|
|
91
143
|
const value = (o, data) => !o.lock.eq(to) ? 0n : !token ? o.capacity : o.type?.eq(token) ? tokenAmount(data) : 0n;
|
|
144
|
+
// The rest at once, reusing those already looked up.
|
|
145
|
+
const inputs = await Promise.all(tx.inputs.map((input, i) => (known.has(i) ? known.get(i) : client.getCell(input.previousOutput))));
|
|
92
146
|
let payerInput = -1;
|
|
93
147
|
let toIn = 0n;
|
|
94
|
-
for (
|
|
95
|
-
const cell = await client.getCell(tx.inputs[i].previousOutput);
|
|
148
|
+
for (const [i, cell] of inputs.entries()) {
|
|
96
149
|
if (!cell)
|
|
97
150
|
return { ok: false, reason: "an input of the payment cannot be resolved" };
|
|
98
151
|
if (payerInput < 0 && cell.cellOutput.lock.eq(payerLock))
|
|
@@ -101,6 +154,12 @@ export async function verifyAccess(client, proof, expect) {
|
|
|
101
154
|
}
|
|
102
155
|
if (payerInput < 0)
|
|
103
156
|
return { ok: false, reason: "the signing key did not pay this transaction" };
|
|
157
|
+
// One memo among the witnesses a signature covers. A memo in a witness nobody
|
|
158
|
+
// signed (the session cell's, say) was not put there by the payer and cannot
|
|
159
|
+
// void the payment; several signed ones could each claim it (audit, 30 Oct).
|
|
160
|
+
const covered = signedWitnesses(tx, inputs.map((c) => c.cellOutput.lock), (lock) => isSecp256k1Lock(lock, payerLock));
|
|
161
|
+
if (!soleMemo(tx, covered))
|
|
162
|
+
return { ok: false, reason: "a payment must carry exactly one memo" };
|
|
104
163
|
// The witness the payer's lock verified (the first of its group) must carry the memo.
|
|
105
164
|
const witness = tx.getWitnessArgsAt(payerInput);
|
|
106
165
|
if (!witness?.inputType || ccc.hexFrom(witness.inputType) !== ccc.hexFrom(expect.memo)) {
|
package/dist/place.d.ts
CHANGED
|
@@ -51,11 +51,20 @@ export declare function decodePlacement(memo: ccc.HexLike, board?: PlaceBoard):
|
|
|
51
51
|
/** What a placement costs, in shannons. */
|
|
52
52
|
export declare function placementPrice(pixels: number, board?: PlaceBoard): bigint;
|
|
53
53
|
/**
|
|
54
|
-
* The
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* The placement memo of `tx` and where it sits (the witness index), or `undefined`.
|
|
55
|
+
*
|
|
56
|
+
* With `inputs` (the cells `tx` spends, resolved, in input order) this is exact:
|
|
57
|
+
* exactly one memo among the witnesses a signature covers (`signedWitnesses`: the
|
|
58
|
+
* first witness of a secp256k1 lock group, as the session key's key cell has, or
|
|
59
|
+
* one past the inputs), at an input's index, and a valid placement. A memo in a
|
|
60
|
+
* witness nobody signed (the session cell's, the board's) is ignored: whoever
|
|
61
|
+
* relays the transaction could have put it there (audit, 30 Oct).
|
|
62
|
+
*
|
|
63
|
+
* Without `inputs` it is the cheap check, before resolving any input: `undefined`
|
|
64
|
+
* means `tx` is certainly no placement; otherwise the first witness at an input's
|
|
65
|
+
* index holding a valid placement, which `checkPlacement` then settles.
|
|
57
66
|
*/
|
|
58
|
-
export declare function placementMemo(tx: ccc.TransactionLike, board?: PlaceBoard): {
|
|
67
|
+
export declare function placementMemo(tx: ccc.TransactionLike, board?: PlaceBoard, inputs?: ccc.CellOutputLike[]): {
|
|
59
68
|
index: number;
|
|
60
69
|
pixels: Pixel[];
|
|
61
70
|
} | undefined;
|
|
@@ -71,11 +80,11 @@ export type PlacementCheck = {
|
|
|
71
80
|
* Whether `tx` is a valid placement on the board under `boardLock`. `inputs` are
|
|
72
81
|
* the cells `tx` spends, resolved, in input order.
|
|
73
82
|
*
|
|
74
|
-
* - exactly one memo, and a valid one
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
83
|
+
* - exactly one memo among the witnesses a signature covers, and a valid one,
|
|
84
|
+
* in the first witness of a secp256k1 lock group that is not the board's
|
|
85
|
+
* (`placementMemo` with `inputs`): the witness the payer's lock signs (the
|
|
86
|
+
* session key's key cell), so the payment commits to these pixels and no relay
|
|
87
|
+
* can swap them, nor add a memo of its own that counts;
|
|
79
88
|
* - the board's lock gained at least `price × pixels` (outputs minus inputs).
|
|
80
89
|
*/
|
|
81
90
|
export declare function checkPlacement(tx: ccc.TransactionLike, inputs: ccc.CellOutputLike[], boardLock: ccc.ScriptLike, board?: PlaceBoard): PlacementCheck;
|
package/dist/place.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ccc } from "@ckb-ccc/core";
|
|
2
|
+
import { isSecp256k1Lock, memoWitnesses, signedWitnesses, soleMemo } from "./paywall.js";
|
|
2
3
|
/** The demo's board: 32×32, 16 colours, 0.1 CKB a pixel, up to 32 pixels a payment. */
|
|
3
4
|
export const PLACE_BOARD = { width: 32, height: 32, colours: 16, price: 10000000n, maxPixels: 32 };
|
|
4
5
|
export class PlaceMemoError extends Error {
|
|
@@ -67,27 +68,34 @@ export function placementPrice(pixels, board = PLACE_BOARD) {
|
|
|
67
68
|
return board.price * BigInt(pixels);
|
|
68
69
|
}
|
|
69
70
|
/**
|
|
70
|
-
* The
|
|
71
|
-
*
|
|
72
|
-
*
|
|
71
|
+
* The placement memo of `tx` and where it sits (the witness index), or `undefined`.
|
|
72
|
+
*
|
|
73
|
+
* With `inputs` (the cells `tx` spends, resolved, in input order) this is exact:
|
|
74
|
+
* exactly one memo among the witnesses a signature covers (`signedWitnesses`: the
|
|
75
|
+
* first witness of a secp256k1 lock group, as the session key's key cell has, or
|
|
76
|
+
* one past the inputs), at an input's index, and a valid placement. A memo in a
|
|
77
|
+
* witness nobody signed (the session cell's, the board's) is ignored: whoever
|
|
78
|
+
* relays the transaction could have put it there (audit, 30 Oct).
|
|
79
|
+
*
|
|
80
|
+
* Without `inputs` it is the cheap check, before resolving any input: `undefined`
|
|
81
|
+
* means `tx` is certainly no placement; otherwise the first witness at an input's
|
|
82
|
+
* index holding a valid placement, which `checkPlacement` then settles.
|
|
73
83
|
*/
|
|
74
|
-
export function placementMemo(tx, board = PLACE_BOARD) {
|
|
84
|
+
export function placementMemo(tx, board = PLACE_BOARD, inputs) {
|
|
75
85
|
const t = ccc.Transaction.from(tx);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
86
|
+
if (!inputs) {
|
|
87
|
+
for (const m of memoWitnesses(t)) {
|
|
88
|
+
const pixels = m.index < t.inputs.length ? decodePlacement(m.memo, board) : undefined;
|
|
89
|
+
if (pixels)
|
|
90
|
+
return { index: m.index, pixels };
|
|
81
91
|
}
|
|
82
|
-
|
|
83
|
-
continue; // not WitnessArgs: carries no memo
|
|
84
|
-
}
|
|
85
|
-
if (inputType === undefined)
|
|
86
|
-
continue;
|
|
87
|
-
if (found)
|
|
88
|
-
return undefined; // two memos: which one was paid for is ambiguous
|
|
89
|
-
found = { index: i, memo: inputType };
|
|
92
|
+
return undefined;
|
|
90
93
|
}
|
|
94
|
+
if (inputs.length !== t.inputs.length)
|
|
95
|
+
return undefined;
|
|
96
|
+
const locks = inputs.map((c) => ccc.CellOutput.from(c).lock);
|
|
97
|
+
// Two signed memos: which one was paid for is ambiguous.
|
|
98
|
+
const found = soleMemo(t, signedWitnesses(t, locks, (lock) => isSecp256k1Lock(lock)));
|
|
91
99
|
if (!found || found.index >= t.inputs.length)
|
|
92
100
|
return undefined;
|
|
93
101
|
const pixels = decodePlacement(found.memo, board);
|
|
@@ -97,28 +105,24 @@ export function placementMemo(tx, board = PLACE_BOARD) {
|
|
|
97
105
|
* Whether `tx` is a valid placement on the board under `boardLock`. `inputs` are
|
|
98
106
|
* the cells `tx` spends, resolved, in input order.
|
|
99
107
|
*
|
|
100
|
-
* - exactly one memo, and a valid one
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
108
|
+
* - exactly one memo among the witnesses a signature covers, and a valid one,
|
|
109
|
+
* in the first witness of a secp256k1 lock group that is not the board's
|
|
110
|
+
* (`placementMemo` with `inputs`): the witness the payer's lock signs (the
|
|
111
|
+
* session key's key cell), so the payment commits to these pixels and no relay
|
|
112
|
+
* can swap them, nor add a memo of its own that counts;
|
|
105
113
|
* - the board's lock gained at least `price × pixels` (outputs minus inputs).
|
|
106
114
|
*/
|
|
107
115
|
export function checkPlacement(tx, inputs, boardLock, board = PLACE_BOARD) {
|
|
108
116
|
const t = ccc.Transaction.from(tx);
|
|
109
117
|
if (inputs.length !== t.inputs.length)
|
|
110
118
|
return { ok: false, reason: "inputs not resolved" };
|
|
111
|
-
const memo = placementMemo(t, board);
|
|
119
|
+
const memo = placementMemo(t, board, inputs);
|
|
112
120
|
if (!memo)
|
|
113
121
|
return { ok: false, reason: "no valid placement memo" };
|
|
114
122
|
const lock = ccc.Script.from(boardLock);
|
|
115
123
|
const cells = inputs.map((c) => ccc.CellOutput.from(c));
|
|
116
|
-
|
|
117
|
-
if (memoLock.eq(lock))
|
|
124
|
+
if (cells[memo.index].lock.eq(lock))
|
|
118
125
|
return { ok: false, reason: "the memo is in the board's own witness" };
|
|
119
|
-
if (cells.slice(0, memo.index).some((c) => c.lock.eq(memoLock))) {
|
|
120
|
-
return { ok: false, reason: "the memo is not in the first witness of its lock group" };
|
|
121
|
-
}
|
|
122
126
|
const sum = (cs) => cs.reduce((s, c) => (c.lock.eq(lock) ? s + c.capacity : s), 0n);
|
|
123
127
|
const paid = sum(t.outputs) - sum(cells);
|
|
124
128
|
const price = placementPrice(memo.pixels.length, board);
|
package/dist/session.d.ts
CHANGED
|
@@ -46,8 +46,21 @@ export interface CreateOptions {
|
|
|
46
46
|
/** Injectable for tests; defaults to the platform CSPRNG. */
|
|
47
47
|
randomBytes?: (n: number) => Uint8Array;
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Why a policy error is not about the request itself: "payto-not-anyone-can-pay",
|
|
51
|
+
* the payer's own setup (a charge can never top such a `payTo` up); or
|
|
52
|
+
* "too-many-cells", more cells under a subscription's lock than a status or a
|
|
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.
|
|
59
|
+
*/
|
|
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";
|
|
49
61
|
export declare class SessionPolicyError extends Error {
|
|
50
|
-
|
|
62
|
+
readonly reason?: SessionPolicyReason;
|
|
63
|
+
constructor(message: string, reason?: SessionPolicyReason);
|
|
51
64
|
}
|
|
52
65
|
export declare function createSession(policy: SessionPolicy, options?: CreateOptions): Session;
|
|
53
66
|
export declare function isActive(session: Session, now?: Date): boolean;
|
package/dist/session.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { ccc } from "@ckb-ccc/core";
|
|
2
2
|
export class SessionPolicyError extends Error {
|
|
3
|
-
|
|
3
|
+
reason;
|
|
4
|
+
constructor(message, reason) {
|
|
4
5
|
super(message);
|
|
5
6
|
this.name = "SessionPolicyError";
|
|
7
|
+
if (reason)
|
|
8
|
+
this.reason = reason;
|
|
6
9
|
}
|
|
7
10
|
}
|
|
8
11
|
const SECP256K1_N = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141");
|
package/dist/store.d.ts
CHANGED
|
@@ -7,7 +7,8 @@ export interface SessionStore {
|
|
|
7
7
|
load(): Promise<Session | undefined>;
|
|
8
8
|
/**
|
|
9
9
|
* Stores `session`. Refuses (SessionStoreConflict) to replace a *different*
|
|
10
|
-
* session: its key may hold funds, and another tab may have just saved it.
|
|
10
|
+
* session: its key may hold funds, and another tab may have just saved it. Also
|
|
11
|
+
* refuses to replace the same session's on-chain binding with none.
|
|
11
12
|
*/
|
|
12
13
|
save(session: Session): Promise<void>;
|
|
13
14
|
/** Forgets `session`, only if it is still the stored one; another tab may have replaced it. */
|
package/dist/store.js
CHANGED
|
@@ -20,6 +20,21 @@ function storedKey(raw) {
|
|
|
20
20
|
throw new Error(UNREADABLE);
|
|
21
21
|
return key;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Throws SessionStoreConflict unless `session` may replace the stored value: a
|
|
25
|
+
* different key may hold funds, and a save of the same key without the on-chain
|
|
26
|
+
* binding (a stale tab that never saw the funding) would lose what finds them
|
|
27
|
+
* (review, 28 Oct).
|
|
28
|
+
*/
|
|
29
|
+
function assertReplaceable(raw, session) {
|
|
30
|
+
const held = storedKey(raw);
|
|
31
|
+
if (held === undefined)
|
|
32
|
+
return;
|
|
33
|
+
if (held !== session.publicKey)
|
|
34
|
+
throw new SessionStoreConflict();
|
|
35
|
+
if (!session.onchain && JSON.parse(raw).onchain)
|
|
36
|
+
throw new SessionStoreConflict();
|
|
37
|
+
}
|
|
23
38
|
export function serializeSession(s) {
|
|
24
39
|
const stored = {
|
|
25
40
|
v: 1,
|
|
@@ -91,9 +106,7 @@ export function memoryStore() {
|
|
|
91
106
|
return {
|
|
92
107
|
load: async () => (value === undefined ? undefined : deserializeSession(value)),
|
|
93
108
|
save: async (s) => {
|
|
94
|
-
|
|
95
|
-
if (held !== undefined && held !== s.publicKey)
|
|
96
|
-
throw new SessionStoreConflict();
|
|
109
|
+
assertReplaceable(value, s);
|
|
97
110
|
value = serializeSession(s);
|
|
98
111
|
},
|
|
99
112
|
clear: async (s) => {
|
|
@@ -168,9 +181,7 @@ export function indexedDbStore(idb = globalThis.indexedDB, key = KEY) {
|
|
|
168
181
|
save: async (session) => {
|
|
169
182
|
const value = serializeSession(session);
|
|
170
183
|
await update((raw) => {
|
|
171
|
-
|
|
172
|
-
if (held !== undefined && held !== session.publicKey)
|
|
173
|
-
throw new SessionStoreConflict();
|
|
184
|
+
assertReplaceable(raw, session);
|
|
174
185
|
return "put";
|
|
175
186
|
}, value);
|
|
176
187
|
},
|
package/dist/subscription.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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
4
|
/**
|
|
4
5
|
* Subscriptions (docs/design/subscriptions.md): the same session lock, with the
|
|
5
6
|
* roles moved. The subscriber is the owner; the creator's charger key is the
|
|
@@ -47,6 +48,42 @@ export declare function subscriptionLock(deployment: SessionLockDeployment, sub:
|
|
|
47
48
|
* secret the subscriber's browser presents to the creator.
|
|
48
49
|
*/
|
|
49
50
|
export declare function subscriptionMemo(account: string): ccc.Hex;
|
|
51
|
+
/**
|
|
52
|
+
* The most cells under one subscription's lock that a status or a charge reads
|
|
53
|
+
* (security audit, 30 Oct). Anyone can create cells under any lock, and each
|
|
54
|
+
* cell read costs RPC calls (its creating transaction, and that transaction's
|
|
55
|
+
* inputs), so without a bound one request could be made to cost thousands. A
|
|
56
|
+
* subscription in use holds one cell, or a few after top-ups; 16 is far above that.
|
|
57
|
+
*
|
|
58
|
+
* Beyond it, a status or a charge is refused with a `SessionPolicyError` of
|
|
59
|
+
* reason "too-many-cells", never answered from part of the cells: the cells
|
|
60
|
+
* left unread could hold the newest charge (so access would be denied as
|
|
61
|
+
* unpaid, with a wrong balance) or a mature cell (so a charge would leave it free
|
|
62
|
+
* to pay a second price in the same period). Refusing grants nothing and charges
|
|
63
|
+
* nothing. Only cells of the subscription's own kind count (plain CKB, or its
|
|
64
|
+
* token). The subscriber clears cells planted among them with
|
|
65
|
+
* `clearPlantedCells`, which leaves the subscription's own cells as they are,
|
|
66
|
+
* or with `cancelSubscription`.
|
|
67
|
+
*/
|
|
68
|
+
export declare const MAX_SUBSCRIPTION_CELLS = 16;
|
|
69
|
+
/**
|
|
70
|
+
* The most transactions that only created cells under the lock that `lastCharge`
|
|
71
|
+
* pages past in the recent history (review W6 M2). Anyone can make those, but
|
|
72
|
+
* they are skipped without a fetch, so only the indexer's pages cost anything
|
|
73
|
+
* (`HISTORY_PAGE` entries each). Past it, a status or a charge is refused
|
|
74
|
+
* rather than stopping short of the last charge: blocking one period this way
|
|
75
|
+
* takes over 1024 transactions within it, each locking up at least a cell's
|
|
76
|
+
* size (153 CKB) under the subscription's lock.
|
|
77
|
+
*/
|
|
78
|
+
export declare const MAX_CREATED_HISTORY: number;
|
|
79
|
+
/**
|
|
80
|
+
* The memo `clearPlantedCells` puts in the witness (`input_type`) of the
|
|
81
|
+
* subscriber's own input, under the subscriber's signature: a status tells its
|
|
82
|
+
* cleanup from a cancel by it, so a cleanup does not end a paid period (review
|
|
83
|
+
* W6 L2). Only the subscriber can sign it; a subscriber who marks a cancel
|
|
84
|
+
* with it keeps access only through a period already paid for.
|
|
85
|
+
*/
|
|
86
|
+
export declare const CLEAR_PLANTED_MEMO: `0x${string}`;
|
|
50
87
|
/**
|
|
51
88
|
* The subscriber opens a subscription, in one transaction and one signature: the
|
|
52
89
|
* first period's price goes to `payTo` at once (a new cell cannot be charged for a
|
|
@@ -87,13 +124,18 @@ export declare function openSubscription(subscriber: ccc.Signer, deployment: Ses
|
|
|
87
124
|
};
|
|
88
125
|
account?: string;
|
|
89
126
|
beforeSend?: (sub: Subscription) => Promise<void>;
|
|
90
|
-
|
|
127
|
+
/** Let an unreviewed charger lock through, with a warning (`allowUnreviewedOwnerLock` covers the subscriber's own lock only). */
|
|
128
|
+
allowUnreviewedChargerLock?: boolean;
|
|
129
|
+
} & SigningLockOptions): Promise<{
|
|
91
130
|
txHash: ccc.Hex;
|
|
92
131
|
subscription: Subscription;
|
|
93
132
|
}>;
|
|
94
133
|
/**
|
|
95
134
|
* 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
|
|
135
|
+
* (units; 0 without a token), and how many blocks until one can be charged: once
|
|
136
|
+
* a cell is a period old and the last charge (the newest transaction that spent
|
|
137
|
+
* a cell under the lock) is too. Throws a `SessionPolicyError` ("too-many-cells")
|
|
138
|
+
* with over `MAX_SUBSCRIPTION_CELLS` cells under the lock.
|
|
97
139
|
*/
|
|
98
140
|
export declare function findSubscriptionCells(client: ccc.Client, deployment: SessionLockDeployment, sub: Subscription): Promise<{
|
|
99
141
|
lock: ccc.Script;
|
|
@@ -106,19 +148,30 @@ export declare function findSubscriptionCells(client: ccc.Client, deployment: Se
|
|
|
106
148
|
* The creator's charger key takes one price from the subscription and pays it to
|
|
107
149
|
* `payTo` (topping up its anyone-can-pay cell with `topUp`). The fee comes from
|
|
108
150
|
* 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
|
|
110
|
-
* cannot cover a price and still leave a cell behind.
|
|
151
|
+
* `SessionBalanceError` while no cell is a full period old or the last charge is
|
|
152
|
+
* under one, or once what is left cannot cover a price and still leave a cell behind.
|
|
111
153
|
*
|
|
112
154
|
* In a token subscription the price is in token units and always tops up
|
|
113
155
|
* `payTo`'s cell of the token (`topUp` is implied). The change keeps every
|
|
114
156
|
* shannon that came in (no CKB may leave) and the tokens less one price, in
|
|
115
157
|
* exactly 16 data bytes.
|
|
158
|
+
*
|
|
159
|
+
* With `topUp` (or a token), `payTo`'s cell is a confirmed one that no
|
|
160
|
+
* transaction in the node's pool spends, where the node can tell
|
|
161
|
+
* (`pickPayoutCell`). A charge the node rejects for a spent or unknown input is
|
|
162
|
+
* built again after clearing the client's cache, with another payout cell,
|
|
163
|
+
* after a short wait: up to `retry.attempts` in all (default 3), waiting
|
|
164
|
+
* `retry.delayMs` (default 1000) doubling each time (`rebuildOnDeadInputs`).
|
|
165
|
+
* `beforeSend` runs again with each new hash. A send whose outcome is unknown
|
|
166
|
+
* is never retried.
|
|
116
167
|
*/
|
|
117
|
-
export declare function chargeSubscription(charger: ccc.Signer, deployment: SessionLockDeployment, sub: Subscription, opts:
|
|
168
|
+
export declare function chargeSubscription(charger: ccc.Signer, deployment: SessionLockDeployment, sub: Subscription, opts: ChargeOptions): Promise<ccc.Hex>;
|
|
169
|
+
type ChargeOptions = {
|
|
118
170
|
payTo: string;
|
|
119
171
|
topUp?: boolean;
|
|
120
172
|
beforeSend?: (txHash: ccc.Hex) => Promise<void>;
|
|
121
|
-
|
|
173
|
+
retry?: RetryOptions;
|
|
174
|
+
};
|
|
122
175
|
/**
|
|
123
176
|
* The subscriber takes everything left back, in owner mode: no creator involved
|
|
124
177
|
* and no wait. `nothingFound` means no cell is left (already cancelled, or run dry).
|
|
@@ -132,6 +185,40 @@ export declare function cancelSubscription(subscriber: ccc.Signer, deployment: S
|
|
|
132
185
|
refundedTokens?: bigint;
|
|
133
186
|
nothingFound?: true;
|
|
134
187
|
}>;
|
|
188
|
+
/**
|
|
189
|
+
* The subscriber clears cells a third party created under the subscription's
|
|
190
|
+
* lock, without cancelling (mentor review, 2 Nov). Anyone can create cells under
|
|
191
|
+
* any lock; past `MAX_SUBSCRIPTION_CELLS` of the subscription's kind, a status or
|
|
192
|
+
* a charge is refused ("too-many-cells").
|
|
193
|
+
*
|
|
194
|
+
* A cell is the subscription's own when its creating transaction is the
|
|
195
|
+
* opening (`sub.openTx`), or had an input under the subscriber's or the
|
|
196
|
+
* charger's lock: an opening, a charge, a top-up by the subscriber. Nobody else
|
|
197
|
+
* can sign for those. Every other cell of the subscription's kind is planted:
|
|
198
|
+
* one owner-mode transaction (one signature) sweeps them to the subscriber,
|
|
199
|
+
* who keeps their CKB and tokens. The subscription's own cells are not touched,
|
|
200
|
+
* so their age and the payments that made them still count: access and the
|
|
201
|
+
* charge schedule go on as before.
|
|
202
|
+
*
|
|
203
|
+
* In a token subscription, look-alikes of the token (cells typed with the
|
|
204
|
+
* token's code and longer args that start with the token's, which xUDT accepts
|
|
205
|
+
* and anyone can create with 0 units) are planted too, and swept the same way
|
|
206
|
+
* when an owner sweep can spend them (plain xUDT, no flags; review W6 M1).
|
|
207
|
+
*
|
|
208
|
+
* Bounded: one call keeps at most `4 * MAX_SUBSCRIPTION_CELLS` cells of the
|
|
209
|
+
* kind, reads at most twice as many listed cells for each of the two scans,
|
|
210
|
+
* and looks each creating transaction up once. `more` says cells were left
|
|
211
|
+
* unread: call again once this sweep commits. Refused when none of the cells read is the
|
|
212
|
+
* subscription's own: cancel instead. `nothingFound` when nothing is planted.
|
|
213
|
+
*/
|
|
214
|
+
export declare function clearPlantedCells(subscriber: ccc.Signer, deployment: SessionLockDeployment, sub: Subscription): Promise<{
|
|
215
|
+
txHash?: ccc.Hex;
|
|
216
|
+
swept: number;
|
|
217
|
+
refunded: bigint;
|
|
218
|
+
refundedTokens?: bigint;
|
|
219
|
+
more: boolean;
|
|
220
|
+
nothingFound?: true;
|
|
221
|
+
}>;
|
|
135
222
|
export type SubscriptionCheck = {
|
|
136
223
|
ok: true;
|
|
137
224
|
subscription: Subscription;
|
|
@@ -181,7 +268,10 @@ export declare function verifySubscription(client: ccc.Client, deployment: Sessi
|
|
|
181
268
|
token?: ccc.ScriptLike;
|
|
182
269
|
}): Promise<SubscriptionCheck>;
|
|
183
270
|
export interface SubscriptionStatus {
|
|
184
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* The block until which the subscriber has paid, from the newest paying cell or
|
|
273
|
+
* a charge that took the whole balance; undefined when neither is left.
|
|
274
|
+
*/
|
|
185
275
|
paidThroughBlock: bigint | undefined;
|
|
186
276
|
tip: bigint;
|
|
187
277
|
/** Whether the tip is within the paid period. */
|
|
@@ -203,6 +293,11 @@ export interface SubscriptionStatus {
|
|
|
203
293
|
* Each must have been created by a payment, so a cell re-created by the
|
|
204
294
|
* subscriber to reset its age counts for nothing. Cancelling ends access at once.
|
|
205
295
|
* A token subscription (`sub.token`) counts payments in its token, as `verifySubscription` does.
|
|
296
|
+
*
|
|
297
|
+
* Throws a `SessionPolicyError` of reason "too-many-cells" when over
|
|
298
|
+
* `MAX_SUBSCRIPTION_CELLS` cells (or recent transactions) sit under the lock:
|
|
299
|
+
* not a verdict on the subscription, so not an `ok: false`. A server should
|
|
300
|
+
* answer it as a conflict the subscriber can clear by cancelling and opening again.
|
|
206
301
|
*/
|
|
207
302
|
export declare function subscriptionStatus(client: ccc.Client, deployment: SessionLockDeployment, sub: Subscription, proof: {
|
|
208
303
|
openTx: ccc.HexLike;
|
|
@@ -219,3 +314,4 @@ export declare function subscriptionStatus(client: ccc.Client, deployment: Sessi
|
|
|
219
314
|
* `sample` blocks. Block time varies; round up when a period means "no more often than".
|
|
220
315
|
*/
|
|
221
316
|
export declare function blocksForSeconds(client: ccc.Client, seconds: number, sample?: bigint): Promise<bigint>;
|
|
317
|
+
export {};
|