@utxopia/sdk 0.1.0-alpha.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.
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/package.json +108 -0
- package/packages/btc-client/src/esplora-client.ts +153 -0
- package/packages/btc-client/src/index.ts +3 -0
- package/packages/btc-client/src/op-return.ts +93 -0
- package/packages/btc-client/src/types.ts +112 -0
- package/packages/sdk/README.md +277 -0
- package/packages/sdk/dist/announcement-client.d.ts +64 -0
- package/packages/sdk/dist/announcement-client.js +337 -0
- package/packages/sdk/dist/auditor-ciphertext.d.ts +72 -0
- package/packages/sdk/dist/auditor-ciphertext.js +135 -0
- package/packages/sdk/dist/auditor.d.ts +115 -0
- package/packages/sdk/dist/auditor.js +292 -0
- package/packages/sdk/dist/bitcoin/ika.d.ts +47 -0
- package/packages/sdk/dist/bitcoin/ika.js +74 -0
- package/packages/sdk/dist/bitcoin/index.d.ts +2 -0
- package/packages/sdk/dist/bitcoin/index.js +1 -0
- package/packages/sdk/dist/bound-params.d.ts +96 -0
- package/packages/sdk/dist/bound-params.js +249 -0
- package/packages/sdk/dist/chadbuffer.d.ts +124 -0
- package/packages/sdk/dist/chadbuffer.js +405 -0
- package/packages/sdk/dist/claim-link.d.ts +30 -0
- package/packages/sdk/dist/claim-link.js +50 -0
- package/packages/sdk/dist/client.d.ts +233 -0
- package/packages/sdk/dist/client.js +416 -0
- package/packages/sdk/dist/commitment-tree.d.ts +268 -0
- package/packages/sdk/dist/commitment-tree.js +564 -0
- package/packages/sdk/dist/config.d.ts +177 -0
- package/packages/sdk/dist/config.js +426 -0
- package/packages/sdk/dist/core/esplora.d.ts +110 -0
- package/packages/sdk/dist/core/esplora.js +187 -0
- package/packages/sdk/dist/core/mempool.d.ts +60 -0
- package/packages/sdk/dist/core/mempool.js +107 -0
- package/packages/sdk/dist/crypto-babyjub.d.ts +105 -0
- package/packages/sdk/dist/crypto-babyjub.js +312 -0
- package/packages/sdk/dist/crypto-ed25519.d.ts +116 -0
- package/packages/sdk/dist/crypto-ed25519.js +235 -0
- package/packages/sdk/dist/crypto.d.ts +59 -0
- package/packages/sdk/dist/crypto.js +140 -0
- package/packages/sdk/dist/event-client.d.ts +64 -0
- package/packages/sdk/dist/event-client.js +165 -0
- package/packages/sdk/dist/events.d.ts +127 -0
- package/packages/sdk/dist/events.js +305 -0
- package/packages/sdk/dist/explorer.d.ts +86 -0
- package/packages/sdk/dist/explorer.js +197 -0
- package/packages/sdk/dist/index.d.ts +61 -0
- package/packages/sdk/dist/index.js +216 -0
- package/packages/sdk/dist/instructions.d.ts +1125 -0
- package/packages/sdk/dist/instructions.js +1760 -0
- package/packages/sdk/dist/keys.d.ts +467 -0
- package/packages/sdk/dist/keys.js +799 -0
- package/packages/sdk/dist/logger.d.ts +10 -0
- package/packages/sdk/dist/logger.js +40 -0
- package/packages/sdk/dist/magicblock.d.ts +90 -0
- package/packages/sdk/dist/magicblock.js +164 -0
- package/packages/sdk/dist/merkle.d.ts +82 -0
- package/packages/sdk/dist/merkle.js +141 -0
- package/packages/sdk/dist/note.d.ts +348 -0
- package/packages/sdk/dist/note.js +483 -0
- package/packages/sdk/dist/pda.d.ts +160 -0
- package/packages/sdk/dist/pda.js +384 -0
- package/packages/sdk/dist/pool-state.d.ts +100 -0
- package/packages/sdk/dist/pool-state.js +126 -0
- package/packages/sdk/dist/poseidon.d.ts +53 -0
- package/packages/sdk/dist/poseidon.js +136 -0
- package/packages/sdk/dist/prover/index.d.ts +10 -0
- package/packages/sdk/dist/prover/index.js +10 -0
- package/packages/sdk/dist/prover/mobile.d.ts +45 -0
- package/packages/sdk/dist/prover/mobile.js +227 -0
- package/packages/sdk/dist/prover/web.d.ts +136 -0
- package/packages/sdk/dist/prover/web.js +597 -0
- package/packages/sdk/dist/psbt.d.ts +73 -0
- package/packages/sdk/dist/psbt.js +202 -0
- package/packages/sdk/dist/selective-disclosure.d.ts +181 -0
- package/packages/sdk/dist/selective-disclosure.js +172 -0
- package/packages/sdk/dist/sender-memo.d.ts +149 -0
- package/packages/sdk/dist/sender-memo.js +250 -0
- package/packages/sdk/dist/sns-resolver.d.ts +121 -0
- package/packages/sdk/dist/sns-resolver.js +229 -0
- package/packages/sdk/dist/solana/connection.d.ts +77 -0
- package/packages/sdk/dist/solana/connection.js +133 -0
- package/packages/sdk/dist/solana/priority-fee.d.ts +78 -0
- package/packages/sdk/dist/solana/priority-fee.js +137 -0
- package/packages/sdk/dist/stealth.d.ts +429 -0
- package/packages/sdk/dist/stealth.js +703 -0
- package/packages/sdk/dist/taproot.d.ts +172 -0
- package/packages/sdk/dist/taproot.js +494 -0
- package/packages/sdk/dist/token-registry.d.ts +96 -0
- package/packages/sdk/dist/token-registry.js +122 -0
- package/packages/sdk/dist/utils/encoding.d.ts +19 -0
- package/packages/sdk/dist/utils/encoding.js +29 -0
- package/packages/sdk/dist/vk-registry.d.ts +100 -0
- package/packages/sdk/dist/vk-registry.js +212 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WASM-based Groth16 Proof Generator for UTXOPIA
|
|
3
|
+
*
|
|
4
|
+
* Universal prover that works in both Browser and Node.js environments.
|
|
5
|
+
* Uses Groth16 proofs via snarkjs with lazy loading.
|
|
6
|
+
*
|
|
7
|
+
* JOINSPLIT MODEL:
|
|
8
|
+
* - Commitment = Poseidon(npk, token, amount)
|
|
9
|
+
* - Nullifier = Poseidon(nullifyingKey, leafIndex)
|
|
10
|
+
* - Signature = EdDSA-Poseidon over (merkleRoot, boundParamsHash, nullifiers..., commitmentsOut...)
|
|
11
|
+
*/
|
|
12
|
+
import { poseidonHashSync, computeJoinSplitCommitmentSync, computeJoinSplitNullifierSync, } from "../poseidon";
|
|
13
|
+
import { BN254_FIELD_PRIME } from "../crypto";
|
|
14
|
+
import { TREE_DEPTH } from "../merkle";
|
|
15
|
+
import { getConfig } from "../config";
|
|
16
|
+
import { MAX_SAFE_JOINSPLIT_SIZE } from "../vk-registry";
|
|
17
|
+
/** Maximum satoshis (total BTC supply) */
|
|
18
|
+
const MAX_SATOSHIS = 21000000n * 100000000n;
|
|
19
|
+
/**
|
|
20
|
+
* Validate that proof inputs are within BN254 field bounds.
|
|
21
|
+
*/
|
|
22
|
+
function validateFieldInputs(fields) {
|
|
23
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
24
|
+
if (value < 0n) {
|
|
25
|
+
throw new Error(`Invalid proof input: ${name} is negative`);
|
|
26
|
+
}
|
|
27
|
+
if (value >= BN254_FIELD_PRIME) {
|
|
28
|
+
throw new Error(`Invalid proof input: ${name} exceeds BN254 field prime`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function validateAmount(amount, label) {
|
|
33
|
+
if (amount <= 0n) {
|
|
34
|
+
throw new Error(`Invalid proof input: ${label} must be positive`);
|
|
35
|
+
}
|
|
36
|
+
if (amount > MAX_SATOSHIS) {
|
|
37
|
+
throw new Error(`Invalid proof input: ${label} exceeds total BTC supply`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Environment detection
|
|
41
|
+
const isBrowser = typeof window !== "undefined";
|
|
42
|
+
const isNode = typeof process !== "undefined" && process.versions?.node;
|
|
43
|
+
// Configurable circuit paths
|
|
44
|
+
let circuitBasePath = isBrowser ? "/circuits/groth16" : "./circuits";
|
|
45
|
+
/**
|
|
46
|
+
* Set the base path for circuit artifacts
|
|
47
|
+
*/
|
|
48
|
+
export function setCircuitPath(path) {
|
|
49
|
+
if (path === circuitBasePath)
|
|
50
|
+
return;
|
|
51
|
+
circuitBasePath = path;
|
|
52
|
+
circuitCache.clear();
|
|
53
|
+
artifactBytesCache.clear();
|
|
54
|
+
artifactDownloadCache.clear();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Get the current circuit base path
|
|
58
|
+
*/
|
|
59
|
+
export function getCircuitPath() {
|
|
60
|
+
return circuitBasePath;
|
|
61
|
+
}
|
|
62
|
+
// Lazy-loaded snarkjs module
|
|
63
|
+
let snarkjs = null;
|
|
64
|
+
const circuitCache = new Map();
|
|
65
|
+
let proverInitialized = false;
|
|
66
|
+
/**
|
|
67
|
+
* Load snarkjs module
|
|
68
|
+
*/
|
|
69
|
+
async function ensureSnarkjsLoaded() {
|
|
70
|
+
if (snarkjs)
|
|
71
|
+
return;
|
|
72
|
+
console.log("[Prover] Loading snarkjs module...");
|
|
73
|
+
// Prefer an app-provided instance: browser bundlers resolve snarkjs's
|
|
74
|
+
// `browser` export themselves and hand it over via globalThis, since the
|
|
75
|
+
// string-indirected import below is (deliberately) opaque to them.
|
|
76
|
+
snarkjs = globalThis.snarkjs ?? null;
|
|
77
|
+
if (!snarkjs) {
|
|
78
|
+
const snarkjsModule = "snarkjs";
|
|
79
|
+
snarkjs = await import(snarkjsModule).catch(() => null);
|
|
80
|
+
}
|
|
81
|
+
if (!snarkjs) {
|
|
82
|
+
throw new Error("Groth16 prover requires the snarkjs package. " +
|
|
83
|
+
"Install it with: bun add snarkjs");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Fully-downloaded circuit artifacts, keyed by URL. Proving reads zkey
|
|
88
|
+
* sections non-sequentially; over HTTP that becomes thousands of small
|
|
89
|
+
* range requests, which dominates proving time. Downloading once into
|
|
90
|
+
* memory (Railgun-style) makes proving IO-free.
|
|
91
|
+
*/
|
|
92
|
+
const artifactBytesCache = new Map();
|
|
93
|
+
const artifactDownloadCache = new Map();
|
|
94
|
+
/**
|
|
95
|
+
* Expected SHA-256 of each artifact, keyed by its path under the circuit base — e.g.
|
|
96
|
+
* `joinsplit_1x1/joinsplit_1x1_js/joinsplit_1x1.wasm`. Empty until a caller supplies one.
|
|
97
|
+
*/
|
|
98
|
+
let artifactDigests = null;
|
|
99
|
+
/**
|
|
100
|
+
* Pin the circuit artifacts this app is willing to prove with.
|
|
101
|
+
*
|
|
102
|
+
* Worth doing even though it costs a hash per download. The `.wasm` is the witness generator:
|
|
103
|
+
* it is handed the spending key, the nullifying key, note randomness, amounts and the full
|
|
104
|
+
* Merkle path in the clear. It arrives over plain `fetch`, where subresource integrity does not
|
|
105
|
+
* apply, and CDNs typically serve it `immutable, max-age=31536000` — so a substituted file keeps
|
|
106
|
+
* working out of browser and edge caches long after the origin is cleaned up. The proofs it
|
|
107
|
+
* produces still verify, so nothing fails and no user notices.
|
|
108
|
+
*
|
|
109
|
+
* Digests must come from the consuming build (they change whenever circuits are rebuilt), which
|
|
110
|
+
* is why the SDK cannot ship them. Once set, verification is enforced and fails closed: an
|
|
111
|
+
* artifact with no recorded digest is refused rather than trusted.
|
|
112
|
+
*/
|
|
113
|
+
export function setCircuitArtifactDigests(digests) {
|
|
114
|
+
artifactDigests = digests;
|
|
115
|
+
// Anything admitted under the old policy must be re-checked under the new one.
|
|
116
|
+
artifactBytesCache.clear();
|
|
117
|
+
}
|
|
118
|
+
const toHex = (buf) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
119
|
+
async function verifyArtifactBytes(url, bytes) {
|
|
120
|
+
if (!artifactDigests)
|
|
121
|
+
return;
|
|
122
|
+
// Key by path-under-base so one manifest works across origins (local dev vs CDN).
|
|
123
|
+
const key = Object.keys(artifactDigests).find((k) => url.endsWith(k));
|
|
124
|
+
if (!key) {
|
|
125
|
+
throw new Error(`No integrity digest recorded for circuit artifact ${url} — refusing to prove. ` +
|
|
126
|
+
`Regenerate the artifact manifest so it covers every shape this origin serves.`);
|
|
127
|
+
}
|
|
128
|
+
const actual = toHex(await crypto.subtle.digest("SHA-256", bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)));
|
|
129
|
+
if (actual !== artifactDigests[key]) {
|
|
130
|
+
throw new Error(`Circuit artifact ${key} failed its integrity check (expected ${artifactDigests[key]}, ` +
|
|
131
|
+
`got ${actual}). Refusing to prove against an artifact this build does not recognise.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function fetchArtifactToMemory(url) {
|
|
135
|
+
const cached = artifactBytesCache.get(url);
|
|
136
|
+
if (cached)
|
|
137
|
+
return { type: "mem", data: cached };
|
|
138
|
+
let download = artifactDownloadCache.get(url);
|
|
139
|
+
if (!download) {
|
|
140
|
+
download = (async () => {
|
|
141
|
+
const res = await fetch(url, { cache: "force-cache" });
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
throw new Error(`Failed to fetch circuit artifact ${url}: HTTP ${res.status}`);
|
|
144
|
+
}
|
|
145
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
146
|
+
// Before the cache, so a rejected artifact is never reachable from it.
|
|
147
|
+
await verifyArtifactBytes(url, bytes);
|
|
148
|
+
artifactBytesCache.set(url, bytes);
|
|
149
|
+
return bytes;
|
|
150
|
+
})();
|
|
151
|
+
artifactDownloadCache.set(url, download);
|
|
152
|
+
void download.finally(() => artifactDownloadCache.delete(url)).catch(() => { });
|
|
153
|
+
}
|
|
154
|
+
const bytes = await download;
|
|
155
|
+
return { type: "mem", data: bytes };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* In browsers (or with remote artifact URLs), resolve artifacts to in-memory
|
|
159
|
+
* fastfile objects; in Node, local file paths are already fast.
|
|
160
|
+
*/
|
|
161
|
+
async function resolveProveArtifacts(artifacts) {
|
|
162
|
+
const isRemote = artifacts.zkeyPath.startsWith("http://") || artifacts.zkeyPath.startsWith("https://");
|
|
163
|
+
if (!isBrowser && !isRemote) {
|
|
164
|
+
return { wasm: artifacts.wasmPath, zkey: artifacts.zkeyPath };
|
|
165
|
+
}
|
|
166
|
+
const [wasm, zkey] = await Promise.all([
|
|
167
|
+
fetchArtifactToMemory(artifacts.wasmPath),
|
|
168
|
+
fetchArtifactToMemory(artifacts.zkeyPath),
|
|
169
|
+
]);
|
|
170
|
+
return { wasm, zkey };
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Resolve circuit artifact paths
|
|
174
|
+
*/
|
|
175
|
+
function getCircuitArtifactPaths(circuitType) {
|
|
176
|
+
if (circuitCache.has(circuitType)) {
|
|
177
|
+
return circuitCache.get(circuitType);
|
|
178
|
+
}
|
|
179
|
+
const name = circuitType; // "joinsplit_NxM"
|
|
180
|
+
const artifact = {
|
|
181
|
+
wasmPath: `${circuitBasePath}/${name}/${name}_js/${name}.wasm`,
|
|
182
|
+
zkeyPath: `${circuitBasePath}/${name}/${name}.zkey`,
|
|
183
|
+
};
|
|
184
|
+
circuitCache.set(circuitType, artifact);
|
|
185
|
+
return artifact;
|
|
186
|
+
}
|
|
187
|
+
function getJoinSplitCircuitType(nInputs, nOutputs) {
|
|
188
|
+
if (!Number.isInteger(nInputs) ||
|
|
189
|
+
!Number.isInteger(nOutputs) ||
|
|
190
|
+
nInputs < 1 ||
|
|
191
|
+
nOutputs < 1 ||
|
|
192
|
+
nInputs + nOutputs > MAX_SAFE_JOINSPLIT_SIZE) {
|
|
193
|
+
throw new Error(`Invalid JoinSplit dimensions: ${nInputs}x${nOutputs} ` +
|
|
194
|
+
`(N+M must be 2..${MAX_SAFE_JOINSPLIT_SIZE})`);
|
|
195
|
+
}
|
|
196
|
+
return `joinsplit_${nInputs}x${nOutputs}`;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Download the selected JoinSplit artifacts into the browser cache and the
|
|
200
|
+
* prover's in-memory fastfile cache without generating a proof. Calling this
|
|
201
|
+
* while the user reviews a transaction removes CDN latency from the confirm
|
|
202
|
+
* path. Concurrent preloads and proofs share the same downloads.
|
|
203
|
+
*/
|
|
204
|
+
export async function preloadJoinSplitCircuit(nInputs, nOutputs) {
|
|
205
|
+
const circuitType = getJoinSplitCircuitType(nInputs, nOutputs);
|
|
206
|
+
await resolveProveArtifacts(getCircuitArtifactPaths(circuitType));
|
|
207
|
+
}
|
|
208
|
+
// Detect bun runtime (snarkjs WASM hangs in bun)
|
|
209
|
+
const isBun = typeof process !== "undefined" && !!process.versions?.bun;
|
|
210
|
+
/**
|
|
211
|
+
* Generate a Groth16 proof for a circuit with given inputs.
|
|
212
|
+
* Uses Node.js subprocess when running in bun (snarkjs WASM incompatibility).
|
|
213
|
+
*/
|
|
214
|
+
async function generateProof(circuitType, inputs) {
|
|
215
|
+
console.log(`[Prover] Generating ${circuitType} Groth16 proof...`);
|
|
216
|
+
const startTime = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
217
|
+
const artifacts = getCircuitArtifactPaths(circuitType);
|
|
218
|
+
let proof;
|
|
219
|
+
let publicSignals;
|
|
220
|
+
if (isBun && isNode) {
|
|
221
|
+
const result = await generateProofViaNodeSubprocess(artifacts, inputs);
|
|
222
|
+
proof = result.proof;
|
|
223
|
+
publicSignals = result.publicSignals;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
await ensureSnarkjsLoaded();
|
|
227
|
+
const resolved = await resolveProveArtifacts(artifacts);
|
|
228
|
+
// Browser: ffjavascript's worker pool can deadlock (proofs hang
|
|
229
|
+
// indefinitely); single-thread proving completes in seconds.
|
|
230
|
+
const proverOptions = isBrowser ? { singleThread: true } : undefined;
|
|
231
|
+
const result = await snarkjs.groth16.fullProve(inputs, resolved.wasm, resolved.zkey, undefined, undefined, proverOptions);
|
|
232
|
+
proof = result.proof;
|
|
233
|
+
publicSignals = result.publicSignals;
|
|
234
|
+
}
|
|
235
|
+
const elapsed = (typeof performance !== "undefined" ? performance.now() : Date.now()) - startTime;
|
|
236
|
+
console.log(`[Prover] Groth16 proof generated in ${elapsed.toFixed(0)}ms`);
|
|
237
|
+
const proofBytes = serializeProof(proof);
|
|
238
|
+
console.log(`[Prover] Proof size: ${proofBytes.length} bytes`);
|
|
239
|
+
return {
|
|
240
|
+
proof: proofBytes,
|
|
241
|
+
publicInputs: publicSignals,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Generate proof via Node.js subprocess (for bun compatibility)
|
|
246
|
+
*/
|
|
247
|
+
async function generateProofViaNodeSubprocess(artifacts, inputs) {
|
|
248
|
+
// Build a CommonJS-aware `require` that works in both CJS and ESM execution
|
|
249
|
+
// contexts. In ESM, `require` isn't a global; `module.createRequire(url)`
|
|
250
|
+
// creates one bound to a given module URL.
|
|
251
|
+
// `import.meta.url` is only valid in ESM modules; in CJS bundles
|
|
252
|
+
// (`type: "commonjs"`) we fall back to the inherited `require`. Both
|
|
253
|
+
// bundlers (esbuild / tsc) preserve `import.meta.url` correctly.
|
|
254
|
+
let _require;
|
|
255
|
+
if (typeof globalThis.require === "function") {
|
|
256
|
+
_require = globalThis.require;
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
const nodeModuleSpecifier = "node:" + "module";
|
|
260
|
+
const { createRequire } = await import(nodeModuleSpecifier);
|
|
261
|
+
_require = createRequire(import.meta.url);
|
|
262
|
+
}
|
|
263
|
+
const { execFileSync } = _require("child_process");
|
|
264
|
+
const fs = _require("fs");
|
|
265
|
+
const path = _require("path");
|
|
266
|
+
const os = _require("os");
|
|
267
|
+
const wasmPath = path.resolve(artifacts.wasmPath);
|
|
268
|
+
const zkeyPath = path.resolve(artifacts.zkeyPath);
|
|
269
|
+
// Private 0700 tmp dir so the prover input (nullifyingKey, values, leaf indices)
|
|
270
|
+
// is not world-readable in the shared circuit-artifact dir.
|
|
271
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "utxopia-prover-"));
|
|
272
|
+
const tmpInput = path.join(tmpDir, "input.json");
|
|
273
|
+
const tmpProof = path.join(tmpDir, "proof.json");
|
|
274
|
+
const tmpPublic = path.join(tmpDir, "public.json");
|
|
275
|
+
fs.writeFileSync(tmpInput, JSON.stringify(inputs), { mode: 0o600 });
|
|
276
|
+
try {
|
|
277
|
+
// Use execFileSync to avoid shell injection via file paths
|
|
278
|
+
// Take the last 5 argv entries — Node 24's --eval TypeScript transform
|
|
279
|
+
// can prepend extra paths, so positional destructuring [,, ...] breaks.
|
|
280
|
+
const script = `
|
|
281
|
+
const snarkjs = require('snarkjs');
|
|
282
|
+
const fs = require('fs');
|
|
283
|
+
const [inputPath, wasmP, zkeyP, proofPath, publicPath] = process.argv.slice(-5);
|
|
284
|
+
(async () => {
|
|
285
|
+
const input = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
|
|
286
|
+
const { proof, publicSignals } = await snarkjs.groth16.fullProve(input, wasmP, zkeyP);
|
|
287
|
+
fs.writeFileSync(proofPath, JSON.stringify(proof));
|
|
288
|
+
fs.writeFileSync(publicPath, JSON.stringify(publicSignals));
|
|
289
|
+
process.exit(0);
|
|
290
|
+
})().catch(e => { console.error(e); process.exit(1); });
|
|
291
|
+
`;
|
|
292
|
+
execFileSync("node", ["-e", script, tmpInput, wasmPath, zkeyPath, tmpProof, tmpPublic], {
|
|
293
|
+
timeout: 120000,
|
|
294
|
+
});
|
|
295
|
+
const proof = JSON.parse(fs.readFileSync(tmpProof, "utf8"));
|
|
296
|
+
const publicSignals = JSON.parse(fs.readFileSync(tmpPublic, "utf8"));
|
|
297
|
+
return { proof, publicSignals };
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
// Best-effort overwrite-then-remove of the secret-bearing input, then the dir.
|
|
301
|
+
try {
|
|
302
|
+
fs.writeFileSync(tmpInput, "0".repeat(64), { mode: 0o600 });
|
|
303
|
+
}
|
|
304
|
+
catch { }
|
|
305
|
+
try {
|
|
306
|
+
fs.unlinkSync(tmpInput);
|
|
307
|
+
}
|
|
308
|
+
catch { }
|
|
309
|
+
try {
|
|
310
|
+
fs.unlinkSync(tmpProof);
|
|
311
|
+
}
|
|
312
|
+
catch { }
|
|
313
|
+
try {
|
|
314
|
+
fs.unlinkSync(tmpPublic);
|
|
315
|
+
}
|
|
316
|
+
catch { }
|
|
317
|
+
try {
|
|
318
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
319
|
+
}
|
|
320
|
+
catch { }
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Serialize snarkjs Groth16 proof to 256 bytes (2 G1 + 1 G2 on BN254)
|
|
325
|
+
*/
|
|
326
|
+
function serializeProof(proof) {
|
|
327
|
+
const bytes = new Uint8Array(256);
|
|
328
|
+
const piA = proof.pi_a;
|
|
329
|
+
const piB = proof.pi_b;
|
|
330
|
+
const piC = proof.pi_c;
|
|
331
|
+
// G1 point A (64 bytes)
|
|
332
|
+
writeBigIntBE(bytes, 0, BigInt(piA[0]), 32);
|
|
333
|
+
writeBigIntBE(bytes, 32, BigInt(piA[1]), 32);
|
|
334
|
+
// G2 point B (128 bytes): [x_imag, x_real, y_imag, y_real]
|
|
335
|
+
writeBigIntBE(bytes, 64, BigInt(piB[0][1]), 32);
|
|
336
|
+
writeBigIntBE(bytes, 96, BigInt(piB[0][0]), 32);
|
|
337
|
+
writeBigIntBE(bytes, 128, BigInt(piB[1][1]), 32);
|
|
338
|
+
writeBigIntBE(bytes, 160, BigInt(piB[1][0]), 32);
|
|
339
|
+
// G1 point C (64 bytes)
|
|
340
|
+
writeBigIntBE(bytes, 192, BigInt(piC[0]), 32);
|
|
341
|
+
writeBigIntBE(bytes, 224, BigInt(piC[1]), 32);
|
|
342
|
+
return bytes;
|
|
343
|
+
}
|
|
344
|
+
function writeBigIntBE(buf, offset, value, length) {
|
|
345
|
+
for (let i = length - 1; i >= 0; i--) {
|
|
346
|
+
buf[offset + i] = Number(value & 0xffn);
|
|
347
|
+
value >>= 8n;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// ==========================================================================
|
|
351
|
+
// Public API
|
|
352
|
+
// ==========================================================================
|
|
353
|
+
/**
|
|
354
|
+
* Initialize the prover (preloads snarkjs module)
|
|
355
|
+
*/
|
|
356
|
+
export async function initProver() {
|
|
357
|
+
await ensureSnarkjsLoaded();
|
|
358
|
+
proverInitialized = true;
|
|
359
|
+
console.log("[Prover] Groth16 prover initialized and ready");
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Generate a Groth16 proof for any circuit by name. Useful for non-JoinSplit
|
|
363
|
+
* circuits (ownership, range_sum) that follow the same artifact layout
|
|
364
|
+
* `<circuitBasePath>/<name>/<name>_js/<name>.wasm` + `<circuitBasePath>/<name>/<name>.zkey`.
|
|
365
|
+
*
|
|
366
|
+
* Returns `{ proof: 256 bytes, publicInputs }` matching `ProofData`.
|
|
367
|
+
*/
|
|
368
|
+
export async function generateGenericGroth16Proof(circuitName, inputs) {
|
|
369
|
+
// Reuse the same artifact lookup pattern as JoinSplit variants.
|
|
370
|
+
const artifacts = {
|
|
371
|
+
wasmPath: `${circuitBasePath}/${circuitName}/${circuitName}_js/${circuitName}.wasm`,
|
|
372
|
+
zkeyPath: `${circuitBasePath}/${circuitName}/${circuitName}.zkey`,
|
|
373
|
+
};
|
|
374
|
+
let proof;
|
|
375
|
+
let publicSignals;
|
|
376
|
+
if (isBun && isNode) {
|
|
377
|
+
const result = await generateProofViaNodeSubprocess(artifacts, inputs);
|
|
378
|
+
proof = result.proof;
|
|
379
|
+
publicSignals = result.publicSignals;
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
await ensureSnarkjsLoaded();
|
|
383
|
+
const resolved = await resolveProveArtifacts(artifacts);
|
|
384
|
+
const proverOptions = isBrowser ? { singleThread: true } : undefined;
|
|
385
|
+
const result = await snarkjs.groth16.fullProve(inputs, resolved.wasm, resolved.zkey, undefined, undefined, proverOptions);
|
|
386
|
+
proof = result.proof;
|
|
387
|
+
publicSignals = result.publicSignals;
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
proof: serializeProof(proof),
|
|
391
|
+
publicInputs: publicSignals,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Check if prover is available in current environment
|
|
396
|
+
*/
|
|
397
|
+
export async function isProverAvailable() {
|
|
398
|
+
try {
|
|
399
|
+
await ensureSnarkjsLoaded();
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Verify a Groth16 proof against a verifying key + public signals.
|
|
408
|
+
*
|
|
409
|
+
* Accepts the snarkjs-native shapes:
|
|
410
|
+
* - `vkey`: the JSON emitted by `snarkjs zkey export verificationkey`
|
|
411
|
+
* - `publicSignals`: array of decimal strings
|
|
412
|
+
* - `proof`: `{ pi_a, pi_b, pi_c, protocol, curve }` as produced by `snarkjs.groth16.fullProve`
|
|
413
|
+
*
|
|
414
|
+
* Lazy-loads snarkjs so calling code doesn't pay the bundle cost unless it
|
|
415
|
+
* verifies. Returns true on a valid proof, false on an invalid one; throws
|
|
416
|
+
* if snarkjs is missing or the inputs are structurally malformed.
|
|
417
|
+
*/
|
|
418
|
+
export async function verifyGroth16Proof(vkey, publicSignals, proof) {
|
|
419
|
+
await ensureSnarkjsLoaded();
|
|
420
|
+
return snarkjs.groth16.verify(vkey, publicSignals, proof);
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Generate a JoinSplit proof
|
|
424
|
+
*
|
|
425
|
+
* Unified prover for all JoinSplit variants.
|
|
426
|
+
* Selects the joinsplit_NxM circuit based on nInputs/nOutputs.
|
|
427
|
+
*/
|
|
428
|
+
export async function generateJoinSplitProof(inputs) {
|
|
429
|
+
const { nInputs, nOutputs } = inputs;
|
|
430
|
+
const variantName = getJoinSplitCircuitType(nInputs, nOutputs);
|
|
431
|
+
// Validate array lengths match declared dimensions
|
|
432
|
+
if (inputs.inputs.length !== nInputs) {
|
|
433
|
+
throw new Error(`Expected ${nInputs} inputs, got ${inputs.inputs.length}`);
|
|
434
|
+
}
|
|
435
|
+
if (inputs.outputs.length !== nOutputs) {
|
|
436
|
+
throw new Error(`Expected ${nOutputs} outputs, got ${inputs.outputs.length}`);
|
|
437
|
+
}
|
|
438
|
+
validateFieldInputs({
|
|
439
|
+
merkleRoot: inputs.merkleRoot,
|
|
440
|
+
boundParamsHash: inputs.boundParamsHash,
|
|
441
|
+
token: inputs.token,
|
|
442
|
+
publicKeyX: inputs.publicKey[0],
|
|
443
|
+
publicKeyY: inputs.publicKey[1],
|
|
444
|
+
nullifyingKey: inputs.nullifyingKey,
|
|
445
|
+
});
|
|
446
|
+
// Validate input amounts and Merkle proof depths
|
|
447
|
+
let totalIn = 0n;
|
|
448
|
+
for (let i = 0; i < inputs.inputs.length; i++) {
|
|
449
|
+
const inp = inputs.inputs[i];
|
|
450
|
+
validateAmount(inp.value, `input[${i}].value`);
|
|
451
|
+
validateFieldInputs({ [`input[${i}].random`]: inp.random });
|
|
452
|
+
if (inp.merkleProof.siblings.length !== TREE_DEPTH) {
|
|
453
|
+
throw new Error(`input[${i}].merkleProof: expected ${TREE_DEPTH} siblings, got ${inp.merkleProof.siblings.length}`);
|
|
454
|
+
}
|
|
455
|
+
if (inp.merkleProof.indices.length !== TREE_DEPTH) {
|
|
456
|
+
throw new Error(`input[${i}].merkleProof: expected ${TREE_DEPTH} indices, got ${inp.merkleProof.indices.length}`);
|
|
457
|
+
}
|
|
458
|
+
totalIn += inp.value;
|
|
459
|
+
}
|
|
460
|
+
// Validate output amounts
|
|
461
|
+
let totalOut = 0n;
|
|
462
|
+
for (let i = 0; i < inputs.outputs.length; i++) {
|
|
463
|
+
const out = inputs.outputs[i];
|
|
464
|
+
validateAmount(out.value, `output[${i}].value`);
|
|
465
|
+
validateFieldInputs({ [`output[${i}].npk`]: out.npk });
|
|
466
|
+
totalOut += out.value;
|
|
467
|
+
}
|
|
468
|
+
// Validate conservation of value (inputs must equal outputs)
|
|
469
|
+
if (totalIn !== totalOut) {
|
|
470
|
+
throw new Error(`Value mismatch: inputs sum to ${totalIn} sats but outputs sum to ${totalOut} sats`);
|
|
471
|
+
}
|
|
472
|
+
// Compute nullifiers
|
|
473
|
+
const nullifiers = [];
|
|
474
|
+
for (const inp of inputs.inputs) {
|
|
475
|
+
const nullifier = computeJoinSplitNullifierSync(inputs.nullifyingKey, inp.leafIndex);
|
|
476
|
+
nullifiers.push(nullifier);
|
|
477
|
+
}
|
|
478
|
+
// Compute output commitments
|
|
479
|
+
const commitmentsOut = [];
|
|
480
|
+
for (const out of inputs.outputs) {
|
|
481
|
+
const commitment = computeJoinSplitCommitmentSync(out.npk, inputs.token, out.value);
|
|
482
|
+
commitmentsOut.push(commitment);
|
|
483
|
+
}
|
|
484
|
+
// Compute message hash: Poseidon(merkleRoot, boundParamsHash, nullifiers..., commitmentsOut...)
|
|
485
|
+
const hashInputs = [
|
|
486
|
+
inputs.merkleRoot,
|
|
487
|
+
inputs.boundParamsHash,
|
|
488
|
+
...nullifiers,
|
|
489
|
+
...commitmentsOut,
|
|
490
|
+
];
|
|
491
|
+
const _msgHash = poseidonHashSync(hashInputs);
|
|
492
|
+
// Build circuit inputs
|
|
493
|
+
const circuitInputs = {
|
|
494
|
+
merkleRoot: inputs.merkleRoot.toString(),
|
|
495
|
+
boundParamsHash: inputs.boundParamsHash.toString(),
|
|
496
|
+
nullifiers: nullifiers.map(n => n.toString()),
|
|
497
|
+
commitmentsOut: commitmentsOut.map(c => c.toString()),
|
|
498
|
+
token: inputs.token.toString(),
|
|
499
|
+
publicKey: inputs.publicKey.map(p => p.toString()),
|
|
500
|
+
signature: inputs.signature.map(s => s.toString()),
|
|
501
|
+
nullifyingKey: inputs.nullifyingKey.toString(),
|
|
502
|
+
randomIn: inputs.inputs.map(i => i.random.toString()),
|
|
503
|
+
valueIn: inputs.inputs.map(i => i.value.toString()),
|
|
504
|
+
leavesIndices: inputs.inputs.map(i => i.leafIndex.toString()),
|
|
505
|
+
npkOut: inputs.outputs.map(o => o.npk.toString()),
|
|
506
|
+
valueOut: inputs.outputs.map(o => o.value.toString()),
|
|
507
|
+
};
|
|
508
|
+
// Circuit derives path-direction bits from leavesIndices, so only siblings are passed.
|
|
509
|
+
const pathElements = [];
|
|
510
|
+
for (const inp of inputs.inputs) {
|
|
511
|
+
pathElements.push(inp.merkleProof.siblings.map(s => s.toString()));
|
|
512
|
+
}
|
|
513
|
+
circuitInputs.pathElements = pathElements;
|
|
514
|
+
return generateProof(variantName, circuitInputs);
|
|
515
|
+
}
|
|
516
|
+
// ==========================================================================
|
|
517
|
+
// Circuit Availability & Verification
|
|
518
|
+
// ==========================================================================
|
|
519
|
+
/**
|
|
520
|
+
* Check if circuit artifacts exist for a given circuit type
|
|
521
|
+
*/
|
|
522
|
+
export async function circuitExists(circuitType) {
|
|
523
|
+
try {
|
|
524
|
+
const artifacts = getCircuitArtifactPaths(circuitType);
|
|
525
|
+
const isUrl = artifacts.wasmPath.startsWith("http://") || artifacts.wasmPath.startsWith("https://");
|
|
526
|
+
if (isBrowser || isUrl) {
|
|
527
|
+
// Browser or remote URL (S3, CDN) — use fetch
|
|
528
|
+
const [wasmRes, zkeyRes] = await Promise.all([
|
|
529
|
+
fetch(artifacts.wasmPath, { method: "HEAD" }),
|
|
530
|
+
fetch(artifacts.zkeyPath, { method: "HEAD" }),
|
|
531
|
+
]);
|
|
532
|
+
return wasmRes.ok && zkeyRes.ok;
|
|
533
|
+
}
|
|
534
|
+
// Node/Bun with local paths
|
|
535
|
+
const _require = new Function("m", "return require(m)");
|
|
536
|
+
const { existsSync } = _require("fs");
|
|
537
|
+
return existsSync(artifacts.wasmPath) && existsSync(artifacts.zkeyPath);
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Convert proof to raw bytes for on-chain submission
|
|
545
|
+
*/
|
|
546
|
+
export function proofToBytes(proof) {
|
|
547
|
+
return proof.proof;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Cleanup all cached resources
|
|
551
|
+
*/
|
|
552
|
+
export async function cleanup() {
|
|
553
|
+
circuitCache.clear();
|
|
554
|
+
console.log("[Prover] Cleaned up all cached resources");
|
|
555
|
+
}
|
|
556
|
+
// ==========================================================================
|
|
557
|
+
// Solana Instruction Building (for Groth16 on-chain verification)
|
|
558
|
+
// ==========================================================================
|
|
559
|
+
/**
|
|
560
|
+
* Groth16 verifier program ID (from current config)
|
|
561
|
+
*/
|
|
562
|
+
export function getGroth16VerifierProgramId() {
|
|
563
|
+
const config = getConfig();
|
|
564
|
+
return config.groth16VerifierProgramId;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Build instruction data for Groth16 verification
|
|
568
|
+
*/
|
|
569
|
+
export function buildVerifyInstructionData(proof, publicSignals, vkHash) {
|
|
570
|
+
const piBytes = publicSignals.flatMap((pi) => {
|
|
571
|
+
const bytes = new Array(32).fill(0);
|
|
572
|
+
const bigint = BigInt(pi);
|
|
573
|
+
for (let i = 31; i >= 0; i--) {
|
|
574
|
+
bytes[i] = Number((bigint >> BigInt((31 - i) * 8)) & 0xffn);
|
|
575
|
+
}
|
|
576
|
+
return bytes;
|
|
577
|
+
});
|
|
578
|
+
const cleanHex = vkHash.startsWith("0x") ? vkHash.slice(2) : vkHash;
|
|
579
|
+
const vkHashBytes = new Uint8Array(cleanHex.length / 2);
|
|
580
|
+
for (let i = 0; i < vkHashBytes.length; i++) {
|
|
581
|
+
vkHashBytes[i] = parseInt(cleanHex.substr(i * 2, 2), 16);
|
|
582
|
+
}
|
|
583
|
+
const totalSize = proof.length + 4 + piBytes.length + 32;
|
|
584
|
+
const data = new Uint8Array(totalSize);
|
|
585
|
+
let offset = 0;
|
|
586
|
+
data.set(proof, offset);
|
|
587
|
+
offset += proof.length;
|
|
588
|
+
const piCount = publicSignals.length;
|
|
589
|
+
data[offset++] = piCount & 0xff;
|
|
590
|
+
data[offset++] = (piCount >> 8) & 0xff;
|
|
591
|
+
data[offset++] = (piCount >> 16) & 0xff;
|
|
592
|
+
data[offset++] = (piCount >> 24) & 0xff;
|
|
593
|
+
data.set(new Uint8Array(piBytes), offset);
|
|
594
|
+
offset += piBytes.length;
|
|
595
|
+
data.set(vkHashBytes, offset);
|
|
596
|
+
return data;
|
|
597
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PSBT builder for UTXOpia non-interactive deposits.
|
|
3
|
+
*
|
|
4
|
+
* Creates a Partially-Signed Bitcoin Transaction with:
|
|
5
|
+
* - Input(s): user's UTXOs (P2TR or P2WPKH)
|
|
6
|
+
* - Output 1: P2TR deposit (commitment-bound Taproot address)
|
|
7
|
+
* - Output 2: OP_RETURN (73 bytes: header || poolTag || ephemeralPub || npk)
|
|
8
|
+
* - Output 3: change back to user (if needed)
|
|
9
|
+
*
|
|
10
|
+
* Uses @scure/btc-signer for PSBT construction.
|
|
11
|
+
*/
|
|
12
|
+
/** UTXO descriptor for PSBT inputs */
|
|
13
|
+
export interface UtxoDescriptor {
|
|
14
|
+
/** Transaction ID (hex, 64 chars) */
|
|
15
|
+
txid: string;
|
|
16
|
+
/** Output index */
|
|
17
|
+
vout: number;
|
|
18
|
+
/** Value in satoshis */
|
|
19
|
+
value: number;
|
|
20
|
+
/** Raw scriptPubkey (hex) */
|
|
21
|
+
scriptPubkeyHex: string;
|
|
22
|
+
/** Witness UTXO script type (inferred from scriptPubkey if omitted) */
|
|
23
|
+
type?: "p2tr" | "p2wpkh";
|
|
24
|
+
}
|
|
25
|
+
/** Parameters for building a deposit PSBT */
|
|
26
|
+
export interface BuildDepositPsbtParams {
|
|
27
|
+
/** Sender's UTXOs to spend */
|
|
28
|
+
senderUtxos: UtxoDescriptor[];
|
|
29
|
+
/** Taproot deposit address (bc1p... or tb1p...) */
|
|
30
|
+
depositAddress: string;
|
|
31
|
+
/** Deposit amount in satoshis */
|
|
32
|
+
depositAmountSats: number;
|
|
33
|
+
/** Compact deposit OP_RETURN payload (from buildDepositOpReturn) */
|
|
34
|
+
opReturnPayload: Uint8Array;
|
|
35
|
+
/** Change address (same type as sender) */
|
|
36
|
+
changeAddress: string;
|
|
37
|
+
/** Fee rate in sats/vbyte */
|
|
38
|
+
feeRate: number;
|
|
39
|
+
/** Bitcoin network */
|
|
40
|
+
network?: "mainnet" | "testnet" | "signet";
|
|
41
|
+
}
|
|
42
|
+
/** Result of PSBT construction */
|
|
43
|
+
export interface BuildDepositPsbtResult {
|
|
44
|
+
/** PSBT encoded as base64 */
|
|
45
|
+
psbtBase64: string;
|
|
46
|
+
/** PSBT encoded as hex */
|
|
47
|
+
psbtHex: string;
|
|
48
|
+
/** Estimated transaction fee in satoshis */
|
|
49
|
+
estimatedFee: number;
|
|
50
|
+
/** Total input value in satoshis */
|
|
51
|
+
totalInput: number;
|
|
52
|
+
/** Change amount in satoshis (0 if no change) */
|
|
53
|
+
changeAmount: number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Estimate the transaction fee for a deposit PSBT.
|
|
57
|
+
*/
|
|
58
|
+
export declare function estimateDepositFee(numInputs: number, feeRate: number, inputType?: "p2tr" | "p2wpkh", hasChange?: boolean): number;
|
|
59
|
+
/**
|
|
60
|
+
* Build a deposit PSBT with OP_RETURN for non-interactive stealth deposits.
|
|
61
|
+
*
|
|
62
|
+
* The PSBT is unsigned — it must be signed by the user's wallet (e.g. via sats-connect).
|
|
63
|
+
*/
|
|
64
|
+
export declare function buildDepositPsbt(params: BuildDepositPsbtParams): BuildDepositPsbtResult;
|
|
65
|
+
/**
|
|
66
|
+
* Fetch UTXOs for an address from mempool.space API.
|
|
67
|
+
*/
|
|
68
|
+
export declare function fetchUtxos(address: string, network?: "mainnet" | "testnet" | "signet"): Promise<UtxoDescriptor[]>;
|
|
69
|
+
/**
|
|
70
|
+
* Select UTXOs to cover the target amount + estimated fee.
|
|
71
|
+
* Simple greedy algorithm: sort descending by value, take until covered.
|
|
72
|
+
*/
|
|
73
|
+
export declare function selectUtxos(utxos: UtxoDescriptor[], targetSats: number, feeRate: number): UtxoDescriptor[];
|