@claviss/sdk 0.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/README.md +33 -0
- package/dist/chain/abi.d.ts +1149 -0
- package/dist/chain/abi.js +5 -0
- package/dist/chain/chains.d.ts +106 -0
- package/dist/chain/chains.js +24 -0
- package/dist/chain/index.d.ts +2 -0
- package/dist/chain/index.js +2 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +22 -0
- package/dist/pool/crypto.d.ts +17 -0
- package/dist/pool/crypto.js +79 -0
- package/dist/pool/index.d.ts +3 -0
- package/dist/pool/index.js +3 -0
- package/dist/pool/note.d.ts +22 -0
- package/dist/pool/note.js +66 -0
- package/dist/pool/prover.d.ts +35 -0
- package/dist/pool/prover.js +48 -0
- package/dist/shielded/index.d.ts +4 -0
- package/dist/shielded/index.js +4 -0
- package/dist/shielded/keypair.d.ts +23 -0
- package/dist/shielded/keypair.js +72 -0
- package/dist/shielded/note.d.ts +22 -0
- package/dist/shielded/note.js +32 -0
- package/dist/shielded/prover.d.ts +56 -0
- package/dist/shielded/prover.js +100 -0
- package/dist/shielded/scan.d.ts +21 -0
- package/dist/shielded/scan.js +28 -0
- package/dist/stealth/index.d.ts +2 -0
- package/dist/stealth/index.js +2 -0
- package/dist/stealth/scanner.d.ts +20 -0
- package/dist/stealth/scanner.js +31 -0
- package/dist/stealth/stealth.d.ts +41 -0
- package/dist/stealth/stealth.js +122 -0
- package/dist/zk/calldata.d.ts +21 -0
- package/dist/zk/calldata.js +11 -0
- package/dist/zk/index.d.ts +3 -0
- package/dist/zk/index.js +3 -0
- package/dist/zk/merkle.d.ts +36 -0
- package/dist/zk/merkle.js +92 -0
- package/dist/zk/prover.d.ts +20 -0
- package/dist/zk/prover.js +27 -0
- package/package.json +49 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { buildPoseidon } from "circomlibjs";
|
|
2
|
+
import { x25519 } from "@noble/curves/ed25519";
|
|
3
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
4
|
+
// BN254 scalar field.
|
|
5
|
+
export const FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
|
|
6
|
+
let _poseidon;
|
|
7
|
+
export async function poseidon() {
|
|
8
|
+
if (!_poseidon)
|
|
9
|
+
_poseidon = await buildPoseidon();
|
|
10
|
+
return _poseidon;
|
|
11
|
+
}
|
|
12
|
+
export async function H(xs) {
|
|
13
|
+
const p = await poseidon();
|
|
14
|
+
return BigInt(p.F.toString(p(xs)));
|
|
15
|
+
}
|
|
16
|
+
const enc = new TextEncoder();
|
|
17
|
+
function concat(a, b) {
|
|
18
|
+
const out = new Uint8Array(a.length + b.length);
|
|
19
|
+
out.set(a, 0);
|
|
20
|
+
out.set(b, a.length);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
function bytesToField(b) {
|
|
24
|
+
let v = 0n;
|
|
25
|
+
for (const x of b)
|
|
26
|
+
v = (v << 8n) | BigInt(x);
|
|
27
|
+
return v % FIELD;
|
|
28
|
+
}
|
|
29
|
+
function toBytes32(v) {
|
|
30
|
+
const out = new Uint8Array(32);
|
|
31
|
+
let x = v;
|
|
32
|
+
for (let i = 31; i >= 0; i--) {
|
|
33
|
+
out[i] = Number(x & 0xffn);
|
|
34
|
+
x >>= 8n;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
function b64url(bytes) {
|
|
39
|
+
let s = "";
|
|
40
|
+
for (const x of bytes)
|
|
41
|
+
s += String.fromCharCode(x);
|
|
42
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
43
|
+
}
|
|
44
|
+
function unb64url(s) {
|
|
45
|
+
const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
46
|
+
const bin = atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4));
|
|
47
|
+
const out = new Uint8Array(bin.length);
|
|
48
|
+
for (let i = 0; i < bin.length; i++)
|
|
49
|
+
out[i] = bin.charCodeAt(i);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/** Derive a shielded identity from a 32+ byte seed (e.g. a wallet signature). */
|
|
53
|
+
export async function deriveShieldedKeypair(seed) {
|
|
54
|
+
const privateKey = bytesToField(sha256(concat(seed, enc.encode("clavis-shielded-spend"))));
|
|
55
|
+
const encPriv = sha256(concat(seed, enc.encode("clavis-shielded-enc")));
|
|
56
|
+
return {
|
|
57
|
+
privateKey,
|
|
58
|
+
publicKey: await H([privateKey]),
|
|
59
|
+
encPriv,
|
|
60
|
+
encPub: x25519.getPublicKey(encPriv),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Shareable shielded address = base64url(publicKey || encPub). */
|
|
64
|
+
export function encodeShieldedAddress(kp) {
|
|
65
|
+
return b64url(concat(toBytes32(kp.publicKey), kp.encPub));
|
|
66
|
+
}
|
|
67
|
+
export function decodeShieldedAddress(s) {
|
|
68
|
+
const bytes = unb64url(s.trim());
|
|
69
|
+
if (bytes.length !== 64)
|
|
70
|
+
throw new Error("invalid shielded address");
|
|
71
|
+
return { publicKey: bytesToField(bytes.slice(0, 32)), encPub: bytes.slice(32, 64) };
|
|
72
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A shielded UTXO. `amount` and `blinding` are secret; `pubkey` is the owner's
|
|
3
|
+
* shielded publicKey. commitment = Poseidon(amount, pubkey, blinding).
|
|
4
|
+
*/
|
|
5
|
+
export interface Note {
|
|
6
|
+
amount: bigint;
|
|
7
|
+
pubkey: bigint;
|
|
8
|
+
blinding: bigint;
|
|
9
|
+
}
|
|
10
|
+
export declare function randomBlinding(): bigint;
|
|
11
|
+
export declare function newNote(amount: bigint, pubkey: bigint): Note;
|
|
12
|
+
export declare const commitment: (n: Note) => Promise<bigint>;
|
|
13
|
+
/** nullifier = Poseidon(commitment, Poseidon(privateKey, commitment)). Needs the
|
|
14
|
+
* owner's spending key, so only the owner can spend the note. */
|
|
15
|
+
export declare function nullifier(n: Note, privateKey: bigint): Promise<bigint>;
|
|
16
|
+
/** Encrypt a note's secret (amount, blinding) to a recipient's X25519 enc key. */
|
|
17
|
+
export declare function encryptNoteSecret(n: Note, recipientEncPub: Uint8Array): Promise<string>;
|
|
18
|
+
/** Decrypt a note secret with the owner's X25519 enc key. Throws if not ours. */
|
|
19
|
+
export declare function decryptNoteSecret(blob: string, encPriv: Uint8Array): Promise<{
|
|
20
|
+
amount: bigint;
|
|
21
|
+
blinding: bigint;
|
|
22
|
+
}>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { H, FIELD } from "./keypair.js";
|
|
2
|
+
import { encryptNote as eciesEncrypt, decryptNote as eciesDecrypt } from "../pool/crypto.js";
|
|
3
|
+
export function randomBlinding() {
|
|
4
|
+
const b = new Uint8Array(31);
|
|
5
|
+
globalThis.crypto.getRandomValues(b);
|
|
6
|
+
let v = 0n;
|
|
7
|
+
for (const x of b)
|
|
8
|
+
v = (v << 8n) | BigInt(x);
|
|
9
|
+
return v % FIELD;
|
|
10
|
+
}
|
|
11
|
+
export function newNote(amount, pubkey) {
|
|
12
|
+
return { amount, pubkey, blinding: randomBlinding() };
|
|
13
|
+
}
|
|
14
|
+
export const commitment = (n) => H([n.amount, n.pubkey, n.blinding]);
|
|
15
|
+
/** nullifier = Poseidon(commitment, Poseidon(privateKey, commitment)). Needs the
|
|
16
|
+
* owner's spending key, so only the owner can spend the note. */
|
|
17
|
+
export async function nullifier(n, privateKey) {
|
|
18
|
+
const cm = await commitment(n);
|
|
19
|
+
const sig = await H([privateKey, cm]);
|
|
20
|
+
return H([cm, sig]);
|
|
21
|
+
}
|
|
22
|
+
// ── note-secret encryption (so a recipient can find + spend the note) ──
|
|
23
|
+
/** Encrypt a note's secret (amount, blinding) to a recipient's X25519 enc key. */
|
|
24
|
+
export function encryptNoteSecret(n, recipientEncPub) {
|
|
25
|
+
return eciesEncrypt(`${n.amount.toString(16)}:${n.blinding.toString(16)}`, recipientEncPub);
|
|
26
|
+
}
|
|
27
|
+
/** Decrypt a note secret with the owner's X25519 enc key. Throws if not ours. */
|
|
28
|
+
export async function decryptNoteSecret(blob, encPriv) {
|
|
29
|
+
const s = await eciesDecrypt(blob, encPriv);
|
|
30
|
+
const [a, b] = s.split(":");
|
|
31
|
+
return { amount: BigInt("0x" + a), blinding: BigInt("0x" + b) };
|
|
32
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ShieldedKeypair } from "./keypair.js";
|
|
2
|
+
import { Note } from "./note.js";
|
|
3
|
+
import { type ProofCalldata } from "../zk/calldata.js";
|
|
4
|
+
/**
|
|
5
|
+
* keccak256(recipient(20) ‖ relayer(20) ‖ int256 extAmount ‖ uint256 fee) with the
|
|
6
|
+
* top 3 bits cleared — MUST match ShieldedPools.transact's abi.encodePacked hash.
|
|
7
|
+
* (Deliberately different from the Solana SDK's little-endian preimage.)
|
|
8
|
+
*/
|
|
9
|
+
export declare function extDataHash(recipient: `0x${string}`, relayer: `0x${string}`, extAmount: bigint, fee: bigint): bigint;
|
|
10
|
+
/** publicAmount = (extAmount - fee) mod p. */
|
|
11
|
+
export declare function publicAmount(extAmount: bigint, fee: bigint): bigint;
|
|
12
|
+
export interface TxInput {
|
|
13
|
+
note: Note;
|
|
14
|
+
pathElements: bigint[];
|
|
15
|
+
pathIndices: number[];
|
|
16
|
+
}
|
|
17
|
+
export interface TxOutput {
|
|
18
|
+
note: Note;
|
|
19
|
+
/** Recipient's X25519 enc key; the note secret is encrypted to it. */
|
|
20
|
+
encPub: Uint8Array;
|
|
21
|
+
}
|
|
22
|
+
export interface BuildTxParams {
|
|
23
|
+
/** Real notes being spent (0–2). Padded to 2 with dummies. */
|
|
24
|
+
inputs: TxInput[];
|
|
25
|
+
/** Outputs (1–2). Padded to 2 with a dummy owned by the spender. */
|
|
26
|
+
outputs: TxOutput[];
|
|
27
|
+
/** Spender — owns every input and any padding dummies. */
|
|
28
|
+
spendKeypair: ShieldedKeypair;
|
|
29
|
+
extAmount: bigint;
|
|
30
|
+
fee: bigint;
|
|
31
|
+
recipient: `0x${string}`;
|
|
32
|
+
relayer: `0x${string}`;
|
|
33
|
+
root: bigint;
|
|
34
|
+
wasmPath: string;
|
|
35
|
+
zkeyPath: string;
|
|
36
|
+
}
|
|
37
|
+
/** Everything needed to call ShieldedPools.transact + feed the operator. */
|
|
38
|
+
export interface FormattedTx extends ProofCalldata {
|
|
39
|
+
ext: {
|
|
40
|
+
recipient: `0x${string}`;
|
|
41
|
+
relayer: `0x${string}`;
|
|
42
|
+
extAmount: bigint;
|
|
43
|
+
fee: bigint;
|
|
44
|
+
encryptedOutput1: `0x${string}`;
|
|
45
|
+
encryptedOutput2: `0x${string}`;
|
|
46
|
+
};
|
|
47
|
+
nullifiers: bigint[];
|
|
48
|
+
outputCommitments: bigint[];
|
|
49
|
+
/** Encrypted note secrets, aligned to outputCommitments — hand to the operator. */
|
|
50
|
+
encryptedSecrets: string[];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build a hidden-amount transaction proof for ShieldedPools.transact.
|
|
54
|
+
* Caller must ensure Σinputs + extAmount == Σoutputs + fee.
|
|
55
|
+
*/
|
|
56
|
+
export declare function buildTransaction(p: BuildTxParams): Promise<FormattedTx>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { groth16 } from "snarkjs";
|
|
2
|
+
import { keccak_256 } from "@noble/hashes/sha3";
|
|
3
|
+
import { FIELD } from "./keypair.js";
|
|
4
|
+
import { newNote, commitment, nullifier, encryptNoteSecret } from "./note.js";
|
|
5
|
+
import { toCalldata } from "../zk/calldata.js";
|
|
6
|
+
const LEVELS = 20;
|
|
7
|
+
function addrBytes(addr) {
|
|
8
|
+
const hex = addr.slice(2).padStart(40, "0");
|
|
9
|
+
return Uint8Array.from(hex.match(/../g).map((b) => parseInt(b, 16)));
|
|
10
|
+
}
|
|
11
|
+
function be32(v) {
|
|
12
|
+
const out = new Uint8Array(32);
|
|
13
|
+
let x = BigInt.asUintN(256, v);
|
|
14
|
+
for (let i = 31; i >= 0; i--) {
|
|
15
|
+
out[i] = Number(x & 0xffn);
|
|
16
|
+
x >>= 8n;
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* keccak256(recipient(20) ‖ relayer(20) ‖ int256 extAmount ‖ uint256 fee) with the
|
|
22
|
+
* top 3 bits cleared — MUST match ShieldedPools.transact's abi.encodePacked hash.
|
|
23
|
+
* (Deliberately different from the Solana SDK's little-endian preimage.)
|
|
24
|
+
*/
|
|
25
|
+
export function extDataHash(recipient, relayer, extAmount, fee) {
|
|
26
|
+
const buf = new Uint8Array(104);
|
|
27
|
+
buf.set(addrBytes(recipient), 0);
|
|
28
|
+
buf.set(addrBytes(relayer), 20);
|
|
29
|
+
buf.set(be32(extAmount), 40);
|
|
30
|
+
buf.set(be32(fee), 72);
|
|
31
|
+
let v = 0n;
|
|
32
|
+
for (const b of keccak_256(buf))
|
|
33
|
+
v = (v << 8n) | BigInt(b);
|
|
34
|
+
return v & ((1n << 253n) - 1n);
|
|
35
|
+
}
|
|
36
|
+
/** publicAmount = (extAmount - fee) mod p. */
|
|
37
|
+
export function publicAmount(extAmount, fee) {
|
|
38
|
+
let v = extAmount - fee;
|
|
39
|
+
if (v < 0n)
|
|
40
|
+
v += FIELD;
|
|
41
|
+
return v % FIELD;
|
|
42
|
+
}
|
|
43
|
+
const zeros = (n) => Array(n).fill("0");
|
|
44
|
+
function utf8Hex(s) {
|
|
45
|
+
let out = "0x";
|
|
46
|
+
for (const b of new TextEncoder().encode(s))
|
|
47
|
+
out += b.toString(16).padStart(2, "0");
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build a hidden-amount transaction proof for ShieldedPools.transact.
|
|
52
|
+
* Caller must ensure Σinputs + extAmount == Σoutputs + fee.
|
|
53
|
+
*/
|
|
54
|
+
export async function buildTransaction(p) {
|
|
55
|
+
const sk = p.spendKeypair;
|
|
56
|
+
const inputs = [...p.inputs];
|
|
57
|
+
while (inputs.length < 2) {
|
|
58
|
+
inputs.push({
|
|
59
|
+
note: newNote(0n, sk.publicKey),
|
|
60
|
+
pathElements: zeros(LEVELS).map(BigInt),
|
|
61
|
+
pathIndices: Array(LEVELS).fill(0),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const outputs = [...p.outputs];
|
|
65
|
+
while (outputs.length < 2)
|
|
66
|
+
outputs.push({ note: newNote(0n, sk.publicKey), encPub: sk.encPub });
|
|
67
|
+
const inNullifiers = await Promise.all(inputs.map((i) => nullifier(i.note, sk.privateKey)));
|
|
68
|
+
const outCommitments = await Promise.all(outputs.map((o) => commitment(o.note)));
|
|
69
|
+
const encryptedSecrets = await Promise.all(outputs.map((o) => encryptNoteSecret(o.note, o.encPub)));
|
|
70
|
+
const witness = {
|
|
71
|
+
root: p.root.toString(),
|
|
72
|
+
publicAmount: publicAmount(p.extAmount, p.fee).toString(),
|
|
73
|
+
extDataHash: extDataHash(p.recipient, p.relayer, p.extAmount, p.fee).toString(),
|
|
74
|
+
inputNullifier: inNullifiers.map(String),
|
|
75
|
+
outputCommitment: outCommitments.map(String),
|
|
76
|
+
inAmount: inputs.map((i) => i.note.amount.toString()),
|
|
77
|
+
inPrivateKey: inputs.map(() => sk.privateKey.toString()),
|
|
78
|
+
inBlinding: inputs.map((i) => i.note.blinding.toString()),
|
|
79
|
+
inPathIndices: inputs.map((i) => i.pathIndices.map(String)),
|
|
80
|
+
inPathElements: inputs.map((i) => i.pathElements.map(String)),
|
|
81
|
+
outAmount: outputs.map((o) => o.note.amount.toString()),
|
|
82
|
+
outPubkey: outputs.map((o) => o.note.pubkey.toString()),
|
|
83
|
+
outBlinding: outputs.map((o) => o.note.blinding.toString()),
|
|
84
|
+
};
|
|
85
|
+
const raw = await groth16.fullProve(witness, p.wasmPath, p.zkeyPath);
|
|
86
|
+
return {
|
|
87
|
+
...toCalldata(raw),
|
|
88
|
+
ext: {
|
|
89
|
+
recipient: p.recipient,
|
|
90
|
+
relayer: p.relayer,
|
|
91
|
+
extAmount: p.extAmount,
|
|
92
|
+
fee: p.fee,
|
|
93
|
+
encryptedOutput1: utf8Hex(encryptedSecrets[0]),
|
|
94
|
+
encryptedOutput2: utf8Hex(encryptedSecrets[1]),
|
|
95
|
+
},
|
|
96
|
+
nullifiers: inNullifiers,
|
|
97
|
+
outputCommitments: outCommitments,
|
|
98
|
+
encryptedSecrets,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ShieldedKeypair } from "./keypair.js";
|
|
2
|
+
import { Note } from "./note.js";
|
|
3
|
+
/** One output emitted by `transact`, paired with its encrypted secret. */
|
|
4
|
+
export interface OutputRecord {
|
|
5
|
+
commitment: bigint;
|
|
6
|
+
encryptedSecret: string;
|
|
7
|
+
leafIndex: number;
|
|
8
|
+
}
|
|
9
|
+
export interface OwnedNote extends Note {
|
|
10
|
+
leafIndex: number;
|
|
11
|
+
nullifier: bigint;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Scan emitted outputs for notes owned by `kp`: decrypt each secret, and keep it
|
|
15
|
+
* if the reconstructed commitment matches and the amount is non-zero. Returns
|
|
16
|
+
* spendable UTXOs (callers should drop any whose nullifier is already spent
|
|
17
|
+
* on-chain).
|
|
18
|
+
*/
|
|
19
|
+
export declare function scanOutputs(records: OutputRecord[], kp: ShieldedKeypair): Promise<OwnedNote[]>;
|
|
20
|
+
/** Total spendable balance from a set of owned notes. */
|
|
21
|
+
export declare const balance: (notes: OwnedNote[]) => bigint;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { commitment, nullifier, decryptNoteSecret } from "./note.js";
|
|
2
|
+
/**
|
|
3
|
+
* Scan emitted outputs for notes owned by `kp`: decrypt each secret, and keep it
|
|
4
|
+
* if the reconstructed commitment matches and the amount is non-zero. Returns
|
|
5
|
+
* spendable UTXOs (callers should drop any whose nullifier is already spent
|
|
6
|
+
* on-chain).
|
|
7
|
+
*/
|
|
8
|
+
export async function scanOutputs(records, kp) {
|
|
9
|
+
const owned = [];
|
|
10
|
+
for (const r of records) {
|
|
11
|
+
let secret;
|
|
12
|
+
try {
|
|
13
|
+
secret = await decryptNoteSecret(r.encryptedSecret, kp.encPriv);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
continue; // not encrypted to us
|
|
17
|
+
}
|
|
18
|
+
const note = { amount: secret.amount, pubkey: kp.publicKey, blinding: secret.blinding };
|
|
19
|
+
if (secret.amount === 0n)
|
|
20
|
+
continue;
|
|
21
|
+
if ((await commitment(note)) !== r.commitment)
|
|
22
|
+
continue;
|
|
23
|
+
owned.push({ ...note, leafIndex: r.leafIndex, nullifier: await nullifier(note, kp.privateKey) });
|
|
24
|
+
}
|
|
25
|
+
return owned;
|
|
26
|
+
}
|
|
27
|
+
/** Total spendable balance from a set of owned notes. */
|
|
28
|
+
export const balance = (notes) => notes.reduce((s, n) => s + n.amount, 0n);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { StealthKeys } from "./stealth.js";
|
|
2
|
+
/** One published announcement: the sender's ephemeral key + a view tag. */
|
|
3
|
+
export interface Announcement {
|
|
4
|
+
ephemeralPub: Uint8Array;
|
|
5
|
+
viewTag: number;
|
|
6
|
+
stealthPub?: Uint8Array;
|
|
7
|
+
slot?: number;
|
|
8
|
+
signature?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface DetectedPayment {
|
|
11
|
+
stealthPub: Uint8Array;
|
|
12
|
+
stealthScalar: bigint;
|
|
13
|
+
announcement: Announcement;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Scan a batch of announcements for payments addressed to `keys`.
|
|
17
|
+
* The view tag lets us reject ~255/256 of non-matching announcements with a
|
|
18
|
+
* single hash before doing the full point recovery.
|
|
19
|
+
*/
|
|
20
|
+
export declare function scanAnnouncements(keys: StealthKeys, announcements: Announcement[]): DetectedPayment[];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { recoverStealth } from "./stealth.js";
|
|
2
|
+
/**
|
|
3
|
+
* Scan a batch of announcements for payments addressed to `keys`.
|
|
4
|
+
* The view tag lets us reject ~255/256 of non-matching announcements with a
|
|
5
|
+
* single hash before doing the full point recovery.
|
|
6
|
+
*/
|
|
7
|
+
export function scanAnnouncements(keys, announcements) {
|
|
8
|
+
const found = [];
|
|
9
|
+
for (const ann of announcements) {
|
|
10
|
+
const res = recoverStealth(keys, ann.ephemeralPub, ann.viewTag);
|
|
11
|
+
if (!res)
|
|
12
|
+
continue;
|
|
13
|
+
// If the registry recorded the destination, confirm it matches ours.
|
|
14
|
+
if (ann.stealthPub && !equal(ann.stealthPub, res.stealthPub))
|
|
15
|
+
continue;
|
|
16
|
+
found.push({
|
|
17
|
+
stealthPub: res.stealthPub,
|
|
18
|
+
stealthScalar: res.stealthScalar,
|
|
19
|
+
announcement: ann,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return found;
|
|
23
|
+
}
|
|
24
|
+
function equal(a, b) {
|
|
25
|
+
if (a.length !== b.length)
|
|
26
|
+
return false;
|
|
27
|
+
let diff = 0;
|
|
28
|
+
for (let i = 0; i < a.length; i++)
|
|
29
|
+
diff |= a[i] ^ b[i];
|
|
30
|
+
return diff === 0;
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface MetaAddress {
|
|
2
|
+
spendPub: Uint8Array;
|
|
3
|
+
viewPub: Uint8Array;
|
|
4
|
+
}
|
|
5
|
+
export interface StealthKeys {
|
|
6
|
+
spendScalar: bigint;
|
|
7
|
+
viewScalar: bigint;
|
|
8
|
+
meta: MetaAddress;
|
|
9
|
+
}
|
|
10
|
+
export interface StealthOutput {
|
|
11
|
+
stealthPub: Uint8Array;
|
|
12
|
+
stealthAddress: `0x${string}`;
|
|
13
|
+
ephemeralPub: Uint8Array;
|
|
14
|
+
viewTag: number;
|
|
15
|
+
}
|
|
16
|
+
/** EVM address of a secp256k1 point: keccak256(uncompressed[1:])[12:]. */
|
|
17
|
+
export declare function pubToAddress(pub: Uint8Array): `0x${string}`;
|
|
18
|
+
/** The recovered stealth scalar as a 0x private key for viem's privateKeyToAccount. */
|
|
19
|
+
export declare function stealthScalarToPrivateKey(scalar: bigint): `0x${string}`;
|
|
20
|
+
/** Generate a recipient's stealth key material + shareable meta-address. */
|
|
21
|
+
export declare function generateStealthKeys(): StealthKeys;
|
|
22
|
+
/**
|
|
23
|
+
* Derive a recipient's stealth keys deterministically from a `seed` — typically
|
|
24
|
+
* the recipient's signature over a fixed message from their main wallet.
|
|
25
|
+
* Spend and view scalars are domain-separated so the view key (shareable with a
|
|
26
|
+
* scanning service) never leaks the spend key.
|
|
27
|
+
*/
|
|
28
|
+
export declare function deriveStealthKeys(seed: Uint8Array): StealthKeys;
|
|
29
|
+
/**
|
|
30
|
+
* SENDER: derive a one-time stealth address for a recipient's meta-address.
|
|
31
|
+
* Publish `ephemeralPub` (R) and `viewTag` so the recipient can detect it.
|
|
32
|
+
*/
|
|
33
|
+
export declare function deriveStealthAddress(meta: MetaAddress): StealthOutput;
|
|
34
|
+
/**
|
|
35
|
+
* RECIPIENT: given an announced ephemeral key R, recompute the stealth pubkey
|
|
36
|
+
* and (if it's ours) the one-time private scalar. Returns null if no match.
|
|
37
|
+
*/
|
|
38
|
+
export declare function recoverStealth(keys: StealthKeys, ephemeralPub: Uint8Array, expectedViewTag?: number): {
|
|
39
|
+
stealthPub: Uint8Array;
|
|
40
|
+
stealthScalar: bigint;
|
|
41
|
+
} | null;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { secp256k1 } from "@noble/curves/secp256k1";
|
|
2
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
3
|
+
import { keccak_256 } from "@noble/hashes/sha3";
|
|
4
|
+
import { bytesToNumberBE, numberToBytesBE } from "@noble/curves/abstract/utils";
|
|
5
|
+
/**
|
|
6
|
+
* Dual-key stealth addresses on secp256k1 (ERC-5564 style, EVM-native).
|
|
7
|
+
*
|
|
8
|
+
* A recipient publishes a META-ADDRESS made of two compressed public keys:
|
|
9
|
+
* - spend public key S = s*G (controls spending)
|
|
10
|
+
* - view public key V = v*G (lets the recipient scan cheaply)
|
|
11
|
+
*
|
|
12
|
+
* A sender, knowing (S, V), derives a fresh one-time stealth address per payment
|
|
13
|
+
* and publishes only an ephemeral public key R. Nobody watching the chain can
|
|
14
|
+
* link the stealth address back to the recipient's meta-address.
|
|
15
|
+
*
|
|
16
|
+
* Unlike the Solana/ed25519 version, spending needs no custom signer: the
|
|
17
|
+
* recovered `stealthScalar` IS a normal secp256k1 private key — load it with
|
|
18
|
+
* viem's privateKeyToAccount and send from the stealth address directly
|
|
19
|
+
* (the sweep transaction pays its own gas from the received funds).
|
|
20
|
+
*/
|
|
21
|
+
const N = secp256k1.CURVE.n; // group order
|
|
22
|
+
const Point = secp256k1.ProjectivePoint;
|
|
23
|
+
function concat(parts) {
|
|
24
|
+
const len = parts.reduce((n, p) => n + p.length, 0);
|
|
25
|
+
const out = new Uint8Array(len);
|
|
26
|
+
let o = 0;
|
|
27
|
+
for (const p of parts) {
|
|
28
|
+
out.set(p, o);
|
|
29
|
+
o += p.length;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
function hashToScalar(...chunks) {
|
|
34
|
+
const h = sha512(concat(chunks));
|
|
35
|
+
return bytesToNumberBE(h) % N;
|
|
36
|
+
}
|
|
37
|
+
/** EVM address of a secp256k1 point: keccak256(uncompressed[1:])[12:]. */
|
|
38
|
+
export function pubToAddress(pub) {
|
|
39
|
+
const uncompressed = Point.fromHex(pub).toRawBytes(false);
|
|
40
|
+
const h = keccak_256(uncompressed.slice(1));
|
|
41
|
+
let s = "0x";
|
|
42
|
+
for (const b of h.slice(12))
|
|
43
|
+
s += b.toString(16).padStart(2, "0");
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
46
|
+
/** The recovered stealth scalar as a 0x private key for viem's privateKeyToAccount. */
|
|
47
|
+
export function stealthScalarToPrivateKey(scalar) {
|
|
48
|
+
let s = "0x";
|
|
49
|
+
for (const b of numberToBytesBE(scalar, 32))
|
|
50
|
+
s += b.toString(16).padStart(2, "0");
|
|
51
|
+
return s;
|
|
52
|
+
}
|
|
53
|
+
/** Generate a recipient's stealth key material + shareable meta-address. */
|
|
54
|
+
export function generateStealthKeys() {
|
|
55
|
+
const s = bytesToNumberBE(secp256k1.utils.randomPrivateKey()) % N;
|
|
56
|
+
const v = bytesToNumberBE(secp256k1.utils.randomPrivateKey()) % N;
|
|
57
|
+
return {
|
|
58
|
+
spendScalar: s,
|
|
59
|
+
viewScalar: v,
|
|
60
|
+
meta: {
|
|
61
|
+
spendPub: Point.BASE.multiply(s).toRawBytes(true),
|
|
62
|
+
viewPub: Point.BASE.multiply(v).toRawBytes(true),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Derive a recipient's stealth keys deterministically from a `seed` — typically
|
|
68
|
+
* the recipient's signature over a fixed message from their main wallet.
|
|
69
|
+
* Spend and view scalars are domain-separated so the view key (shareable with a
|
|
70
|
+
* scanning service) never leaks the spend key.
|
|
71
|
+
*/
|
|
72
|
+
export function deriveStealthKeys(seed) {
|
|
73
|
+
const enc = new TextEncoder();
|
|
74
|
+
const s = hashToScalar(enc.encode("clavis:stealth:spend:v1"), seed);
|
|
75
|
+
const v = hashToScalar(enc.encode("clavis:stealth:view:v1"), seed);
|
|
76
|
+
return {
|
|
77
|
+
spendScalar: s,
|
|
78
|
+
viewScalar: v,
|
|
79
|
+
meta: {
|
|
80
|
+
spendPub: Point.BASE.multiply(s).toRawBytes(true),
|
|
81
|
+
viewPub: Point.BASE.multiply(v).toRawBytes(true),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* SENDER: derive a one-time stealth address for a recipient's meta-address.
|
|
87
|
+
* Publish `ephemeralPub` (R) and `viewTag` so the recipient can detect it.
|
|
88
|
+
*/
|
|
89
|
+
export function deriveStealthAddress(meta) {
|
|
90
|
+
const r = bytesToNumberBE(secp256k1.utils.randomPrivateKey()) % N;
|
|
91
|
+
const R = Point.BASE.multiply(r).toRawBytes(true);
|
|
92
|
+
const Vpoint = Point.fromHex(meta.viewPub);
|
|
93
|
+
const shared = Vpoint.multiply(r).toRawBytes(true); // r*V = (r*v)*G
|
|
94
|
+
const tweak = hashToScalar(shared);
|
|
95
|
+
const Spoint = Point.fromHex(meta.spendPub);
|
|
96
|
+
const stealth = Spoint.add(Point.BASE.multiply(tweak)); // P = S + tweak*G
|
|
97
|
+
const stealthPub = stealth.toRawBytes(true);
|
|
98
|
+
const viewTag = keccak_256(concat([new Uint8Array([0x01]), shared]))[0];
|
|
99
|
+
return {
|
|
100
|
+
stealthPub,
|
|
101
|
+
stealthAddress: pubToAddress(stealthPub),
|
|
102
|
+
ephemeralPub: R,
|
|
103
|
+
viewTag,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* RECIPIENT: given an announced ephemeral key R, recompute the stealth pubkey
|
|
108
|
+
* and (if it's ours) the one-time private scalar. Returns null if no match.
|
|
109
|
+
*/
|
|
110
|
+
export function recoverStealth(keys, ephemeralPub, expectedViewTag) {
|
|
111
|
+
const Rpoint = Point.fromHex(ephemeralPub);
|
|
112
|
+
const shared = Rpoint.multiply(keys.viewScalar).toRawBytes(true); // v*R = (r*v)*G
|
|
113
|
+
if (expectedViewTag !== undefined) {
|
|
114
|
+
const tag = keccak_256(concat([new Uint8Array([0x01]), shared]))[0];
|
|
115
|
+
if (tag !== expectedViewTag)
|
|
116
|
+
return null; // fast reject
|
|
117
|
+
}
|
|
118
|
+
const tweak = hashToScalar(shared);
|
|
119
|
+
const stealthScalar = (keys.spendScalar + tweak) % N; // p = s + tweak
|
|
120
|
+
const stealthPub = Point.BASE.multiply(stealthScalar).toRawBytes(true);
|
|
121
|
+
return { stealthPub, stealthScalar };
|
|
122
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* snarkjs proof → Solidity-verifier calldata shape:
|
|
3
|
+
* verifyProof(uint[2] pA, uint[2][2] pB, uint[2] pC, uint[N] signals)
|
|
4
|
+
* pB rows swap the c1/c0 ordering snarkjs emits (standard EVM convention);
|
|
5
|
+
* unlike the Solana verifier, A is NOT negated.
|
|
6
|
+
*/
|
|
7
|
+
export interface ProofCalldata {
|
|
8
|
+
pA: [bigint, bigint];
|
|
9
|
+
pB: [[bigint, bigint], [bigint, bigint]];
|
|
10
|
+
pC: [bigint, bigint];
|
|
11
|
+
signals: bigint[];
|
|
12
|
+
}
|
|
13
|
+
export interface RawProof {
|
|
14
|
+
proof: {
|
|
15
|
+
pi_a: string[];
|
|
16
|
+
pi_b: string[][];
|
|
17
|
+
pi_c: string[];
|
|
18
|
+
};
|
|
19
|
+
publicSignals: string[];
|
|
20
|
+
}
|
|
21
|
+
export declare function toCalldata({ proof, publicSignals }: RawProof): ProofCalldata;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function toCalldata({ proof, publicSignals }) {
|
|
2
|
+
return {
|
|
3
|
+
pA: [BigInt(proof.pi_a[0]), BigInt(proof.pi_a[1])],
|
|
4
|
+
pB: [
|
|
5
|
+
[BigInt(proof.pi_b[0][1]), BigInt(proof.pi_b[0][0])],
|
|
6
|
+
[BigInt(proof.pi_b[1][1]), BigInt(proof.pi_b[1][0])],
|
|
7
|
+
],
|
|
8
|
+
pC: [BigInt(proof.pi_c[0]), BigInt(proof.pi_c[1])],
|
|
9
|
+
signals: publicSignals.map(BigInt),
|
|
10
|
+
};
|
|
11
|
+
}
|
package/dist/zk/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poseidon Merkle tree whose hashing matches circuits/credential.circom:
|
|
3
|
+
* parent = Poseidon(left, right)
|
|
4
|
+
* leaf = Poseidon(secret) // identity commitment
|
|
5
|
+
*
|
|
6
|
+
* Fixed depth; empty slots are filled with a zero subtree so the root is
|
|
7
|
+
* stable. Keep `depth` in sync with the circuit (default 20).
|
|
8
|
+
*/
|
|
9
|
+
export declare class PoseidonMerkleTree {
|
|
10
|
+
private poseidon;
|
|
11
|
+
private F;
|
|
12
|
+
readonly depth: number;
|
|
13
|
+
private zeros;
|
|
14
|
+
private layers;
|
|
15
|
+
private constructor();
|
|
16
|
+
static create(depth?: number): Promise<PoseidonMerkleTree>;
|
|
17
|
+
private toBig;
|
|
18
|
+
hash1(a: bigint): bigint;
|
|
19
|
+
hash2(a: bigint, b: bigint): bigint;
|
|
20
|
+
/** identity commitment for a secret */
|
|
21
|
+
commitment(secret: bigint): bigint;
|
|
22
|
+
/** insert a leaf (already a commitment) and return its index */
|
|
23
|
+
insert(leaf: bigint): number;
|
|
24
|
+
/**
|
|
25
|
+
* Insert many leaves and rebuild once — O(n) total instead of O(n²) from
|
|
26
|
+
* calling insert() in a loop. Use this to load a tree from a commitment list.
|
|
27
|
+
*/
|
|
28
|
+
insertMany(leaves: bigint[]): void;
|
|
29
|
+
private rebuild;
|
|
30
|
+
root(): bigint;
|
|
31
|
+
/** Merkle proof for the leaf at `index`, formatted for the circuit. */
|
|
32
|
+
proof(index: number): {
|
|
33
|
+
pathElements: bigint[];
|
|
34
|
+
pathIndices: number[];
|
|
35
|
+
};
|
|
36
|
+
}
|