@utxopia/sdk 0.1.0-alpha.5 → 0.1.0-alpha.7

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/dist/client.js CHANGED
@@ -25,6 +25,7 @@ import { setupKeysFromWallet, setupKeysFromSeed, setupKeysFromAuthSignature, rec
25
25
  import { scanUnifiedNotesMulti, scanAnnouncementsViewOnlyMulti, computeNullifierHashForNote, computeNullifierBytes, isDepositForViewerHex, createDepositFromConfig, createTweakDeposit, depositViewingNode, createStealthOutputWithKeys, } from "./stealth";
26
26
  import { selectUtxos } from "./psbt";
27
27
  import { hexToBytes } from "./crypto";
28
+ import { getAddressEncoder } from "@solana/kit";
28
29
  import { EventClient } from "./event-client";
29
30
  // ─── Client ─────────────────────────────────────────────────────────
30
31
  let _instance = null;
@@ -183,25 +184,17 @@ export class UTXOpiaClient {
183
184
  const cached = this._tokenIdCache.get(mintAddress);
184
185
  if (cached !== undefined)
185
186
  return cached;
186
- // Requires PublicKey import dynamically to avoid hard dep
187
- const mintBytes = hexToBytes(mintAddress.padStart(64, "0"));
188
- // If it's a base58 address, convert via PublicKey
189
- let bytes;
190
- try {
191
- // Try as raw hex first (64 chars)
192
- if (mintAddress.length === 64 && /^[0-9a-fA-F]+$/.test(mintAddress)) {
193
- bytes = hexToBytes(mintAddress);
194
- }
195
- else {
196
- // Assume base58 PublicKey — need to decode
197
- // Use the SDK's reduceToField which handles the conversion
198
- const { PublicKey } = require("@solana/web3.js");
199
- bytes = new PublicKey(mintAddress).toBytes();
200
- }
201
- }
202
- catch {
203
- bytes = mintBytes;
204
- }
187
+ // 64 hex chars is a raw token id; anything else is a base58 mint.
188
+ //
189
+ // Decoded with getAddressEncoder, the same path pda.ts uses, because
190
+ // require("@solana/web3.js") is not callable from an ESM browser bundle —
191
+ // it threw for every base58 mint, and the hex fallback behind it threw
192
+ // again on the same input. There is no fallback now: both branches are
193
+ // total for their input, and a malformed mint should raise, not be padded
194
+ // into a different token's id.
195
+ const bytes = mintAddress.length === 64 && /^[0-9a-fA-F]+$/.test(mintAddress)
196
+ ? hexToBytes(mintAddress)
197
+ : new Uint8Array(getAddressEncoder().encode(mintAddress));
205
198
  const tokenId = computeTokenId(bytes);
206
199
  this._tokenIdCache.set(mintAddress, tokenId);
207
200
  return tokenId;
package/dist/config.js CHANGED
@@ -175,8 +175,11 @@ export const MAINNET_CONFIG = {
175
175
  // Bitcoin Network
176
176
  bitcoinNetwork: "mainnet",
177
177
  esploraUrl: "https://mempool.space/api",
178
- // Circuit CDN
179
- circuitCdnUrl: "https://circuit.utxopia.com",
178
+ // Circuit CDN. Must carry the full versioned path: `resolveCircuitPath` turns a bare host
179
+ // into `<host>/circuits/groth16`, which is a DIFFERENT, stale build that still serves 200s
180
+ // for every shape with a different delta. Proofs built from it verify locally and are
181
+ // rejected on chain as "proof invalid".
182
+ circuitCdnUrl: "https://circuit.utxopia.com/circuits/v2/groth16",
180
183
  // Groth16 Verifier (placeholder)
181
184
  groth16VerifierProgramId: address("11111111111111111111111111111111"),
182
185
  // VK Hashes (placeholder - update when deployed)
package/dist/index.d.ts CHANGED
@@ -54,7 +54,7 @@ export { createFetchConnectionAdapter, createConnectionAdapterFromWeb3, createCo
54
54
  export { resolveSnsName, resolveStealthName, parseSnsStealthData, isSnsStealthAddress, isAuditorDisclosable, SnsComplianceFlags, SNS_COMPLIANCE_AUDITOR_OFFSET, SNS_COMPLIANCE_AUDITOR_BYTES, deriveParentDomainKey, SNS_STEALTH_DATA_SIZE, type SnsStealthAddress, } from "./sns-resolver";
55
55
  export { COMMITMENT_TREE_DISCRIMINATOR, parseCommitmentTreeData, isValidRoot, fetchCommitmentTree, getCommitmentIndex, saveCommitmentIndex, CommitmentTreeIndex, buildCommitmentTreeFromChain, fetchLeafIndexForCommitment, fetchMerkleProofForCommitment, getMerkleProofFromTree, type CommitmentTreeState, type RpcClient, type OnChainMerkleProof, } from "./commitment-tree";
56
56
  export { INSTRUCTION_DISCRIMINATORS, buildShieldInstructionData, buildShieldInstruction, type ShieldInstructionOptions, buildApproveRedemptionSigningInstructionData, buildApproveRedemptionSigningInstruction, buildCancelRedemptionInstructionData, buildCancelRedemptionInstruction, type CancelRedemptionInstructionOptions, bigintTo32Bytes, bytes32ToBigint, buildTransactInstructionData, buildTransactInstruction, buildRedeemInstructionData, buildUnshieldInstructionData, buildUnshieldInstruction, buildProposePoolUpdateInstructionData, buildProposePoolUpdateInstruction, buildExecutePoolUpdateInstructionData, buildExecutePoolUpdateInstruction, buildCancelPoolUpdateInstructionData, buildCancelPoolUpdateInstruction, buildRotateTreeInstructionData, buildRotateTreeInstruction, type RotateTreeOptions, buildMagicBlockDelegateInstructionData, buildMagicBlockDelegateInstruction, buildMagicBlockCommitInstructionData, buildMagicBlockCommitInstruction, buildMagicBlockPerPermissionInstructionData, buildMagicBlockPerPermissionInstruction, buildPolicyRequestHash, buildPolicyIntentParts, buildRegisterExitDestinationInstruction, buildRegisterExitDestinationInstructionData, MAX_POLICY_INTENT_PARTS, buildInitializePolicyApprovalInstructionData, buildInitializePolicyApprovalInstruction, buildPolicyApprovalDecisionInstruction, buildPolicyApprovalCommitInstruction, buildCompleteDepositPermissionedInstructionData, buildCompleteDepositPermissionedInstruction, buildShieldPermissionedInstructionData, buildShieldPermissionedInstruction, buildRotateAuditorInstructionData, buildRotateAuditorInstruction, type MagicBlockDelegateTarget, type MagicBlockDelegateInstructionOptions, type MagicBlockCommitInstructionOptions, type MagicBlockPerPermissionOperation, type MagicBlockPerPermissionMember, type MagicBlockPerPermissionInstructionOptions, type PolicyApprovalDecision, type InitializePolicyApprovalOptions, type CompleteDepositPermissionedOptions, type ShieldPermissionedInstructionOptions, type RotateAuditorOptions, buildVerifyTransactionInstructionData, buildCompleteDepositInstructionData, buildVerifyDepositInstructionData, buildVerifyDepositPermissionedInstructionData, buildSetPoolConfigInstructionData, parsePoolConfig, type ParsedPoolConfig, POOL_CONFIG_DISCRIMINATOR, POOL_CONFIG_LEN, POOL_SCRIPT_MAX_LEN, type Instruction, type ApproveRedemptionSigningInstructionOptions, type TransactInstructionOptions, type UnshieldInstructionOptions, type ProposePoolUpdateOptions, type ExecutePoolUpdateOptions, type CancelPoolUpdateOptions, } from "./instructions";
57
- export { VK_REGISTRY_DISCRIMINATOR, VK_REGISTRY_LEN, MAX_IC_POINTS, MAX_SAFE_JOINSPLIT_SIZE, INIT_VK_REGISTRY_DISCRIMINATOR, UPDATE_VK_REGISTRY_DISCRIMINATOR, joinSplitNumPublicInputs, computeVkHash, vkeyJsonToVkMaterial, buildVkRegistryData, parseVkRegistry, assertVkRegistryForShape, isVkRegistryReady, type JoinSplitVkMaterial, type SnarkjsVkeyJson, type ParsedVkRegistry, } from "./vk-registry";
57
+ export { VK_REGISTRY_DISCRIMINATOR, VK_REGISTRY_LEN, MAX_IC_POINTS, MAX_SAFE_JOINSPLIT_SIZE, INIT_VK_REGISTRY_DISCRIMINATOR, UPDATE_VK_REGISTRY_DISCRIMINATOR, joinSplitNumPublicInputs, computeVkHash, vkeyJsonToVkMaterial, buildVkRegistryData, parseVkRegistry, assertVkRegistryForShape, assertVkeyMatchesRegistry, isVkRegistryReady, type JoinSplitVkMaterial, type SnarkjsVkeyJson, type ParsedVkRegistry, } from "./vk-registry";
58
58
  export { fetchExplorerDeposits, fetchExplorerTransfers, fetchExplorerRedemptions, parseNullifierRecord, parseRedemptionRequest, NULLIFIER_RECORD_SIZE, REDEMPTION_REQUEST_SIZE, NULLIFIER_RECORD_DISCRIMINATOR, REDEMPTION_REQUEST_DISCRIMINATOR, OPERATION_TYPE_LABELS, type ExplorerDeposit, type ExplorerTransferEvent, type ExplorerRedemption, type IndexerLeaf, } from "./explorer";
59
59
  export { parseProgramEvents, parseNullifierSpentEvent, parseStealthAnnouncementEvent, parseSenderMemoEvent, parseBtcOriginAttestationEvent, parseAuditorCiphertextEvent, EVENT_NULLIFIER_SPENT, EVENT_STEALTH_ANNOUNCEMENT, EVENT_NULLIFIERS_BATCH, EVENT_ANNOUNCEMENTS_BATCH, EVENT_SENDER_MEMO, EVENT_BTC_ORIGIN_ATTESTATION, EVENT_AUDITOR_CIPHERTEXT, type NullifierSpentEvent, type StealthAnnouncementEvent, type SenderMemoEvent, type BtcOriginAttestationEvent, type AuditorCiphertextEvent, type ProgramEvent, } from "./events";
60
60
  export { AnnouncementClient, type AnnouncementClientConfig, type AnnouncementListener, } from "./announcement-client";
package/dist/index.js CHANGED
@@ -187,7 +187,7 @@ buildSetPoolConfigInstructionData, parsePoolConfig, POOL_CONFIG_DISCRIMINATOR, P
187
187
  // ==========================================================================
188
188
  // VK Registry (JoinSplit Groth16 on-chain verification keys)
189
189
  // ==========================================================================
190
- export { VK_REGISTRY_DISCRIMINATOR, VK_REGISTRY_LEN, MAX_IC_POINTS, MAX_SAFE_JOINSPLIT_SIZE, INIT_VK_REGISTRY_DISCRIMINATOR, UPDATE_VK_REGISTRY_DISCRIMINATOR, joinSplitNumPublicInputs, computeVkHash, vkeyJsonToVkMaterial, buildVkRegistryData, parseVkRegistry, assertVkRegistryForShape, isVkRegistryReady, } from "./vk-registry";
190
+ export { VK_REGISTRY_DISCRIMINATOR, VK_REGISTRY_LEN, MAX_IC_POINTS, MAX_SAFE_JOINSPLIT_SIZE, INIT_VK_REGISTRY_DISCRIMINATOR, UPDATE_VK_REGISTRY_DISCRIMINATOR, joinSplitNumPublicInputs, computeVkHash, vkeyJsonToVkMaterial, buildVkRegistryData, parseVkRegistry, assertVkRegistryForShape, assertVkeyMatchesRegistry, isVkRegistryReady, } from "./vk-registry";
191
191
  // ==========================================================================
192
192
  // ChadBuffer Relay
193
193
  // ==========================================================================
@@ -19,6 +19,14 @@ export interface ProofData {
19
19
  verificationKey?: Uint8Array;
20
20
  }
21
21
  export type CircuitType = `joinsplit_${number}x${number}`;
22
+ /**
23
+ * Fetch a circuit's `*.vkey.json` from the same base path its zkey comes from.
24
+ *
25
+ * Deliberately reads `circuitBasePath` rather than taking a URL: the point of the check this
26
+ * feeds (`assertVkeyMatchesRegistry`) is that the vkey describes the artifacts actually being
27
+ * proved with, and a caller-supplied URL could describe a different build entirely.
28
+ */
29
+ export declare function fetchCircuitVkey(circuitType: CircuitType): Promise<unknown>;
22
30
  /**
23
31
  * Set the base path for circuit artifacts
24
32
  */
@@ -41,6 +41,26 @@ const isBrowser = typeof window !== "undefined";
41
41
  const isNode = typeof process !== "undefined" && process.versions?.node;
42
42
  // Configurable circuit paths
43
43
  let circuitBasePath = isBrowser ? "/circuits/groth16" : "./circuits";
44
+ /**
45
+ * Fetch a circuit's `*.vkey.json` from the same base path its zkey comes from.
46
+ *
47
+ * Deliberately reads `circuitBasePath` rather than taking a URL: the point of the check this
48
+ * feeds (`assertVkeyMatchesRegistry`) is that the vkey describes the artifacts actually being
49
+ * proved with, and a caller-supplied URL could describe a different build entirely.
50
+ */
51
+ export async function fetchCircuitVkey(circuitType) {
52
+ const cached = vkeyCache.get(circuitType);
53
+ if (cached)
54
+ return cached;
55
+ const url = `${circuitBasePath}/${circuitType}/${circuitType}.vkey.json`;
56
+ const res = await fetch(url);
57
+ if (!res.ok) {
58
+ throw new Error(`Could not fetch circuit vkey ${url}: ${res.status} ${res.statusText}`);
59
+ }
60
+ const vkey = await res.json();
61
+ vkeyCache.set(circuitType, vkey);
62
+ return vkey;
63
+ }
44
64
  /**
45
65
  * Set the base path for circuit artifacts
46
66
  */
@@ -49,12 +69,14 @@ export function setCircuitPath(path) {
49
69
  return;
50
70
  circuitBasePath = path;
51
71
  circuitCache.clear();
72
+ vkeyCache.clear();
52
73
  artifactBytesCache.clear();
53
74
  artifactDownloadCache.clear();
54
75
  }
55
76
  // Lazy-loaded snarkjs module
56
77
  let snarkjs = null;
57
78
  const circuitCache = new Map();
79
+ const vkeyCache = new Map();
58
80
  let proverInitialized = false;
59
81
  /**
60
82
  * Load snarkjs module
@@ -98,3 +98,24 @@ export declare function parseVkRegistry(data: Uint8Array): ParsedVkRegistry;
98
98
  export declare function assertVkRegistryForShape(data: Uint8Array | null | undefined, nInputs: number, nOutputs: number): ParsedVkRegistry;
99
99
  /** True if `data` is an initialized `VkRegistry` for the given shape. */
100
100
  export declare function isVkRegistryReady(data: Uint8Array | null | undefined, nInputs: number, nOutputs: number): boolean;
101
+ /**
102
+ * Assert the circuit artifacts this client loaded correspond to the verifying key the chain
103
+ * will check against.
104
+ *
105
+ * `assertVkRegistryForShape` proves the registry exists and names the right shape;
106
+ * `setCircuitArtifactDigests` proves the artifacts are the bytes this build expects. Neither
107
+ * ties the two together, and that gap is a real one: a zkey from a different phase-2 setup
108
+ * proves fine, downloads fine, passes every integrity check, and is then rejected on chain as
109
+ * "proof invalid" — with nothing pointing at the version mismatch.
110
+ *
111
+ * It is not hypothetical. `https://circuit.utxopia.com/circuits/groth16` and
112
+ * `.../circuits/v2/groth16` both serve 200s for every shape, with different `delta` — same
113
+ * ceremony, different per-circuit setup. Both therefore satisfy the program's own vk_hash
114
+ * recomputation, which binds delta+IC to the compiled-in alpha/beta/gamma and so catches a
115
+ * foreign ceremony but NOT a stale phase-2 from the right one. This is the check that does.
116
+ *
117
+ * @param vkey The `*.vkey.json` fetched from the SAME base path as the zkey. Fetching
118
+ * it from anywhere else makes this assertion meaningless.
119
+ * @param registryData Raw `VkRegistry` account data for the shape.
120
+ */
121
+ export declare function assertVkeyMatchesRegistry(vkey: SnarkjsVkeyJson, registryData: Uint8Array | null | undefined, nInputs: number, nOutputs: number): void;
@@ -210,3 +210,42 @@ export function isVkRegistryReady(data, nInputs, nOutputs) {
210
210
  return false;
211
211
  }
212
212
  }
213
+ /**
214
+ * Assert the circuit artifacts this client loaded correspond to the verifying key the chain
215
+ * will check against.
216
+ *
217
+ * `assertVkRegistryForShape` proves the registry exists and names the right shape;
218
+ * `setCircuitArtifactDigests` proves the artifacts are the bytes this build expects. Neither
219
+ * ties the two together, and that gap is a real one: a zkey from a different phase-2 setup
220
+ * proves fine, downloads fine, passes every integrity check, and is then rejected on chain as
221
+ * "proof invalid" — with nothing pointing at the version mismatch.
222
+ *
223
+ * It is not hypothetical. `https://circuit.utxopia.com/circuits/groth16` and
224
+ * `.../circuits/v2/groth16` both serve 200s for every shape, with different `delta` — same
225
+ * ceremony, different per-circuit setup. Both therefore satisfy the program's own vk_hash
226
+ * recomputation, which binds delta+IC to the compiled-in alpha/beta/gamma and so catches a
227
+ * foreign ceremony but NOT a stale phase-2 from the right one. This is the check that does.
228
+ *
229
+ * @param vkey The `*.vkey.json` fetched from the SAME base path as the zkey. Fetching
230
+ * it from anywhere else makes this assertion meaningless.
231
+ * @param registryData Raw `VkRegistry` account data for the shape.
232
+ */
233
+ export function assertVkeyMatchesRegistry(vkey, registryData, nInputs, nOutputs) {
234
+ const registry = assertVkRegistryForShape(registryData, nInputs, nOutputs);
235
+ const expectedIc = joinSplitNumPublicInputs(nInputs, nOutputs) + 1;
236
+ if (vkey.IC.length !== expectedIc) {
237
+ throw new Error(`Circuit vkey for JoinSplit ${nInputs}x${nOutputs} has ${vkey.IC.length} IC points, ` +
238
+ `expected ${expectedIc} — the artifacts are for a different shape.`);
239
+ }
240
+ const local = computeVkHash(vkey);
241
+ if (!local.every((b, i) => b === registry.vkHash[i])) {
242
+ throw new Error(`Circuit artifacts for JoinSplit ${nInputs}x${nOutputs} do not match the on-chain ` +
243
+ `verifying key (artifact vkHash ${bytesToHex(local)}, registry ${bytesToHex(registry.vkHash)}). ` +
244
+ `A proof built from these would be rejected as invalid. Usually the circuit base URL ` +
245
+ `points at a stale build — check it resolves to the same version the registry was ` +
246
+ `registered from.`);
247
+ }
248
+ }
249
+ function bytesToHex(b) {
250
+ return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
251
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@utxopia/sdk",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0-alpha.7",
4
4
  "description": "UTXOpia SDK - Private Bitcoin across Solana and Sui",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/client.ts CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  } from "./stealth";
56
56
  import { selectUtxos, type UtxoDescriptor } from "./psbt";
57
57
  import { hexToBytes, bytesToHex, bigintToBytes } from "./crypto";
58
+ import { getAddressEncoder, type Address } from "@solana/kit";
58
59
  import { EventClient } from "./event-client";
59
60
  import { type DepositOpReturnContext, type BitcoinNetwork } from "./taproot";
60
61
 
@@ -269,23 +270,18 @@ export class UTXOpiaClient {
269
270
  const cached = this._tokenIdCache.get(mintAddress);
270
271
  if (cached !== undefined) return cached;
271
272
 
272
- // Requires PublicKey import dynamically to avoid hard dep
273
- const mintBytes = hexToBytes(mintAddress.padStart(64, "0"));
274
- // If it's a base58 address, convert via PublicKey
275
- let bytes: Uint8Array;
276
- try {
277
- // Try as raw hex first (64 chars)
278
- if (mintAddress.length === 64 && /^[0-9a-fA-F]+$/.test(mintAddress)) {
279
- bytes = hexToBytes(mintAddress);
280
- } else {
281
- // Assume base58 PublicKey — need to decode
282
- // Use the SDK's reduceToField which handles the conversion
283
- const { PublicKey } = require("@solana/web3.js");
284
- bytes = new PublicKey(mintAddress).toBytes();
285
- }
286
- } catch {
287
- bytes = mintBytes;
288
- }
273
+ // 64 hex chars is a raw token id; anything else is a base58 mint.
274
+ //
275
+ // Decoded with getAddressEncoder, the same path pda.ts uses, because
276
+ // require("@solana/web3.js") is not callable from an ESM browser bundle —
277
+ // it threw for every base58 mint, and the hex fallback behind it threw
278
+ // again on the same input. There is no fallback now: both branches are
279
+ // total for their input, and a malformed mint should raise, not be padded
280
+ // into a different token's id.
281
+ const bytes =
282
+ mintAddress.length === 64 && /^[0-9a-fA-F]+$/.test(mintAddress)
283
+ ? hexToBytes(mintAddress)
284
+ : new Uint8Array(getAddressEncoder().encode(mintAddress as Address));
289
285
 
290
286
  const tokenId = computeTokenId(bytes);
291
287
  this._tokenIdCache.set(mintAddress, tokenId);
package/src/config.ts CHANGED
@@ -358,8 +358,11 @@ export const MAINNET_CONFIG: NetworkConfig = {
358
358
  bitcoinNetwork: "mainnet",
359
359
  esploraUrl: "https://mempool.space/api",
360
360
 
361
- // Circuit CDN
362
- circuitCdnUrl: "https://circuit.utxopia.com",
361
+ // Circuit CDN. Must carry the full versioned path: `resolveCircuitPath` turns a bare host
362
+ // into `<host>/circuits/groth16`, which is a DIFFERENT, stale build that still serves 200s
363
+ // for every shape with a different delta. Proofs built from it verify locally and are
364
+ // rejected on chain as "proof invalid".
365
+ circuitCdnUrl: "https://circuit.utxopia.com/circuits/v2/groth16",
363
366
 
364
367
  // Groth16 Verifier (placeholder)
365
368
  groth16VerifierProgramId: address("11111111111111111111111111111111"),
package/src/index.ts CHANGED
@@ -807,6 +807,7 @@ export {
807
807
  buildVkRegistryData,
808
808
  parseVkRegistry,
809
809
  assertVkRegistryForShape,
810
+ assertVkeyMatchesRegistry,
810
811
  isVkRegistryReady,
811
812
  type JoinSplitVkMaterial,
812
813
  type SnarkjsVkeyJson,
package/src/prover/web.ts CHANGED
@@ -67,6 +67,27 @@ const isNode = typeof process !== "undefined" && process.versions?.node;
67
67
  // Configurable circuit paths
68
68
  let circuitBasePath = isBrowser ? "/circuits/groth16" : "./circuits";
69
69
 
70
+ /**
71
+ * Fetch a circuit's `*.vkey.json` from the same base path its zkey comes from.
72
+ *
73
+ * Deliberately reads `circuitBasePath` rather than taking a URL: the point of the check this
74
+ * feeds (`assertVkeyMatchesRegistry`) is that the vkey describes the artifacts actually being
75
+ * proved with, and a caller-supplied URL could describe a different build entirely.
76
+ */
77
+ export async function fetchCircuitVkey(circuitType: CircuitType): Promise<unknown> {
78
+ const cached = vkeyCache.get(circuitType);
79
+ if (cached) return cached;
80
+
81
+ const url = `${circuitBasePath}/${circuitType}/${circuitType}.vkey.json`;
82
+ const res = await fetch(url);
83
+ if (!res.ok) {
84
+ throw new Error(`Could not fetch circuit vkey ${url}: ${res.status} ${res.statusText}`);
85
+ }
86
+ const vkey = await res.json();
87
+ vkeyCache.set(circuitType, vkey);
88
+ return vkey;
89
+ }
90
+
70
91
  /**
71
92
  * Set the base path for circuit artifacts
72
93
  */
@@ -74,6 +95,7 @@ export function setCircuitPath(path: string): void {
74
95
  if (path === circuitBasePath) return;
75
96
  circuitBasePath = path;
76
97
  circuitCache.clear();
98
+ vkeyCache.clear();
77
99
  artifactBytesCache.clear();
78
100
  artifactDownloadCache.clear();
79
101
  }
@@ -87,6 +109,7 @@ interface CircuitArtifact {
87
109
  }
88
110
 
89
111
  const circuitCache = new Map<CircuitType, CircuitArtifact>();
112
+ const vkeyCache = new Map<CircuitType, unknown>();
90
113
  let proverInitialized = false;
91
114
 
92
115
  /**
@@ -293,3 +293,55 @@ export function isVkRegistryReady(
293
293
  return false;
294
294
  }
295
295
  }
296
+
297
+ /**
298
+ * Assert the circuit artifacts this client loaded correspond to the verifying key the chain
299
+ * will check against.
300
+ *
301
+ * `assertVkRegistryForShape` proves the registry exists and names the right shape;
302
+ * `setCircuitArtifactDigests` proves the artifacts are the bytes this build expects. Neither
303
+ * ties the two together, and that gap is a real one: a zkey from a different phase-2 setup
304
+ * proves fine, downloads fine, passes every integrity check, and is then rejected on chain as
305
+ * "proof invalid" — with nothing pointing at the version mismatch.
306
+ *
307
+ * It is not hypothetical. `https://circuit.utxopia.com/circuits/groth16` and
308
+ * `.../circuits/v2/groth16` both serve 200s for every shape, with different `delta` — same
309
+ * ceremony, different per-circuit setup. Both therefore satisfy the program's own vk_hash
310
+ * recomputation, which binds delta+IC to the compiled-in alpha/beta/gamma and so catches a
311
+ * foreign ceremony but NOT a stale phase-2 from the right one. This is the check that does.
312
+ *
313
+ * @param vkey The `*.vkey.json` fetched from the SAME base path as the zkey. Fetching
314
+ * it from anywhere else makes this assertion meaningless.
315
+ * @param registryData Raw `VkRegistry` account data for the shape.
316
+ */
317
+ export function assertVkeyMatchesRegistry(
318
+ vkey: SnarkjsVkeyJson,
319
+ registryData: Uint8Array | null | undefined,
320
+ nInputs: number,
321
+ nOutputs: number,
322
+ ): void {
323
+ const registry = assertVkRegistryForShape(registryData, nInputs, nOutputs);
324
+
325
+ const expectedIc = joinSplitNumPublicInputs(nInputs, nOutputs) + 1;
326
+ if (vkey.IC.length !== expectedIc) {
327
+ throw new Error(
328
+ `Circuit vkey for JoinSplit ${nInputs}x${nOutputs} has ${vkey.IC.length} IC points, ` +
329
+ `expected ${expectedIc} — the artifacts are for a different shape.`,
330
+ );
331
+ }
332
+
333
+ const local = computeVkHash(vkey);
334
+ if (!local.every((b, i) => b === registry.vkHash[i])) {
335
+ throw new Error(
336
+ `Circuit artifacts for JoinSplit ${nInputs}x${nOutputs} do not match the on-chain ` +
337
+ `verifying key (artifact vkHash ${bytesToHex(local)}, registry ${bytesToHex(registry.vkHash)}). ` +
338
+ `A proof built from these would be rejected as invalid. Usually the circuit base URL ` +
339
+ `points at a stale build — check it resolves to the same version the registry was ` +
340
+ `registered from.`,
341
+ );
342
+ }
343
+ }
344
+
345
+ function bytesToHex(b: Uint8Array): string {
346
+ return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
347
+ }