@utxopia/sdk 0.1.0-alpha.4 → 0.1.0-alpha.6

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.
@@ -10,9 +10,8 @@
10
10
  * - Signature = EdDSA-Poseidon over (merkleRoot, boundParamsHash, nullifiers..., commitmentsOut...)
11
11
  */
12
12
  import { poseidonHashSync, computeJoinSplitCommitmentSync, computeJoinSplitNullifierSync, } from "../poseidon";
13
- import { BN254_FIELD_PRIME } from "../crypto";
13
+ import { BN254_FIELD_PRIME, bytesToHex } from "../crypto";
14
14
  import { TREE_DEPTH } from "../merkle";
15
- import { getConfig } from "../config";
16
15
  import { MAX_SAFE_JOINSPLIT_SIZE } from "../vk-registry";
17
16
  /** Maximum satoshis (total BTC supply) */
18
17
  const MAX_SATOSHIS = 21000000n * 100000000n;
@@ -42,6 +41,26 @@ const isBrowser = typeof window !== "undefined";
42
41
  const isNode = typeof process !== "undefined" && process.versions?.node;
43
42
  // Configurable circuit paths
44
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
+ }
45
64
  /**
46
65
  * Set the base path for circuit artifacts
47
66
  */
@@ -50,18 +69,14 @@ export function setCircuitPath(path) {
50
69
  return;
51
70
  circuitBasePath = path;
52
71
  circuitCache.clear();
72
+ vkeyCache.clear();
53
73
  artifactBytesCache.clear();
54
74
  artifactDownloadCache.clear();
55
75
  }
56
- /**
57
- * Get the current circuit base path
58
- */
59
- export function getCircuitPath() {
60
- return circuitBasePath;
61
- }
62
76
  // Lazy-loaded snarkjs module
63
77
  let snarkjs = null;
64
78
  const circuitCache = new Map();
79
+ const vkeyCache = new Map();
65
80
  let proverInitialized = false;
66
81
  /**
67
82
  * Load snarkjs module
@@ -115,7 +130,6 @@ export function setCircuitArtifactDigests(digests) {
115
130
  // Anything admitted under the old policy must be re-checked under the new one.
116
131
  artifactBytesCache.clear();
117
132
  }
118
- const toHex = (buf) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join("");
119
133
  async function verifyArtifactBytes(url, bytes) {
120
134
  if (!artifactDigests)
121
135
  return;
@@ -125,7 +139,7 @@ async function verifyArtifactBytes(url, bytes) {
125
139
  throw new Error(`No integrity digest recorded for circuit artifact ${url} — refusing to prove. ` +
126
140
  `Regenerate the artifact manifest so it covers every shape this origin serves.`);
127
141
  }
128
- const actual = toHex(await crypto.subtle.digest("SHA-256", bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)));
142
+ const actual = bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength))));
129
143
  if (actual !== artifactDigests[key]) {
130
144
  throw new Error(`Circuit artifact ${key} failed its integrity check (expected ${artifactDigests[key]}, ` +
131
145
  `got ${actual}). Refusing to prove against an artifact this build does not recognise.`);
@@ -556,42 +570,3 @@ export async function cleanup() {
556
570
  // ==========================================================================
557
571
  // Solana Instruction Building (for Groth16 on-chain verification)
558
572
  // ==========================================================================
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
- }
package/dist/psbt.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * Uses @scure/btc-signer for PSBT construction.
11
11
  */
12
+ import { type BitcoinNetwork } from "./taproot";
12
13
  /** UTXO descriptor for PSBT inputs */
