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

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.
@@ -2191,124 +2191,6 @@ export { hexToBytes, bytesToHex } from "./crypto";
2191
2191
  // Permissioned Pool Builders
2192
2192
  // =============================================================================
2193
2193
 
2194
- // ---------------------------------------------------------------------------
2195
- // initializePermissioned (disc=21)
2196
- // ---------------------------------------------------------------------------
2197
-
2198
- /** initializePermissioned instruction options */
2199
- export interface InitializePermissionedOptions {
2200
- /** PDA bump for pool state */
2201
- poolBump: number;
2202
- /** PDA bump for commitment tree */
2203
- treeBump: number;
2204
- /** Deposit fee in basis points (u16 LE) */
2205
- depositFeeBps: number;
2206
- /** Withdrawal fee in basis points (u16 LE) */
2207
- withdrawalFeeBps: number;
2208
- /** Auditor's Solana pubkey (32 bytes) */
2209
- auditor: Uint8Array;
2210
- /** Auditor's viewing public key (32 bytes) */
2211
- auditorViewingPubkey: Uint8Array;
2212
- /** Account addresses — same layout as initialize (disc=0) */
2213
- accounts: {
2214
- /** 0. pool_state (writable) */
2215
- poolState: Address;
2216
- /** 1. commitment_tree (writable) */
2217
- commitmentTree: Address;
2218
- /** 2. zkbtc_mint (writable) */
2219
- zkbtcMint: Address;
2220
- /** 3. pool_vault (writable) */
2221
- poolVault: Address;
2222
- /** 4. deposit_vault (writable) */
2223
- depositVault: Address;
2224
- /** 5. authority (signer, writable — pays for storage) */
2225
- authority: Address;
2226
- /** 6. system_program (readonly) */
2227
- systemProgram: Address;
2228
- };
2229
- }
2230
-
2231
- /**
2232
- * Build initializePermissioned instruction data (disc=21).
2233
- *
2234
- * Layout (after disc byte — same as initialize plus two 32-byte fields):
2235
- * pool_bump(1) + tree_bump(1) + deposit_fee_bps(2 LE) + withdrawal_fee_bps(2 LE)
2236
- * + auditor(32) + auditor_viewing_pubkey(32)
2237
- * = 70 bytes of payload; 71 bytes total with disc.
2238
- */
2239
- export function buildInitializePermissionedInstructionData(options: {
2240
- poolBump: number;
2241
- treeBump: number;
2242
- depositFeeBps: number;
2243
- withdrawalFeeBps: number;
2244
- auditor: Uint8Array;
2245
- auditorViewingPubkey: Uint8Array;
2246
- }): Uint8Array {
2247
- if (options.auditor.length !== 32) {
2248
- throw new Error(`auditor must be 32 bytes, got ${options.auditor.length}`);
2249
- }
2250
- if (options.auditorViewingPubkey.length !== 32) {
2251
- throw new Error(`auditorViewingPubkey must be 32 bytes, got ${options.auditorViewingPubkey.length}`);
2252
- }
2253
-
2254
- // disc(1) + pool_bump(1) + tree_bump(1) + deposit_fee_bps(2) + withdrawal_fee_bps(2) + auditor(32) + auditor_viewing_pubkey(32) = 71
2255
- const data = new Uint8Array(71);
2256
- const view = new DataView(data.buffer);
2257
- let offset = 0;
2258
-
2259
- data[offset++] = PERMISSIONED_DISC.INITIALIZE_PERMISSIONED; // disc = 21
2260
- data[offset++] = options.poolBump;
2261
- data[offset++] = options.treeBump;
2262
- view.setUint16(offset, options.depositFeeBps, true); offset += 2;
2263
- view.setUint16(offset, options.withdrawalFeeBps, true); offset += 2;
2264
- data.set(options.auditor, offset); offset += 32;
2265
- data.set(options.auditorViewingPubkey, offset);
2266
-
2267
- return data;
2268
- }
2269
-
2270
- /**
2271
- * Build a complete initializePermissioned instruction (disc=21).
2272
- *
2273
- * Initializes a pool in permissioned mode; deposits/shields require auditor co-signing.
2274
- *
2275
- * Accounts (identical to initialize, disc=0):
2276
- * 0. pool_state (writable)
2277
- * 1. commitment_tree (writable)
2278
- * 2. zkbtc_mint (writable)
2279
- * 3. pool_vault (writable)
2280
- * 4. deposit_vault (writable)
2281
- * 5. authority (writable signer)
2282
- * 6. system_program (readonly)
2283
- */
2284
- export function buildInitializePermissionedInstruction(
2285
- options: InitializePermissionedOptions,
2286
- ): Instruction {
2287
- const config = getConfig();
2288
- const data = buildInitializePermissionedInstructionData({
2289
- poolBump: options.poolBump,
2290
- treeBump: options.treeBump,
2291
- depositFeeBps: options.depositFeeBps,
2292
- withdrawalFeeBps: options.withdrawalFeeBps,
2293
- auditor: options.auditor,
2294
- auditorViewingPubkey: options.auditorViewingPubkey,
2295
- });
2296
-
2297
- return {
2298
- programAddress: config.utxopiaProgramId,
2299
- accounts: [
2300
- { address: options.accounts.poolState, role: AccountRole.WRITABLE },
2301
- { address: options.accounts.commitmentTree, role: AccountRole.WRITABLE },
2302
- { address: options.accounts.zkbtcMint, role: AccountRole.WRITABLE },
2303
- { address: options.accounts.poolVault, role: AccountRole.WRITABLE },
2304
- { address: options.accounts.depositVault, role: AccountRole.WRITABLE },
2305
- { address: options.accounts.authority, role: AccountRole.WRITABLE_SIGNER },
2306
- { address: options.accounts.systemProgram, role: AccountRole.READONLY },
2307
- ],
2308
- data,
2309
- };
2310
- }
2311
-
2312
2194
  // ---------------------------------------------------------------------------
