@utxopia/sdk 0.1.0-alpha.3 → 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.
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) */
@@ -31,13 +32,20 @@ export interface BuildDepositPsbtParams {
31
32
  /** Deposit amount in satoshis */
32
33
  depositAmountSats: number;
33
34
  /** Compact deposit OP_RETURN payload (from buildDepositOpReturn) */
34
- opReturnPayload: Uint8Array;
35
+ /**
36
+ * 73-byte deposit metadata, for the OP_RETURN flow.
37
+ *
38
+ * Omit it for an OP_RETURN-free deposit (`verify_deposit`), where the address
39
+ * itself binds the note keys through its tapleaf. The transaction is then a
40
+ * plain payment, which is what lets an exchange withdrawal fund it.
41
+ */
42
+ opReturnPayload?: Uint8Array;
35
43
  /** Change address (same type as sender) */
36
44
  changeAddress: string;
37
45
  /** Fee rate in sats/vbyte */
38
46
  feeRate: number;
39
47
  /** Bitcoin network */
40
- network?: "mainnet" | "testnet" | "signet";
48
+ network?: BitcoinNetwork;
41
49
  }
42
50
  /** Result of PSBT construction */
43
51
  export interface BuildDepositPsbtResult {
@@ -55,7 +63,10 @@ export interface BuildDepositPsbtResult {
55
63
  /**
56
64
  * Estimate the transaction fee for a deposit PSBT.
57
65
  */
58
- export declare function estimateDepositFee(numInputs: number, feeRate: number, inputType?: "p2tr" | "p2wpkh", hasChange?: boolean): number;
66
+ export declare function estimateDepositFee(numInputs: number, feeRate: number, inputType?: "p2tr" | "p2wpkh", hasChange?: boolean,
67
+ /** An OP_RETURN-free deposit has one output fewer; defaults true for callers
68
+ * written before that flow existed. */
69
+ hasOpReturn?: boolean): number;
59
70
  /**
60
71
  * Build a deposit PSBT with OP_RETURN for non-interactive stealth deposits.
61
72
  *
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
  // =============================================================================
@@ -33,13 +33,15 @@ const TX_OVERHEAD_VBYTES = 11;
33
33
  /**
34
34
  * Estimate the transaction fee for a deposit PSBT.
35
35
  */
36
- export function estimateDepositFee(numInputs, feeRate, inputType = "p2tr", hasChange = true) {
36
+ export function estimateDepositFee(numInputs, feeRate, inputType = "p2tr", hasChange = true,
37
+ /** An OP_RETURN-free deposit has one output fewer; defaults true for callers
38
+ * written before that flow existed. */
39
+ hasOpReturn = true) {
37
40
  const inputVbytes = inputType === "p2tr" ? P2TR_INPUT_VBYTES : P2WPKH_INPUT_VBYTES;
38
- const outputCount = hasChange ? 3 : 2; // deposit + OP_RETURN + optional change
39
41
  const vsize = TX_OVERHEAD_VBYTES +
40
42
  numInputs * inputVbytes +
41
43
  P2TR_OUTPUT_VBYTES + // deposit output
42
- OP_RETURN_OUTPUT_VBYTES + // OP_RETURN output
44
+ (hasOpReturn ? OP_RETURN_OUTPUT_VBYTES : 0) +
43
45
  (hasChange ? P2TR_OUTPUT_VBYTES : 0); // change output
44
46
  return Math.ceil(vsize * feeRate);
45
47
  }
@@ -59,20 +61,24 @@ export function buildDepositPsbt(params) {
59
61
  if (depositAmountSats < DUST_LIMIT) {
60
62
  throw new Error(`Deposit amount ${depositAmountSats} is below dust limit ${DUST_LIMIT}`);
61
63
  }
62
- if (opReturnPayload.length !== DEPOSIT_OP_RETURN_SIZE) {
64
+ if (opReturnPayload && opReturnPayload.length !== DEPOSIT_OP_RETURN_SIZE) {
63
65
  throw new Error(`OP_RETURN payload must be ${DEPOSIT_OP_RETURN_SIZE} bytes, got ${opReturnPayload.length}`);
64
66
  }
65
- 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) };
66
72
  // Calculate total input value
67
73
  const totalInput = senderUtxos.reduce((sum, u) => sum + u.value, 0);
68
74
  // Detect input type from first UTXO
69
75
  const firstScript = hex.decode(senderUtxos[0].scriptPubkeyHex);
70
76
  const inputType = firstScript[0] === 0x51 ? "p2tr" : "p2wpkh";
71
77
  // Estimate fee with change
72
- const feeWithChange = estimateDepositFee(senderUtxos.length, feeRate, inputType, true);
78
+ const feeWithChange = estimateDepositFee(senderUtxos.length, feeRate, inputType, true, Boolean(opReturnPayload));
73
79
  const changeAmount = totalInput - depositAmountSats - feeWithChange;
74
80
  // Check if we have enough funds
75
- const feeWithoutChange = estimateDepositFee(senderUtxos.length, feeRate, inputType, false);
81
+ const feeWithoutChange = estimateDepositFee(senderUtxos.length, feeRate, inputType, false, Boolean(opReturnPayload));
76
82
  if (totalInput < depositAmountSats + feeWithoutChange) {
77
83
  throw new Error(`Insufficient funds: have ${totalInput} sats, need ${depositAmountSats + feeWithoutChange} sats (including fee)`);
78
84
  }
@@ -113,12 +119,15 @@ export function buildDepositPsbt(params) {
113
119
  }
114
120
  // Output 1: P2TR deposit
115
121
  tx.addOutputAddress(depositAddress, BigInt(depositAmountSats), btcNetwork);
116
- // Output 2: OP_RETURN with compact deposit payload.
117
- const opReturnScript = createOpReturnScriptFromPayload(opReturnPayload);
118
- tx.addOutput({
119
- script: opReturnScript,
120
- amount: 0n,
121
- });
122
+ // Output 2: OP_RETURN with compact deposit payload — only for that flow. A
123
+ // tweak-bound deposit adds nothing here, so the transaction is indistinguishable
124
+ // from an ordinary payment.
125
+ if (opReturnPayload) {
126
+ tx.addOutput({
127
+ script: createOpReturnScriptFromPayload(opReturnPayload),
128
+ amount: 0n,
129
+ });
130
+ }
122
131
  // Output 3: Change (if above dust)
123
132
  if (hasChange) {
124
133
  tx.addOutputAddress(changeAddress, BigInt(changeAmount), btcNetwork);
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@utxopia/sdk",
3
- "version": "0.1.0-alpha.3",
3
+ "version": "0.1.0-alpha.5",
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 {
package/src/client.ts CHANGED
@@ -56,7 +56,7 @@ import {
56
56
  import { selectUtxos, type UtxoDescriptor } from "./psbt";
57
57
  import { hexToBytes, bytesToHex, bigintToBytes } from "./crypto";
58
58
  import { EventClient } from "./event-client";
59
- import { type DepositOpReturnContext } from "./taproot";
59
+ import { type DepositOpReturnContext, type BitcoinNetwork } from "./taproot";
60
60
 
61
61
  // ─── Types ──────────────────────────────────────────────────────────
62
62
 
@@ -425,7 +425,7 @@ export class UTXOpiaClient {
425
425
  depositIndex: number;
426
426
  ikaXOnlyPubkey: Uint8Array;
427
427
  recipient?: StealthMetaAddress;
428
- network?: "mainnet" | "testnet" | "regtest";
428
+ network?: BitcoinNetwork;
429
429
  }): Promise<TweakDepositResult> {
430
430
  if (!this._keys) {
431
431
  throw new Error("No keys (login first)");
@@ -457,7 +457,7 @@ export class UTXOpiaClient {
457
457
  */
458
458
  async prepareDeposit(opts: {
459
459
  recipient?: StealthMetaAddress;
460
- network?: "mainnet" | "testnet" | "regtest";
460
+ network?: BitcoinNetwork;
461
461
  opReturnContext: DepositOpReturnContext;
462
462
  }): Promise<NonInteractiveDepositResult> {
463
463
  const meta = opts.recipient ?? this._stealthAddress;
@@ -631,7 +631,7 @@ export class UTXOpiaClient {
631
631
  }
632
632
  }
633
633
 
634
- function sdkBitcoinNetworkToAddressNetwork(network: NetworkConfig["bitcoinNetwork"]): "mainnet" | "testnet" | "regtest" {
634
+ function sdkBitcoinNetworkToAddressNetwork(network: NetworkConfig["bitcoinNetwork"]): BitcoinNetwork {
635
635
  if (network === "mainnet") return "mainnet";
636
636
  if (network === "regtest") return "regtest";
637
637
  return "testnet";
@@ -35,11 +35,6 @@ export const BABYJUB_D = 168696n;
35
35
  export const BABYJUB_ORDER =
36
36
  2736030358979909402780800718157159386076813972158567259200215660948447373041n;
37
37
 
38
- /**
39
- * Baby Jubjub cofactor
40
- */
41
- export const BABYJUB_COFACTOR = 8n;
42
-
43
38
  /**
44
39
  * Generator point (BASE8) - matches circomlib's BabyPbk() generator
45
40
  * This is the base point of the prime-order subgroup (cofactor-cleared).
package/src/crypto.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { sha256 } from "@noble/hashes/sha2.js";
17
+ import { bytesToHex as nobleBytesToHex, hexToBytes as nobleHexToBytes } from "@noble/hashes/utils.js";
17
18
 
18
19
  // =============================================================================
19
20
  // Field Constants
@@ -110,22 +111,15 @@ export function bytesToBigint(bytes: Uint8Array): bigint {
110
111
  * Convert hex string to Uint8Array
111
112
  */
112
113
  export function hexToBytes(hex: string): Uint8Array {
113
- const cleanHex = hex.startsWith("0x") ? hex.slice(2) : hex;
114
- const bytes = new Uint8Array(cleanHex.length / 2);
115
- for (let i = 0; i < cleanHex.length; i += 2) {
116
- bytes[i / 2] = parseInt(cleanHex.substr(i, 2), 16);
117
- }
118
- return bytes;
114
+ // The 0x prefix is ours; noble rejects it. Everything after is noble's, which
115
+ // also means malformed hex now throws instead of decoding to zero bytes.
116
+ return nobleHexToBytes(hex.startsWith("0x") ? hex.slice(2) : hex);
119
117
  }
120
118
 
121
119
  /**
122
120
  * Convert Uint8Array to hex string
123
121
  */
124
- export function bytesToHex(bytes: Uint8Array): string {
125
- return Array.from(bytes)
126
- .map((b) => b.toString(16).padStart(2, "0"))
127
- .join("");
128
- }
122
+ export const bytesToHex = nobleBytesToHex;
129
123
 
130
124
  // =============================================================================
131
125
  // Hashing Utilities
@@ -184,16 +178,3 @@ export function scalarFromBytes(bytes: Uint8Array): bigint {
184
178
  }
185
179
  return mod(result, BABYJUB_ORDER);
186
180
  }
187
-
188
- /**
189
- * Convert a bigint scalar to 32 bytes (big-endian)
190
- */
191
- export function scalarToBytes(scalar: bigint): Uint8Array {
192
- const bytes = new Uint8Array(32);
193
- let temp = mod(scalar, BABYJUB_ORDER);
194
- for (let i = 31; i >= 0; i--) {
195
- bytes[i] = Number(temp & 0xffn);
196
- temp = temp >> 8n;
197
- }
198
- return bytes;
199
- }
package/src/index.ts CHANGED
@@ -349,6 +349,10 @@ export {
349
349
  DEPOSIT_OP_RETURN_VERSION,
350
350
  DEPOSIT_POOL_TAG_SIZE,
351
351
  DEPOSIT_OP_RETURN_SIZE,
352
+ bech32Hrp,
353
+ networkForHrp,
354
+ p2trAddress,
355
+ type BitcoinNetwork,
352
356
  type DepositDestinationChain,
353
357
  type DepositBitcoinNetwork,
354
358
  type DepositOpReturnContext,