13
14
  export interface UtxoDescriptor {
14
15
  /** Transaction ID (hex, 64 chars) */
@@ -44,7 +45,7 @@ export interface BuildDepositPsbtParams {
44
45
  /** Fee rate in sats/vbyte */
45
46
  feeRate: number;
46
47
  /** Bitcoin network */
47
- network?: "mainnet" | "testnet" | "signet";
48
+ network?: BitcoinNetwork;
48
49
  }
49
50
  /** Result of PSBT construction */
50
51
  export interface BuildDepositPsbtResult {
package/dist/psbt.js CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import * as btc from "@scure/btc-signer";
13
13
  import { hex } from "@scure/base";
14
- import { DEPOSIT_OP_RETURN_SIZE, createOpReturnScriptFromPayload } from "./taproot";
14
+ import { DEPOSIT_OP_RETURN_SIZE, createOpReturnScriptFromPayload, bech32Hrp } from "./taproot";
15
15
  // =============================================================================
16
16
  // Constants
17
17
  // =============================================================================
@@ -64,7 +64,11 @@ export function buildDepositPsbt(params) {
64
64
  if (opReturnPayload && opReturnPayload.length !== DEPOSIT_OP_RETURN_SIZE) {
65
65
  throw new Error(`OP_RETURN payload must be ${DEPOSIT_OP_RETURN_SIZE} bytes, got ${opReturnPayload.length}`);
66
66
  }
67
- const btcNetwork = network === "mainnet" ? btc.NETWORK : btc.TEST_NETWORK;
67
+ // Regtest shares testnet's version bytes but not its bech32 hrp, so folding it
68
+ // into TEST_NETWORK leaves every bcrt1 address undecodable.
69
+ const btcNetwork = network === "mainnet"
70
+ ? btc.NETWORK
71
+ : { ...btc.TEST_NETWORK, bech32: bech32Hrp(network) };
68
72
  // Calculate total input value
69
73
  const totalInput = senderUtxos.reduce((sum, u) => sum + u.value, 0);
70
74
  // Detect input type from first UTXO
package/dist/spend-doc.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * Poseidon(merkleRoot, boundParamsHash, nullifiers.., commitmentsOut..).
12
12
  */
13
13
  import { computeSolanaDomainBoundParamsHash, createRedeemBoundParams, createTransferBoundParams, createUnshieldBoundParams, } from "./bound-params";
14
+ import { bytesToHex as hex } from "./crypto";
14
15
  export class SpendDocMismatch extends Error {
15
16
  }
16
17
  function fmt(raw, decimals) {
@@ -20,7 +21,6 @@ function fmt(raw, decimals) {
20
21
  const frac = decimals === 0 ? "" : s.slice(s.length - decimals).replace(/0+$/, "");
21
22
  return `${neg ? "-" : ""}${whole}${frac ? `.${frac}` : ""}`;
22
23
  }
23
- const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
24
24
  function sortedValues(v) {
25
25
  return [...v].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join(",");
26
26
  }
package/dist/stealth.d.ts CHANGED
@@ -189,8 +189,8 @@ export interface NonInteractiveDepositWithRefundResult extends NonInteractiveDep
189
189
  * @param network - Bitcoin network for address encoding
190
190
  * @param userRefundPubkey - Optional 32-byte x-only pubkey for refund script path
191
191
  */
192
- export declare function createNonInteractiveDeposit(recipientMeta: StealthMetaAddress, custodyInternalKey: Uint8Array, network?: "mainnet" | "testnet" | "regtest", userRefundPubkey?: undefined, opReturnContext?: DepositOpReturnContext): Promise<NonInteractiveDepositResult>;
193
- export declare function createNonInteractiveDeposit(recipientMeta: StealthMetaAddress, custodyInternalKey: Uint8Array, network: "mainnet" | "testnet" | "regtest", userRefundPubkey: Uint8Array, opReturnContext: DepositOpReturnContext): Promise<NonInteractiveDepositWithRefundResult>;
192
+ export declare function createNonInteractiveDeposit(recipientMeta: StealthMetaAddress, custodyInternalKey: Uint8Array, network?: BitcoinNetwork, userRefundPubkey?: undefined, opReturnContext?: DepositOpReturnContext): Promise<NonInteractiveDepositResult>;
193
+ export declare function createNonInteractiveDeposit(recipientMeta: StealthMetaAddress, custodyInternalKey: Uint8Array, network: BitcoinNetwork, userRefundPubkey: Uint8Array, opReturnContext: DepositOpReturnContext): Promise<NonInteractiveDepositWithRefundResult>;
194
194
  /**
195
195
  * Create a non-interactive deposit directly to an Ika-controlled vault.
196
196
  *
@@ -199,7 +199,7 @@ export declare function createNonInteractiveDeposit(recipientMeta: StealthMetaAd
199
199
  * in OP_RETURN(header || poolTag || ephemeralPub || npk), and the destination chain
200
200
  * credits the note from that transaction.
201
201
  */
202
- export declare function createDirectVaultDeposit(recipientMeta: StealthMetaAddress, vaultXOnlyPubkey: Uint8Array, network?: "mainnet" | "testnet" | "regtest", opReturnContext?: DepositOpReturnContext): Promise<NonInteractiveDepositResult>;
202
+ export declare function createDirectVaultDeposit(recipientMeta: StealthMetaAddress, vaultXOnlyPubkey: Uint8Array, network?: BitcoinNetwork, opReturnContext?: DepositOpReturnContext): Promise<NonInteractiveDepositResult>;
203
203
  /**
204
204
  * The delegable half of deposit recovery.
205
205
  *
@@ -322,7 +322,7 @@ export interface TweakDepositResult {
322
322
  * with no OP_RETURN is invisible to block scanning, so an unregistered address
323
323
  * is one nobody is watching.
324
324
  */
325
- export declare function createTweakDeposit(recipientMeta: StealthMetaAddress, vaultXOnlyPubkey: Uint8Array, recovery: DepositRecoveryMaterial, network?: "mainnet" | "testnet" | "regtest"): Promise<TweakDepositResult>;
325
+ export declare function createTweakDeposit(recipientMeta: StealthMetaAddress, vaultXOnlyPubkey: Uint8Array, recovery: DepositRecoveryMaterial, network?: BitcoinNetwork): Promise<TweakDepositResult>;
326
326
  /**
327
327
  * Create a non-interactive deposit using the current SDK config.
328
328
  *
@@ -333,7 +333,7 @@ export declare function createTweakDeposit(recipientMeta: StealthMetaAddress, va
333
333
  * credits the note by SPV-verifying that deposit transaction directly. Legacy sweep-mode
334
334
  * address derivation is intentionally not selected from config anymore.
335
335
  */
336
- export declare function createDepositFromConfig(recipientMeta: StealthMetaAddress, network?: "mainnet" | "testnet" | "regtest", opReturnContext?: DepositOpReturnContext): Promise<NonInteractiveDepositResult>;
336
+ export declare function createDepositFromConfig(recipientMeta: StealthMetaAddress, network?: BitcoinNetwork, opReturnContext?: DepositOpReturnContext): Promise<NonInteractiveDepositResult>;
337
337
  export declare function isDirectVaultDepositMode(mode?: string): boolean;
338
338
  /**
339
339
  * Choose the Taproot internal key for deposit-address derivation.
@@ -448,6 +448,7 @@ export declare function scanUnifiedNotesMulti(source: WalletSignerAdapter | UTXO
448
448
  */
449
449
  export declare function scanUnifiedNotes(source: WalletSignerAdapter | UTXOpiaKeys, announcements: OnChainStealthAnnouncement[], tokenId: bigint): Promise<ScannedNote[]>;
450
450
  import type { Address } from "@solana/kit";
451
+ import type { BitcoinNetwork } from "./taproot";
451
452
  export interface ConnectionAdapter {
452
453
  getAccountInfo: (pubkey: Address) => Promise<{
453
454
  data: Uint8Array;
@@ -480,13 +481,6 @@ export declare function packEncryptedAmountWithSign(encryptedAmount: Uint8Array,
480
481
  * Convert StealthOutputData to circuit-ready format
481
482
  */
482
483
  export declare function packStealthOutputForCircuit(output: StealthOutputData): CircuitStealthOutput;
483
- /**
484
- * Unpack encrypted amount from packed Field element
485
- */
486
- export declare function unpackEncryptedAmountWithSign(packed: bigint): {
487
- encryptedAmount: Uint8Array;
488
- ySign: boolean;
489
- };
490
484
  /**
491
485
  * Create stealth output data for a self-send (change output)
492
486
  */
package/dist/stealth.js CHANGED
@@ -737,20 +737,6 @@ export function packStealthOutputForCircuit(output) {
737
737
  encryptedAmountWithSign,
738
738
  };
739
739
  }
740
- /**
741
- * Unpack encrypted amount from packed Field element
742
- */
743
- export function unpackEncryptedAmountWithSign(packed) {
744
- const ySign = (packed & (1n << 64n)) !== 0n;
745
- const amount = packed & ((1n << 64n) - 1n);
746
- const encryptedAmount = new Uint8Array(8);
747
- let temp = amount;
748
- for (let i = 0; i < 8; i++) {
749
- encryptedAmount[i] = Number(temp & 0xffn);
750
- temp >>= 8n;
751
- }
752
- return { encryptedAmount, ySign };
753
- }
754
740
  /**
755
741
  * Create stealth output data for a self-send (change output)
756
742
  */
package/dist/taproot.d.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  * @param internalKey - Configured FROST/Ika custody key (x-only, 32 bytes; required)
19
19
  * @returns Taproot address (bc1p... or tb1p...)
20
20
  */
21
- export declare function deriveTaprootAddress(commitment: Uint8Array, network?: "mainnet" | "testnet" | "regtest", internalKey?: Uint8Array): {
21
+ export declare function deriveTaprootAddress(commitment: Uint8Array, network?: BitcoinNetwork, internalKey?: Uint8Array): {
22
22
  address: string;
23
23
  outputKey: Uint8Array;
24
24
  tweak: Uint8Array;
@@ -64,7 +64,7 @@ export declare function depositLeafScript(commitment: Uint8Array, ikaXOnlyPubkey
64
64
  * Returns everything the sweeper needs to spend it: a script-path witness is
65
65
  * `[signature, leafScript, controlBlock]`.
66
66
  */
67
- export declare function deriveDepositAddress(commitment: Uint8Array, ikaXOnlyPubkey: Uint8Array, network?: "mainnet" | "testnet" | "regtest"): {
67
+ export declare function deriveDepositAddress(commitment: Uint8Array, ikaXOnlyPubkey: Uint8Array, network?: BitcoinNetwork): {
68
68
  address: string;
69
69
  outputKey: Uint8Array;
70
70
  leafScript: Uint8Array;
@@ -80,6 +80,34 @@ export declare function deriveDepositAddress(commitment: Uint8Array, ikaXOnlyPub
80
80
  * @returns true if address matches expected derivation
81
81
  */
82
82
  export declare function verifyTaprootAddress(address: string, commitment: Uint8Array, internalKey?: Uint8Array): boolean;
83
+ /**
84
+ * The networks whose addresses this SDK derives.
85
+ *
86
+ * Kept as one alias because the members drifted when it was spelled out at each
87
+ * call site: psbt.ts folded regtest into testnet while taproot.ts did not, and
88
+ * the two disagreed about what a bcrt1 address was.
89
+ */
90
+ export type BitcoinNetwork = "mainnet" | "testnet" | "signet" | "regtest";
91
+ /**
92
+ * bech32 human-readable prefix for a Bitcoin network.
93
+ *
94
+ * Regtest is the reason this exists: it shares testnet's version bytes but not
95
+ * its prefix, and every place that folded the two together produced addresses
96
+ * no regtest node would accept.
97
+ */
98
+ export declare function bech32Hrp(network: BitcoinNetwork): string;
99
+ /**
100
+ * The network an address's bech32 prefix names, or null if it names none.
101
+ *
102
+ * Null rather than a testnet default: an unrecognised prefix is an address this
103
+ * SDK cannot place, and quietly calling it testnet is how bcrt1 addresses ended
104
+ * up classified as testnet everywhere.
105
+ */
106
+ export declare function networkForHrp(prefix: string): BitcoinNetwork | null;
107
+ /**
108
+ * Encode an x-only output key as a witness-v1 (P2TR) address.
109
+ */
110
+ export declare function p2trAddress(outputKey: Uint8Array, network: BitcoinNetwork): string;
83
111
  /**
84
112
  * Generate a P2TR (Pay-to-Taproot) script pubkey
85
113
  *
@@ -100,7 +128,7 @@ export declare function parseP2TRScriptPubkey(scriptPubkey: Uint8Array): Uint8Ar
100
128
  export declare function isValidBitcoinAddress(address: string): {
101
129
  valid: boolean;
102
130
  type: "p2pkh" | "p2sh" | "p2wpkh" | "p2wsh" | "p2tr" | "unknown";
103
- network: "mainnet" | "testnet" | "unknown";
131
+ network: BitcoinNetwork | "unknown";
104
132
  };
105
133
  /** Destination chain encoded in the compact deposit OP_RETURN header. */
106
134
  export declare const DEPOSIT_DESTINATION_CHAIN: {
@@ -201,7 +229,7 @@ export declare function computeTapLeafHash(script: Uint8Array, leafVersion?: num
201
229
  * @param internalKey - 32-byte x-only FROST group public key
202
230
  * @param network - Bitcoin network for address encoding
203
231
  */
204
- export declare function deriveTaprootAddressWithRefund(npk: Uint8Array, userRefundPubkey: Uint8Array, internalKey: Uint8Array, network?: "mainnet" | "testnet" | "regtest"): {
232
+ export declare function deriveTaprootAddressWithRefund(npk: Uint8Array, userRefundPubkey: Uint8Array, internalKey: Uint8Array, network?: BitcoinNetwork): {
205
233
  address: string;
206
234
  outputKey: Uint8Array;
207
235
  merkleRoot: Uint8Array;
package/dist/taproot.js CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { sha256 } from "@noble/hashes/sha2.js";
9
9
  import { taggedHash, hexToBytes, bytesToHex } from "./crypto";
10
- import * as bech32 from "bech32";
10
+ import { bech32, bech32m } from "@scure/base";
11
11
  import { secp256k1 } from "@noble/curves/secp256k1.js";
12
12
  // Never use the secp256k1 generator as a custody key. Its discrete log is
13
13
  // public, so a key-path output derived from it is sweepable by anyone.
@@ -59,11 +59,7 @@ export function deriveTaprootAddress(commitment, network = "testnet", internalKe
59
59
  // x-only output key (BIP-340): drop prefix from compressed form
60
60
  const outputKeyHex = outputPoint.toHex(true); // 33-byte compressed hex
61
61
  const outputKey = hexToBytes(outputKeyHex.slice(2)); // drop "02"/"03" prefix
62
- // Encode as bech32m address
63
- const hrp = network === "mainnet" ? "bc" : network === "regtest" ? "bcrt" : "tb";
64
- const words = bech32.bech32m.toWords(outputKey);
65
- // Witness version 1 for taproot
66
- const address = bech32.bech32m.encode(hrp, [1, ...words]);
62
+ const address = p2trAddress(outputKey, network);
67
63
  return {
68
64
  address,
69
65
  outputKey,
@@ -151,20 +147,56 @@ export function deriveDepositAddress(commitment, ikaXOnlyPubkey, network = "test
151
147
  */
152
148
  export function verifyTaprootAddress(address, commitment, internalKey) {
153
149
  try {
154
- const decoded = bech32.bech32m.decode(address);
150
+ const decoded = bech32m.decode(address);
155
151
  const witnessVersion = decoded.words[0];
156
152
  if (witnessVersion !== 1) {
157
153
  return false;
158
154
  }
159
- const actualOutputKey = new Uint8Array(bech32.bech32m.fromWords(decoded.words.slice(1)));
160
- const network = decoded.prefix === "bc" ? "mainnet" : "testnet";
155
+ const network = networkForHrp(decoded.prefix);
156
+ if (!network)
157
+ return false;
161
158
  const expected = deriveTaprootAddress(commitment, network, internalKey);
162
- return arraysEqual(actualOutputKey, expected.outputKey);
159
+ // Compare the whole address, not just the output key: the key is identical
160
+ // on every network, so an output-key match said nothing about the encoding.
161
+ // bech32 is case-insensitive, hence the fold.
162
+ return address.toLowerCase() === expected.address;
163
163
  }
164
164
  catch {
165
165
  return false;
166
166
  }
167
167
  }
168
+ /**
169
+ * bech32 human-readable prefix for a Bitcoin network.
170
+ *
171
+ * Regtest is the reason this exists: it shares testnet's version bytes but not
172
+ * its prefix, and every place that folded the two together produced addresses
173
+ * no regtest node would accept.
174
+ */
175
+ export function bech32Hrp(network) {
176
+ return network === "mainnet" ? "bc" : network === "regtest" ? "bcrt" : "tb";
177
+ }
178
+ /**
179
+ * The network an address's bech32 prefix names, or null if it names none.
180
+ *
181
+ * Null rather than a testnet default: an unrecognised prefix is an address this
182
+ * SDK cannot place, and quietly calling it testnet is how bcrt1 addresses ended
183
+ * up classified as testnet everywhere.
184
+ */
185
+ export function networkForHrp(prefix) {
186
+ return prefix === "bc"
187
+ ? "mainnet"
188
+ : prefix === "tb"
189
+ ? "testnet"
190
+ : prefix === "bcrt"
191
+ ? "regtest"
192
+ : null;
193
+ }
194
+ /**
195
+ * Encode an x-only output key as a witness-v1 (P2TR) address.
196
+ */
197
+ export function p2trAddress(outputKey, network) {
198
+ return bech32m.encode(bech32Hrp(network), [1, ...bech32m.toWords(outputKey)]);
199
+ }
168
200
  /**
169
201
  * Generate a P2TR (Pay-to-Taproot) script pubkey
170
202
  *
@@ -203,32 +235,24 @@ export function parseP2TRScriptPubkey(scriptPubkey) {
203
235
  export function isValidBitcoinAddress(address) {
204
236
  try {
205
237
  // Bech32m (Taproot)
206
- if (address.startsWith("bc1p") || address.startsWith("tb1p")) {
207
- const decoded = bech32.bech32m.decode(address);
208
- if (decoded.words[0] === 1 && decoded.words.length === 53) {
209
- return {
210
- valid: true,
211
- type: "p2tr",
212
- network: decoded.prefix === "bc" ? "mainnet" : "testnet",
213
- };
238
+ if (address.startsWith("bc1p") ||
239
+ address.startsWith("tb1p") ||
240
+ address.startsWith("bcrt1p")) {
241
+ const decoded = bech32m.decode(address);
242
+ const network = networkForHrp(decoded.prefix);
243
+ if (network && decoded.words[0] === 1 && decoded.words.length === 53) {
244
+ return { valid: true, type: "p2tr", network };
214
245
  }
215
246
  }
216
247
  // Bech32 (SegWit v0)
217
248
  if (address.startsWith("bc1q") ||
218
249
  address.startsWith("tb1q") ||
219
250
  address.startsWith("bcrt1q")) {
220
- const decoded = bech32.bech32.decode(address);
221
- if (decoded.words[0] === 0) {
251
+ const decoded = bech32.decode(address);
252
+ const network = networkForHrp(decoded.prefix);
253
+ if (network && decoded.words[0] === 0) {
222
254
  const type = decoded.words.length === 33 ? "p2wpkh" : "p2wsh";
223
- return {
224
- valid: true,
225
- type,
226
- network: decoded.prefix === "bc"
227
- ? "mainnet"
228
- : decoded.prefix === "bcrt"
229
- ? "testnet"
230
- : "testnet",
231
- };
255
+ return { valid: true, type, network };
232
256
  }
233
257
  }
234
258
  // Legacy (base58check)
@@ -533,9 +557,7 @@ export function deriveTaprootAddressWithRefund(npk, userRefundPubkey, internalKe
533
557
  controlBlock[0] = 0xc0 | parityBit;
534
558
  controlBlock.set(internalKey, 1);
535
559
  // 7. Encode as bech32m address
536
- const hrp = network === "mainnet" ? "bc" : network === "regtest" ? "bcrt" : "tb";
537
- const words = bech32.bech32m.toWords(outputKey);
538
- const address = bech32.bech32m.encode(hrp, [1, ...words]);
560
+ const address = p2trAddress(outputKey, network);
539
561
  return {
540
562
  address,
541
563
  outputKey,
@@ -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.4",
3
+ "version": "0.1.0-alpha.6",
4
4
  "description": "UTXOpia SDK - Private Bitcoin across Solana and Sui",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -27,40 +27,10 @@
27
27
  "import": "./dist/prover/mobile.js",
28
28
  "default": "./dist/prover/mobile.js"
29
29
  },
30
- "./stealth": {
31
- "types": "./dist/stealth/index.d.ts",
32
- "import": "./dist/stealth/index.js",
33
- "default": "./dist/stealth/index.js"
34
- },
35
30
  "./bitcoin": {
36
31
  "types": "./dist/bitcoin/index.d.ts",
37
32
  "import": "./dist/bitcoin/index.js",
38
33
  "default": "./dist/bitcoin/index.js"
39
- },
40
- "./solana": {
41
- "types": "./dist/solana/index.d.ts",
42
- "import": "./dist/solana/index.js",
43
- "default": "./dist/solana/index.js"
44
- },
45
- "./watcher": {
46
- "types": "./dist/watcher/index.d.ts",
47
- "import": "./dist/watcher/index.js",
48
- "default": "./dist/watcher/index.js"
49
- },
50
- "./watcher/web": {
51
- "types": "./dist/watcher/web.d.ts",
52
- "import": "./dist/watcher/web.js",
53
- "default": "./dist/watcher/web.js"
54
- },
55
- "./watcher/native": {
56
- "types": "./dist/watcher/native.d.ts",
57
- "import": "./dist/watcher/native.js",
58
- "default": "./dist/watcher/native.js"
59
- },
60
- "./react": {
61
- "types": "./dist/react/index.d.ts",
62
- "import": "./dist/react/index.js",
63
- "default": "./dist/react/index.js"
64
34
  }
65
35
  },
66
36
  "files": [
@@ -93,7 +63,6 @@
93
63
  "@scure/btc-signer": "2.0.1",
94
64
  "@solana-program/system": "^0.10.0",
95
65
  "@solana/kit": "^5.5.0",
96
- "bech32": "^2.0.0",
97
66
  "circomlibjs": "^0.1.7",
98
67
  "poseidon-lite": "0.3.0"
99
68
  },
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { taggedHash, hexToBytes, bytesToHex } from "../crypto";
11
- import { bech32m } from "bech32";
11
+ import { p2trAddress, type BitcoinNetwork } from "../taproot";
12
12
  import { secp256k1 } from "@noble/curves/secp256k1.js";
13
13
 
14
14
  /**
@@ -33,12 +33,12 @@ export type IkaDWalletRef =
33
33
  * address = bech32m(hrp, [witness_version=1, ...words(output_key)])
34
34
  *
35
35
  * @param ref The Ika dWallet reference (literal pubkey, or future async id).
36
- * @param network "mainnet" | "testnet" | "regtest"
36
+ * @param network BitcoinNetwork
37
37
  * @returns The P2TR address (`bc1p…` / `tb1p…` / `bcrt1p…`)
38
38
  */
39
39
  export function deriveCustodyAddressFromIkaDWallet(
40
40
  ref: IkaDWalletRef,
41
- network: "mainnet" | "testnet" | "regtest"
41
+ network: BitcoinNetwork
42
42
  ): string {
43
43
  const xonly = extractXOnly(ref);
44
44
  const tweak = taggedHash("TapTweak", xonly);
@@ -52,10 +52,7 @@ export function deriveCustodyAddressFromIkaDWallet(
52
52
  // Drop the 1-byte parity prefix to get the x-only output key.
53
53
  const outputKey = hexToBytes(outputPoint.toHex(true).slice(2));
54
54
 
55
- const hrp =
56
- network === "mainnet" ? "bc" : network === "regtest" ? "bcrt" : "tb";
57
- const words = bech32m.toWords(outputKey);
58
- return bech32m.encode(hrp, [1, ...words]);
55
+ return p2trAddress(outputKey, network);
59
56
  }
60
57
 
61
58
  /**
@@ -67,14 +64,12 @@ export function deriveCustodyAddressFromIkaDWallet(
67
64
  */
68
65
  export function deriveRawXOnlyP2TRAddress(
69
66
  xonlyPubkey: Uint8Array,
70
- network: "mainnet" | "testnet" | "regtest"
67
+ network: BitcoinNetwork
71
68
  ): string {
72
69
  if (xonlyPubkey.length !== 32) {
73
70
  throw new Error("xonlyPubkey must be 32 bytes");
74
71
  }
75
- const hrp =
76
- network === "mainnet" ? "bc" : network === "regtest" ? "bcrt" : "tb";
77
- return bech32m.encode(hrp, [1, ...bech32m.toWords(xonlyPubkey)]);
72
+ return p2trAddress(xonlyPubkey, network);
78
73
  }
79
74
 
80
75
  function extractXOnly(ref: IkaDWalletRef): Uint8Array {