2313
2195
  // completeDepositPermissioned (disc=22)
2314
2196
  // ---------------------------------------------------------------------------
@@ -2681,102 +2563,6 @@ export function buildRegisterExitDestinationInstruction(options: {
2681
2563
  };
2682
2564
  }
2683
2565
 
2684
- // ---------------------------------------------------------------------------
2685
- // setAuditorFrozen (disc=28)
2686
- // ---------------------------------------------------------------------------
2687
-
2688
- /** setAuditorFrozen instruction options */
2689
- export interface SetAuditorFrozenOptions {
2690
- /** true = freeze the auditor role; false = un-freeze */
2691
- frozen: boolean;
2692
- accounts: {
2693
- /** 0. pool_state (writable) */
2694
- poolState: Address;
2695
- /** 1. auditor (signer) */
2696
- auditor: Address;
2697
- };
2698
- }
2699
-
2700
- /**
2701
- * Build setAuditorFrozen instruction data (disc=28).
2702
- *
2703
- * Layout: disc(1) + frozen(1) — frozen byte: 0 = not frozen, 1 = frozen.
2704
- */
2705
- export function buildSetAuditorFrozenInstructionData(frozen: boolean): Uint8Array {
2706
- return new Uint8Array([INSTRUCTION.SET_AUDITOR_FROZEN, frozen ? 1 : 0]);
2707
- }
2708
-
2709
- /**
2710
- * Build a complete setAuditorFrozen instruction (disc=28).
2711
- *
2712
- * Accounts:
2713
- * 0. pool_state (writable)
2714
- * 1. auditor (signer)
2715
- */
2716
- export function buildSetAuditorFrozenInstruction(options: SetAuditorFrozenOptions): Instruction {
2717
- const config = getConfig();
2718
- return {
2719
- programAddress: config.utxopiaProgramId,
2720
- accounts: [
2721
- { address: options.accounts.poolState, role: AccountRole.WRITABLE },
2722
- { address: options.accounts.auditor, role: AccountRole.READONLY_SIGNER },
2723
- ],
2724
- data: buildSetAuditorFrozenInstructionData(options.frozen),
2725
- };
2726
- }
2727
-
2728
- // ---------------------------------------------------------------------------
2729
- // setAuditorViewingPubkey (disc=29)
2730
- // ---------------------------------------------------------------------------
2731
-
2732
- /** setAuditorViewingPubkey instruction options */
2733
- export interface SetAuditorViewingPubkeyOptions {
2734
- /** New 32-byte viewing pubkey for the auditor */
2735
- viewingPubkey: Uint8Array;
2736
- accounts: {
2737
- /** 0. pool_state (writable) */
2738
- poolState: Address;
2739
- /** 1. auditor (signer) */
2740
- auditor: Address;
2741
- };
2742
- }
2743
-
2744
- /**
2745
- * Build setAuditorViewingPubkey instruction data (disc=29).
2746
- *
2747
- * Layout: disc(1) + viewing_pubkey(32) = 33 bytes.
2748
- */
2749
- export function buildSetAuditorViewingPubkeyInstructionData(viewingPubkey: Uint8Array): Uint8Array {
2750
- if (viewingPubkey.length !== 32) {
2751
- throw new Error(`viewingPubkey must be 32 bytes, got ${viewingPubkey.length}`);
2752
- }
2753
- const data = new Uint8Array(33);
2754
- data[0] = INSTRUCTION.SET_AUDITOR_VIEWING_PUBKEY;
2755
- data.set(viewingPubkey, 1);
2756
- return data;
2757
- }
2758
-
2759
- /**
2760
- * Build a complete setAuditorViewingPubkey instruction (disc=29).
2761
- *
2762
- * Accounts:
2763
- * 0. pool_state (writable)
2764
- * 1. auditor (signer)
2765
- */
2766
- export function buildSetAuditorViewingPubkeyInstruction(
2767
- options: SetAuditorViewingPubkeyOptions,
2768
- ): Instruction {
2769
- const config = getConfig();
2770
- return {
2771
- programAddress: config.utxopiaProgramId,
2772
- accounts: [
2773
- { address: options.accounts.poolState, role: AccountRole.WRITABLE },
2774
- { address: options.accounts.auditor, role: AccountRole.READONLY_SIGNER },
2775
- ],
2776
- data: buildSetAuditorViewingPubkeyInstructionData(options.viewingPubkey),
2777
- };
2778
- }
2779
-
2780
2566
  // ---------------------------------------------------------------------------
