@sip-protocol/sdk 0.3.2 → 0.4.0

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.
Files changed (47) hide show
  1. package/dist/browser.d.mts +2 -2
  2. package/dist/browser.d.ts +2 -2
  3. package/dist/browser.js +1019 -146
  4. package/dist/browser.mjs +49 -1
  5. package/dist/chunk-AOZIY3GU.mjs +12995 -0
  6. package/dist/chunk-BCLIX5T2.mjs +12940 -0
  7. package/dist/chunk-FKXPHKYD.mjs +12955 -0
  8. package/dist/chunk-OPQ2GQIO.mjs +13013 -0
  9. package/dist/index-BcWNakUD.d.ts +7990 -0
  10. package/dist/index-BsKY3Hr0.d.mts +7990 -0
  11. package/dist/index.d.mts +2 -2
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.js +990 -117
  14. package/dist/index.mjs +49 -1
  15. package/package.json +2 -1
  16. package/src/adapters/near-intents.ts +8 -0
  17. package/src/bitcoin/index.ts +51 -0
  18. package/src/bitcoin/silent-payments.ts +865 -0
  19. package/src/bitcoin/taproot.ts +590 -0
  20. package/src/cosmos/ibc-stealth.ts +825 -0
  21. package/src/cosmos/index.ts +83 -0
  22. package/src/cosmos/stealth.ts +487 -0
  23. package/src/index.ts +51 -0
  24. package/src/move/aptos.ts +369 -0
  25. package/src/move/index.ts +35 -0
  26. package/src/move/sui.ts +367 -0
  27. package/src/oracle/types.ts +8 -0
  28. package/src/settlement/backends/direct-chain.ts +8 -0
  29. package/src/stealth.ts +3 -3
  30. package/src/validation.ts +42 -1
  31. package/src/wallet/aptos/adapter.ts +422 -0
  32. package/src/wallet/aptos/index.ts +10 -0
  33. package/src/wallet/aptos/mock.ts +410 -0
  34. package/src/wallet/aptos/types.ts +278 -0
  35. package/src/wallet/bitcoin/adapter.ts +470 -0
  36. package/src/wallet/bitcoin/index.ts +38 -0
  37. package/src/wallet/bitcoin/mock.ts +516 -0
  38. package/src/wallet/bitcoin/types.ts +274 -0
  39. package/src/wallet/cosmos/adapter.ts +484 -0
  40. package/src/wallet/cosmos/index.ts +63 -0
  41. package/src/wallet/cosmos/mock.ts +596 -0
  42. package/src/wallet/cosmos/types.ts +462 -0
  43. package/src/wallet/index.ts +127 -0
  44. package/src/wallet/sui/adapter.ts +471 -0
  45. package/src/wallet/sui/index.ts +10 -0
  46. package/src/wallet/sui/mock.ts +439 -0
  47. package/src/wallet/sui/types.ts +245 -0
