ckb-session-kit 0.0.0-stage → 2.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/CHANGELOG.md +337 -0
- package/LICENSE +21 -0
- package/README.md +718 -3
- package/dist/deployment.d.ts +21 -0
- package/dist/deployment.js +92 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +9 -0
- package/dist/lock.d.ts +70 -0
- package/dist/lock.js +106 -0
- package/dist/onchain.d.ts +451 -0
- package/dist/onchain.js +1150 -0
- package/dist/paywall.d.ts +60 -0
- package/dist/paywall.js +175 -0
- package/dist/place.d.ts +113 -0
- package/dist/place.js +155 -0
- package/dist/session.d.ts +71 -0
- package/dist/session.js +57 -0
- package/dist/store.d.ts +27 -0
- package/dist/store.js +192 -0
- package/dist/subscription.d.ts +317 -0
- package/dist/subscription.js +959 -0
- package/dist/transfer.d.ts +19 -0
- package/dist/transfer.js +33 -0
- package/package.json +80 -6
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ccc } from "@ckb-ccc/core";
|
|
2
|
+
import type { Session } from "./session.js";
|
|
3
|
+
/** The memo for a payment: a short UTF-8 label, e.g. `read:article-1`. */
|
|
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;
|
|
34
|
+
export interface AccessProof {
|
|
35
|
+
txHash: ccc.Hex;
|
|
36
|
+
memo: ccc.Hex;
|
|
37
|
+
/** The session key's public key. */
|
|
38
|
+
publicKey: ccc.Hex;
|
|
39
|
+
signature: ccc.Hex;
|
|
40
|
+
}
|
|
41
|
+
/** The payer's side: prove that the key which paid `txHash` is asking. */
|
|
42
|
+
export declare function signAccess(session: Session, txHash: ccc.HexLike, memo: ccc.HexLike): Promise<AccessProof>;
|
|
43
|
+
export type AccessCheck = {
|
|
44
|
+
ok: true;
|
|
45
|
+
paid: bigint;
|
|
46
|
+
} | {
|
|
47
|
+
ok: false;
|
|
48
|
+
reason: string;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* The server's side. `to` is the recipient that must have been paid, as a lock
|
|
52
|
+
* script; `minAmount` in shannons. With `token` (an xUDT type script), the
|
|
53
|
+
* payment is counted in that token's units instead, and `minAmount` is in units.
|
|
54
|
+
*/
|
|
55
|
+
export declare function verifyAccess(client: ccc.Client, proof: AccessProof, expect: {
|
|
56
|
+
to: ccc.ScriptLike;
|
|
57
|
+
minAmount: bigint;
|
|
58
|
+
memo: ccc.HexLike;
|
|
59
|
+
token?: ccc.ScriptLike;
|
|
60
|
+
}): Promise<AccessCheck>;
|
package/dist/paywall.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { ccc } from "@ckb-ccc/core";
|
|
2
|
+
import { tokenAmount } from "./onchain.js";
|
|
3
|
+
import { sessionSigner } from "./transfer.js";
|
|
4
|
+
/**
|
|
5
|
+
* Paid access, checked against the chain with no database.
|
|
6
|
+
*
|
|
7
|
+
* The payer pays with `spendInSession(…, { memo })`. The memo goes into the
|
|
8
|
+
* witness of the session key's own input, which that key's signature covers, so
|
|
9
|
+
* the payment commits to what it pays for: it cannot be reused for something else.
|
|
10
|
+
*
|
|
11
|
+
* To claim the access, the payer signs a request with the same session key
|
|
12
|
+
* (`signAccess`). A server checks both with `verifyAccess`: the payment is
|
|
13
|
+
* committed, the recipient gained enough, the memo matches, and the request is
|
|
14
|
+
* signed by the key that paid. Anyone can see the payment on-chain; only the
|
|
15
|
+
* payer can use it.
|
|
16
|
+
*
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
/** A session payment has a handful of inputs; more is not one, and costs RPC calls to check. */
|
|
24
|
+
const MAX_PAYMENT_INPUTS = 16;
|
|
25
|
+
/** The memo for a payment: a short UTF-8 label, e.g. `read:article-1`. */
|
|
26
|
+
export function paymentMemo(label) {
|
|
27
|
+
return ccc.hexFrom(ccc.bytesFrom(label, "utf8"));
|
|
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
|
+
}
|
|
78
|
+
function accessMessage(txHash, memo) {
|
|
79
|
+
return `ckb-session-kit access ${ccc.hexFrom(txHash)} ${ccc.hexFrom(memo)}`;
|
|
80
|
+
}
|
|
81
|
+
/** The payer's side: prove that the key which paid `txHash` is asking. */
|
|
82
|
+
export async function signAccess(session, txHash, memo) {
|
|
83
|
+
const signer = sessionSigner(session, new ccc.ClientPublicTestnet());
|
|
84
|
+
const { signature } = await signer.signMessage(accessMessage(txHash, memo));
|
|
85
|
+
return { txHash: ccc.hexFrom(txHash), memo: ccc.hexFrom(memo), publicKey: signer.publicKey, signature: ccc.hexFrom(signature) };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The server's side. `to` is the recipient that must have been paid, as a lock
|
|
89
|
+
* script; `minAmount` in shannons. With `token` (an xUDT type script), the
|
|
90
|
+
* payment is counted in that token's units instead, and `minAmount` is in units.
|
|
91
|
+
*/
|
|
92
|
+
export async function verifyAccess(client, proof, expect) {
|
|
93
|
+
if (ccc.hexFrom(proof.memo) !== ccc.hexFrom(expect.memo))
|
|
94
|
+
return { ok: false, reason: "memo does not match" };
|
|
95
|
+
// Always the CKB secp256k1 scheme: a sign type chosen by the caller is not trusted.
|
|
96
|
+
let signed = false;
|
|
97
|
+
try {
|
|
98
|
+
signed = ccc.verifyMessageCkbSecp256k1(accessMessage(proof.txHash, proof.memo), proof.signature, proof.publicKey);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
signed = false;
|
|
102
|
+
}
|
|
103
|
+
if (!signed)
|
|
104
|
+
return { ok: false, reason: "request not signed by the given key" };
|
|
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);
|
|
109
|
+
if (!res)
|
|
110
|
+
return { ok: false, reason: "payment not found" };
|
|
111
|
+
if (res.status !== "committed")
|
|
112
|
+
return { ok: false, reason: `payment is ${res.status}, not committed` };
|
|
113
|
+
const tx = res.transaction;
|
|
114
|
+
// Cheap checks first, before resolving any input over RPC.
|
|
115
|
+
if (tx.inputs.length > MAX_PAYMENT_INPUTS)
|
|
116
|
+
return { ok: false, reason: "not a session payment: too many inputs" };
|
|
117
|
+
const memos = memoWitnesses(tx);
|
|
118
|
+
if (!memos.length)
|
|
119
|
+
return { ok: false, reason: "a payment must carry exactly one memo" };
|
|
120
|
+
// The payer's lock: the standard secp256k1 lock of the key that signed the request.
|
|
121
|
+
const payerLock = (await new ccc.SignerCkbPublicKey(client, proof.publicKey).getAddressObjSecp256k1()).script;
|
|
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
|
|
124
|
+
// public payment, and must not get to make the server resolve every input.
|
|
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)
|
|
138
|
+
return { ok: false, reason: "the signing key did not pay this transaction" };
|
|
139
|
+
const to = ccc.Script.from(expect.to);
|
|
140
|
+
const token = expect.token && ccc.Script.from(expect.token);
|
|
141
|
+
// What a cell gives the recipient: its capacity, or with a token its units of that exact token
|
|
142
|
+
// (a committed xUDT cell always holds the 16-byte amount).
|
|
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))));
|
|
146
|
+
let payerInput = -1;
|
|
147
|
+
let toIn = 0n;
|
|
148
|
+
for (const [i, cell] of inputs.entries()) {
|
|
149
|
+
if (!cell)
|
|
150
|
+
return { ok: false, reason: "an input of the payment cannot be resolved" };
|
|
151
|
+
if (payerInput < 0 && cell.cellOutput.lock.eq(payerLock))
|
|
152
|
+
payerInput = i;
|
|
153
|
+
toIn += value(cell.cellOutput, cell.outputData);
|
|
154
|
+
}
|
|
155
|
+
if (payerInput < 0)
|
|
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" };
|
|
163
|
+
// The witness the payer's lock verified (the first of its group) must carry the memo.
|
|
164
|
+
const witness = tx.getWitnessArgsAt(payerInput);
|
|
165
|
+
if (!witness?.inputType || ccc.hexFrom(witness.inputType) !== ccc.hexFrom(expect.memo)) {
|
|
166
|
+
return { ok: false, reason: "the payment does not carry this memo" };
|
|
167
|
+
}
|
|
168
|
+
const toOut = tx.outputs.reduce((sum, o, i) => sum + value(o, tx.outputsData[i] ?? "0x"), 0n);
|
|
169
|
+
const paid = toOut - toIn;
|
|
170
|
+
if (paid < expect.minAmount) {
|
|
171
|
+
const shown = token ? `${paid} units` : `${ccc.fixedPointToString(paid)} CKB`;
|
|
172
|
+
return { ok: false, reason: `paid ${shown}, below the price` };
|
|
173
|
+
}
|
|
174
|
+
return { ok: true, paid };
|
|
175
|
+
}
|
package/dist/place.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { ccc } from "@ckb-ccc/core";
|
|
2
|
+
/**
|
|
3
|
+
* CKB Place: a shared pixel board kept on-chain, with no database.
|
|
4
|
+
*
|
|
5
|
+
* A player pays the board's anyone-can-pay address with
|
|
6
|
+
* `spendInSession(…, { to: board, amount: price × pixels, topUp: true, memo })`,
|
|
7
|
+
* where `memo` (from `encodePlacement`) lists the pixels. Anyone can rebuild the
|
|
8
|
+
* board from the chain: take the transactions that touch the board's lock, keep
|
|
9
|
+
* those that paid enough for their memo (`checkPlacement`), and apply them in
|
|
10
|
+
* chain order (`applyPlacements`). Last write wins.
|
|
11
|
+
*
|
|
12
|
+
* This module is the pure part: no network. Finding and resolving the
|
|
13
|
+
* transactions is the caller's.
|
|
14
|
+
*
|
|
15
|
+
* Memo format, version 1 (bytes):
|
|
16
|
+
*
|
|
17
|
+
* "px1" (0x70 0x78 0x31) | x u8 | y u8 | colour u8 | x | y | colour | …
|
|
18
|
+
*
|
|
19
|
+
* One to `maxPixels` pixels; each must lie on the board and use a palette index
|
|
20
|
+
* below `colours`. Anything else (wrong magic, a partial triple, a pixel off the
|
|
21
|
+
* board, too many pixels) makes the whole memo invalid: memos come from anyone,
|
|
22
|
+
* and a payment is never half-applied. A pixel may appear twice; each entry is
|
|
23
|
+
* paid for and the later one wins.
|
|
24
|
+
*/
|
|
25
|
+
export interface PlaceBoard {
|
|
26
|
+
/** Columns, 1–256 (x is one byte). */
|
|
27
|
+
width: number;
|
|
28
|
+
/** Rows, 1–256 (y is one byte). */
|
|
29
|
+
height: number;
|
|
30
|
+
/** Palette size, 1–256; a colour is an index below it. */
|
|
31
|
+
colours: number;
|
|
32
|
+
/** Shannons per pixel. */
|
|
33
|
+
price: bigint;
|
|
34
|
+
/** Most pixels one memo may carry; bounds the memo at 3 + 3 × maxPixels bytes. */
|
|
35
|
+
maxPixels: number;
|
|
36
|
+
}
|
|
37
|
+
/** The demo's board: 32×32, 16 colours, 0.1 CKB a pixel, up to 32 pixels a payment. */
|
|
38
|
+
export declare const PLACE_BOARD: PlaceBoard;
|
|
39
|
+
export interface Pixel {
|
|
40
|
+
x: number;
|
|
41
|
+
y: number;
|
|
42
|
+
colour: number;
|
|
43
|
+
}
|
|
44
|
+
export declare class PlaceMemoError extends Error {
|
|
45
|
+
constructor(message: string);
|
|
46
|
+
}
|
|
47
|
+
/** The memo for a placement. Throws `PlaceMemoError` for an empty, oversized or off-board list. */
|
|
48
|
+
export declare function encodePlacement(pixels: Pixel[], board?: PlaceBoard): ccc.Hex;
|
|
49
|
+
/** The pixels in a memo, or `undefined` if it is not a valid version-1 memo for this board. Never throws. */
|
|
50
|
+
export declare function decodePlacement(memo: ccc.HexLike, board?: PlaceBoard): Pixel[] | undefined;
|
|
51
|
+
/** What a placement costs, in shannons. */
|
|
52
|
+
export declare function placementPrice(pixels: number, board?: PlaceBoard): bigint;
|
|
53
|
+
/**
|
|
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.
|
|
66
|
+
*/
|
|
67
|
+
export declare function placementMemo(tx: ccc.TransactionLike, board?: PlaceBoard, inputs?: ccc.CellOutputLike[]): {
|
|
68
|
+
index: number;
|
|
69
|
+
pixels: Pixel[];
|
|
70
|
+
} | undefined;
|
|
71
|
+
export type PlacementCheck = {
|
|
72
|
+
ok: true;
|
|
73
|
+
pixels: Pixel[];
|
|
74
|
+
paid: bigint;
|
|
75
|
+
} | {
|
|
76
|
+
ok: false;
|
|
77
|
+
reason: string;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Whether `tx` is a valid placement on the board under `boardLock`. `inputs` are
|
|
81
|
+
* the cells `tx` spends, resolved, in input order.
|
|
82
|
+
*
|
|
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;
|
|
88
|
+
* - the board's lock gained at least `price × pixels` (outputs minus inputs).
|
|
89
|
+
*/
|
|
90
|
+
export declare function checkPlacement(tx: ccc.TransactionLike, inputs: ccc.CellOutputLike[], boardLock: ccc.ScriptLike, board?: PlaceBoard): PlacementCheck;
|
|
91
|
+
/** A checked placement and where it sits in the chain. */
|
|
92
|
+
export interface Placement {
|
|
93
|
+
txHash: ccc.Hex;
|
|
94
|
+
blockNumber: bigint;
|
|
95
|
+
/** Position of the transaction in its block. */
|
|
96
|
+
txIndex: number;
|
|
97
|
+
pixels: Pixel[];
|
|
98
|
+
}
|
|
99
|
+
/** One square of the board: its colour and the transaction that set it last. */
|
|
100
|
+
export interface PlacedPixel {
|
|
101
|
+
colour: number;
|
|
102
|
+
txHash: ccc.Hex;
|
|
103
|
+
blockNumber: bigint;
|
|
104
|
+
}
|
|
105
|
+
/** Row-major, `width × height` long; `undefined` where nothing has been placed. */
|
|
106
|
+
export type BoardState = (PlacedPixel | undefined)[];
|
|
107
|
+
/**
|
|
108
|
+
* The board after `placements`, applied in chain order (block number, then
|
|
109
|
+
* position in the block) whatever order they are given in; within one
|
|
110
|
+
* placement, in memo order. Last write wins. A transaction listed twice counts
|
|
111
|
+
* once, and a pixel off the board is skipped.
|
|
112
|
+
*/
|
|
113
|
+
export declare function applyPlacements(placements: Placement[], board?: PlaceBoard): BoardState;
|
package/dist/place.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { ccc } from "@ckb-ccc/core";
|
|
2
|
+
import { isSecp256k1Lock, memoWitnesses, signedWitnesses, soleMemo } from "./paywall.js";
|
|
3
|
+
/** The demo's board: 32×32, 16 colours, 0.1 CKB a pixel, up to 32 pixels a payment. */
|
|
4
|
+
export const PLACE_BOARD = { width: 32, height: 32, colours: 16, price: 10000000n, maxPixels: 32 };
|
|
5
|
+
export class PlaceMemoError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "PlaceMemoError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const MAGIC = [0x70, 0x78, 0x31]; // "px1"
|
|
12
|
+
const byteSized = (n) => Number.isInteger(n) && n >= 1 && n <= 256;
|
|
13
|
+
function checkBoard(board) {
|
|
14
|
+
if (!byteSized(board.width) || !byteSized(board.height) || !byteSized(board.colours)) {
|
|
15
|
+
throw new PlaceMemoError("board width, height and colours must be 1–256");
|
|
16
|
+
}
|
|
17
|
+
if (!Number.isInteger(board.maxPixels) || board.maxPixels < 1)
|
|
18
|
+
throw new PlaceMemoError("maxPixels must be at least 1");
|
|
19
|
+
if (board.price <= 0n)
|
|
20
|
+
throw new PlaceMemoError("price must be positive");
|
|
21
|
+
}
|
|
22
|
+
function onBoard(p, board) {
|
|
23
|
+
const ok = (v, max) => Number.isInteger(v) && v >= 0 && v < max;
|
|
24
|
+
return ok(p.x, board.width) && ok(p.y, board.height) && ok(p.colour, board.colours);
|
|
25
|
+
}
|
|
26
|
+
/** The memo for a placement. Throws `PlaceMemoError` for an empty, oversized or off-board list. */
|
|
27
|
+
export function encodePlacement(pixels, board = PLACE_BOARD) {
|
|
28
|
+
checkBoard(board);
|
|
29
|
+
if (!pixels.length)
|
|
30
|
+
throw new PlaceMemoError("no pixels to place");
|
|
31
|
+
if (pixels.length > board.maxPixels)
|
|
32
|
+
throw new PlaceMemoError(`at most ${board.maxPixels} pixels per placement`);
|
|
33
|
+
const bytes = [...MAGIC];
|
|
34
|
+
for (const p of pixels) {
|
|
35
|
+
if (!onBoard(p, board))
|
|
36
|
+
throw new PlaceMemoError(`pixel (${p.x}, ${p.y}) colour ${p.colour} is not on the board`);
|
|
37
|
+
bytes.push(p.x, p.y, p.colour);
|
|
38
|
+
}
|
|
39
|
+
return ccc.hexFrom(new Uint8Array(bytes));
|
|
40
|
+
}
|
|
41
|
+
/** The pixels in a memo, or `undefined` if it is not a valid version-1 memo for this board. Never throws. */
|
|
42
|
+
export function decodePlacement(memo, board = PLACE_BOARD) {
|
|
43
|
+
let bytes;
|
|
44
|
+
try {
|
|
45
|
+
checkBoard(board);
|
|
46
|
+
bytes = ccc.bytesFrom(memo);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
// Length first: an oversized memo is refused before it is read.
|
|
52
|
+
const n = (bytes.length - MAGIC.length) / 3;
|
|
53
|
+
if (!Number.isInteger(n) || n < 1 || n > board.maxPixels)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (MAGIC.some((b, i) => bytes[i] !== b))
|
|
56
|
+
return undefined;
|
|
57
|
+
const pixels = [];
|
|
58
|
+
for (let i = MAGIC.length; i < bytes.length; i += 3) {
|
|
59
|
+
const p = { x: bytes[i], y: bytes[i + 1], colour: bytes[i + 2] };
|
|
60
|
+
if (!onBoard(p, board))
|
|
61
|
+
return undefined;
|
|
62
|
+
pixels.push(p);
|
|
63
|
+
}
|
|
64
|
+
return pixels;
|
|
65
|
+
}
|
|
66
|
+
/** What a placement costs, in shannons. */
|
|
67
|
+
export function placementPrice(pixels, board = PLACE_BOARD) {
|
|
68
|
+
return board.price * BigInt(pixels);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
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.
|
|
83
|
+
*/
|
|
84
|
+
export function placementMemo(tx, board = PLACE_BOARD, inputs) {
|
|
85
|
+
const t = ccc.Transaction.from(tx);
|
|
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 };
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
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)));
|
|
99
|
+
if (!found || found.index >= t.inputs.length)
|
|
100
|
+
return undefined;
|
|
101
|
+
const pixels = decodePlacement(found.memo, board);
|
|
102
|
+
return pixels && { index: found.index, pixels };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Whether `tx` is a valid placement on the board under `boardLock`. `inputs` are
|
|
106
|
+
* the cells `tx` spends, resolved, in input order.
|
|
107
|
+
*
|
|
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;
|
|
113
|
+
* - the board's lock gained at least `price × pixels` (outputs minus inputs).
|
|
114
|
+
*/
|
|
115
|
+
export function checkPlacement(tx, inputs, boardLock, board = PLACE_BOARD) {
|
|
116
|
+
const t = ccc.Transaction.from(tx);
|
|
117
|
+
if (inputs.length !== t.inputs.length)
|
|
118
|
+
return { ok: false, reason: "inputs not resolved" };
|
|
119
|
+
const memo = placementMemo(t, board, inputs);
|
|
120
|
+
if (!memo)
|
|
121
|
+
return { ok: false, reason: "no valid placement memo" };
|
|
122
|
+
const lock = ccc.Script.from(boardLock);
|
|
123
|
+
const cells = inputs.map((c) => ccc.CellOutput.from(c));
|
|
124
|
+
if (cells[memo.index].lock.eq(lock))
|
|
125
|
+
return { ok: false, reason: "the memo is in the board's own witness" };
|
|
126
|
+
const sum = (cs) => cs.reduce((s, c) => (c.lock.eq(lock) ? s + c.capacity : s), 0n);
|
|
127
|
+
const paid = sum(t.outputs) - sum(cells);
|
|
128
|
+
const price = placementPrice(memo.pixels.length, board);
|
|
129
|
+
if (paid < price) {
|
|
130
|
+
return { ok: false, reason: `paid ${ccc.fixedPointToString(paid)} CKB for ${memo.pixels.length} pixel(s), below ${ccc.fixedPointToString(price)}` };
|
|
131
|
+
}
|
|
132
|
+
return { ok: true, pixels: memo.pixels, paid };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The board after `placements`, applied in chain order (block number, then
|
|
136
|
+
* position in the block) whatever order they are given in; within one
|
|
137
|
+
* placement, in memo order. Last write wins. A transaction listed twice counts
|
|
138
|
+
* once, and a pixel off the board is skipped.
|
|
139
|
+
*/
|
|
140
|
+
export function applyPlacements(placements, board = PLACE_BOARD) {
|
|
141
|
+
const state = new Array(board.width * board.height).fill(undefined);
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
const ordered = [...placements].sort((a, b) => a.blockNumber !== b.blockNumber ? (a.blockNumber < b.blockNumber ? -1 : 1) : a.txIndex - b.txIndex);
|
|
144
|
+
for (const p of ordered) {
|
|
145
|
+
if (seen.has(p.txHash))
|
|
146
|
+
continue;
|
|
147
|
+
seen.add(p.txHash);
|
|
148
|
+
for (const px of p.pixels) {
|
|
149
|
+
if (!onBoard(px, board))
|
|
150
|
+
continue;
|
|
151
|
+
state[px.y * board.width + px.x] = { colour: px.colour, txHash: p.txHash, blockNumber: p.blockNumber };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return state;
|
|
155
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ccc } from "@ckb-ccc/core";
|
|
2
|
+
/**
|
|
3
|
+
* What a session key may do. This is the off-chain mirror of what the session
|
|
4
|
+
* lock script will enforce on-chain (Week 4): the browser refuses out-of-scope
|
|
5
|
+
* requests early, and the chain refuses them regardless.
|
|
6
|
+
*/
|
|
7
|
+
export interface SessionScope {
|
|
8
|
+
/** Largest amount one transaction may move, in shannons. */
|
|
9
|
+
maxPerTx: bigint;
|
|
10
|
+
/** Recipient addresses allowed. Omitted means any recipient. */
|
|
11
|
+
recipients?: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface SessionPolicy {
|
|
14
|
+
expiresAt: Date;
|
|
15
|
+
scope: SessionScope;
|
|
16
|
+
}
|
|
17
|
+
export interface Session {
|
|
18
|
+
/** Never leaves the browser. */
|
|
19
|
+
privateKey: ccc.Hex;
|
|
20
|
+
publicKey: ccc.Hex;
|
|
21
|
+
policy: SessionPolicy;
|
|
22
|
+
/** Set once the owner has funded the session under the on-chain session lock. */
|
|
23
|
+
onchain?: {
|
|
24
|
+
ownerLock: {
|
|
25
|
+
codeHash: ccc.Hex;
|
|
26
|
+
hashType: ccc.HashType;
|
|
27
|
+
args: ccc.Hex;
|
|
28
|
+
};
|
|
29
|
+
minInterval: bigint;
|
|
30
|
+
recipientLockHash?: ccc.Hex;
|
|
31
|
+
/** A token session: the xUDT type its cells hold, and the most units one spend may move. */
|
|
32
|
+
token?: {
|
|
33
|
+
type: {
|
|
34
|
+
codeHash: ccc.Hex;
|
|
35
|
+
hashType: ccc.HashType;
|
|
36
|
+
args: ccc.Hex;
|
|
37
|
+
};
|
|
38
|
+
maxPerTx: bigint;
|
|
39
|
+
};
|
|
40
|
+
/** The funding transaction, so a key is not forgotten while its funds are still on the way. */
|
|
41
|
+
openTx?: ccc.Hex;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export interface CreateOptions {
|
|
45
|
+
now?: Date;
|
|
46
|
+
/** Injectable for tests; defaults to the platform CSPRNG. */
|
|
47
|
+
randomBytes?: (n: number) => Uint8Array;
|
|
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`).
|
|
54
|
+
*/
|
|
55
|
+
export type SessionPolicyReason = "payto-not-anyone-can-pay" | "too-many-cells" | "owner-lock-unsafe" | "owner-lock-unreviewed";
|
|
56
|
+
export declare class SessionPolicyError extends Error {
|
|
57
|
+
readonly reason?: SessionPolicyReason;
|
|
58
|
+
constructor(message: string, reason?: SessionPolicyReason);
|
|
59
|
+
}
|
|
60
|
+
export declare function createSession(policy: SessionPolicy, options?: CreateOptions): Session;
|
|
61
|
+
export declare function isActive(session: Session, now?: Date): boolean;
|
|
62
|
+
export type ScopeCheck = {
|
|
63
|
+
ok: true;
|
|
64
|
+
} | {
|
|
65
|
+
ok: false;
|
|
66
|
+
reason: string;
|
|
67
|
+
};
|
|
68
|
+
export declare function checkRequest(session: Session, request: {
|
|
69
|
+
to: string;
|
|
70
|
+
amount: bigint;
|
|
71
|
+
}, now?: Date): ScopeCheck;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ccc } from "@ckb-ccc/core";
|
|
2
|
+
export class SessionPolicyError extends Error {
|
|
3
|
+
reason;
|
|
4
|
+
constructor(message, reason) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "SessionPolicyError";
|
|
7
|
+
if (reason)
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const SECP256K1_N = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141");
|
|
12
|
+
export function createSession(policy, options = {}) {
|
|
13
|
+
const now = options.now ?? new Date();
|
|
14
|
+
if (!Number.isFinite(policy.expiresAt.getTime())) {
|
|
15
|
+
throw new SessionPolicyError("expiresAt is not a valid date");
|
|
16
|
+
}
|
|
17
|
+
if (policy.expiresAt.getTime() <= now.getTime()) {
|
|
18
|
+
throw new SessionPolicyError("expiresAt must be in the future");
|
|
19
|
+
}
|
|
20
|
+
if (policy.scope.maxPerTx <= 0n) {
|
|
21
|
+
throw new SessionPolicyError("scope.maxPerTx must be positive");
|
|
22
|
+
}
|
|
23
|
+
if (policy.scope.recipients?.length === 0) {
|
|
24
|
+
throw new SessionPolicyError("scope.recipients must be omitted or non-empty");
|
|
25
|
+
}
|
|
26
|
+
const random = options.randomBytes ?? ((n) => crypto.getRandomValues(new Uint8Array(n)));
|
|
27
|
+
let privateKey;
|
|
28
|
+
// Rejection-sample until the key is a valid secp256k1 scalar (1..n-1).
|
|
29
|
+
for (;;) {
|
|
30
|
+
const candidate = ccc.hexFrom(random(32));
|
|
31
|
+
const k = BigInt(candidate);
|
|
32
|
+
if (k > 0n && k < SECP256K1_N) {
|
|
33
|
+
privateKey = candidate;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// The signer only derives the public key here; no network call is made.
|
|
38
|
+
const signer = new ccc.SignerCkbPrivateKey(new ccc.ClientPublicTestnet(), privateKey);
|
|
39
|
+
return { privateKey, publicKey: signer.publicKey, policy };
|
|
40
|
+
}
|
|
41
|
+
export function isActive(session, now = new Date()) {
|
|
42
|
+
return now.getTime() < session.policy.expiresAt.getTime();
|
|
43
|
+
}
|
|
44
|
+
export function checkRequest(session, request, now = new Date()) {
|
|
45
|
+
if (!isActive(session, now))
|
|
46
|
+
return { ok: false, reason: "session expired" };
|
|
47
|
+
const { scope } = session.policy;
|
|
48
|
+
if (request.amount <= 0n)
|
|
49
|
+
return { ok: false, reason: "amount must be positive" };
|
|
50
|
+
if (request.amount > scope.maxPerTx) {
|
|
51
|
+
return { ok: false, reason: `amount exceeds maxPerTx (${ccc.fixedPointToString(scope.maxPerTx)} CKB)` };
|
|
52
|
+
}
|
|
53
|
+
if (scope.recipients && !scope.recipients.includes(request.to)) {
|
|
54
|
+
return { ok: false, reason: "recipient not in scope" };
|
|
55
|
+
}
|
|
56
|
+
return { ok: true };
|
|
57
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Session } from "./session.js";
|
|
2
|
+
/**
|
|
3
|
+
* Where a session lives between page loads. The key never leaves the device:
|
|
4
|
+
* the browser store writes to IndexedDB, which is per-origin and local.
|
|
5
|
+
*/
|
|
6
|
+
export interface SessionStore {
|
|
7
|
+
load(): Promise<Session | undefined>;
|
|
8
|
+
/**
|
|
9
|
+
* Stores `session`. Refuses (SessionStoreConflict) to replace a *different*
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
save(session: Session): Promise<void>;
|
|
14
|
+
/** Forgets `session`, only if it is still the stored one; another tab may have replaced it. */
|
|
15
|
+
clear(session: Session): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export declare class SessionStoreConflict extends Error {
|
|
18
|
+
constructor();
|
|
19
|
+
}
|
|
20
|
+
export declare function serializeSession(s: Session): string;
|
|
21
|
+
export declare function deserializeSession(json: string): Session;
|
|
22
|
+
export declare function memoryStore(): SessionStore;
|
|
23
|
+
/**
|
|
24
|
+
* One session per `key`. Pages on the same origin that each run their own
|
|
25
|
+
* session (a wallet page and a reader page, say) pass different keys.
|
|
26
|
+
*/
|
|
27
|
+
export declare function indexedDbStore(idb?: IDBFactory, key?: string): SessionStore;
|