2781
2567
  // rotateAuditor (disc=35)
2782
2568
  // ---------------------------------------------------------------------------
package/src/poseidon.ts CHANGED
@@ -164,12 +164,3 @@ export function computeTokenId(mintBytes: Uint8Array): bigint {
164
164
  const reduced = reduceToField(mintBytes);
165
165
  return poseidonHashSync([reduced, 0n]);
166
166
  }
167
-
168
- /**
169
- * Convenience: compute token_id from a Solana address string
170
- */
171
- export function computeTokenIdFromAddress(mintAddress: string): bigint {
172
- // Base58 decode — import from @solana/kit if available, otherwise use raw bytes
173
- // For now, caller should pass raw bytes via computeTokenId
174
- throw new Error("Use computeTokenId(mintBytes) with raw pubkey bytes");
175
- }
@@ -9,11 +9,3 @@
9
9
 
10
10
  // Re-export everything from the web prover (default for browser/Node.js)
11
11
  export * from "./web";
12
-
13
- // Re-export types
14
- export type {
15
- MerkleProofInput,
16
- ProofData,
17
- CircuitType,
18
- JoinSplitProofInputs,
19
- } from "./web";
package/src/prover/web.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  computeJoinSplitCommitmentSync,
16
16
  computeJoinSplitNullifierSync,
17
17
  } from "../poseidon";
18
- import { BN254_FIELD_PRIME } from "../crypto";
18
+ import { BN254_FIELD_PRIME, bytesToHex } from "../crypto";
19
19
  import { TREE_DEPTH } from "../merkle";
20
20
  import { getConfig } from "../config";
21
21
  import { MAX_SAFE_JOINSPLIT_SIZE } from "../vk-registry";