@@ -0,0 +1,865 @@
1
+ /**
2
+ * Bitcoin Silent Payments (BIP-352) Implementation
3
+ *
4
+ * Implements Silent Payments for Bitcoin - a protocol for reusable payment codes
5
+ * that eliminate address reuse without requiring sender-receiver interaction.
6
+ *
7
+ * Spec: https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki
8
+ *
9
+ * Key features:
10
+ * - Reusable payment addresses (sp1q... for mainnet)
11
+ * - No on-chain overhead or sender-receiver interaction
12
+ * - Supports labels for payment categorization
13
+ * - Separates scanning (online) from spending (offline) responsibilities
14
+ *
15
+ * @module bitcoin/silent-payments
16
+ */
17
+
18
+ import { secp256k1 } from '@noble/curves/secp256k1'
19
+ import { sha256 } from '@noble/hashes/sha256'
20
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
21
+ import type { HexString } from '@sip-protocol/types'
22
+ import { ValidationError } from '../errors'
23
+ import { isValidHex, isValidPrivateKey } from '../validation'
24
+ import type { BitcoinNetwork } from './taproot'
25
+
26
+ /**
27
+ * Silent Payment address structure (BIP-352)
28
+ */
29
+ export interface SilentPaymentAddress {
30
+ /** Full bech32m-encoded address (sp1q... or sp1t...) */
31
+ address: string
32
+ /** 33-byte compressed scan public key */
33
+ scanPubKey: HexString
34
+ /** 33-byte compressed spend public key */
35
+ spendPubKey: HexString
36
+ /** Network the address is for */
37
+ network: BitcoinNetwork
38
+ /** Optional label (m) for payment categorization */
39
+ label?: number
40
+ }
41
+
42
+ /**
43
+ * Parsed silent payment address (decoded from string)
44
+ */
45
+ export interface ParsedSilentPaymentAddress {
46
+ /** 33-byte compressed scan public key */
47
+ scanPubKey: Uint8Array
48
+ /** 33-byte compressed spend public key */
49
+ spendPubKey: Uint8Array
50
+ /** Network the address is for */
51
+ network: BitcoinNetwork
52
+ /** Version (currently must be 0) */
53
+ version: number
54
+ }
55
+
56
+ /**
57
+ * Sender's input for creating a silent payment
58
+ */
59
+ export interface SenderInput {
60
+ /** Transaction ID of the UTXO being spent */
61
+ txid: string
62
+ /** Output index */
63
+ vout: number
64
+ /** Script pubkey of the UTXO */
65
+ scriptPubKey: Uint8Array
66
+ /** Private key for signing (32 bytes) */
67
+ privateKey: Uint8Array
68
+ /** Whether this is a taproot keypath spend */
69
+ isTaprootKeyPath?: boolean
70
+ /** Taproot internal key if isTaprootKeyPath is true */
71
+ taprootInternalKey?: Uint8Array
72
+ }
73
+
74
+ /**
75
+ * Silent payment output created by sender
76
+ */
77
+ export interface SilentPaymentOutput {
78
+ /** P2TR scriptPubKey (OP_1 + 32-byte tweaked pubkey) */
79
+ scriptPubKey: Uint8Array
80
+ /** Amount in satoshis */
81
+ amount: bigint
82
+ /** Tweaked public key (32 bytes x-only) */
83
+ tweakedPubKey: Uint8Array
84
+ }
85
+
86
+ /**
87
+ * Output to scan by recipient
88
+ */
89
+ export interface OutputToScan {
90
+ /** Output index in transaction */
91
+ outputIndex: number
92
+ /** P2TR scriptPubKey to check */
93
+ scriptPubKey: Uint8Array
94
+ /** Amount in satoshis */
95
+ amount: bigint
96
+ }
97
+
98
+ /**
99
+ * Payment received by recipient (after scanning)
100
+ */
101
+ export interface ReceivedPayment {
102
+ /** Output index in the transaction */
103
+ outputIndex: number
104
+ /** Amount received in satoshis */
105
+ amount: bigint
106
+ /** Tweak data used to derive the private key */
107
+ tweakData: Uint8Array
108
+ /** Tweaked public key */
109
+ tweakedPubKey: Uint8Array
110
+ }
111
+
112
+ // ═══════════════════════════════════════════════════════════════════════════════
113
+ // BECH32M ENCODING (BIP-350)
114
+ // ═══════════════════════════════════════════════════════════════════════════════
115
+
116
+ /** Bech32m character set */
117
+ const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
118
+
119
+ /** Bech32m generator values */
120
+ const BECH32_GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
121
+
122
+ /** Bech32m constant (different from bech32) */
123
+ const BECH32M_CONST = 0x2bc830a3
124
+
125
+ /**
126
+ * Bech32m polymod for checksum computation
127
+ */
128
+ function bech32Polymod(values: number[]): number {
129
+ let chk = 1
130
+ for (const value of values) {
131
+ const top = chk >> 25
132
+ chk = ((chk & 0x1ffffff) << 5) ^ value
133
+ for (let i = 0; i < 5; i++) {
134
+ if ((top >> i) & 1) {
135
+ chk ^= BECH32_GENERATOR[i]
136
+ }
137
+ }
138
+ }
139
+ return chk
140
+ }
141
+
142
+ /**
143
+ * Expand HRP for bech32m checksum
144
+ */
145
+ function bech32HrpExpand(hrp: string): number[] {
146
+ const result: number[] = []
147
+ for (let i = 0; i < hrp.length; i++) {
148
+ result.push(hrp.charCodeAt(i) >> 5)
149
+ }
150
+ result.push(0)
151
+ for (let i = 0; i < hrp.length; i++) {
152
+ result.push(hrp.charCodeAt(i) & 31)
153
+ }
154
+ return result
155
+ }
156
+
157
+ /**
158
+ * Verify bech32m checksum
159
+ */
160
+ function bech32VerifyChecksum(hrp: string, data: number[]): boolean {
161
+ return bech32Polymod([...bech32HrpExpand(hrp), ...data]) === BECH32M_CONST
162
+ }
163
+
164
+ /**
165
+ * Create bech32m checksum
166
+ */
167
+ function bech32CreateChecksum(hrp: string, data: number[]): number[] {
168
+ const values = [...bech32HrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0]
169
+ const polymod = bech32Polymod(values) ^ BECH32M_CONST
170
+ const checksum: number[] = []
171
+ for (let i = 0; i < 6; i++) {
172
+ checksum.push((polymod >> (5 * (5 - i))) & 31)
173
+ }
174
+ return checksum
175
+ }
176
+
177
+ /**
178
+ * Convert 8-bit bytes to 5-bit groups for bech32
179
+ */
180
+ function convertBits(
181
+ data: Uint8Array,
182
+ fromBits: number,
183
+ toBits: number,
184
+ pad: boolean,
185
+ ): number[] | null {
186
+ let acc = 0
187
+ let bits = 0
188
+ const result: number[] = []
189
+ const maxv = (1 << toBits) - 1
190
+
191
+ for (const value of data) {
192
+ if (value < 0 || value >> fromBits !== 0) {
193
+ return null
194
+ }
195
+ acc = (acc << fromBits) | value
196
+ bits += fromBits
197
+ while (bits >= toBits) {
198
+ bits -= toBits
199
+ result.push((acc >> bits) & maxv)
200
+ }
201
+ }
202
+
203
+ if (pad) {
204
+ if (bits > 0) {
205
+ result.push((acc << (toBits - bits)) & maxv)
206
+ }
207
+ } else if (bits >= fromBits || (acc << (toBits - bits)) & maxv) {
208
+ return null
209
+ }
210
+
211
+ return result
212
+ }
213
+
214
+ /**
215
+ * Encode data to bech32m string
216
+ */
217
+ function encodeBech32m(hrp: string, version: number, data: Uint8Array): string {
218
+ // Convert to 5-bit groups
219
+ const words = convertBits(data, 8, 5, true)
220
+ if (!words) {
221
+ throw new ValidationError('Failed to convert data to bech32m format', 'data')
222
+ }
223
+
224
+ // Prepend version
225
+ const dataWithVersion = [version, ...words]
226
+
227
+ // Create checksum
228
+ const checksum = bech32CreateChecksum(hrp, dataWithVersion)
229
+
230
+ // Encode
231
+ const combined = [...dataWithVersion, ...checksum]
232
+ let result = hrp + '1'
233
+ for (const value of combined) {
234
+ result += BECH32_CHARSET[value]
235
+ }
236
+
237
+ return result
238
+ }
239
+
240
+ /**
241
+ * Decode bech32m string
242
+ */
243
+ function decodeBech32m(address: string): {
244
+ hrp: string
245
+ version: number
246
+ data: Uint8Array
247
+ } {
248
+ // Validate format
249
+ if (typeof address !== 'string' || address.length < 8 || address.length > 120) {
250
+ throw new ValidationError('Invalid bech32m address format', 'address')
251
+ }
252
+
253
+ const addressLower = address.toLowerCase()
254
+
255
+ // Find separator
256
+ const sepIndex = addressLower.lastIndexOf('1')
257
+ if (sepIndex === -1 || sepIndex + 7 > addressLower.length) {
258
+ throw new ValidationError('Invalid bech32m address: no separator', 'address')
259
+ }
260
+
261
+ // Extract HRP and data
262
+ const hrp = addressLower.slice(0, sepIndex)
263
+ const dataStr = addressLower.slice(sepIndex + 1)
264
+
265
+ // Decode data
266
+ const data: number[] = []
267
+ for (const char of dataStr) {
268
+ const index = BECH32_CHARSET.indexOf(char)
269
+ if (index === -1) {
270
+ throw new ValidationError(`Invalid bech32m character: ${char}`, 'address')
271
+ }
272
+ data.push(index)
273
+ }
274
+
275
+ // Verify checksum
276
+ if (!bech32VerifyChecksum(hrp, data)) {
277
+ throw new ValidationError('Invalid bech32m checksum', 'address')
278
+ }
279
+
280
+ // Extract version and program
281
+ const version = data[0]
282
+
283
+ // Convert from 5-bit to 8-bit
284
+ const program = convertBits(new Uint8Array(data.slice(1, -6)), 5, 8, false)
285
+ if (!program) {
286
+ throw new ValidationError('Invalid bech32m program', 'address')
287
+ }
288
+
289
+ return {
290
+ hrp,
291
+ version,
292
+ data: new Uint8Array(program),
293
+ }
294
+ }
295
+
296
+ // ═══════════════════════════════════════════════════════════════════════════════
297
+ // BIP-352 TAGGED HASHES
298
+ // ═══════════════════════════════════════════════════════════════════════════════
299
+
300
+ /**
301
+ * Tagged hash for BIP-352 Silent Payments
302
+ * Format: SHA256(SHA256(tag) || SHA256(tag) || data)
303
+ */
304
+ function taggedHash(tag: string, data: Uint8Array): Uint8Array {
305
+ const tagHash = sha256(new TextEncoder().encode(tag))
306
+ const taggedData = new Uint8Array(tagHash.length * 2 + data.length)
307
+ taggedData.set(tagHash, 0)
308
+ taggedData.set(tagHash, tagHash.length)
309
+ taggedData.set(data, tagHash.length * 2)
310
+ return sha256(taggedData)
311
+ }
312
+
313
+ // ═══════════════════════════════════════════════════════════════════════════════
314
+ // SILENT PAYMENT ADDRESS GENERATION
315
+ // ═══════════════════════════════════════════════════════════════════════════════
316
+
317
+ /**
318
+ * Generate a Silent Payment address (BIP-352)
319
+ *
320
+ * Creates a reusable payment address from scan and spend keys.
321
+ *
322
+ * @param scanKey - Scan private key (32 bytes)
323
+ * @param spendKey - Spend private key (32 bytes)
324
+ * @param network - Bitcoin network (mainnet, testnet, regtest)
325
+ * @param label - Optional label for payment categorization (0-2^31-1)
326
+ * @returns Silent Payment address structure
327
+ *
328
+ * @example
329
+ * ```typescript
330
+ * const scanKey = randomBytes(32)
331
+ * const spendKey = randomBytes(32)
332
+ * const address = generateSilentPaymentAddress(scanKey, spendKey, 'mainnet')
333
+ * console.log(address.address) // sp1q...
334
+ * ```
335
+ */
336
+ export function generateSilentPaymentAddress(
337
+ scanKey: Uint8Array,
338
+ spendKey: Uint8Array,
339
+ network: BitcoinNetwork = 'mainnet',
340
+ label?: number,
341
+ ): SilentPaymentAddress {
342
+ // Validate inputs
343
+ if (scanKey.length !== 32) {
344
+ throw new ValidationError('scanKey must be 32 bytes', 'scanKey')
345
+ }
346
+
347
+ if (spendKey.length !== 32) {
348
+ throw new ValidationError('spendKey must be 32 bytes', 'spendKey')
349
+ }
350
+
351
+ if (label !== undefined && (label < 0 || label > 2 ** 31 - 1 || !Number.isInteger(label))) {
352
+ throw new ValidationError('label must be an integer between 0 and 2^31-1', 'label')
353
+ }
354
+
355
+ // Derive public keys (compressed)
356
+ const scanPubKey = secp256k1.getPublicKey(scanKey, true)
357
+ let spendPubKey = secp256k1.getPublicKey(spendKey, true)
358
+
359
+ // If label is provided, tweak spend pubkey: B_m = B_spend + hash(b_scan || m)*G
360
+ if (label !== undefined) {
361
+ // Prepare label data: ser256(b_scan) || ser32(m)
362
+ const labelData = new Uint8Array(36)
363
+ labelData.set(scanKey, 0)
364
+ // Write label as big-endian 32-bit integer
365
+ const labelView = new DataView(labelData.buffer, 32, 4)
366
+ labelView.setUint32(0, label, false)
367
+
368
+ // Compute tweak: hash_BIP0352/Label(ser256(b_scan) || ser32(m))
369
+ const tweak = taggedHash('BIP0352/Label', labelData)
370
+ const tweakScalar = BigInt('0x' + bytesToHex(tweak)) % secp256k1.CURVE.n
371
+
372
+ // Tweak spend pubkey: B_m = B_spend + tweak*G
373
+ const spendPoint = secp256k1.ProjectivePoint.fromHex(spendPubKey)
374
+ const tweakPoint = secp256k1.ProjectivePoint.BASE.multiply(tweakScalar)
375
+ const tweakedPoint = spendPoint.add(tweakPoint)
376
+ spendPubKey = tweakedPoint.toRawBytes(true)
377
+ }
378
+
379
+ // Encode as bech32m
380
+ // Data: scanPubKey (33 bytes) || spendPubKey (33 bytes) = 66 bytes
381
+ const addressData = new Uint8Array(66)
382
+ addressData.set(scanPubKey, 0)
383
+ addressData.set(spendPubKey, 33)
384
+
385
+ // HRP based on network
386
+ const hrp = network === 'mainnet' ? 'sp' : 'tsp'
387
+
388
+ // Version 0
389
+ const version = 0
390
+
391
+ const address = encodeBech32m(hrp, version, addressData)
392
+
393
+ return {
394
+ address,
395
+ scanPubKey: `0x${bytesToHex(scanPubKey)}` as HexString,
396
+ spendPubKey: `0x${bytesToHex(spendPubKey)}` as HexString,
397
+ network,
398
+ label,
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Parse a Silent Payment address
404
+ *
405
+ * Decodes a bech32m-encoded Silent Payment address (sp1q... or tsp1q...).
406
+ *
407
+ * @param address - Silent Payment address string
408
+ * @returns Parsed address components
409
+ *
410
+ * @example
411
+ * ```typescript
412
+ * const parsed = parseSilentPaymentAddress('sp1q...')
413
+ * console.log(parsed.scanPubKey) // 33-byte scan public key
414
+ * console.log(parsed.spendPubKey) // 33-byte spend public key
415
+ * ```
416
+ */
417
+ export function parseSilentPaymentAddress(address: string): ParsedSilentPaymentAddress {
418
+ // Decode bech32m
419
+ const { hrp, version, data } = decodeBech32m(address)
420
+
421
+ // Validate HRP
422
+ let network: BitcoinNetwork
423
+ if (hrp === 'sp') {
424
+ network = 'mainnet'
425
+ } else if (hrp === 'tsp') {
426
+ network = 'testnet'
427
+ } else {
428
+ throw new ValidationError(`Unknown HRP for Silent Payment address: ${hrp}`, 'address')
429
+ }
430
+
431
+ // Version must be 0 for now
432
+ if (version !== 0) {
433
+ throw new ValidationError(`Unsupported Silent Payment version: ${version}`, 'address')
434
+ }
435
+
436
+ // Data must be exactly 66 bytes (33 for scan + 33 for spend)
437
+ if (data.length !== 66) {
438
+ throw new ValidationError(
439
+ `Invalid Silent Payment address data length: expected 66 bytes, got ${data.length}`,
440
+ 'address',
441
+ )
442
+ }
443
+
444
+ // Extract keys
445
+ const scanPubKey = data.slice(0, 33)
446
+ const spendPubKey = data.slice(33, 66)
447
+
448
+ // Validate keys are valid compressed secp256k1 points
449
+ try {
450
+ secp256k1.ProjectivePoint.fromHex(scanPubKey)
451
+ secp256k1.ProjectivePoint.fromHex(spendPubKey)
452
+ } catch (err) {
453
+ throw new ValidationError('Invalid public keys in Silent Payment address', 'address')
454
+ }
455
+
456
+ return {
457
+ scanPubKey,
458
+ spendPubKey,
459
+ network,
460
+ version,
461
+ }
462
+ }
463
+
464
+ // ═══════════════════════════════════════════════════════════════════════════════
465
+ // SENDER: CREATE SILENT PAYMENT OUTPUT
466
+ // ═══════════════════════════════════════════════════════════════════════════════
467
+
468
+ /**
469
+ * Create a Silent Payment output (sender side)
470
+ *
471
+ * Generates a unique P2TR output for the recipient without requiring interaction.
472
+ *
473
+ * Algorithm (BIP-352):
474
+ * 1. Sum input private keys: a = sum(a_i)
475
+ * 2. Compute input_hash = hash(outpoint_L || A) where A = a*G
476
+ * 3. Compute shared secret: ecdh_shared_secret = input_hash * a * B_scan
477
+ * 4. Derive tweak: t_k = hash(ecdh_shared_secret || ser32(k))
478
+ * 5. Create output: P_k = B_spend + t_k*G
479
+ *
480
+ * @param recipientAddress - Recipient's Silent Payment address (sp1q...)
481
+ * @param senderInputs - UTXOs being spent by sender
482
+ * @param amount - Amount in satoshis
483
+ * @param outputIndex - Output index (k) for this recipient (default 0)
484
+ * @returns Silent Payment output (P2TR scriptPubKey)
485
+ *
486
+ * @example
487
+ * ```typescript
488
+ * const output = createSilentPaymentOutput(
489
+ * 'sp1q...',
490
+ * [{ txid: '...', vout: 0, scriptPubKey, privateKey }],
491
+ * 100000n,
492
+ * 0
493
+ * )
494
+ * ```
495
+ */
496
+ export function createSilentPaymentOutput(
497
+ recipientAddress: string,
498
+ senderInputs: SenderInput[],
499
+ amount: bigint,
500
+ outputIndex: number = 0,
501
+ ): SilentPaymentOutput {
502
+ // Validate inputs
503
+ if (senderInputs.length === 0) {
504
+ throw new ValidationError('At least one sender input is required', 'senderInputs')
505
+ }
506
+
507
+ if (amount <= 0n) {
508
+ throw new ValidationError('Amount must be greater than zero', 'amount')
509
+ }
510
+
511
+ if (outputIndex < 0 || !Number.isInteger(outputIndex)) {
512
+ throw new ValidationError('outputIndex must be a non-negative integer', 'outputIndex')
513
+ }
514
+
515
+ // Parse recipient address
516
+ const parsed = parseSilentPaymentAddress(recipientAddress)
517
+
518
+ // Step 1: Sum input private keys and compute aggregate public key
519
+ let aggregateScalar = 0n
520
+ const inputPublicKeys: Uint8Array[] = []
521
+
522
+ for (const input of senderInputs) {
523
+ if (input.privateKey.length !== 32) {
524
+ throw new ValidationError('privateKey must be 32 bytes', 'input.privateKey')
525
+ }
526
+
527
+ // Get scalar from private key
528
+ const scalar = BigInt('0x' + bytesToHex(input.privateKey)) % secp256k1.CURVE.n
529
+
530
+ // For taproot keypath spends, negate if Y coordinate is odd
531
+ let adjustedScalar = scalar
532
+ if (input.isTaprootKeyPath && input.taprootInternalKey) {
533
+ // Get Y coordinate from public key
534
+ const pubKey = secp256k1.getPublicKey(input.privateKey, false)
535
+ const yCoord = pubKey.slice(33, 65)
536
+ const yBigInt = BigInt('0x' + bytesToHex(yCoord))
537
+ const isOddY = (yBigInt & 1n) === 1n
538
+
539
+ if (isOddY) {
540
+ // Negate the private key
541
+ adjustedScalar = secp256k1.CURVE.n - scalar
542
+ }
543
+ }
544
+
545
+ aggregateScalar = (aggregateScalar + adjustedScalar) % secp256k1.CURVE.n
546
+ const pubKey = secp256k1.getPublicKey(input.privateKey, true)
547
+ inputPublicKeys.push(pubKey)
548
+ }
549
+
550
+ if (aggregateScalar === 0n) {
551
+ throw new ValidationError('Aggregate private key cannot be zero', 'senderInputs')
552
+ }
553
+
554
+ // Compute aggregate public key
555
+ const aggregatePubKey = secp256k1.ProjectivePoint.BASE.multiply(aggregateScalar).toRawBytes(true)
556
+
557
+ // Step 2: Compute input_hash
558
+ // Find smallest outpoint lexicographically
559
+ const outpoints = senderInputs.map((input) => {
560
+ // Reverse txid for little-endian (Bitcoin convention)
561
+ const txidBytes = hexToBytes(input.txid.replace(/^0x/, ''))
562
+ const txidLE = new Uint8Array(txidBytes).reverse()
563
+ const voutBytes = new Uint8Array(4)
564
+ new DataView(voutBytes.buffer).setUint32(0, input.vout, true) // little-endian
565
+ return { txid: txidLE, vout: voutBytes }
566
+ })
567
+
568
+ // Sort lexicographically
569
+ outpoints.sort((a, b) => {
570
+ for (let i = 0; i < 32; i++) {
571
+ if (a.txid[i] !== b.txid[i]) {
572
+ return a.txid[i] - b.txid[i]
573
+ }
574
+ }
575
+ for (let i = 0; i < 4; i++) {
576
+ if (a.vout[i] !== b.vout[i]) {
577
+ return a.vout[i] - b.vout[i]
578
+ }
579
+ }
580
+ return 0
581
+ })
582
+
583
+ const smallestOutpoint = new Uint8Array(36)
584
+ smallestOutpoint.set(outpoints[0].txid, 0)
585
+ smallestOutpoint.set(outpoints[0].vout, 32)
586
+
587
+ // input_hash = hash_BIP0352/Inputs(outpoint_L || A)
588
+ const inputHashData = new Uint8Array(36 + 33)
589
+ inputHashData.set(smallestOutpoint, 0)
590
+ inputHashData.set(aggregatePubKey, 36)
591
+ const inputHash = taggedHash('BIP0352/Inputs', inputHashData)
592
+
593
+ // Step 3: Compute shared secret
594
+ // ecdh_shared_secret = input_hash * a * B_scan
595
+ const inputHashScalar = BigInt('0x' + bytesToHex(inputHash)) % secp256k1.CURVE.n
596
+ const sharedSecretScalar = (inputHashScalar * aggregateScalar) % secp256k1.CURVE.n
597
+ const scanPubKeyPoint = secp256k1.ProjectivePoint.fromHex(parsed.scanPubKey)
598
+ const sharedSecretPoint = scanPubKeyPoint.multiply(sharedSecretScalar)
599
+ const sharedSecret = sharedSecretPoint.toRawBytes(true)
600
+
601
+ // Step 4: Derive tweak for output k
602
+ // t_k = hash_BIP0352/SharedSecret(ecdh_shared_secret || ser32(k))
603
+ const tweakData = new Uint8Array(33 + 4)
604
+ tweakData.set(sharedSecret, 0)
605
+ new DataView(tweakData.buffer, 33, 4).setUint32(0, outputIndex, false) // big-endian
606
+ const tweak = taggedHash('BIP0352/SharedSecret', tweakData)
607
+ const tweakScalar = BigInt('0x' + bytesToHex(tweak)) % secp256k1.CURVE.n
608
+
609
+ // Step 5: Create output public key
610
+ // P_k = B_spend + t_k*G
611
+ const spendPubKeyPoint = secp256k1.ProjectivePoint.fromHex(parsed.spendPubKey)
612
+ const tweakPoint = secp256k1.ProjectivePoint.BASE.multiply(tweakScalar)
613
+ const outputPubKeyPoint = spendPubKeyPoint.add(tweakPoint)
614
+ const outputPubKey = outputPubKeyPoint.toRawBytes(false) // uncompressed
615
+
616
+ // Extract x-only public key (32 bytes, for P2TR)
617
+ const xOnlyPubKey = outputPubKey.slice(1, 33)
618
+
619
+ // Create P2TR scriptPubKey: OP_1 (0x51) + 32-byte x-only pubkey
620
+ const scriptPubKey = new Uint8Array(34)
621
+ scriptPubKey[0] = 0x51 // OP_1
622
+ scriptPubKey[1] = 0x20 // 32 bytes
623
+ scriptPubKey.set(xOnlyPubKey, 2)
624
+
625
+ return {
626
+ scriptPubKey,
627
+ amount,
628
+ tweakedPubKey: xOnlyPubKey,
629
+ }
630
+ }
631
+
632
+ // ═══════════════════════════════════════════════════════════════════════════════
633
+ // RECIPIENT: SCAN FOR PAYMENTS
634
+ // ═══════════════════════════════════════════════════════════════════════════════
635
+
636
+ /**
637
+ * Scan for Silent Payments (recipient side)
638
+ *
639
+ * Scans transaction outputs to find payments to the recipient's Silent Payment address.
640
+ *
641
+ * Algorithm (BIP-352):
642
+ * 1. Extract input public keys from transaction
643
+ * 2. Compute input_hash = hash(outpoint_L || A) where A = sum(A_i)
644
+ * 3. Compute shared secret: ecdh_shared_secret = input_hash * b_scan * A
645
+ * 4. For each output index k, compute: P_k = B_spend + hash(ecdh_shared_secret || k)*G
646
+ * 5. Check if computed P_k matches any transaction output
647
+ *
648
+ * @param scanPrivateKey - Recipient's scan private key (32 bytes)
649
+ * @param spendPublicKey - Recipient's spend public key (33 bytes compressed)
650
+ * @param inputPubKeys - Public keys from transaction inputs
651
+ * @param outpoints - Transaction outpoints (for input_hash)
652
+ * @param outputs - Transaction outputs to scan
653
+ * @returns Array of received payments
654
+ *
655
+ * @example
656
+ * ```typescript
657
+ * const received = scanForPayments(
658
+ * scanPrivKey,
659
+ * spendPubKey,
660
+ * [inputPubKey1, inputPubKey2],
661
+ * [{ txid: '...', vout: 0 }],
662
+ * [{ outputIndex: 0, scriptPubKey, amount: 100000n }]
663
+ * )
664
+ * ```
665
+ */
666
+ export function scanForPayments(
667
+ scanPrivateKey: Uint8Array,
668
+ spendPublicKey: Uint8Array,
669
+ inputPubKeys: Uint8Array[],
670
+ outpoints: Array<{ txid: string; vout: number }>,
671
+ outputs: OutputToScan[],
672
+ ): ReceivedPayment[] {
673
+ // Validate inputs
674
+ if (scanPrivateKey.length !== 32) {
675
+ throw new ValidationError('scanPrivateKey must be 32 bytes', 'scanPrivateKey')
676
+ }
677
+
678
+ if (spendPublicKey.length !== 33) {
679
+ throw new ValidationError('spendPublicKey must be 33 bytes (compressed)', 'spendPublicKey')
680
+ }
681
+
682
+ if (inputPubKeys.length === 0) {
683
+ throw new ValidationError('At least one input public key is required', 'inputPubKeys')
684
+ }
685
+
686
+ if (outpoints.length === 0) {
687
+ throw new ValidationError('At least one outpoint is required', 'outpoints')
688
+ }
689
+
690
+ // Step 1: Aggregate input public keys
691
+ let aggregatePoint = secp256k1.ProjectivePoint.ZERO
692
+ for (const pubKey of inputPubKeys) {
693
+ if (pubKey.length !== 33) {
694
+ throw new ValidationError('Input public key must be 33 bytes (compressed)', 'inputPubKeys')
695
+ }
696
+ const point = secp256k1.ProjectivePoint.fromHex(pubKey)
697
+ aggregatePoint = aggregatePoint.add(point)
698
+ }
699
+
700
+ const aggregatePubKey = aggregatePoint.toRawBytes(true)
701
+
702
+ // Step 2: Compute input_hash
703
+ // Find smallest outpoint
704
+ const sortedOutpoints = [...outpoints].sort((a, b) => {
705
+ const aTxid = hexToBytes(a.txid.replace(/^0x/, ''))
706
+ const bTxid = hexToBytes(b.txid.replace(/^0x/, ''))
707
+ for (let i = 0; i < 32; i++) {
708
+ if (aTxid[i] !== bTxid[i]) return aTxid[i] - bTxid[i]
709
+ }
710
+ return a.vout - b.vout
711
+ })
712
+
713
+ const smallestOutpoint = new Uint8Array(36)
714
+ const txidBytes = hexToBytes(sortedOutpoints[0].txid.replace(/^0x/, ''))
715
+ smallestOutpoint.set(new Uint8Array(txidBytes).reverse(), 0) // little-endian
716
+ new DataView(smallestOutpoint.buffer, 32, 4).setUint32(
717
+ 0,
718
+ sortedOutpoints[0].vout,
719
+ true,
720
+ ) // little-endian
721
+
722
+ const inputHashData = new Uint8Array(36 + 33)
723
+ inputHashData.set(smallestOutpoint, 0)
724
+ inputHashData.set(aggregatePubKey, 36)
725
+ const inputHash = taggedHash('BIP0352/Inputs', inputHashData)
726
+
727
+ // Step 3: Compute shared secret
728
+ const inputHashScalar = BigInt('0x' + bytesToHex(inputHash)) % secp256k1.CURVE.n
729
+ const scanScalar = BigInt('0x' + bytesToHex(scanPrivateKey)) % secp256k1.CURVE.n
730
+ const sharedSecretScalar = (inputHashScalar * scanScalar) % secp256k1.CURVE.n
731
+ const aggregatePointFromPubKey = secp256k1.ProjectivePoint.fromHex(aggregatePubKey)
732
+ const sharedSecretPoint = aggregatePointFromPubKey.multiply(sharedSecretScalar)
733
+ const sharedSecret = sharedSecretPoint.toRawBytes(true)
734
+
735
+ // Step 4: Scan outputs
736
+ const receivedPayments: ReceivedPayment[] = []
737
+ const spendPubKeyPoint = secp256k1.ProjectivePoint.fromHex(spendPublicKey)
738
+
739
+ for (let k = 0; k < outputs.length; k++) {
740
+ const output = outputs[k]
741
+
742
+ // Only scan P2TR outputs (OP_1 + 32 bytes)
743
+ if (output.scriptPubKey.length !== 34) continue
744
+ if (output.scriptPubKey[0] !== 0x51 || output.scriptPubKey[1] !== 0x20) continue
745
+
746
+ const outputXOnly = output.scriptPubKey.slice(2, 34)
747
+
748
+ // Compute expected output for index k
749
+ const tweakData = new Uint8Array(33 + 4)
750
+ tweakData.set(sharedSecret, 0)
751
+ new DataView(tweakData.buffer, 33, 4).setUint32(0, k, false) // big-endian
752
+ const tweak = taggedHash('BIP0352/SharedSecret', tweakData)
753
+ const tweakScalar = BigInt('0x' + bytesToHex(tweak)) % secp256k1.CURVE.n
754
+
755
+ const tweakPoint = secp256k1.ProjectivePoint.BASE.multiply(tweakScalar)
756
+ const expectedPoint = spendPubKeyPoint.add(tweakPoint)
757
+ const expectedPubKey = expectedPoint.toRawBytes(false) // uncompressed
758
+ const expectedXOnly = expectedPubKey.slice(1, 33)
759
+
760
+ // Check if matches
761
+ if (bytesToHex(expectedXOnly) === bytesToHex(outputXOnly)) {
762
+ receivedPayments.push({
763
+ outputIndex: output.outputIndex,
764
+ amount: output.amount,
765
+ tweakData: tweak,
766
+ tweakedPubKey: expectedXOnly,
767
+ })
768
+ }
769
+ }
770
+
771
+ return receivedPayments
772
+ }
773
+
774
+ // ═══════════════════════════════════════════════════════════════════════════════
775
+ // RECIPIENT: DERIVE SPENDING KEY
776
+ // ═══════════════════════════════════════════════════════════════════════════════
777
+
778
+ /**
779
+ * Derive spending key for a received Silent Payment (recipient side)
780
+ *
781
+ * Allows recipient to compute the private key needed to spend a received output.
782
+ *
783
+ * Algorithm (BIP-352):
784
+ * - Spending key: p_k = (b_spend + t_k) mod n
785
+ * - Where t_k is the tweak from scanning
786
+ *
787
+ * @param payment - Received payment from scanForPayments()
788
+ * @param spendPrivateKey - Recipient's spend private key (32 bytes)
789
+ * @returns Private key for spending the output (32 bytes)
790
+ *
791
+ * @example
792
+ * ```typescript
793
+ * const payments = scanForPayments(...)
794
+ * for (const payment of payments) {
795
+ * const privKey = deriveSpendingKey(payment, spendPrivKey)
796
+ * // Use privKey to sign transaction spending this output
797
+ * }
798
+ * ```
799
+ */
800
+ export function deriveSpendingKey(
801
+ payment: ReceivedPayment,
802
+ spendPrivateKey: Uint8Array,
803
+ ): Uint8Array {
804
+ // Validate inputs
805
+ if (spendPrivateKey.length !== 32) {
806
+ throw new ValidationError('spendPrivateKey must be 32 bytes', 'spendPrivateKey')
807
+ }
808
+
809
+ if (payment.tweakData.length !== 32) {
810
+ throw new ValidationError('payment.tweakData must be 32 bytes', 'payment.tweakData')
811
+ }
812
+
813
+ // Compute spending key: p_k = (b_spend + t_k) mod n
814
+ const spendScalar = BigInt('0x' + bytesToHex(spendPrivateKey)) % secp256k1.CURVE.n
815
+ const tweakScalar = BigInt('0x' + bytesToHex(payment.tweakData)) % secp256k1.CURVE.n
816
+ let spendingScalar = (spendScalar + tweakScalar) % secp256k1.CURVE.n
817
+
818
+ // Convert to bytes (big-endian)
819
+ const spendingKey = new Uint8Array(32)
820
+ for (let i = 31; i >= 0; i--) {
821
+ spendingKey[i] = Number(spendingScalar & 0xffn)
822
+ spendingScalar >>= 8n
823
+ }
824
+
825
+ return spendingKey
826
+ }
827
+
828
+ // ═══════════════════════════════════════════════════════════════════════════════
829
+ // VALIDATION HELPERS
830
+ // ═══════════════════════════════════════════════════════════════════════════════
831
+
832
+ /**
833
+ * Validate a Silent Payment address format
834
+ *
835
+ * @param address - Address to validate
836
+ * @returns true if valid
837
+ */
838
+ export function isValidSilentPaymentAddress(address: string): boolean {
839
+ try {
840
+ parseSilentPaymentAddress(address)
841
+ return true
842
+ } catch {
843
+ return false
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Convert HexString private key to Uint8Array
849
+ */
850
+ export function hexToPrivateKey(key: HexString): Uint8Array {
851
+ if (!isValidPrivateKey(key)) {
852
+ throw new ValidationError('Invalid private key format', 'key')
853
+ }
854
+ return hexToBytes(key.slice(2))
855
+ }
856
+
857
+ /**
858
+ * Convert HexString public key to Uint8Array
859
+ */
860
+ export function hexToPublicKey(key: HexString): Uint8Array {
861
+ if (!isValidHex(key)) {
862
+ throw new ValidationError('Invalid public key format', 'key')
863
+ }
864
+ return hexToBytes(key.slice(2))
865
+ }