@cloakely/sdk 0.0.0-stage → 0.2.1

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.
@@ -0,0 +1,135 @@
1
+ // Private-account cryptography: Poseidon notes, x25519-encrypted memos, deterministic keys.
2
+ import { x25519 } from "@noble/curves/ed25519";
3
+ import { xchacha20poly1305 } from "@noble/ciphers/chacha";
4
+ import { hkdf } from "@noble/hashes/hkdf";
5
+ import { sha256 } from "@noble/hashes/sha256";
6
+ import { randomBytes } from "@noble/hashes/utils";
7
+ import { concat, keccak256, toBytes, toHex } from "viem";
8
+ export const FIELD_SIZE = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
9
+ export const LEVELS = 20;
10
+ export const MAX_NOTE_AMOUNT = 2n ** 128n;
11
+ /** Key index of the main spending key; any other index is a one-time key used for a single public entry. */
12
+ export const MAIN_KEY = 0xffffffff;
13
+ const fieldMod = (x) => ((x % FIELD_SIZE) + FIELD_SIZE) % FIELD_SIZE;
14
+ export const randomField = () => BigInt(toHex(randomBytes(31)));
15
+ export const randomKeyIndex = () => Number(BigInt(toHex(randomBytes(4))) % 0xfffffffen);
16
+ let _poseidon;
17
+ export async function poseidon(inputs) {
18
+ _poseidon ??= await (await import("circomlibjs")).buildPoseidon(); // loaded on first use
19
+ return _poseidon.F.toObject(_poseidon(inputs.map((x) => BigInt(x))));
20
+ }
21
+ /** The message a wallet signs to derive its Cloak private account (deterministic for standard EOAs). */
22
+ export const ACCOUNT_MESSAGE = "Cloak private account v1\n\nSigning this derives your private trading keys on Elysium. It costs nothing and does not move funds.";
23
+ /**
24
+ * EIP-712 request whose signature derives the private keys of a Cloak v2 account. It is bound to the chain and the
25
+ * router, so a signature collected by another site or for another contract can't reproduce the keys.
26
+ */
27
+ export const ACCOUNT_TYPED_DATA = (chainId, router) => ({
28
+ domain: { name: "Cloak", version: "2", chainId, verifyingContract: router },
29
+ types: { PrivateAccount: [{ name: "purpose", type: "string" }] },
30
+ primaryType: "PrivateAccount",
31
+ message: {
32
+ purpose: "Derive my Cloak private trading keys. This costs nothing and moves no funds. Only sign this on cloak.boblabs.tech.",
33
+ },
34
+ });
35
+ /**
36
+ * One seed → a main spending key, a view key (memo encryption) and 2³² one-time keys. One-time keys receive every note
37
+ * that enters the pool publicly (shielding, swap outputs), so on-chain `depositTo` calls never repeat a pubKey and
38
+ * cannot be linked to each other or to the main key.
39
+ */
40
+ export class CloakKeys {
41
+ seed;
42
+ encPriv;
43
+ encPub;
44
+ cache = new Map();
45
+ constructor(seed, encPriv, encPub) {
46
+ this.seed = seed;
47
+ this.encPriv = encPriv;
48
+ this.encPub = encPub;
49
+ }
50
+ static fromSeed(seed) {
51
+ const encPriv = toBytes(keccak256(concat([toBytes("cloak-view-v1"), seed])));
52
+ return new CloakKeys(seed, encPriv, x25519.getPublicKey(encPriv));
53
+ }
54
+ /** Derive from a wallet signature over ACCOUNT_MESSAGE. */
55
+ static fromSignature(sig) {
56
+ return CloakKeys.fromSeed(toBytes(keccak256(sig)));
57
+ }
58
+ async key(index) {
59
+ const hit = this.cache.get(index);
60
+ if (hit)
61
+ return hit;
62
+ const tag = index === MAIN_KEY ? toBytes("cloak-spend-v1") : concat([toBytes("cloak-otk-v1"), toBytes(toHex(index, { size: 4 }))]);
63
+ const privKey = fieldMod(BigInt(keccak256(concat([tag, this.seed]))));
64
+ const k = { index, privKey, pubKey: await poseidon([privKey]) };
65
+ this.cache.set(index, k);
66
+ return k;
67
+ }
68
+ main() {
69
+ return this.key(MAIN_KEY);
70
+ }
71
+ }
72
+ export const noteCommitment = (n) => poseidon([n.amount, n.pubKey, n.blinding]);
73
+ export async function nullifierOf(n, leafIndex, privKey) {
74
+ const c = await noteCommitment(n);
75
+ const sig = await poseidon([privKey, c, BigInt(leafIndex)]);
76
+ return poseidon([c, BigInt(leafIndex), sig]);
77
+ }
78
+ // ── memos ──
79
+ // 0x03 | eph(32) | nonce(24) | enc(amount16 ‖ blinding32 ‖ keyIndex4) + tag(16) — notes created by a proof
80
+ // 0x02 | amount(16) | eph(32) | nonce(24) | enc(blinding32 ‖ keyIndex4) + tag(16) — notes created by the router
81
+ // (the router prepends the public amount; the client only encrypts blinding + key index)
82
+ const INFO = toBytes("cloak-note-v1");
83
+ function seal(encPub, pt) {
84
+ const eph = randomBytes(32);
85
+ const key = hkdf(sha256, x25519.getSharedSecret(eph, encPub), undefined, INFO, 32);
86
+ const nonce = randomBytes(24);
87
+ return concat([x25519.getPublicKey(eph), nonce, xchacha20poly1305(key, nonce).encrypt(pt)]);
88
+ }
89
+ function open(encPriv, b) {
90
+ try {
91
+ const key = hkdf(sha256, x25519.getSharedSecret(encPriv, b.slice(0, 32)), undefined, INFO, 32);
92
+ return xchacha20poly1305(key, b.slice(32, 56)).decrypt(b.slice(56));
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ const u32 = (n) => toBytes(toHex(n, { size: 4 }));
99
+ export function encryptFullMemo(encPub, amount, blinding, keyIndex) {
100
+ return toHex(concat([new Uint8Array([3]), seal(encPub, concat([toBytes(toHex(amount, { size: 16 })), toBytes(toHex(blinding, { size: 32 })), u32(keyIndex)]))]));
101
+ }
102
+ /** For router-created notes: the router adds `0x02 ‖ amount` in front on-chain. */
103
+ export function encryptRouterMemo(encPub, blinding, keyIndex) {
104
+ return toHex(seal(encPub, concat([toBytes(toHex(blinding, { size: 32 })), u32(keyIndex)])));
105
+ }
106
+ export function readMemo(encPriv, memo) {
107
+ const b = toBytes(memo);
108
+ if (b.length === 1 + 32 + 24 + 52 + 16 && b[0] === 3) {
109
+ const pt = open(encPriv, b.slice(1));
110
+ if (!pt)
111
+ return null;
112
+ return { amount: BigInt(toHex(pt.slice(0, 16))), blinding: BigInt(toHex(pt.slice(16, 48))), keyIndex: Number(BigInt(toHex(pt.slice(48, 52)))) };
113
+ }
114
+ if (b.length === 1 + 16 + 32 + 24 + 36 + 16 && b[0] === 2) {
115
+ const pt = open(encPriv, b.slice(17));
116
+ if (!pt)
117
+ return null;
118
+ return { amount: BigInt(toHex(b.slice(1, 17))), blinding: BigInt(toHex(pt.slice(0, 32))), keyIndex: Number(BigInt(toHex(pt.slice(32, 36)))) };
119
+ }
120
+ return null;
121
+ }
122
+ // ── Cloak addresses: share one to receive private transfers ──
123
+ // cloak:<pubKey 32 bytes><view key 32 bytes>
124
+ export function formatCloakAddress(pubKey, encPub) {
125
+ return `cloak:${toHex(pubKey, { size: 32 }).slice(2)}${toHex(encPub).slice(2)}`;
126
+ }
127
+ export function parseCloakAddress(addr) {
128
+ const a = addr.trim();
129
+ if (!/^cloak:[0-9a-fA-F]{128}$/.test(a))
130
+ throw new Error("not a cloak: address");
131
+ const pubKey = BigInt("0x" + a.slice(6, 70));
132
+ if (pubKey >= FIELD_SIZE)
133
+ throw new Error("bad cloak: address");
134
+ return { pubKey, encPub: toBytes(`0x${a.slice(70)}`) };
135
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./crypto.js";
2
+ export * from "./tree.js";
3
+ export * from "./prover.js";
4
+ export * from "./tx.js";
5
+ export * from "./client.js";
@@ -0,0 +1,5 @@
1
+ export * from "./crypto.js";
2
+ export * from "./tree.js";
3
+ export * from "./prover.js";
4
+ export * from "./tx.js";
5
+ export * from "./client.js";
@@ -0,0 +1,31 @@
1
+ export interface ProofArgs {
2
+ a: [bigint, bigint];
3
+ b: [[bigint, bigint], [bigint, bigint]];
4
+ c: [bigint, bigint];
5
+ root: bigint;
6
+ publicAmount: bigint;
7
+ extDataHash: bigint;
8
+ inputNullifiers: [bigint, bigint];
9
+ outputCommitments: [bigint, bigint];
10
+ }
11
+ export type Prover = (input: Record<string, unknown>) => Promise<ProofArgs>;
12
+ /** Groth16 prover for the 2-in/2-out circuit (`transaction2x2`). Runs on the user's device. */
13
+ export declare function createProver(wasm: Uint8Array, zkey: Uint8Array): Prover;
14
+ /** Hosted circuit artifacts. */
15
+ export declare const CIRCUITS_URL = "https://cloak.boblabs.tech/circuits";
16
+ /** SHA-256 of the artifacts this SDK version was built and tested against. */
17
+ export declare const CIRCUIT_HASHES: {
18
+ readonly wasm: "e44f07497b34f3498352da5bfd9fd7a100c2e8cb7ebddc643f1e6e29d4dfcf6a";
19
+ readonly zkey: "5d64f2162ef439ae53f0e3c61f276245f7fa1b934907ef66ce3cf149056acad0";
20
+ };
21
+ /**
22
+ * Fetch the circuit artifacts (12 MB, once) and build a prover. Works in browsers and Node 18+. The files are checked
23
+ * against `CIRCUIT_HASHES`, so a tampered or mismatched download is refused; pass `hashes: null` to skip that when
24
+ * serving your own build. In Node you can also read the files yourself and call `createProver`.
25
+ */
26
+ export declare function loadProver(baseUrl?: string, opts?: {
27
+ hashes?: {
28
+ wasm: string;
29
+ zkey: string;
30
+ } | null;
31
+ }): Promise<Prover>;
@@ -0,0 +1,55 @@
1
+ const str = (v) => (Array.isArray(v) ? v.map(str) : String(v));
2
+ /** Groth16 prover for the 2-in/2-out circuit (`transaction2x2`). Runs on the user's device. */
3
+ export function createProver(wasm, zkey) {
4
+ return async (input) => {
5
+ const snarkjs = await import("snarkjs"); // ~1 MB, loaded on the first proof only
6
+ const { proof, publicSignals } = await snarkjs.groth16.fullProve(Object.fromEntries(Object.entries(input).map(([k, v]) => [k, str(v)])), wasm, zkey);
7
+ const s = publicSignals.map(BigInt);
8
+ return {
9
+ a: [BigInt(proof.pi_a[0]), BigInt(proof.pi_a[1])],
10
+ b: [
11
+ [BigInt(proof.pi_b[0][1]), BigInt(proof.pi_b[0][0])],
12
+ [BigInt(proof.pi_b[1][1]), BigInt(proof.pi_b[1][0])],
13
+ ],
14
+ c: [BigInt(proof.pi_c[0]), BigInt(proof.pi_c[1])],
15
+ root: s[0],
16
+ publicAmount: s[1],
17
+ extDataHash: s[2],
18
+ inputNullifiers: [s[3], s[4]],
19
+ outputCommitments: [s[5], s[6]],
20
+ };
21
+ };
22
+ }
23
+ /** Hosted circuit artifacts. */
24
+ export const CIRCUITS_URL = "https://cloak.boblabs.tech/circuits";
25
+ /** SHA-256 of the artifacts this SDK version was built and tested against. */
26
+ export const CIRCUIT_HASHES = {
27
+ wasm: "e44f07497b34f3498352da5bfd9fd7a100c2e8cb7ebddc643f1e6e29d4dfcf6a",
28
+ zkey: "5d64f2162ef439ae53f0e3c61f276245f7fa1b934907ef66ce3cf149056acad0",
29
+ };
30
+ async function sha256(bytes) {
31
+ const d = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes));
32
+ return Array.from(d, (b) => b.toString(16).padStart(2, "0")).join("");
33
+ }
34
+ /**
35
+ * Fetch the circuit artifacts (12 MB, once) and build a prover. Works in browsers and Node 18+. The files are checked
36
+ * against `CIRCUIT_HASHES`, so a tampered or mismatched download is refused; pass `hashes: null` to skip that when
37
+ * serving your own build. In Node you can also read the files yourself and call `createProver`.
38
+ */
39
+ export async function loadProver(baseUrl = CIRCUITS_URL, opts = {}) {
40
+ const base = baseUrl.replace(/\/$/, "");
41
+ const get = async (name) => {
42
+ const res = await fetch(`${base}/${name}`);
43
+ if (!res.ok)
44
+ throw new Error(`could not download ${name} from ${base} (${res.status})`);
45
+ return new Uint8Array(await res.arrayBuffer());
46
+ };
47
+ const [wasm, zkey] = await Promise.all([get("transaction2x2.wasm"), get("transaction2x2_final.zkey")]);
48
+ const want = opts.hashes === undefined ? CIRCUIT_HASHES : opts.hashes;
49
+ if (want) {
50
+ const [hw, hz] = await Promise.all([sha256(wasm), sha256(zkey)]);
51
+ if (hw !== want.wasm || hz !== want.zkey)
52
+ throw new Error("circuit files don't match this SDK version's hashes; refusing to prove with them");
53
+ }
54
+ return createProver(wasm, zkey);
55
+ }
@@ -0,0 +1,13 @@
1
+ export declare class MerkleTree {
2
+ leaves: bigint[];
3
+ zeros: bigint[] | null;
4
+ layersCache: bigint[][];
5
+ init(): Promise<void>;
6
+ root(): Promise<bigint>;
7
+ path(index: number): Promise<{
8
+ pathElements: bigint[];
9
+ pathIndices: number;
10
+ root: bigint;
11
+ }>;
12
+ insert(leaf: bigint): Promise<void>;
13
+ }
@@ -0,0 +1,52 @@
1
+ import { poseidon, LEVELS } from "./crypto.js";
2
+ export class MerkleTree {
3
+ leaves = [];
4
+ zeros = null;
5
+ layersCache = [];
6
+ async init() {
7
+ if (this.zeros)
8
+ return;
9
+ this.zeros = [0n];
10
+ for (let i = 0; i < LEVELS; i++)
11
+ this.zeros.push(await poseidon([this.zeros[i], this.zeros[i]]));
12
+ this.layersCache = [this.leaves.slice()];
13
+ for (let l = 0; l < LEVELS; l++) {
14
+ const cur = this.layersCache[l], next = [];
15
+ for (let i = 0; i < cur.length; i += 2) {
16
+ next.push(await poseidon([cur[i], i + 1 < cur.length ? cur[i + 1] : this.zeros[l]]));
17
+ }
18
+ this.layersCache.push(next);
19
+ }
20
+ }
21
+ async root() {
22
+ await this.init();
23
+ if (this.leaves.length === 0)
24
+ return this.zeros[LEVELS];
25
+ return this.layersCache[LEVELS][0] || this.zeros[LEVELS];
26
+ }
27
+ async path(index) {
28
+ await this.init();
29
+ const pathElements = [];
30
+ let idx = index;
31
+ for (let l = 0; l < LEVELS; l++) {
32
+ const sib = idx ^ 1;
33
+ pathElements.push(sib < this.layersCache[l].length ? this.layersCache[l][sib] : this.zeros[l]);
34
+ idx >>= 1;
35
+ }
36
+ return { pathElements, pathIndices: index, root: this.layersCache[LEVELS][0] };
37
+ }
38
+ async insert(leaf) {
39
+ await this.init();
40
+ this.leaves.push(leaf);
41
+ this.layersCache[0].push(leaf);
42
+ let idx = this.leaves.length - 1;
43
+ for (let l = 0; l < LEVELS; l++) {
44
+ const sib = idx ^ 1;
45
+ const left = sib > idx ? this.layersCache[l][idx] : (sib < this.layersCache[l].length ? this.layersCache[l][sib] : this.zeros[l]);
46
+ const right = sib > idx ? (sib < this.layersCache[l].length ? this.layersCache[l][sib] : this.zeros[l]) : this.layersCache[l][idx];
47
+ const p = await poseidon([left, right]);
48
+ idx >>= 1;
49
+ this.layersCache[l + 1][idx] = p;
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,121 @@
1
+ import { type Address, type Hex } from "viem";
2
+ import type { MerkleTree } from "./tree.js";
3
+ import type { ProofArgs, Prover } from "./prover.js";
4
+ export interface ExtData {
5
+ recipient: Address;
6
+ extAmount: bigint;
7
+ relayer: Address;
8
+ fee: bigint;
9
+ encryptedOutput1: Hex;
10
+ encryptedOutput2: Hex;
11
+ data: Hex;
12
+ }
13
+ /** A note we can spend: leaf position + the spending key it belongs to. */
14
+ export interface OwnedNote {
15
+ amount: bigint;
16
+ pubKey: bigint;
17
+ blinding: bigint;
18
+ leafIndex: number;
19
+ epoch?: number;
20
+ keyIndex: number;
21
+ privKey: bigint;
22
+ commitment: bigint;
23
+ nullifier: bigint;
24
+ spent: boolean;
25
+ }
26
+ export interface OutputNote {
27
+ amount: bigint;
28
+ pubKey: bigint;
29
+ blinding?: bigint;
30
+ memo: (blinding: bigint) => Hex;
31
+ }
32
+ export declare const SWAP_DATA_ABI: readonly [{
33
+ readonly type: "tuple";
34
+ readonly components: readonly [{
35
+ readonly name: "pair";
36
+ readonly type: "address";
37
+ }, {
38
+ readonly name: "poolOut";
39
+ readonly type: "address";
40
+ }, {
41
+ readonly name: "minOut";
42
+ readonly type: "uint256";
43
+ }, {
44
+ readonly name: "outPubKey";
45
+ readonly type: "uint256";
46
+ }, {
47
+ readonly name: "outBlinding";
48
+ readonly type: "uint256";
49
+ }, {
50
+ readonly name: "outMemo";
51
+ readonly type: "bytes";
52
+ }, {
53
+ readonly name: "deadline";
54
+ readonly type: "uint64";
55
+ }];
56
+ }];
57
+ export declare const ORDER_DATA_ABI: readonly [{
58
+ readonly type: "tuple";
59
+ readonly components: readonly [{
60
+ readonly name: "token";
61
+ readonly type: "address";
62
+ }, {
63
+ readonly name: "outPubKey";
64
+ readonly type: "uint256";
65
+ }, {
66
+ readonly name: "outBlinding";
67
+ readonly type: "uint256";
68
+ }, {
69
+ readonly name: "outMemo";
70
+ readonly type: "bytes";
71
+ }];
72
+ }];
73
+ /** `CloakMigrator.MoveData`: where a moved balance lands in the new deployment. */
74
+ export declare const MOVE_DATA_ABI: readonly [{
75
+ readonly type: "tuple";
76
+ readonly components: readonly [{
77
+ readonly name: "outPubKey";
78
+ readonly type: "uint256";
79
+ }, {
80
+ readonly name: "outBlinding";
81
+ readonly type: "uint256";
82
+ }, {
83
+ readonly name: "outMemo";
84
+ readonly type: "bytes";
85
+ }];
86
+ }];
87
+ /**
88
+ * Split an amount into at most `maxOrders` standard sizes (1, 2, 5 × 10ⁿ), largest first. Everyone's orders come
89
+ * from the same small set of sizes, so no order stands out.
90
+ */
91
+ export declare function splitStandard(amount: bigint, maxOrders?: number): {
92
+ sizes: bigint[];
93
+ rest: bigint;
94
+ };
95
+ export declare function hashExtData(ext: ExtData): bigint;
96
+ /**
97
+ * Build and prove a 2-in/2-out shielded transaction. `data` is bound into the proof through `extDataHash`, which is
98
+ * what lets the router act on a withdrawal without trusting whoever submits it.
99
+ */
100
+ export declare function buildTransaction(p: {
101
+ tree: MerkleTree;
102
+ inputs: OwnedNote[];
103
+ outputs: OutputNote[];
104
+ extAmount?: bigint;
105
+ fee?: bigint;
106
+ recipient?: Address;
107
+ relayer?: Address;
108
+ data?: Hex;
109
+ dummyPubKey: bigint;
110
+ dummyMemo: (blinding: bigint) => Hex;
111
+ prover: Prover;
112
+ }): Promise<{
113
+ proof: ProofArgs;
114
+ extData: ExtData;
115
+ }>;
116
+ /** Pick 1–2 notes covering `target` (smallest single note, else the tightest pair). */
117
+ export declare function selectNotes(notes: OwnedNote[], target: bigint): {
118
+ inputs: OwnedNote[];
119
+ } | {
120
+ merge: OwnedNote[];
121
+ };
@@ -0,0 +1,157 @@
1
+ import { encodeAbiParameters, keccak256, zeroAddress } from "viem";
2
+ import { FIELD_SIZE, LEVELS, MAX_NOTE_AMOUNT, noteCommitment, nullifierOf, poseidon, randomField } from "./crypto.js";
3
+ const EXT_DATA_ABI = [
4
+ {
5
+ type: "tuple",
6
+ components: [
7
+ { name: "recipient", type: "address" },
8
+ { name: "extAmount", type: "int256" },
9
+ { name: "relayer", type: "address" },
10
+ { name: "fee", type: "uint256" },
11
+ { name: "encryptedOutput1", type: "bytes" },
12
+ { name: "encryptedOutput2", type: "bytes" },
13
+ { name: "data", type: "bytes" },
14
+ ],
15
+ },
16
+ ];
17
+ export const SWAP_DATA_ABI = [
18
+ {
19
+ type: "tuple",
20
+ components: [
21
+ { name: "pair", type: "address" },
22
+ { name: "poolOut", type: "address" },
23
+ { name: "minOut", type: "uint256" },
24
+ { name: "outPubKey", type: "uint256" },
25
+ { name: "outBlinding", type: "uint256" },
26
+ { name: "outMemo", type: "bytes" },
27
+ { name: "deadline", type: "uint64" },
28
+ ],
29
+ },
30
+ ];
31
+ export const ORDER_DATA_ABI = [
32
+ {
33
+ type: "tuple",
34
+ components: [
35
+ { name: "token", type: "address" },
36
+ { name: "outPubKey", type: "uint256" },
37
+ { name: "outBlinding", type: "uint256" },
38
+ { name: "outMemo", type: "bytes" },
39
+ ],
40
+ },
41
+ ];
42
+ /** `CloakMigrator.MoveData`: where a moved balance lands in the new deployment. */
43
+ export const MOVE_DATA_ABI = [
44
+ {
45
+ type: "tuple",
46
+ components: [
47
+ { name: "outPubKey", type: "uint256" },
48
+ { name: "outBlinding", type: "uint256" },
49
+ { name: "outMemo", type: "bytes" },
50
+ ],
51
+ },
52
+ ];
53
+ /**
54
+ * Split an amount into at most `maxOrders` standard sizes (1, 2, 5 × 10ⁿ), largest first. Everyone's orders come
55
+ * from the same small set of sizes, so no order stands out.
56
+ */
57
+ export function splitStandard(amount, maxOrders = 6) {
58
+ const sizes = [];
59
+ let rest = amount;
60
+ while (rest > 0n && sizes.length < maxOrders) {
61
+ const pow = 10n ** BigInt(rest.toString().length - 1);
62
+ const lead = rest / pow; // 1..9
63
+ const d = lead >= 5n ? 5n : lead >= 2n ? 2n : 1n;
64
+ sizes.push(d * pow);
65
+ rest -= d * pow;
66
+ }
67
+ return { sizes, rest };
68
+ }
69
+ export function hashExtData(ext) {
70
+ return BigInt(keccak256(encodeAbiParameters(EXT_DATA_ABI, [ext]))) % FIELD_SIZE;
71
+ }
72
+ const fieldMod = (x) => ((x % FIELD_SIZE) + FIELD_SIZE) % FIELD_SIZE;
73
+ /**
74
+ * Build and prove a 2-in/2-out shielded transaction. `data` is bound into the proof through `extDataHash`, which is
75
+ * what lets the router act on a withdrawal without trusting whoever submits it.
76
+ */
77
+ export async function buildTransaction(p) {
78
+ const extAmount = p.extAmount ?? 0n;
79
+ const fee = p.fee ?? 0n;
80
+ if (p.inputs.length > 2 || p.outputs.length > 2)
81
+ throw new Error("max 2 inputs and 2 outputs");
82
+ const ins = [...p.inputs];
83
+ while (ins.length < 2) {
84
+ const privKey = randomField();
85
+ ins.push({ amount: 0n, privKey, pubKey: await poseidon([privKey]), blinding: randomField(), leafIndex: 0 });
86
+ }
87
+ const outs = p.outputs.map((o) => ({ ...o, blinding: o.blinding ?? randomField() }));
88
+ while (outs.length < 2)
89
+ outs.push({ amount: 0n, pubKey: p.dummyPubKey, blinding: randomField(), memo: p.dummyMemo });
90
+ for (const o of outs)
91
+ if (o.amount < 0n || o.amount >= MAX_NOTE_AMOUNT)
92
+ throw new Error("output amount out of range");
93
+ const sumIn = ins.reduce((s, n) => s + n.amount, 0n);
94
+ const sumOut = outs.reduce((s, n) => s + n.amount, 0n);
95
+ if (sumIn + extAmount - fee !== sumOut)
96
+ throw new Error("unbalanced transaction");
97
+ const root = await p.tree.root();
98
+ const inPathElements = [];
99
+ for (const n of ins) {
100
+ if (n.amount === 0n) {
101
+ inPathElements.push(new Array(LEVELS).fill(0n));
102
+ continue;
103
+ }
104
+ const path = await p.tree.path(n.leafIndex);
105
+ if (path.root !== root)
106
+ throw new Error("merkle path does not match root");
107
+ inPathElements.push(path.pathElements);
108
+ }
109
+ const extData = {
110
+ recipient: p.recipient ?? zeroAddress,
111
+ extAmount,
112
+ relayer: p.relayer ?? zeroAddress,
113
+ fee,
114
+ encryptedOutput1: outs[0].memo(outs[0].blinding),
115
+ encryptedOutput2: outs[1].memo(outs[1].blinding),
116
+ data: p.data ?? "0x",
117
+ };
118
+ const proof = await p.prover({
119
+ root,
120
+ publicAmount: fieldMod(extAmount - fee),
121
+ extDataHash: hashExtData(extData),
122
+ inputNullifier: await Promise.all(ins.map((n) => nullifierOf(n, n.leafIndex, n.privKey))),
123
+ outputCommitment: await Promise.all(outs.map((o) => noteCommitment(o))),
124
+ inAmount: ins.map((n) => n.amount),
125
+ inPrivateKey: ins.map((n) => n.privKey),
126
+ inBlinding: ins.map((n) => n.blinding),
127
+ inPathIndices: ins.map((n) => BigInt(n.leafIndex)),
128
+ inPathElements,
129
+ outAmount: outs.map((o) => o.amount),
130
+ outPubkey: outs.map((o) => o.pubKey),
131
+ outBlinding: outs.map((o) => o.blinding),
132
+ });
133
+ return { proof, extData };
134
+ }
135
+ /** Pick 1–2 notes covering `target` (smallest single note, else the tightest pair). */
136
+ export function selectNotes(notes, target) {
137
+ const live = notes.filter((n) => !n.spent).sort((a, b) => (a.amount < b.amount ? -1 : 1));
138
+ const single = live.find((n) => n.amount >= target);
139
+ if (single)
140
+ return { inputs: [single] };
141
+ let best = null;
142
+ let bestSum = 0n;
143
+ for (let i = 0; i < live.length; i++)
144
+ for (let j = i + 1; j < live.length; j++) {
145
+ const s = live[i].amount + live[j].amount;
146
+ if (s >= target && (!best || s < bestSum)) {
147
+ best = [live[i], live[j]];
148
+ bestSum = s;
149
+ }
150
+ }
151
+ if (best)
152
+ return { inputs: best };
153
+ const total = live.reduce((s, n) => s + n.amount, 0n);
154
+ if (total < target || live.length < 2)
155
+ throw new Error("insufficient private balance");
156
+ return { merge: live.slice(-2) };
157
+ }
package/dist/seal.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { Address, Hex } from "viem";
2
+ /** What a sealed bid hides until its drand round: everything needed to call `reveal`. */
3
+ export interface BidPreimage {
4
+ auction: Address;
5
+ bidder: Address;
6
+ tick: number;
7
+ quantity: bigint;
8
+ salt: Hex;
9
+ }
10
+ /** First drand quicknet round that is published at or after `unixSeconds`. */
11
+ export declare function drandRoundAt(unixSeconds: number): number;
12
+ /** Unix time (seconds) at which a drand quicknet round is published. */
13
+ export declare function drandRoundTime(round: number): number;
14
+ /** Encrypt a bid preimage to a future drand round. Nobody — sequencer, DA committee, us — can open it early. */
15
+ export declare function sealBid(p: BidPreimage, round: number): Promise<Hex>;
16
+ /** Decrypt a sealed bid once its round is out. Throws before that (the beacon has not released the key). */
17
+ export declare function unsealBid(sealed: Hex): Promise<BidPreimage>;
package/dist/seal.js ADDED
@@ -0,0 +1,41 @@
1
+ import { timelockDecrypt, timelockEncrypt, mainnetClient, roundAt, defaultChainInfo, Buffer } from "tlock-js";
2
+ import { bytesToString, stringToHex, hexToString } from "viem";
3
+ let client;
4
+ const drand = () => (client ??= mainnetClient()); // drand quicknet, 3 s rounds
5
+ /** First drand quicknet round that is published at or after `unixSeconds`. */
6
+ export function drandRoundAt(unixSeconds) {
7
+ return roundAt(unixSeconds * 1000, defaultChainInfo) + 1;
8
+ }
9
+ /** Unix time (seconds) at which a drand quicknet round is published. */
10
+ export function drandRoundTime(round) {
11
+ return defaultChainInfo.genesis_time + (round - 1) * defaultChainInfo.period;
12
+ }
13
+ /** Encrypt a bid preimage to a future drand round. Nobody — sequencer, DA committee, us — can open it early. */
14
+ export async function sealBid(p, round) {
15
+ const json = JSON.stringify({ v: 1, a: p.auction, b: p.bidder, t: p.tick, q: p.quantity.toString(), s: p.salt });
16
+ const armored = await timelockEncrypt(round, Buffer.from(json), drand());
17
+ return stringToHex(armored);
18
+ }
19
+ // tlock-js logs every beacon it fetches; keep keeper output readable.
20
+ async function quiet(fn) {
21
+ const log = console.log;
22
+ console.log = (...a) => {
23
+ if (typeof a[0] === "string" && a[0].startsWith("beacon received"))
24
+ return;
25
+ log(...a);
26
+ };
27
+ try {
28
+ return await fn();
29
+ }
30
+ finally {
31
+ console.log = log;
32
+ }
33
+ }
34
+ /** Decrypt a sealed bid once its round is out. Throws before that (the beacon has not released the key). */
35
+ export async function unsealBid(sealed) {
36
+ const plain = await quiet(() => timelockDecrypt(hexToString(sealed), drand()));
37
+ const j = JSON.parse(bytesToString(new Uint8Array(plain)));
38
+ if (j.v !== 1)
39
+ throw new Error("unknown sealed bid version");
40
+ return { auction: j.a, bidder: j.b, tick: Number(j.t), quantity: BigInt(j.q), salt: j.s };
41
+ }