@@ -60,13 +60,6 @@ export interface ProofData {
60
60
 
61
61
  export type CircuitType = `joinsplit_${number}x${number}`;
62
62
 
63
- /** Names of non-JoinSplit auxiliary circuits (selective disclosure). */
64
- export type AuxCircuitName =
65
- | "ownership"
66
- | "range_sum"
67
- | "range_sum_4"
68
- | "range_sum_16";
69
-
70
63
  // Environment detection
71
64
  const isBrowser = typeof window !== "undefined";
72
65
  const isNode = typeof process !== "undefined" && process.versions?.node;
@@ -85,13 +78,6 @@ export function setCircuitPath(path: string): void {
85
78
  artifactDownloadCache.clear();
86
79
  }
87
80
 
88
- /**
89
- * Get the current circuit base path
90
- */
91
- export function getCircuitPath(): string {
92
- return circuitBasePath;
93
- }
94
-
95
81
  // Lazy-loaded snarkjs module
96
82
  let snarkjs: any = null;
97
83
 
@@ -164,9 +150,6 @@ export function setCircuitArtifactDigests(digests: Record<string, string> | null
164
150
  artifactBytesCache.clear();
165
151
  }
166
152
 
167
- const toHex = (buf: ArrayBuffer) =>
168
- Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join("");
169
-
170
153
  async function verifyArtifactBytes(url: string, bytes: Uint8Array): Promise<void> {
171
154
  if (!artifactDigests) return;
172
155
  // Key by path-under-base so one manifest works across origins (local dev vs CDN).
@@ -177,8 +160,8 @@ async function verifyArtifactBytes(url: string, bytes: Uint8Array): Promise<void
177
160
  `Regenerate the artifact manifest so it covers every shape this origin serves.`,
178
161
  );
179
162
  }
