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/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +558 -3
- package/dist/index.d.ts +15 -0
- package/dist/index.js +8 -0
- package/dist/lock.d.ts +70 -0
- package/dist/lock.js +106 -0
- package/dist/onchain.d.ts +212 -0
- package/dist/onchain.js +561 -0
- package/dist/paywall.d.ts +31 -0
- package/dist/paywall.js +116 -0
- package/dist/place.d.ts +104 -0
- package/dist/place.js +151 -0
- package/dist/session.d.ts +63 -0
- package/dist/session.js +54 -0
- package/dist/store.d.ts +26 -0
- package/dist/store.js +181 -0
- package/dist/subscription.d.ts +221 -0
- package/dist/subscription.js +622 -0
- package/dist/transfer.d.ts +19 -0
- package/dist/transfer.js +33 -0
- package/package.json +70 -6
package/dist/paywall.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
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.
|
|
18
|
+
* Otherwise one transaction could pay the creator once and carry memos from
|
|
19
|
+
* several keys, each claiming the full amount for a different article or reader.
|
|
20
|
+
*/
|
|
21
|
+
/** A session payment has a handful of inputs; more is not one, and costs RPC calls to check. */
|
|
22
|
+
const MAX_PAYMENT_INPUTS = 16;
|
|
23
|
+
/** The memo for a payment: a short UTF-8 label, e.g. `read:article-1`. */
|
|
24
|
+
export function paymentMemo(label) {
|
|
25
|
+
return ccc.hexFrom(ccc.bytesFrom(label, "utf8"));
|
|
26
|
+
}
|
|
27
|
+
function accessMessage(txHash, memo) {
|
|
28
|
+
return `ckb-session-kit access ${ccc.hexFrom(txHash)} ${ccc.hexFrom(memo)}`;
|
|
29
|
+
}
|
|
30
|
+
/** The payer's side: prove that the key which paid `txHash` is asking. */
|
|
31
|
+
export async function signAccess(session, txHash, memo) {
|
|
32
|
+
const signer = sessionSigner(session, new ccc.ClientPublicTestnet());
|
|
33
|
+
const { signature } = await signer.signMessage(accessMessage(txHash, memo));
|
|
34
|
+
return { txHash: ccc.hexFrom(txHash), memo: ccc.hexFrom(memo), publicKey: signer.publicKey, signature: ccc.hexFrom(signature) };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The server's side. `to` is the recipient that must have been paid, as a lock
|
|
38
|
+
* script; `minAmount` in shannons. With `token` (an xUDT type script), the
|
|
39
|
+
* payment is counted in that token's units instead, and `minAmount` is in units.
|
|
40
|
+
*/
|
|
41
|
+
export async function verifyAccess(client, proof, expect) {
|
|
42
|
+
if (ccc.hexFrom(proof.memo) !== ccc.hexFrom(expect.memo))
|
|
43
|
+
return { ok: false, reason: "memo does not match" };
|
|
44
|
+
// Always the CKB secp256k1 scheme: a sign type chosen by the caller is not trusted.
|
|
45
|
+
let signed = false;
|
|
46
|
+
try {
|
|
47
|
+
signed = ccc.verifyMessageCkbSecp256k1(accessMessage(proof.txHash, proof.memo), proof.signature, proof.publicKey);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
signed = false;
|
|
51
|
+
}
|
|
52
|
+
if (!signed)
|
|
53
|
+
return { ok: false, reason: "request not signed by the given key" };
|
|
54
|
+
const res = await client.getTransaction(proof.txHash);
|
|
55
|
+
if (!res)
|
|
56
|
+
return { ok: false, reason: "payment not found" };
|
|
57
|
+
if (res.status !== "committed")
|
|
58
|
+
return { ok: false, reason: `payment is ${res.status}, not committed` };
|
|
59
|
+
const tx = res.transaction;
|
|
60
|
+
// Cheap checks first, before resolving any input over RPC.
|
|
61
|
+
if (tx.inputs.length > MAX_PAYMENT_INPUTS)
|
|
62
|
+
return { ok: false, reason: "not a session payment: too many inputs" };
|
|
63
|
+
let memos = 0;
|
|
64
|
+
let memoAt = -1;
|
|
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)
|
|
79
|
+
return { ok: false, reason: "a payment must carry exactly one memo" };
|
|
80
|
+
// The payer's lock: the standard secp256k1 lock of the key that signed the request.
|
|
81
|
+
const payerLock = (await new ccc.SignerCkbPublicKey(client, proof.publicKey).getAddressObjSecp256k1()).script;
|
|
82
|
+
// One lookup before all of them: anyone can sign a request for someone else's
|
|
83
|
+
// public payment, and must not get to make the server resolve every input.
|
|
84
|
+
const memoCell = memoAt < tx.inputs.length ? await client.getCell(tx.inputs[memoAt].previousOutput) : undefined;
|
|
85
|
+
if (!memoCell?.cellOutput.lock.eq(payerLock))
|
|
86
|
+
return { ok: false, reason: "the signing key did not pay this transaction" };
|
|
87
|
+
const to = ccc.Script.from(expect.to);
|
|
88
|
+
const token = expect.token && ccc.Script.from(expect.token);
|
|
89
|
+
// What a cell gives the recipient: its capacity, or with a token its units of that exact token
|
|
90
|
+
// (a committed xUDT cell always holds the 16-byte amount).
|
|
91
|
+
const value = (o, data) => !o.lock.eq(to) ? 0n : !token ? o.capacity : o.type?.eq(token) ? tokenAmount(data) : 0n;
|
|
92
|
+
let payerInput = -1;
|
|
93
|
+
let toIn = 0n;
|
|
94
|
+
for (let i = 0; i < tx.inputs.length; i++) {
|
|
95
|
+
const cell = await client.getCell(tx.inputs[i].previousOutput);
|
|
96
|
+
if (!cell)
|
|
97
|
+
return { ok: false, reason: "an input of the payment cannot be resolved" };
|
|
98
|
+
if (payerInput < 0 && cell.cellOutput.lock.eq(payerLock))
|
|
99
|
+
payerInput = i;
|
|
100
|
+
toIn += value(cell.cellOutput, cell.outputData);
|
|
101
|
+
}
|
|
102
|
+
if (payerInput < 0)
|
|
103
|
+
return { ok: false, reason: "the signing key did not pay this transaction" };
|
|
104
|
+
// The witness the payer's lock verified (the first of its group) must carry the memo.
|
|
105
|
+
const witness = tx.getWitnessArgsAt(payerInput);
|
|
106
|
+
if (!witness?.inputType || ccc.hexFrom(witness.inputType) !== ccc.hexFrom(expect.memo)) {
|
|
107
|
+
return { ok: false, reason: "the payment does not carry this memo" };
|
|
108
|
+
}
|
|
109
|
+
const toOut = tx.outputs.reduce((sum, o, i) => sum + value(o, tx.outputsData[i] ?? "0x"), 0n);
|
|
110
|
+
const paid = toOut - toIn;
|
|
111
|
+
if (paid < expect.minAmount) {
|
|
112
|
+
const shown = token ? `${paid} units` : `${ccc.fixedPointToString(paid)} CKB`;
|
|
113
|
+
return { ok: false, reason: `paid ${shown}, below the price` };
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, paid };
|
|
116
|
+
}
|
package/dist/place.d.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
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 cheap part of the check, before resolving any input: the transaction
|
|
55
|
+
* carries exactly one memo, it is a valid placement, and where it sits (the
|
|
56
|
+
* witness index). `undefined` if not.
|
|
57
|
+
*/
|
|
58
|
+
export declare function placementMemo(tx: ccc.TransactionLike, board?: PlaceBoard): {
|
|
59
|
+
index: number;
|
|
60
|
+
pixels: Pixel[];
|
|
61
|
+
} | undefined;
|
|
62
|
+
export type PlacementCheck = {
|
|
63
|
+
ok: true;
|
|
64
|
+
pixels: Pixel[];
|
|
65
|
+
paid: bigint;
|
|
66
|
+
} | {
|
|
67
|
+
ok: false;
|
|
68
|
+
reason: string;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Whether `tx` is a valid placement on the board under `boardLock`. `inputs` are
|
|
72
|
+
* the cells `tx` spends, resolved, in input order.
|
|
73
|
+
*
|
|
74
|
+
* - exactly one memo, and a valid one (`placementMemo`);
|
|
75
|
+
* - the memo sits in the first witness of an input's lock group, and that lock is
|
|
76
|
+
* not the board's: it is the witness the payer's lock verifies, so a lock that
|
|
77
|
+
* signs its witnesses (secp256k1: the session key's key cell) commits the
|
|
78
|
+
* payment to these pixels and no relay can swap them;
|
|
79
|
+
* - the board's lock gained at least `price × pixels` (outputs minus inputs).
|
|
80
|
+
*/
|
|
81
|
+
export declare function checkPlacement(tx: ccc.TransactionLike, inputs: ccc.CellOutputLike[], boardLock: ccc.ScriptLike, board?: PlaceBoard): PlacementCheck;
|
|
82
|
+
/** A checked placement and where it sits in the chain. */
|
|
83
|
+
export interface Placement {
|
|
84
|
+
txHash: ccc.Hex;
|
|
85
|
+
blockNumber: bigint;
|
|
86
|
+
/** Position of the transaction in its block. */
|
|
87
|
+
txIndex: number;
|
|
88
|
+
pixels: Pixel[];
|
|
89
|
+
}
|
|
90
|
+
/** One square of the board: its colour and the transaction that set it last. */
|
|
91
|
+
export interface PlacedPixel {
|
|
92
|
+
colour: number;
|
|
93
|
+
txHash: ccc.Hex;
|
|
94
|
+
blockNumber: bigint;
|
|
95
|
+
}
|
|
96
|
+
/** Row-major, `width × height` long; `undefined` where nothing has been placed. */
|
|
97
|
+
export type BoardState = (PlacedPixel | undefined)[];
|
|
98
|
+
/**
|
|
99
|
+
* The board after `placements`, applied in chain order (block number, then
|
|
100
|
+
* position in the block) whatever order they are given in; within one
|
|
101
|
+
* placement, in memo order. Last write wins. A transaction listed twice counts
|
|
102
|
+
* once, and a pixel off the board is skipped.
|
|
103
|
+
*/
|
|
104
|
+
export declare function applyPlacements(placements: Placement[], board?: PlaceBoard): BoardState;
|
package/dist/place.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { ccc } from "@ckb-ccc/core";
|
|
2
|
+
/** The demo's board: 32×32, 16 colours, 0.1 CKB a pixel, up to 32 pixels a payment. */
|
|
3
|
+
export const PLACE_BOARD = { width: 32, height: 32, colours: 16, price: 10000000n, maxPixels: 32 };
|
|
4
|
+
export class PlaceMemoError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "PlaceMemoError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const MAGIC = [0x70, 0x78, 0x31]; // "px1"
|
|
11
|
+
const byteSized = (n) => Number.isInteger(n) && n >= 1 && n <= 256;
|
|
12
|
+
function checkBoard(board) {
|
|
13
|
+
if (!byteSized(board.width) || !byteSized(board.height) || !byteSized(board.colours)) {
|
|
14
|
+
throw new PlaceMemoError("board width, height and colours must be 1–256");
|
|
15
|
+
}
|
|
16
|
+
if (!Number.isInteger(board.maxPixels) || board.maxPixels < 1)
|
|
17
|
+
throw new PlaceMemoError("maxPixels must be at least 1");
|
|
18
|
+
if (board.price <= 0n)
|
|
19
|
+
throw new PlaceMemoError("price must be positive");
|
|
20
|
+
}
|
|
21
|
+
function onBoard(p, board) {
|
|
22
|
+
const ok = (v, max) => Number.isInteger(v) && v >= 0 && v < max;
|
|
23
|
+
return ok(p.x, board.width) && ok(p.y, board.height) && ok(p.colour, board.colours);
|
|
24
|
+
}
|
|
25
|
+
/** The memo for a placement. Throws `PlaceMemoError` for an empty, oversized or off-board list. */
|
|
26
|
+
export function encodePlacement(pixels, board = PLACE_BOARD) {
|
|
27
|
+
checkBoard(board);
|
|
28
|
+
if (!pixels.length)
|
|
29
|
+
throw new PlaceMemoError("no pixels to place");
|
|
30
|
+
if (pixels.length > board.maxPixels)
|
|
31
|
+
throw new PlaceMemoError(`at most ${board.maxPixels} pixels per placement`);
|
|
32
|
+
const bytes = [...MAGIC];
|
|
33
|
+
for (const p of pixels) {
|
|
34
|
+
if (!onBoard(p, board))
|
|
35
|
+
throw new PlaceMemoError(`pixel (${p.x}, ${p.y}) colour ${p.colour} is not on the board`);
|
|
36
|
+
bytes.push(p.x, p.y, p.colour);
|
|
37
|
+
}
|
|
38
|
+
return ccc.hexFrom(new Uint8Array(bytes));
|
|
39
|
+
}
|
|
40
|
+
/** The pixels in a memo, or `undefined` if it is not a valid version-1 memo for this board. Never throws. */
|
|
41
|
+
export function decodePlacement(memo, board = PLACE_BOARD) {
|
|
42
|
+
let bytes;
|
|
43
|
+
try {
|
|
44
|
+
checkBoard(board);
|
|
45
|
+
bytes = ccc.bytesFrom(memo);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
// Length first: an oversized memo is refused before it is read.
|
|
51
|
+
const n = (bytes.length - MAGIC.length) / 3;
|
|
52
|
+
if (!Number.isInteger(n) || n < 1 || n > board.maxPixels)
|
|
53
|
+
return undefined;
|
|
54
|
+
if (MAGIC.some((b, i) => bytes[i] !== b))
|
|
55
|
+
return undefined;
|
|
56
|
+
const pixels = [];
|
|
57
|
+
for (let i = MAGIC.length; i < bytes.length; i += 3) {
|
|
58
|
+
const p = { x: bytes[i], y: bytes[i + 1], colour: bytes[i + 2] };
|
|
59
|
+
if (!onBoard(p, board))
|
|
60
|
+
return undefined;
|
|
61
|
+
pixels.push(p);
|
|
62
|
+
}
|
|
63
|
+
return pixels;
|
|
64
|
+
}
|
|
65
|
+
/** What a placement costs, in shannons. */
|
|
66
|
+
export function placementPrice(pixels, board = PLACE_BOARD) {
|
|
67
|
+
return board.price * BigInt(pixels);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The cheap part of the check, before resolving any input: the transaction
|
|
71
|
+
* carries exactly one memo, it is a valid placement, and where it sits (the
|
|
72
|
+
* witness index). `undefined` if not.
|
|
73
|
+
*/
|
|
74
|
+
export function placementMemo(tx, board = PLACE_BOARD) {
|
|
75
|
+
const t = ccc.Transaction.from(tx);
|
|
76
|
+
let found;
|
|
77
|
+
for (let i = 0; i < t.witnesses.length; i++) {
|
|
78
|
+
let inputType;
|
|
79
|
+
try {
|
|
80
|
+
inputType = t.getWitnessArgsAt(i)?.inputType;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
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 };
|
|
90
|
+
}
|
|
91
|
+
if (!found || found.index >= t.inputs.length)
|
|
92
|
+
return undefined;
|
|
93
|
+
const pixels = decodePlacement(found.memo, board);
|
|
94
|
+
return pixels && { index: found.index, pixels };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Whether `tx` is a valid placement on the board under `boardLock`. `inputs` are
|
|
98
|
+
* the cells `tx` spends, resolved, in input order.
|
|
99
|
+
*
|
|
100
|
+
* - exactly one memo, and a valid one (`placementMemo`);
|
|
101
|
+
* - the memo sits in the first witness of an input's lock group, and that lock is
|
|
102
|
+
* not the board's: it is the witness the payer's lock verifies, so a lock that
|
|
103
|
+
* signs its witnesses (secp256k1: the session key's key cell) commits the
|
|
104
|
+
* payment to these pixels and no relay can swap them;
|
|
105
|
+
* - the board's lock gained at least `price × pixels` (outputs minus inputs).
|
|
106
|
+
*/
|
|
107
|
+
export function checkPlacement(tx, inputs, boardLock, board = PLACE_BOARD) {
|
|
108
|
+
const t = ccc.Transaction.from(tx);
|
|
109
|
+
if (inputs.length !== t.inputs.length)
|
|
110
|
+
return { ok: false, reason: "inputs not resolved" };
|
|
111
|
+
const memo = placementMemo(t, board);
|
|
112
|
+
if (!memo)
|
|
113
|
+
return { ok: false, reason: "no valid placement memo" };
|
|
114
|
+
const lock = ccc.Script.from(boardLock);
|
|
115
|
+
const cells = inputs.map((c) => ccc.CellOutput.from(c));
|
|
116
|
+
const memoLock = cells[memo.index].lock;
|
|
117
|
+
if (memoLock.eq(lock))
|
|
118
|
+
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
|
+
const sum = (cs) => cs.reduce((s, c) => (c.lock.eq(lock) ? s + c.capacity : s), 0n);
|
|
123
|
+
const paid = sum(t.outputs) - sum(cells);
|
|
124
|
+
const price = placementPrice(memo.pixels.length, board);
|
|
125
|
+
if (paid < price) {
|
|
126
|
+
return { ok: false, reason: `paid ${ccc.fixedPointToString(paid)} CKB for ${memo.pixels.length} pixel(s), below ${ccc.fixedPointToString(price)}` };
|
|
127
|
+
}
|
|
128
|
+
return { ok: true, pixels: memo.pixels, paid };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The board after `placements`, applied in chain order (block number, then
|
|
132
|
+
* position in the block) whatever order they are given in; within one
|
|
133
|
+
* placement, in memo order. Last write wins. A transaction listed twice counts
|
|
134
|
+
* once, and a pixel off the board is skipped.
|
|
135
|
+
*/
|
|
136
|
+
export function applyPlacements(placements, board = PLACE_BOARD) {
|
|
137
|
+
const state = new Array(board.width * board.height).fill(undefined);
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
const ordered = [...placements].sort((a, b) => a.blockNumber !== b.blockNumber ? (a.blockNumber < b.blockNumber ? -1 : 1) : a.txIndex - b.txIndex);
|
|
140
|
+
for (const p of ordered) {
|
|
141
|
+
if (seen.has(p.txHash))
|
|
142
|
+
continue;
|
|
143
|
+
seen.add(p.txHash);
|
|
144
|
+
for (const px of p.pixels) {
|
|
145
|
+
if (!onBoard(px, board))
|
|
146
|
+
continue;
|
|
147
|
+
state[px.y * board.width + px.x] = { colour: px.colour, txHash: p.txHash, blockNumber: p.blockNumber };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return state;
|
|
151
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
export declare class SessionPolicyError extends Error {
|
|
50
|
+
constructor(message: string);
|
|
51
|
+
}
|
|
52
|
+
export declare function createSession(policy: SessionPolicy, options?: CreateOptions): Session;
|
|
53
|
+
export declare function isActive(session: Session, now?: Date): boolean;
|
|
54
|
+
export type ScopeCheck = {
|
|
55
|
+
ok: true;
|
|
56
|
+
} | {
|
|
57
|
+
ok: false;
|
|
58
|
+
reason: string;
|
|
59
|
+
};
|
|
60
|
+
export declare function checkRequest(session: Session, request: {
|
|
61
|
+
to: string;
|
|
62
|
+
amount: bigint;
|
|
63
|
+
}, now?: Date): ScopeCheck;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ccc } from "@ckb-ccc/core";
|
|
2
|
+
export class SessionPolicyError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "SessionPolicyError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
const SECP256K1_N = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141");
|
|
9
|
+
export function createSession(policy, options = {}) {
|
|
10
|
+
const now = options.now ?? new Date();
|
|
11
|
+
if (!Number.isFinite(policy.expiresAt.getTime())) {
|
|
12
|
+
throw new SessionPolicyError("expiresAt is not a valid date");
|
|
13
|
+
}
|
|
14
|
+
if (policy.expiresAt.getTime() <= now.getTime()) {
|
|
15
|
+
throw new SessionPolicyError("expiresAt must be in the future");
|
|
16
|
+
}
|
|
17
|
+
if (policy.scope.maxPerTx <= 0n) {
|
|
18
|
+
throw new SessionPolicyError("scope.maxPerTx must be positive");
|
|
19
|
+
}
|
|
20
|
+
if (policy.scope.recipients?.length === 0) {
|
|
21
|
+
throw new SessionPolicyError("scope.recipients must be omitted or non-empty");
|
|
22
|
+
}
|
|
23
|
+
const random = options.randomBytes ?? ((n) => crypto.getRandomValues(new Uint8Array(n)));
|
|
24
|
+
let privateKey;
|
|
25
|
+
// Rejection-sample until the key is a valid secp256k1 scalar (1..n-1).
|
|
26
|
+
for (;;) {
|
|
27
|
+
const candidate = ccc.hexFrom(random(32));
|
|
28
|
+
const k = BigInt(candidate);
|
|
29
|
+
if (k > 0n && k < SECP256K1_N) {
|
|
30
|
+
privateKey = candidate;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// The signer only derives the public key here; no network call is made.
|
|
35
|
+
const signer = new ccc.SignerCkbPrivateKey(new ccc.ClientPublicTestnet(), privateKey);
|
|
36
|
+
return { privateKey, publicKey: signer.publicKey, policy };
|
|
37
|
+
}
|
|
38
|
+
export function isActive(session, now = new Date()) {
|
|
39
|
+
return now.getTime() < session.policy.expiresAt.getTime();
|
|
40
|
+
}
|
|
41
|
+
export function checkRequest(session, request, now = new Date()) {
|
|
42
|
+
if (!isActive(session, now))
|
|
43
|
+
return { ok: false, reason: "session expired" };
|
|
44
|
+
const { scope } = session.policy;
|
|
45
|
+
if (request.amount <= 0n)
|
|
46
|
+
return { ok: false, reason: "amount must be positive" };
|
|
47
|
+
if (request.amount > scope.maxPerTx) {
|
|
48
|
+
return { ok: false, reason: `amount exceeds maxPerTx (${ccc.fixedPointToString(scope.maxPerTx)} CKB)` };
|
|
49
|
+
}
|
|
50
|
+
if (scope.recipients && !scope.recipients.includes(request.to)) {
|
|
51
|
+
return { ok: false, reason: "recipient not in scope" };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true };
|
|
54
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
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.
|
|
11
|
+
*/
|
|
12
|
+
save(session: Session): Promise<void>;
|
|
13
|
+
/** Forgets `session`, only if it is still the stored one; another tab may have replaced it. */
|
|
14
|
+
clear(session: Session): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export declare class SessionStoreConflict extends Error {
|
|
17
|
+
constructor();
|
|
18
|
+
}
|
|
19
|
+
export declare function serializeSession(s: Session): string;
|
|
20
|
+
export declare function deserializeSession(json: string): Session;
|
|
21
|
+
export declare function memoryStore(): SessionStore;
|
|
22
|
+
/**
|
|
23
|
+
* One session per `key`. Pages on the same origin that each run their own
|
|
24
|
+
* session (a wallet page and a reader page, say) pass different keys.
|
|
25
|
+
*/
|
|
26
|
+
export declare function indexedDbStore(idb?: IDBFactory, key?: string): SessionStore;
|