@soteria1/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/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/confidential/confidential.d.ts +142 -0
- package/dist/confidential/confidential.js +266 -0
- package/dist/confidential/index.d.ts +1 -0
- package/dist/confidential/index.js +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +24 -0
- package/dist/pool/crypto.d.ts +17 -0
- package/dist/pool/crypto.js +79 -0
- package/dist/pool/index.d.ts +4 -0
- package/dist/pool/index.js +4 -0
- package/dist/pool/note.d.ts +22 -0
- package/dist/pool/note.js +66 -0
- package/dist/pool/pdas.d.ts +54 -0
- package/dist/pool/pdas.js +109 -0
- package/dist/pool/prover.d.ts +44 -0
- package/dist/pool/prover.js +73 -0
- package/dist/shielded/index.d.ts +5 -0
- package/dist/shielded/index.js +5 -0
- package/dist/shielded/instruction.d.ts +20 -0
- package/dist/shielded/instruction.js +50 -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 +46 -0
- package/dist/shielded/prover.js +90 -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 +46 -0
- package/dist/stealth/stealth.js +119 -0
- package/dist/zk/index.d.ts +2 -0
- package/dist/zk/index.js +2 -0
- package/dist/zk/merkle.d.ts +36 -0
- package/dist/zk/merkle.js +92 -0
- package/dist/zk/prover.d.ts +50 -0
- package/dist/zk/prover.js +87 -0
- package/package.json +65 -0
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
// BN254 base field (for negating proof.A).
|
|
6
|
+
const Q = 21888242871839275222246405745257275088696311157297823662689037894645226208583n;
|
|
7
|
+
const LEVELS = 20;
|
|
8
|
+
function be32(v) {
|
|
9
|
+
let x = BigInt(v);
|
|
10
|
+
const o = new Array(32).fill(0);
|
|
11
|
+
for (let i = 31; i >= 0; i--) {
|
|
12
|
+
o[i] = Number(x & 0xffn);
|
|
13
|
+
x >>= 8n;
|
|
14
|
+
}
|
|
15
|
+
return o;
|
|
16
|
+
}
|
|
17
|
+
// keccak(recipient || relayer || extAmount_le_i64 || fee_le_u64), masked below
|
|
18
|
+
// the field — MUST byte-match the program's ext_data_hash.
|
|
19
|
+
export function extDataHash(recipient, relayer, extAmount, fee) {
|
|
20
|
+
const buf = new Uint8Array(80);
|
|
21
|
+
buf.set(recipient.toBytes(), 0);
|
|
22
|
+
buf.set(relayer.toBytes(), 32);
|
|
23
|
+
const ea = BigInt.asUintN(64, extAmount);
|
|
24
|
+
const fe = BigInt.asUintN(64, fee);
|
|
25
|
+
for (let i = 0; i < 8; i++) {
|
|
26
|
+
buf[64 + i] = Number((ea >> BigInt(8 * i)) & 0xffn);
|
|
27
|
+
buf[72 + i] = Number((fe >> BigInt(8 * i)) & 0xffn);
|
|
28
|
+
}
|
|
29
|
+
const hsh = keccak_256(buf);
|
|
30
|
+
hsh[0] &= 0x1f;
|
|
31
|
+
let v = 0n;
|
|
32
|
+
for (const b of hsh)
|
|
33
|
+
v = (v << 8n) | BigInt(b);
|
|
34
|
+
return v;
|
|
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
|
+
/**
|
|
45
|
+
* Build a hidden-amount transaction proof for the on-chain `transact`
|
|
46
|
+
* instruction. Caller must ensure Σinputs + extAmount == Σoutputs + fee.
|
|
47
|
+
*/
|
|
48
|
+
export async function buildTransaction(p) {
|
|
49
|
+
const sk = p.spendKeypair;
|
|
50
|
+
const inputs = [...p.inputs];
|
|
51
|
+
while (inputs.length < 2) {
|
|
52
|
+
inputs.push({ note: newNote(0n, sk.publicKey), pathElements: zeros(LEVELS).map(BigInt), pathIndices: Array(LEVELS).fill(0) });
|
|
53
|
+
}
|
|
54
|
+
const outputs = [...p.outputs];
|
|
55
|
+
while (outputs.length < 2)
|
|
56
|
+
outputs.push({ note: newNote(0n, sk.publicKey), encPub: sk.encPub });
|
|
57
|
+
const inNullifiers = await Promise.all(inputs.map((i) => nullifier(i.note, sk.privateKey)));
|
|
58
|
+
const outCommitments = await Promise.all(outputs.map((o) => commitment(o.note)));
|
|
59
|
+
const encryptedSecrets = await Promise.all(outputs.map((o) => encryptNoteSecret(o.note, o.encPub)));
|
|
60
|
+
const witness = {
|
|
61
|
+
root: p.root.toString(),
|
|
62
|
+
publicAmount: publicAmount(p.extAmount, p.fee).toString(),
|
|
63
|
+
extDataHash: extDataHash(p.recipient, p.relayer, p.extAmount, p.fee).toString(),
|
|
64
|
+
inputNullifier: inNullifiers.map(String),
|
|
65
|
+
outputCommitment: outCommitments.map(String),
|
|
66
|
+
inAmount: inputs.map((i) => i.note.amount.toString()),
|
|
67
|
+
inPrivateKey: inputs.map(() => sk.privateKey.toString()),
|
|
68
|
+
inBlinding: inputs.map((i) => i.note.blinding.toString()),
|
|
69
|
+
inPathIndices: inputs.map((i) => i.pathIndices.map(String)),
|
|
70
|
+
inPathElements: inputs.map((i) => i.pathElements.map(String)),
|
|
71
|
+
outAmount: outputs.map((o) => o.note.amount.toString()),
|
|
72
|
+
outPubkey: outputs.map((o) => o.note.pubkey.toString()),
|
|
73
|
+
outBlinding: outputs.map((o) => o.note.blinding.toString()),
|
|
74
|
+
};
|
|
75
|
+
const { proof, publicSignals } = await groth16.fullProve(witness, p.wasmPath, p.zkeyPath);
|
|
76
|
+
const ax = BigInt(proof.pi_a[0]);
|
|
77
|
+
const ay = (Q - (BigInt(proof.pi_a[1]) % Q)) % Q;
|
|
78
|
+
return {
|
|
79
|
+
proofA: [...be32(ax), ...be32(ay)],
|
|
80
|
+
proofB: [
|
|
81
|
+
...be32(proof.pi_b[0][1]), ...be32(proof.pi_b[0][0]),
|
|
82
|
+
...be32(proof.pi_b[1][1]), ...be32(proof.pi_b[1][0]),
|
|
83
|
+
],
|
|
84
|
+
proofC: [...be32(proof.pi_c[0]), ...be32(proof.pi_c[1])],
|
|
85
|
+
publicInputs: publicSignals.map((s) => be32(s)),
|
|
86
|
+
nullifiers: inNullifiers,
|
|
87
|
+
outputCommitments: outCommitments,
|
|
88
|
+
encryptedSecrets,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -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,46 @@
|
|
|
1
|
+
import { PublicKey } from "@solana/web3.js";
|
|
2
|
+
export interface MetaAddress {
|
|
3
|
+
spendPub: Uint8Array;
|
|
4
|
+
viewPub: Uint8Array;
|
|
5
|
+
}
|
|
6
|
+
export interface StealthKeys {
|
|
7
|
+
spendScalar: bigint;
|
|
8
|
+
viewScalar: bigint;
|
|
9
|
+
meta: MetaAddress;
|
|
10
|
+
}
|
|
11
|
+
export interface StealthOutput {
|
|
12
|
+
stealthPub: Uint8Array;
|
|
13
|
+
stealthAddress: PublicKey;
|
|
14
|
+
ephemeralPub: Uint8Array;
|
|
15
|
+
viewTag: number;
|
|
16
|
+
}
|
|
17
|
+
/** Generate a recipient's stealth key material + shareable meta-address. */
|
|
18
|
+
export declare function generateStealthKeys(): StealthKeys;
|
|
19
|
+
/**
|
|
20
|
+
* Derive a recipient's stealth keys deterministically from a `seed` — typically
|
|
21
|
+
* the recipient's signature over a fixed message from their main wallet.
|
|
22
|
+
*
|
|
23
|
+
* Because the same seed always yields the same keys, the recipient recovers
|
|
24
|
+
* their entire stealth identity from their wallet alone: nothing is stored, and
|
|
25
|
+
* losing local state loses nothing. Spend and view scalars are domain-separated
|
|
26
|
+
* so the view key (shareable with a 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 signing 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;
|
|
42
|
+
/**
|
|
43
|
+
* Sign a message with a recovered stealth scalar (raw-scalar ed25519).
|
|
44
|
+
* Use this in a custom Solana transaction signer to spend from a stealth address.
|
|
45
|
+
*/
|
|
46
|
+
export declare function signWithStealthScalar(message: Uint8Array, stealthScalar: bigint): Uint8Array;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { ed25519 } from "@noble/curves/ed25519";
|
|
2
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
3
|
+
import { bytesToNumberLE, numberToBytesLE } from "@noble/curves/abstract/utils";
|
|
4
|
+
import { PublicKey } from "@solana/web3.js";
|
|
5
|
+
/**
|
|
6
|
+
* Dual-key stealth addresses for Solana (ERC-5564 style, adapted to ed25519).
|
|
7
|
+
*
|
|
8
|
+
* A recipient publishes a META-ADDRESS made of two 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
|
+
* This module hides WHICH wallet receives. It is not a mixer: funds are not
|
|
17
|
+
* pooled, and there is no deposit/withdraw that breaks the sender->recipient link.
|
|
18
|
+
*
|
|
19
|
+
* ── Spending caveat (read this) ──────────────────────────────────────────────
|
|
20
|
+
* The one-time signing key is a raw scalar `p = (s + tweak) mod L`. Solana's
|
|
21
|
+
* standard Keypair derives its scalar by hashing a 32-byte seed, so you CANNOT
|
|
22
|
+
* load `p` into a normal web3.js Keypair and sign. Spending requires raw-scalar
|
|
23
|
+
* ed25519 signing (Monero-style). `signWithStealthScalar` below does that; wire
|
|
24
|
+
* it into a custom transaction signer when you build the claim flow.
|
|
25
|
+
*/
|
|
26
|
+
const L = ed25519.CURVE.n; // group order
|
|
27
|
+
const Base = ed25519.ExtendedPoint.BASE;
|
|
28
|
+
function hashToScalar(...chunks) {
|
|
29
|
+
const h = sha512(concat(chunks));
|
|
30
|
+
return bytesToNumberLE(h) % L;
|
|
31
|
+
}
|
|
32
|
+
function concat(parts) {
|
|
33
|
+
const len = parts.reduce((n, p) => n + p.length, 0);
|
|
34
|
+
const out = new Uint8Array(len);
|
|
35
|
+
let o = 0;
|
|
36
|
+
for (const p of parts) {
|
|
37
|
+
out.set(p, o);
|
|
38
|
+
o += p.length;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/** Generate a recipient's stealth key material + shareable meta-address. */
|
|
43
|
+
export function generateStealthKeys() {
|
|
44
|
+
const s = bytesToNumberLE(ed25519.utils.randomPrivateKey()) % L;
|
|
45
|
+
const v = bytesToNumberLE(ed25519.utils.randomPrivateKey()) % L;
|
|
46
|
+
const spendPub = Base.multiply(s).toRawBytes();
|
|
47
|
+
const viewPub = Base.multiply(v).toRawBytes();
|
|
48
|
+
return { spendScalar: s, viewScalar: v, meta: { spendPub, viewPub } };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Derive a recipient's stealth keys deterministically from a `seed` — typically
|
|
52
|
+
* the recipient's signature over a fixed message from their main wallet.
|
|
53
|
+
*
|
|
54
|
+
* Because the same seed always yields the same keys, the recipient recovers
|
|
55
|
+
* their entire stealth identity from their wallet alone: nothing is stored, and
|
|
56
|
+
* losing local state loses nothing. Spend and view scalars are domain-separated
|
|
57
|
+
* so the view key (shareable with a scanning service) never leaks the spend key.
|
|
58
|
+
*/
|
|
59
|
+
export function deriveStealthKeys(seed) {
|
|
60
|
+
const enc = new TextEncoder();
|
|
61
|
+
const s = hashToScalar(enc.encode("soteria:stealth:spend:v1"), seed);
|
|
62
|
+
const v = hashToScalar(enc.encode("soteria:stealth:view:v1"), seed);
|
|
63
|
+
const spendPub = Base.multiply(s).toRawBytes();
|
|
64
|
+
const viewPub = Base.multiply(v).toRawBytes();
|
|
65
|
+
return { spendScalar: s, viewScalar: v, meta: { spendPub, viewPub } };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* SENDER: derive a one-time stealth address for a recipient's meta-address.
|
|
69
|
+
* Publish `ephemeralPub` (R) and `viewTag` so the recipient can detect it.
|
|
70
|
+
*/
|
|
71
|
+
export function deriveStealthAddress(meta) {
|
|
72
|
+
const r = bytesToNumberLE(ed25519.utils.randomPrivateKey()) % L;
|
|
73
|
+
const R = Base.multiply(r).toRawBytes();
|
|
74
|
+
const Vpoint = ed25519.ExtendedPoint.fromHex(meta.viewPub);
|
|
75
|
+
const shared = Vpoint.multiply(r).toRawBytes(); // r*V = (r*v)*G
|
|
76
|
+
const tweak = hashToScalar(shared); // common secret both sides can compute
|
|
77
|
+
const Spoint = ed25519.ExtendedPoint.fromHex(meta.spendPub);
|
|
78
|
+
const stealth = Spoint.add(Base.multiply(tweak)); // P = S + tweak*G
|
|
79
|
+
const stealthPub = stealth.toRawBytes();
|
|
80
|
+
const viewTag = sha512(concat([new Uint8Array([0x01]), shared]))[0];
|
|
81
|
+
return {
|
|
82
|
+
stealthPub,
|
|
83
|
+
stealthAddress: new PublicKey(stealthPub),
|
|
84
|
+
ephemeralPub: R,
|
|
85
|
+
viewTag,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* RECIPIENT: given an announced ephemeral key R, recompute the stealth pubkey
|
|
90
|
+
* and (if it's ours) the one-time signing scalar. Returns null if no match.
|
|
91
|
+
*/
|
|
92
|
+
export function recoverStealth(keys, ephemeralPub, expectedViewTag) {
|
|
93
|
+
const Rpoint = ed25519.ExtendedPoint.fromHex(ephemeralPub);
|
|
94
|
+
const shared = Rpoint.multiply(keys.viewScalar).toRawBytes(); // v*R = (r*v)*G
|
|
95
|
+
if (expectedViewTag !== undefined) {
|
|
96
|
+
const tag = sha512(concat([new Uint8Array([0x01]), shared]))[0];
|
|
97
|
+
if (tag !== expectedViewTag)
|
|
98
|
+
return null; // fast reject
|
|
99
|
+
}
|
|
100
|
+
const tweak = hashToScalar(shared);
|
|
101
|
+
const stealthScalar = (keys.spendScalar + tweak) % L; // p = s + tweak
|
|
102
|
+
const stealthPub = Base.multiply(stealthScalar).toRawBytes(); // must equal P
|
|
103
|
+
return { stealthPub, stealthScalar };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Sign a message with a recovered stealth scalar (raw-scalar ed25519).
|
|
107
|
+
* Use this in a custom Solana transaction signer to spend from a stealth address.
|
|
108
|
+
*/
|
|
109
|
+
export function signWithStealthScalar(message, stealthScalar) {
|
|
110
|
+
// Deterministic nonce from a domain-separated hash of (scalar || message).
|
|
111
|
+
const sBytes = numberToBytesLE(stealthScalar, 32);
|
|
112
|
+
const nonce = bytesToNumberLE(sha512(concat([sBytes, message]))) % L;
|
|
113
|
+
const Rpt = Base.multiply(nonce);
|
|
114
|
+
const Rbytes = Rpt.toRawBytes();
|
|
115
|
+
const Abytes = Base.multiply(stealthScalar).toRawBytes();
|
|
116
|
+
const k = bytesToNumberLE(sha512(concat([Rbytes, Abytes, message]))) % L;
|
|
117
|
+
const S = (nonce + k * stealthScalar) % L;
|
|
118
|
+
return concat([Rbytes, numberToBytesLE(S, 32)]);
|
|
119
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { buildPoseidon } from "circomlibjs";
|
|
2
|
+
/**
|
|
3
|
+
* Poseidon Merkle tree whose hashing matches circuits/credential.circom:
|
|
4
|
+
* parent = Poseidon(left, right)
|
|
5
|
+
* leaf = Poseidon(secret) // identity commitment
|
|
6
|
+
*
|
|
7
|
+
* Fixed depth; empty slots are filled with a zero subtree so the root is
|
|
8
|
+
* stable. Keep `depth` in sync with the circuit (default 20).
|
|
9
|
+
*/
|
|
10
|
+
export class PoseidonMerkleTree {
|
|
11
|
+
constructor(poseidon, depth) {
|
|
12
|
+
this.zeros = [];
|
|
13
|
+
this.layers = [];
|
|
14
|
+
this.poseidon = poseidon;
|
|
15
|
+
this.F = poseidon.F;
|
|
16
|
+
this.depth = depth;
|
|
17
|
+
}
|
|
18
|
+
static async create(depth = 20) {
|
|
19
|
+
const poseidon = await buildPoseidon();
|
|
20
|
+
const t = new PoseidonMerkleTree(poseidon, depth);
|
|
21
|
+
// precompute zero subtree roots per level
|
|
22
|
+
let cur = 0n;
|
|
23
|
+
t.zeros.push(cur);
|
|
24
|
+
for (let i = 0; i < depth; i++) {
|
|
25
|
+
cur = t.hash2(cur, cur);
|
|
26
|
+
t.zeros.push(cur);
|
|
27
|
+
}
|
|
28
|
+
t.layers = [[]];
|
|
29
|
+
return t;
|
|
30
|
+
}
|
|
31
|
+
toBig(x) {
|
|
32
|
+
return BigInt(this.F.toString(x));
|
|
33
|
+
}
|
|
34
|
+
hash1(a) {
|
|
35
|
+
return this.toBig(this.poseidon([a]));
|
|
36
|
+
}
|
|
37
|
+
hash2(a, b) {
|
|
38
|
+
return this.toBig(this.poseidon([a, b]));
|
|
39
|
+
}
|
|
40
|
+
/** identity commitment for a secret */
|
|
41
|
+
commitment(secret) {
|
|
42
|
+
return this.hash1(secret);
|
|
43
|
+
}
|
|
44
|
+
/** insert a leaf (already a commitment) and return its index */
|
|
45
|
+
insert(leaf) {
|
|
46
|
+
const index = this.layers[0].length;
|
|
47
|
+
this.layers[0].push(leaf);
|
|
48
|
+
this.rebuild();
|
|
49
|
+
return index;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Insert many leaves and rebuild once — O(n) total instead of O(n²) from
|
|
53
|
+
* calling insert() in a loop. Use this to load a tree from a commitment list.
|
|
54
|
+
*/
|
|
55
|
+
insertMany(leaves) {
|
|
56
|
+
for (const leaf of leaves)
|
|
57
|
+
this.layers[0].push(leaf);
|
|
58
|
+
this.rebuild();
|
|
59
|
+
}
|
|
60
|
+
rebuild() {
|
|
61
|
+
for (let level = 0; level < this.depth; level++) {
|
|
62
|
+
const cur = this.layers[level];
|
|
63
|
+
const next = [];
|
|
64
|
+
for (let i = 0; i < cur.length; i += 2) {
|
|
65
|
+
const left = cur[i];
|
|
66
|
+
const right = i + 1 < cur.length ? cur[i + 1] : this.zeros[level];
|
|
67
|
+
next.push(this.hash2(left, right));
|
|
68
|
+
}
|
|
69
|
+
this.layers[level + 1] = next;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
root() {
|
|
73
|
+
const top = this.layers[this.depth];
|
|
74
|
+
return top && top.length ? top[0] : this.zeros[this.depth];
|
|
75
|
+
}
|
|
76
|
+
/** Merkle proof for the leaf at `index`, formatted for the circuit. */
|
|
77
|
+
proof(index) {
|
|
78
|
+
const pathElements = [];
|
|
79
|
+
const pathIndices = [];
|
|
80
|
+
let idx = index;
|
|
81
|
+
for (let level = 0; level < this.depth; level++) {
|
|
82
|
+
const cur = this.layers[level] ?? [];
|
|
83
|
+
const isRight = idx % 2;
|
|
84
|
+
const siblingIdx = isRight ? idx - 1 : idx + 1;
|
|
85
|
+
const sibling = siblingIdx < cur.length ? cur[siblingIdx] : this.zeros[level];
|
|
86
|
+
pathElements.push(sibling);
|
|
87
|
+
pathIndices.push(isRight);
|
|
88
|
+
idx = Math.floor(idx / 2);
|
|
89
|
+
}
|
|
90
|
+
return { pathElements, pathIndices };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { PublicKey } from "@solana/web3.js";
|
|
2
|
+
import { PoseidonMerkleTree } from "./merkle.js";
|
|
3
|
+
export interface CredentialInputs {
|
|
4
|
+
secret: bigint;
|
|
5
|
+
tree: PoseidonMerkleTree;
|
|
6
|
+
leafIndex: number;
|
|
7
|
+
externalNullifier: bigint;
|
|
8
|
+
signalHash: bigint;
|
|
9
|
+
}
|
|
10
|
+
export interface FormattedProof {
|
|
11
|
+
proofA: number[];
|
|
12
|
+
proofB: number[];
|
|
13
|
+
proofC: number[];
|
|
14
|
+
publicInputs: number[][];
|
|
15
|
+
}
|
|
16
|
+
export interface RawProof {
|
|
17
|
+
proof: {
|
|
18
|
+
pi_a: string[];
|
|
19
|
+
pi_b: string[][];
|
|
20
|
+
pi_c: string[];
|
|
21
|
+
};
|
|
22
|
+
publicSignals: string[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Generate the raw snarkjs proof + public signals. Use this when submitting via
|
|
26
|
+
* the relay, which formats the bytes server-side. Requires the trusted-setup
|
|
27
|
+
* artifacts (see README).
|
|
28
|
+
*/
|
|
29
|
+
export declare function proveCredentialRaw(inputs: CredentialInputs, wasmPath: string, zkeyPath: string): Promise<RawProof>;
|
|
30
|
+
/**
|
|
31
|
+
* Generate a selective-disclosure proof and format it for the on-chain verifier
|
|
32
|
+
* (direct submission). For the relay path, use proveCredentialRaw.
|
|
33
|
+
*/
|
|
34
|
+
export declare function proveCredential(inputs: CredentialInputs, wasmPath: string, zkeyPath: string): Promise<FormattedProof>;
|
|
35
|
+
export declare function groupPda(groupId: bigint | number): [PublicKey, number];
|
|
36
|
+
export declare function nullifierPda(groupId: bigint | number, nullifierHash: number[]): [PublicKey, number];
|
|
37
|
+
/**
|
|
38
|
+
* Account set for `verify_proof`, ordered to match the on-chain context.
|
|
39
|
+
* Call as verifyProof(externalNullifier, proofA, proofB, proofC, publicInputs)
|
|
40
|
+
* via @coral-xyz/anchor's IDL client; externalNullifier must equal
|
|
41
|
+
* publicInputs[PI_EXTERNAL_NULLIFIER] or the program rejects with ScopeMismatch.
|
|
42
|
+
*/
|
|
43
|
+
export declare function buildVerifyAccounts(payer: PublicKey, groupId: bigint | number, proof: FormattedProof): {
|
|
44
|
+
programId: PublicKey;
|
|
45
|
+
keys: {
|
|
46
|
+
pubkey: PublicKey;
|
|
47
|
+
isSigner: boolean;
|
|
48
|
+
isWritable: boolean;
|
|
49
|
+
}[];
|
|
50
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { groth16 } from "snarkjs";
|
|
2
|
+
import { PublicKey, SystemProgram, } from "@solana/web3.js";
|
|
3
|
+
// BN254 base field prime (for negating proof.A, per groth16-solana convention).
|
|
4
|
+
const Q = 21888242871839275222246405745257275088696311157297823662689037894645226208583n;
|
|
5
|
+
const VERIFIER_PROGRAM_ID = new PublicKey("9HNLpUVFX61pX759oy1vuMMwQaQaGnK9KgMyhTrDrRGs");
|
|
6
|
+
function toBE32(dec) {
|
|
7
|
+
let v = BigInt(dec);
|
|
8
|
+
const out = new Array(32).fill(0);
|
|
9
|
+
for (let i = 31; i >= 0; i--) {
|
|
10
|
+
out[i] = Number(v & 0xffn);
|
|
11
|
+
v >>= 8n;
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Generate the raw snarkjs proof + public signals. Use this when submitting via
|
|
17
|
+
* the relay, which formats the bytes server-side. Requires the trusted-setup
|
|
18
|
+
* artifacts (see README).
|
|
19
|
+
*/
|
|
20
|
+
export async function proveCredentialRaw(inputs, wasmPath, zkeyPath) {
|
|
21
|
+
const { secret, tree, leafIndex, externalNullifier, signalHash } = inputs;
|
|
22
|
+
const { pathElements, pathIndices } = tree.proof(leafIndex);
|
|
23
|
+
const witness = {
|
|
24
|
+
secret: secret.toString(),
|
|
25
|
+
pathElements: pathElements.map((x) => x.toString()),
|
|
26
|
+
pathIndices: pathIndices.map((x) => x.toString()),
|
|
27
|
+
merkleRoot: tree.root().toString(),
|
|
28
|
+
externalNullifier: externalNullifier.toString(),
|
|
29
|
+
signalHash: signalHash.toString(),
|
|
30
|
+
};
|
|
31
|
+
const { proof, publicSignals } = await groth16.fullProve(witness, wasmPath, zkeyPath);
|
|
32
|
+
return { proof, publicSignals };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Generate a selective-disclosure proof and format it for the on-chain verifier
|
|
36
|
+
* (direct submission). For the relay path, use proveCredentialRaw.
|
|
37
|
+
*/
|
|
38
|
+
export async function proveCredential(inputs, wasmPath, zkeyPath) {
|
|
39
|
+
const { proof, publicSignals } = await proveCredentialRaw(inputs, wasmPath, zkeyPath);
|
|
40
|
+
// --- proof.A : negate y, then x||y big-endian (groth16-solana wants -A) ---
|
|
41
|
+
const ax = BigInt(proof.pi_a[0]);
|
|
42
|
+
const ay = (Q - (BigInt(proof.pi_a[1]) % Q)) % Q;
|
|
43
|
+
const proofA = [...toBE32(ax), ...toBE32(ay)];
|
|
44
|
+
// --- proof.B : G2, swap the c0/c1 ordering snarkjs emits ---
|
|
45
|
+
const proofB = [
|
|
46
|
+
...toBE32(proof.pi_b[0][1]),
|
|
47
|
+
...toBE32(proof.pi_b[0][0]),
|
|
48
|
+
...toBE32(proof.pi_b[1][1]),
|
|
49
|
+
...toBE32(proof.pi_b[1][0]),
|
|
50
|
+
];
|
|
51
|
+
// --- proof.C : x||y big-endian ---
|
|
52
|
+
const proofC = [...toBE32(proof.pi_c[0]), ...toBE32(proof.pi_c[1])];
|
|
53
|
+
// publicSignals order = [outputs..., publicInputs...]
|
|
54
|
+
// = [nullifierHash, merkleRoot, externalNullifier, signalHash]
|
|
55
|
+
const publicInputs = publicSignals.map((s) => toBE32(s));
|
|
56
|
+
return { proofA, proofB, proofC, publicInputs };
|
|
57
|
+
}
|
|
58
|
+
function u64le(n) {
|
|
59
|
+
const b = Buffer.alloc(8);
|
|
60
|
+
b.writeBigUInt64LE(BigInt(n));
|
|
61
|
+
return b;
|
|
62
|
+
}
|
|
63
|
+
export function groupPda(groupId) {
|
|
64
|
+
return PublicKey.findProgramAddressSync([Buffer.from("group"), u64le(groupId)], VERIFIER_PROGRAM_ID);
|
|
65
|
+
}
|
|
66
|
+
export function nullifierPda(groupId, nullifierHash) {
|
|
67
|
+
return PublicKey.findProgramAddressSync([Buffer.from("nullifier"), u64le(groupId), Buffer.from(nullifierHash)], VERIFIER_PROGRAM_ID);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Account set for `verify_proof`, ordered to match the on-chain context.
|
|
71
|
+
* Call as verifyProof(externalNullifier, proofA, proofB, proofC, publicInputs)
|
|
72
|
+
* via @coral-xyz/anchor's IDL client; externalNullifier must equal
|
|
73
|
+
* publicInputs[PI_EXTERNAL_NULLIFIER] or the program rejects with ScopeMismatch.
|
|
74
|
+
*/
|
|
75
|
+
export function buildVerifyAccounts(payer, groupId, proof) {
|
|
76
|
+
const [group] = groupPda(groupId);
|
|
77
|
+
const [nullifier] = nullifierPda(groupId, proof.publicInputs[0]);
|
|
78
|
+
return {
|
|
79
|
+
programId: VERIFIER_PROGRAM_ID,
|
|
80
|
+
keys: [
|
|
81
|
+
{ pubkey: payer, isSigner: true, isWritable: true },
|
|
82
|
+
{ pubkey: group, isSigner: false, isWritable: false },
|
|
83
|
+
{ pubkey: nullifier, isSigner: false, isWritable: true },
|
|
84
|
+
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|