180
- const actual = toHex(
181
- await crypto.subtle.digest("SHA-256", bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer),
163
+ const actual = bytesToHex(
164
+ new Uint8Array(await crypto.subtle.digest("SHA-256", bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer)),
182
165
  );
183
166
  if (actual !== artifactDigests[key]) {
184
167
  throw new Error(
@@ -717,55 +700,3 @@ export async function cleanup(): Promise<void> {
717
700
  // ==========================================================================
718
701
  // Solana Instruction Building (for Groth16 on-chain verification)
719
702
  // ==========================================================================
720
-
721
- /**
722
- * Groth16 verifier program ID (from current config)
723
- */
724
- export function getGroth16VerifierProgramId(): Address {
725
- const config = getConfig();
726
- return config.groth16VerifierProgramId;
727
- }
728
-
729
- /**
730
- * Build instruction data for Groth16 verification
731
- */
732
- export function buildVerifyInstructionData(
733
- proof: Uint8Array,
734
- publicSignals: string[],
735
- vkHash: string
736
- ): Uint8Array {
737
- const piBytes = publicSignals.flatMap((pi) => {
738
- const bytes = new Array(32).fill(0);
739
- const bigint = BigInt(pi);
740
- for (let i = 31; i >= 0; i--) {
741
- bytes[i] = Number((bigint >> BigInt((31 - i) * 8)) & 0xFFn);
742
- }
743
- return bytes;
744
- });
745
-
746
- const cleanHex = vkHash.startsWith("0x") ? vkHash.slice(2) : vkHash;
747
- const vkHashBytes = new Uint8Array(cleanHex.length / 2);
748
- for (let i = 0; i < vkHashBytes.length; i++) {
749
- vkHashBytes[i] = parseInt(cleanHex.substr(i * 2, 2), 16);
750
- }
751
-
752
- const totalSize = proof.length + 4 + piBytes.length + 32;
753
- const data = new Uint8Array(totalSize);
754
- let offset = 0;
755
-
756
- data.set(proof, offset);
757
- offset += proof.length;
758
-
759
- const piCount = publicSignals.length;
760
- data[offset++] = piCount & 0xff;
761
- data[offset++] = (piCount >> 8) & 0xff;
762
- data[offset++] = (piCount >> 16) & 0xff;
763
- data[offset++] = (piCount >> 24) & 0xff;
764
-
765
- data.set(new Uint8Array(piBytes), offset);
766
- offset += piBytes.length;
767
-
768
- data.set(vkHashBytes, offset);
769
-
770
- return data;
771
- }
package/src/psbt.ts CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import * as btc from "@scure/btc-signer";
14
14
  import { hex } from "@scure/base";
15
- import { DEPOSIT_OP_RETURN_SIZE, createOpReturnScriptFromPayload } from "./taproot";
15
+ import { DEPOSIT_OP_RETURN_SIZE, createOpReturnScriptFromPayload, bech32Hrp, type BitcoinNetwork } from "./taproot";
16
16
 
17
17
  // =============================================================================
18
18
  // Types
@@ -54,7 +54,7 @@ export interface BuildDepositPsbtParams {
54
54
  /** Fee rate in sats/vbyte */
55
55
  feeRate: number;
56
56
  /** Bitcoin network */
57
- network?: "mainnet" | "testnet" | "signet";
57
+ network?: BitcoinNetwork;
58
58
  }
59
59
 
60
60
  /** Result of PSBT construction */
@@ -151,7 +151,12 @@ export function buildDepositPsbt(params: BuildDepositPsbtParams): BuildDepositPs
151
151
  throw new Error(`OP_RETURN payload must be ${DEPOSIT_OP_RETURN_SIZE} bytes, got ${opReturnPayload.length}`);
152
152
  }
153
153
 
154
- const btcNetwork = network === "mainnet" ? btc.NETWORK : btc.TEST_NETWORK;
154
+ // Regtest shares testnet's version bytes but not its bech32 hrp, so folding it
155
+ // into TEST_NETWORK leaves every bcrt1 address undecodable.
156
+ const btcNetwork =
157
+ network === "mainnet"
158
+ ? btc.NETWORK
159
+ : { ...btc.TEST_NETWORK, bech32: bech32Hrp(network) };
155
160
 
156
161
  // Calculate total input value
157
162
  const totalInput = senderUtxos.reduce((sum, u) => sum + u.value, 0);
package/src/spend-doc.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  createUnshieldBoundParams,
19
19
  type SolanaPrivacyDomainContext,
20
20
  } from "./bound-params";
21
+ import { bytesToHex as hex } from "./crypto";
21
22
 
22
23
  export interface SpendDoc {
23
24
  mode: "transfer" | "unshield" | "redeem";
@@ -64,8 +65,6 @@ function fmt(raw: bigint, decimals: number): string {
64
65
  return `${neg ? "-" : ""}${whole}${frac ? `.${frac}` : ""}`;
65
66
  }
66
67
 
67
- const hex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
68
-
69
68
  function sortedValues(v: bigint[]): string {
70
69
  return [...v].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join(",");
71
70
  }
package/src/stealth.ts CHANGED
@@ -401,21 +401,21 @@ export interface NonInteractiveDepositWithRefundResult extends NonInteractiveDep
401
401
  export async function createNonInteractiveDeposit(
402
402
  recipientMeta: StealthMetaAddress,
403
403
  custodyInternalKey: Uint8Array,
404
- network?: "mainnet" | "testnet" | "regtest",
404
+ network?: BitcoinNetwork,
405
405
  userRefundPubkey?: undefined,
406
406
  opReturnContext?: DepositOpReturnContext,
407
407
  ): Promise<NonInteractiveDepositResult>;
408
408
  export async function createNonInteractiveDeposit(
409
409
  recipientMeta: StealthMetaAddress,
410
410
  custodyInternalKey: Uint8Array,
411
- network: "mainnet" | "testnet" | "regtest",
411
+ network: BitcoinNetwork,
412
412
  userRefundPubkey: Uint8Array,
413
413
  opReturnContext: DepositOpReturnContext,
414
414
  ): Promise<NonInteractiveDepositWithRefundResult>;
415
415
  export async function createNonInteractiveDeposit(
416
416
  recipientMeta: StealthMetaAddress,
417
417
  custodyInternalKey: Uint8Array,
418
- network: "mainnet" | "testnet" | "regtest" = "testnet",
418
+ network: BitcoinNetwork = "testnet",
419
419
  userRefundPubkey?: Uint8Array,
420
420
  opReturnContext?: DepositOpReturnContext,
421
421
  ): Promise<NonInteractiveDepositResult | NonInteractiveDepositWithRefundResult> {
@@ -491,7 +491,7 @@ export async function createNonInteractiveDeposit(
491
491
  export async function createDirectVaultDeposit(
492
492
  recipientMeta: StealthMetaAddress,
493
493
  vaultXOnlyPubkey: Uint8Array,
494
- network: "mainnet" | "testnet" | "regtest" = "testnet",
494
+ network: BitcoinNetwork = "testnet",
495
495
  opReturnContext?: DepositOpReturnContext,
496
496
  ): Promise<NonInteractiveDepositResult> {
497
497
  if (!opReturnContext) {
@@ -735,7 +735,7 @@ export async function createTweakDeposit(
735
735
  recipientMeta: StealthMetaAddress,
736
736
  vaultXOnlyPubkey: Uint8Array,
737
737
  recovery: DepositRecoveryMaterial,
738
- network: "mainnet" | "testnet" | "regtest" = "testnet",
738
+ network: BitcoinNetwork = "testnet",
739
739
  ): Promise<TweakDepositResult> {
740
740
  if (vaultXOnlyPubkey.length !== 32) {
741
741
  throw new Error("vaultXOnlyPubkey must be 32 bytes");
@@ -781,7 +781,7 @@ export async function createTweakDeposit(
781
781
  */
782
782
  export async function createDepositFromConfig(
783
783
  recipientMeta: StealthMetaAddress,
784
- network: "mainnet" | "testnet" | "regtest" = "testnet",
784
+ network: BitcoinNetwork = "testnet",
785
785
  opReturnContext?: DepositOpReturnContext,
786
786
  ): Promise<NonInteractiveDepositResult> {
787
787
  const config = getConfig();
@@ -1220,6 +1220,7 @@ export async function scanUnifiedNotes(
1220
1220
  // ========== Connection Adapter ==========
1221
1221
 
1222
1222
  import type { Address } from "@solana/kit";
1223
+ import type { BitcoinNetwork } from "./taproot";
1223
1224
 
1224
1225
  export interface ConnectionAdapter {
1225
1226
  getAccountInfo: (
@@ -1285,23 +1286,6 @@ export function packStealthOutputForCircuit(output: StealthOutputData): CircuitS
1285
1286
  };
1286
1287
  }
1287
1288
 
1288
- /**
1289
- * Unpack encrypted amount from packed Field element
1290
- */
1291
- export function unpackEncryptedAmountWithSign(packed: bigint): { encryptedAmount: Uint8Array; ySign: boolean } {
1292
- const ySign = (packed & (1n << 64n)) !== 0n;
1293
- const amount = packed & ((1n << 64n) - 1n);
1294
-
1295
- const encryptedAmount = new Uint8Array(8);
1296
- let temp = amount;
1297
- for (let i = 0; i < 8; i++) {
1298
- encryptedAmount[i] = Number(temp & 0xffn);
1299
- temp >>= 8n;
1300
- }
1301
-
1302
- return { encryptedAmount, ySign };
1303
- }
1304
-
1305
1289
  /**
1306
1290
  * Create stealth output data for a self-send (change output)
1307
1291
  */
package/src/taproot.ts CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { sha256 } from "@noble/hashes/sha2.js";
10
10
  import { taggedHash, hexToBytes, bytesToHex } from "./crypto";
11
- import * as bech32 from "bech32";
11
+ import { bech32, bech32m } from "@scure/base";
12
12
  import { secp256k1 } from "@noble/curves/secp256k1.js";
13
13
 
14
14
  // Never use the secp256k1 generator as a custody key. Its discrete log is
@@ -31,7 +31,7 @@ const UNSAFE_GENERATOR_INTERNAL_KEY_HEX =
31
31
  */
32
32
  export function deriveTaprootAddress(
33
33
  commitment: Uint8Array,
34
- network: "mainnet" | "testnet" | "regtest" = "testnet",
34
+ network: BitcoinNetwork = "testnet",
35
35
  internalKey?: Uint8Array
36
36
  ): {
37
37
  address: string;
@@ -77,11 +77,7 @@ export function deriveTaprootAddress(
77
77
  const outputKeyHex = outputPoint.toHex(true); // 33-byte compressed hex
78
78
  const outputKey = hexToBytes(outputKeyHex.slice(2)); // drop "02"/"03" prefix
79
79
 
80
- // Encode as bech32m address
81
- const hrp = network === "mainnet" ? "bc" : network === "regtest" ? "bcrt" : "tb";
82
- const words = bech32.bech32m.toWords(outputKey);
83
- // Witness version 1 for taproot
84
- const address = bech32.bech32m.encode(hrp, [1, ...words]);
80
+ const address = p2trAddress(outputKey, network);
85
81
 
86
82
  return {
87
83
  address,
@@ -164,7 +160,7 @@ export function depositLeafScript(
164
160
  export function deriveDepositAddress(
165
161
  commitment: Uint8Array,
166
162
  ikaXOnlyPubkey: Uint8Array,
167
- network: "mainnet" | "testnet" | "regtest" = "testnet"
163
+ network: BitcoinNetwork = "testnet"
168
164
  ): {
169
165
  address: string;
170
166
  outputKey: Uint8Array;
@@ -203,25 +199,73 @@ export function verifyTaprootAddress(
203
199
  internalKey?: Uint8Array
204
200
  ): boolean {
205
201
  try {
206
- const decoded = bech32.bech32m.decode(address);
202
+ const decoded = bech32m.decode(address as `${string}1${string}`);
207
203
  const witnessVersion = decoded.words[0];
208
204
  if (witnessVersion !== 1) {
209
205
  return false;
210
206
  }
211
207
 
212
- const actualOutputKey = new Uint8Array(
213
- bech32.bech32m.fromWords(decoded.words.slice(1))
214
- );
208
+ const network = networkForHrp(decoded.prefix);
209
+ if (!network) return false;
215
210
 
216
- const network = decoded.prefix === "bc" ? "mainnet" : "testnet";
217
211
  const expected = deriveTaprootAddress(commitment, network, internalKey);
218
212
 
219
- return arraysEqual(actualOutputKey, expected.outputKey);
213
+ // Compare the whole address, not just the output key: the key is identical
214
+ // on every network, so an output-key match said nothing about the encoding.
215
+ // bech32 is case-insensitive, hence the fold.
216
+ return address.toLowerCase() === expected.address;
220
217
  } catch {
221
218
  return false;
222
219
  }
223
220
  }
224
221
 
222
+ /**
223
+ * The networks whose addresses this SDK derives.
224
+ *
225
+ * Kept as one alias because the members drifted when it was spelled out at each
226
+ * call site: psbt.ts folded regtest into testnet while taproot.ts did not, and
227
+ * the two disagreed about what a bcrt1 address was.
228
+ */
229
+ export type BitcoinNetwork = "mainnet" | "testnet" | "signet" | "regtest";
230
+
231
+ /**
232
+ * bech32 human-readable prefix for a Bitcoin network.
233
+ *
234
+ * Regtest is the reason this exists: it shares testnet's version bytes but not
235
+ * its prefix, and every place that folded the two together produced addresses
236
+ * no regtest node would accept.
237
+ */
238
+ export function bech32Hrp(network: BitcoinNetwork): string {
239
+ return network === "mainnet" ? "bc" : network === "regtest" ? "bcrt" : "tb";
240
+ }
241
+
242
+ /**
243
+ * The network an address's bech32 prefix names, or null if it names none.
244
+ *
245
+ * Null rather than a testnet default: an unrecognised prefix is an address this
246
+ * SDK cannot place, and quietly calling it testnet is how bcrt1 addresses ended
247
+ * up classified as testnet everywhere.
248
+ */
249
+ export function networkForHrp(prefix: string): BitcoinNetwork | null {
250
+ return prefix === "bc"
251
+ ? "mainnet"
252
+ : prefix === "tb"
253
+ ? "testnet"
254
+ : prefix === "bcrt"
255
+ ? "regtest"
256
+ : null;
257
+ }
258
+
259
+ /**
260
+ * Encode an x-only output key as a witness-v1 (P2TR) address.
261
+ */
262
+ export function p2trAddress(
263
+ outputKey: Uint8Array,
264
+ network: BitcoinNetwork,
265
+ ): string {
266
+ return bech32m.encode(bech32Hrp(network), [1, ...bech32m.toWords(outputKey)]);
267
+ }
268
+
225
269
  /**
226
270
  * Generate a P2TR (Pay-to-Taproot) script pubkey
227
271
  *
@@ -264,18 +308,19 @@ export function parseP2TRScriptPubkey(
264
308
  export function isValidBitcoinAddress(address: string): {
265
309
  valid: boolean;
266
310
  type: "p2pkh" | "p2sh" | "p2wpkh" | "p2wsh" | "p2tr" | "unknown";
267
- network: "mainnet" | "testnet" | "unknown";
311
+ network: BitcoinNetwork | "unknown";
268
312
  } {
269
313
  try {
270
314
  // Bech32m (Taproot)
271
- if (address.startsWith("bc1p") || address.startsWith("tb1p")) {
272
- const decoded = bech32.bech32m.decode(address);
273
- if (decoded.words[0] === 1 && decoded.words.length === 53) {
274
- return {
275
- valid: true,
276
- type: "p2tr",
277
- network: decoded.prefix === "bc" ? "mainnet" : "testnet",
278
- };
315
+ if (
316
+ address.startsWith("bc1p") ||
317
+ address.startsWith("tb1p") ||
318
+ address.startsWith("bcrt1p")
319
+ ) {
320
+ const decoded = bech32m.decode(address as `${string}1${string}`);
321
+ const network = networkForHrp(decoded.prefix);
322
+ if (network && decoded.words[0] === 1 && decoded.words.length === 53) {
323
+ return { valid: true, type: "p2tr", network };
279
324
  }
280
325
  }
281
326
 
@@ -285,19 +330,11 @@ export function isValidBitcoinAddress(address: string): {
285
330
  address.startsWith("tb1q") ||
286
331
  address.startsWith("bcrt1q")
287
332
  ) {
288
- const decoded = bech32.bech32.decode(address);
289
- if (decoded.words[0] === 0) {
333
+ const decoded = bech32.decode(address as `${string}1${string}`);
334
+ const network = networkForHrp(decoded.prefix);
335
+ if (network && decoded.words[0] === 0) {
290
336
  const type = decoded.words.length === 33 ? "p2wpkh" : "p2wsh";
291
- return {
292
- valid: true,
293
- type,
294
- network:
295
- decoded.prefix === "bc"
296
- ? "mainnet"
297
- : decoded.prefix === "bcrt"
298
- ? "testnet"
299
- : "testnet",
300
- };
337
+ return { valid: true, type, network };
301
338
  }
302
339
  }
303
340
 
@@ -623,7 +660,7 @@ export function deriveTaprootAddressWithRefund(
623
660
  npk: Uint8Array,
624
661
  userRefundPubkey: Uint8Array,
625
662
  internalKey: Uint8Array,
626
- network: "mainnet" | "testnet" | "regtest" = "testnet"
663
+ network: BitcoinNetwork = "testnet"
627
664
  ): {
628
665
  address: string;
629
666
  outputKey: Uint8Array;
@@ -672,9 +709,7 @@ export function deriveTaprootAddressWithRefund(
672
709
  controlBlock.set(internalKey, 1);
673
710
 
674
711
  // 7. Encode as bech32m address
675
- const hrp = network === "mainnet" ? "bc" : network === "regtest" ? "bcrt" : "tb";
676
- const words = bech32.bech32m.toWords(outputKey);
677
- const address = bech32.bech32m.encode(hrp, [1, ...words]);
712
+ const address = p2trAddress(outputKey, network);
678
713
 
679
714
  return {
680
715
  address,