@sip-protocol/sdk 0.8.1 → 0.10.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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/dist/{TransportWebUSB-YQMAGJAJ.mjs → TransportWebUSB-2KITI5HD.mjs} +24 -12
  3. package/dist/browser.d.mts +4 -4
  4. package/dist/browser.d.ts +4 -4
  5. package/dist/browser.js +1358 -847
  6. package/dist/browser.mjs +13 -3
  7. package/dist/{chunk-64AYA5F5.mjs → chunk-G3TBBG2K.mjs} +221 -146
  8. package/dist/{chunk-4GRJ5MAW.mjs → chunk-KXETSSKP.mjs} +4 -0
  9. package/dist/{chunk-YWGJ77A2.mjs → chunk-PT2DNA7E.mjs} +335 -310
  10. package/dist/{constants-LHAAUC2T.mjs → constants-DCJYTIU3.mjs} +5 -1
  11. package/dist/{dist-2OGQ7FED.mjs → dist-PYEXZNFD.mjs} +609 -221
  12. package/dist/{index-DeE1ZzA4.d.mts → index-B1d8pihL.d.mts} +117 -33
  13. package/dist/{index-DXh2IGkz.d.ts → index-UQhQJZbM.d.ts} +117 -33
  14. package/dist/index.d.mts +3 -3
  15. package/dist/index.d.ts +3 -3
  16. package/dist/index.js +1348 -837
  17. package/dist/index.mjs +13 -3
  18. package/dist/{interface-Bf7w1PLW.d.mts → interface-CQi0-WfS.d.mts} +2 -2
  19. package/dist/{interface-Bf7w1PLW.d.ts → interface-CQi0-WfS.d.ts} +2 -2
  20. package/dist/{noir-kzbLVTei.d.mts → noir-CwPIyBLj.d.mts} +1 -1
  21. package/dist/{noir-kzbLVTei.d.ts → noir-CwPIyBLj.d.ts} +1 -1
  22. package/dist/proofs/halo2.d.mts +1 -1
  23. package/dist/proofs/halo2.d.ts +1 -1
  24. package/dist/proofs/kimchi.d.mts +1 -1
  25. package/dist/proofs/kimchi.d.ts +1 -1
  26. package/dist/proofs/noir.d.mts +1 -1
  27. package/dist/proofs/noir.d.ts +1 -1
  28. package/dist/{solana-U3MEGU7W.mjs → solana-ZWNIQTSU.mjs} +6 -6
  29. package/package.json +32 -32
  30. package/src/adapters/gelato-relay.ts +386 -0
  31. package/src/adapters/index.ts +28 -0
  32. package/src/adapters/oneinch.ts +126 -0
  33. package/src/chains/ethereum/constants.ts +33 -1
  34. package/src/chains/ethereum/index.ts +2 -1
  35. package/src/chains/ethereum/privacy-adapter.ts +44 -26
  36. package/src/chains/ethereum/stealth.ts +84 -30
  37. package/src/chains/ethereum/types.ts +4 -0
  38. package/src/chains/near/privacy-adapter.ts +8 -5
  39. package/src/chains/near/resolver.ts +22 -8
  40. package/src/chains/near/stealth.ts +9 -9
  41. package/src/chains/solana/constants.ts +13 -1
  42. package/src/chains/solana/ephemeral-keys.ts +3 -257
  43. package/src/chains/solana/index.ts +2 -3
  44. package/src/chains/solana/providers/helius-enhanced.ts +6 -6
  45. package/src/chains/solana/providers/webhook.ts +2 -2
  46. package/src/chains/solana/scan.ts +9 -8
  47. package/src/chains/solana/stealth-scanner.ts +3 -3
  48. package/src/chains/solana/types.ts +18 -4
  49. package/src/cosmos/ibc-stealth.ts +6 -6
  50. package/src/index.ts +6 -0
  51. package/src/move/aptos.ts +15 -9
  52. package/src/move/sui.ts +15 -9
  53. package/src/nft/private-nft.ts +10 -6
  54. package/src/privacy-backends/shadowwire.ts +13 -0
  55. package/src/stealth/ed25519.ts +173 -12
  56. package/src/stealth/index.ts +47 -4
  57. package/src/stealth/secp256k1.ts +144 -7
  58. package/src/stealth.ts +7 -0
  59. package/src/wallet/ethereum/privacy-adapter.ts +1 -1
  60. package/src/wallet/hardware/ledger-privacy.ts +2 -2
  61. package/src/wallet/near/adapter.ts +2 -2
  62. package/src/wallet/near/meteor-wallet.ts +2 -2
  63. package/src/wallet/near/my-near-wallet.ts +2 -2
  64. package/src/wallet/near/wallet-selector.ts +2 -2
  65. package/src/wallet/solana/privacy-adapter.ts +9 -9
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { sha256 } from '@noble/hashes/sha256'
22
+ import { ed25519 } from '@noble/curves/ed25519'
22
23
  import { secp256k1 } from '@noble/curves/secp256k1'
23
24
  import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
24
25
  import type {
@@ -490,9 +491,12 @@ export class PrivateNFT {
490
491
 
491
492
  const ownedNFTs: OwnedNFT[] = []
492
493
 
493
- // Convert keys to hex for stealth checking
494
- const scanKeyHex = `0x${bytesToHex(scanKey)}` as HexString
494
+ // Convert keys to hex for stealth checking.
495
+ // Canonical EIP-5564 view-only scanning needs the spending PUBLIC key, so
496
+ // derive it from the spending private (scan) key once for both curves.
495
497
  const viewingKeyHex = `0x${bytesToHex(viewingKey)}` as HexString
498
+ const ed25519SpendingPubHex = `0x${bytesToHex(ed25519.getPublicKey(scanKey))}` as HexString
499
+ const secp256k1SpendingPubHex = `0x${bytesToHex(secp256k1.getPublicKey(scanKey, true))}` as HexString
496
500
 
497
501
  // Scan each transfer
498
502
  for (const transfer of transfers) {
@@ -511,14 +515,14 @@ export class PrivateNFT {
511
515
  if (isEd25519Chain(transfer.chain)) {
512
516
  isOwned = checkEd25519StealthAddress(
513
517
  transfer.newOwnerStealth,
514
- scanKeyHex,
515
- viewingKeyHex
518
+ viewingKeyHex,
519
+ ed25519SpendingPubHex
516
520
  )
517
521
  } else {
518
522
  isOwned = checkStealthAddress(
519
523
  transfer.newOwnerStealth,
520
- scanKeyHex,
521
- viewingKeyHex
524
+ viewingKeyHex,
525
+ secp256k1SpendingPubHex
522
526
  )
523
527
  }
524
528
 
@@ -76,6 +76,12 @@ export const SHADOWWIRE_TOKEN_MINTS: Record<TokenSymbol, string> = {
76
76
  USD1: 'USD1exampleaddress1111111111111111111111111', // Placeholder - USD1 stablecoin
77
77
  AOL: 'AOLexampleaddress11111111111111111111111111', // Placeholder
78
78
  IQLABS: 'IQLABSexampleaddress111111111111111111111', // Placeholder - IQ Labs token
79
+ // Added in @radr/shadowwire@1.1.15 — real mints verified against the package's TOKEN_MINTS
80
+ SANA: '5dpN5wMH8j8au29Rp91qn4WfNq6t6xJfcjQNcFeDJ8Ct',
81
+ POKI: '6vK6cL9C66Bsqw7SC2hcCdkgm1UKBDUE6DCYJ4kubonk',
82
+ RAIN: '3iC63FgnB7EhcPaiSaC51UkVweeBDkqu17SaRyy2pump',
83
+ HOSICO: 'Dx2bQe2UPv4k3BmcW8G2KhaL5oKsxduM5XxLSV3Sbonk',
84
+ SKR: 'SKRbvo6Gf7GondiT3BbTfuRDPqLWei4j2Qy2NPGZhW3',
79
85
  }
80
86
 
81
87
  /**
@@ -370,6 +376,13 @@ export class ShadowWireBackend implements PrivacyBackend {
370
376
  amount,
371
377
  token_mint: tokenMint,
372
378
  })
379
+ // @radr/shadowwire@1.1.15 made WithdrawResponse.unsigned_tx_base64 optional
380
+ // (returned only in the unsigned-tx flow). Absence here is an error, not an empty tx.
381
+ if (!response.unsigned_tx_base64) {
382
+ throw new Error(
383
+ `ShadowWire withdraw returned no unsigned transaction${response.error ? `: ${response.error}` : ''}`
384
+ )
385
+ }
373
386
  return {
374
387
  unsignedTx: response.unsigned_tx_base64,
375
388
  amountWithdrawn: response.amount_withdrawn,
@@ -195,11 +195,11 @@ export function generateEd25519StealthMetaAddress(
195
195
  /**
196
196
  * Generate a one-time ed25519 stealth address for a recipient
197
197
  *
198
- * Algorithm (DKSAP for ed25519):
198
+ * Algorithm (DKSAP for ed25519, canonical EIP-5564):
199
199
  * 1. Generate ephemeral keypair (r, R = r*G)
200
- * 2. Compute shared secret: S = r * P_spend (ephemeral scalar * spending public)
200
+ * 2. Compute shared secret: S = r * P_view (ephemeral scalar * viewing public)
201
201
  * 3. Hash shared secret: h = SHA256(S)
202
- * 4. Derive stealth public key: P_stealth = P_view + h*G
202
+ * 4. Derive stealth public key: P_stealth = P_spend + h*G
203
203
  */
204
204
  export function generateEd25519StealthAddress(
205
205
  recipientMetaAddress: StealthMetaAddress,
@@ -226,14 +226,14 @@ export function generateEd25519StealthAddress(
226
226
  throw new Error('CRITICAL: Zero ephemeral scalar after reduction - investigate RNG')
227
227
  }
228
228
 
229
- // S = ephemeral_scalar * P_spend
230
- const spendingPoint = ed25519.ExtendedPoint.fromHex(spendingKeyBytes)
231
- const sharedSecretPoint = spendingPoint.multiply(ephemeralScalar)
229
+ // S = ephemeral_scalar * P_view (canonical EIP-5564: ECDH on the VIEWING key)
230
+ const viewingPoint = ed25519.ExtendedPoint.fromHex(viewingKeyBytes)
231
+ const sharedSecretPoint = viewingPoint.multiply(ephemeralScalar)
232
232
 
233
233
  // Hash the shared secret point
234
234
  const sharedSecretHash = sha256(sharedSecretPoint.toRawBytes())
235
235
 
236
- // Derive stealth public key: P_stealth = P_view + hash(S)*G
236
+ // Derive stealth public key: P_stealth = P_spend + hash(S)*G
237
237
  const hashScalar = bytesToBigInt(sharedSecretHash) % ED25519_ORDER
238
238
  if (hashScalar === 0n) {
239
239
  throw new Error('CRITICAL: Zero hash scalar after reduction - investigate hash computation')
@@ -242,9 +242,9 @@ export function generateEd25519StealthAddress(
242
242
  // Compute hash(S) * G
243
243
  const hashTimesG = ed25519.ExtendedPoint.BASE.multiply(hashScalar)
244
244
 
245
- // Add to viewing key: P_stealth = P_view + hash(S)*G
246
- const viewingPoint = ed25519.ExtendedPoint.fromHex(viewingKeyBytes)
247
- const stealthPoint = viewingPoint.add(hashTimesG)
245
+ // Add to spending key: P_stealth = P_spend + hash(S)*G
246
+ const spendingPoint = ed25519.ExtendedPoint.fromHex(spendingKeyBytes)
247
+ const stealthPoint = spendingPoint.add(hashTimesG)
248
248
  const stealthAddressBytes = stealthPoint.toRawBytes()
249
249
 
250
250
  // Compute view tag
@@ -266,7 +266,9 @@ export function generateEd25519StealthAddress(
266
266
  // ─── Private Key Derivation ─────────────────────────────────────────────────
267
267
 
268
268
  /**
269
- * Derive the private key for an ed25519 stealth address
269
+ * Derive the private key for an ed25519 stealth address (canonical EIP-5564)
270
+ *
271
+ * Requires BOTH the spending and viewing private keys (spending authority).
270
272
  *
271
273
  * **IMPORTANT: Derived Key Format**
272
274
  *
@@ -302,6 +304,86 @@ export function deriveEd25519StealthPrivateKey(
302
304
  const viewingPrivBytes = hexToBytes(viewingPrivateKey.slice(2))
303
305
  const ephemeralPubBytes = hexToBytes(stealthAddress.ephemeralPublicKey.slice(2))
304
306
 
307
+ try {
308
+ // Compute shared secret: S = viewing_scalar * R (canonical: ECDH on the viewing key)
309
+ const rawViewingScalar = getEd25519Scalar(viewingPrivBytes)
310
+ const viewingScalar = rawViewingScalar % ED25519_ORDER
311
+ if (viewingScalar === 0n) {
312
+ throw new Error('CRITICAL: Zero viewing scalar after reduction - investigate key derivation')
313
+ }
314
+ const ephemeralPoint = ed25519.ExtendedPoint.fromHex(ephemeralPubBytes)
315
+ const sharedSecretPoint = ephemeralPoint.multiply(viewingScalar)
316
+
317
+ // Hash the shared secret
318
+ const sharedSecretHash = sha256(sharedSecretPoint.toRawBytes())
319
+
320
+ // Get spending scalar and reduce mod L
321
+ const rawSpendingScalar = getEd25519Scalar(spendingPrivBytes)
322
+ const spendingScalar = rawSpendingScalar % ED25519_ORDER
323
+ if (spendingScalar === 0n) {
324
+ throw new Error('CRITICAL: Zero spending scalar after reduction - investigate key derivation')
325
+ }
326
+
327
+ // Derive stealth private key: s_stealth = s_spend + hash(S) mod L (canonical)
328
+ const hashScalar = bytesToBigInt(sharedSecretHash) % ED25519_ORDER
329
+ if (hashScalar === 0n) {
330
+ throw new Error('CRITICAL: Zero hash scalar after reduction - investigate hash computation')
331
+ }
332
+ const stealthPrivateScalar = (spendingScalar + hashScalar) % ED25519_ORDER
333
+ if (stealthPrivateScalar === 0n) {
334
+ throw new Error('CRITICAL: Zero stealth scalar after reduction - investigate key derivation')
335
+ }
336
+
337
+ // Convert to bytes (little-endian for ed25519)
338
+ const stealthPrivateKey = bigIntToBytesLE(stealthPrivateScalar, 32)
339
+
340
+ const result = {
341
+ stealthAddress: stealthAddress.address,
342
+ ephemeralPublicKey: stealthAddress.ephemeralPublicKey,
343
+ privateKey: `0x${bytesToHex(stealthPrivateKey)}` as HexString,
344
+ }
345
+
346
+ secureWipe(stealthPrivateKey)
347
+
348
+ return result
349
+ } finally {
350
+ secureWipeAll(spendingPrivBytes, viewingPrivBytes)
351
+ }
352
+ }
353
+
354
+ /**
355
+ * @deprecated Legacy SIP:1 swapped-scheme derivation — claim-side back-compat ONLY.
356
+ *
357
+ * Recovers funds sent to stealth addresses generated before the canonical
358
+ * EIP-5564 flip (legacy scheme: ECDH used the spending key, `S = s_spend * R`,
359
+ * and the private key was `p = s_view + H(S)`). Used only when claiming a
360
+ * `SIP:1` announcement. New (SIP:2) sends use {@link deriveEd25519StealthPrivateKey}.
361
+ */
362
+ export function deriveEd25519StealthPrivateKeyV1(
363
+ stealthAddress: StealthAddress,
364
+ spendingPrivateKey: HexString,
365
+ viewingPrivateKey: HexString,
366
+ ): StealthAddressRecovery {
367
+ validateEd25519StealthAddress(stealthAddress)
368
+
369
+ if (!isValidPrivateKey(spendingPrivateKey)) {
370
+ throw new ValidationError(
371
+ 'must be a valid 32-byte hex string',
372
+ 'spendingPrivateKey'
373
+ )
374
+ }
375
+
376
+ if (!isValidPrivateKey(viewingPrivateKey)) {
377
+ throw new ValidationError(
378
+ 'must be a valid 32-byte hex string',
379
+ 'viewingPrivateKey'
380
+ )
381
+ }
382
+
383
+ const spendingPrivBytes = hexToBytes(spendingPrivateKey.slice(2))
384
+ const viewingPrivBytes = hexToBytes(viewingPrivateKey.slice(2))
385
+ const ephemeralPubBytes = hexToBytes(stealthAddress.ephemeralPublicKey.slice(2))
386
+
305
387
  try {
306
388
  // Get spending scalar and reduce mod L
307
389
  const rawSpendingScalar = getEd25519Scalar(spendingPrivBytes)
@@ -354,9 +436,88 @@ export function deriveEd25519StealthPrivateKey(
354
436
  // ─── Address Checking ───────────────────────────────────────────────────────
355
437
 
356
438
  /**
357
- * Check if an ed25519 stealth address was intended for this recipient
439
+ * Check if an ed25519 stealth address is ours canonical EIP-5564 view-only.
440
+ *
441
+ * Requires only the viewing PRIVATE key + the spending PUBLIC key, so a viewing
442
+ * key can be delegated for scanning without granting spend authority. Never
443
+ * touches the spending private key.
444
+ *
445
+ * @param stealthAddress - Stealth address to check
446
+ * @param viewingPrivateKey - Recipient's viewing private key
447
+ * @param spendingPublicKey - Recipient's spending PUBLIC key (meta-address spendingKey)
448
+ * @returns true if this address belongs to the recipient
358
449
  */
359
450
  export function checkEd25519StealthAddress(
451
+ stealthAddress: StealthAddress,
452
+ viewingPrivateKey: HexString,
453
+ spendingPublicKey: HexString,
454
+ ): boolean {
455
+ validateEd25519StealthAddress(stealthAddress)
456
+
457
+ if (!isValidPrivateKey(viewingPrivateKey)) {
458
+ throw new ValidationError(
459
+ 'must be a valid 32-byte hex string',
460
+ 'viewingPrivateKey'
461
+ )
462
+ }
463
+
464
+ if (!isValidEd25519PublicKey(spendingPublicKey)) {
465
+ throw new ValidationError(
466
+ 'must be a valid ed25519 public key (32 bytes)',
467
+ 'spendingPublicKey'
468
+ )
469
+ }
470
+
471
+ const viewingPrivBytes = hexToBytes(viewingPrivateKey.slice(2))
472
+ const spendingPubBytes = hexToBytes(spendingPublicKey.slice(2))
473
+ const ephemeralPubBytes = hexToBytes(stealthAddress.ephemeralPublicKey.slice(2))
474
+
475
+ try {
476
+ // Compute shared secret: S = viewing_scalar * R (canonical: ECDH on the viewing key)
477
+ const rawViewingScalar = getEd25519Scalar(viewingPrivBytes)
478
+ const viewingScalar = rawViewingScalar % ED25519_ORDER
479
+ if (viewingScalar === 0n) {
480
+ throw new Error('CRITICAL: Zero viewing scalar after reduction - investigate key derivation')
481
+ }
482
+ const ephemeralPoint = ed25519.ExtendedPoint.fromHex(ephemeralPubBytes)
483
+ const sharedSecretPoint = ephemeralPoint.multiply(viewingScalar)
484
+
485
+ // Hash the shared secret
486
+ const sharedSecretHash = sha256(sharedSecretPoint.toRawBytes())
487
+
488
+ // View tag check (fast reject)
489
+ if (sharedSecretHash[0] !== stealthAddress.viewTag) {
490
+ return false
491
+ }
492
+
493
+ const hashScalar = bytesToBigInt(sharedSecretHash) % ED25519_ORDER
494
+ if (hashScalar === 0n) {
495
+ throw new Error('CRITICAL: Zero hash scalar after reduction - investigate hash computation')
496
+ }
497
+
498
+ // Expected address: P_stealth = P_spend + hash(S)*G (no spending private key needed)
499
+ const hashTimesG = ed25519.ExtendedPoint.BASE.multiply(hashScalar)
500
+ const spendingPoint = ed25519.ExtendedPoint.fromHex(spendingPubBytes)
501
+ const expectedPoint = spendingPoint.add(hashTimesG)
502
+ const expectedPubKeyBytes = expectedPoint.toRawBytes()
503
+
504
+ // Compare with provided stealth address
505
+ const providedAddress = hexToBytes(stealthAddress.address.slice(2))
506
+
507
+ return bytesToHex(expectedPubKeyBytes) === bytesToHex(providedAddress)
508
+ } finally {
509
+ secureWipe(viewingPrivBytes)
510
+ }
511
+ }
512
+
513
+ /**
514
+ * @deprecated Legacy SIP:1 full-wallet check — requires BOTH private keys.
515
+ *
516
+ * For detecting/claiming pre-flip (SIP:1) announcements only (legacy swapped
517
+ * scheme: `S = s_spend * R`, address built on the viewing key). New code should
518
+ * use the view-only {@link checkEd25519StealthAddress}.
519
+ */
520
+ export function checkEd25519StealthAddressV1(
360
521
  stealthAddress: StealthAddress,
361
522
  spendingPrivateKey: HexString,
362
523
  viewingPrivateKey: HexString,
@@ -30,14 +30,18 @@ import {
30
30
  generateEd25519StealthMetaAddress,
31
31
  generateEd25519StealthAddress,
32
32
  deriveEd25519StealthPrivateKey,
33
+ deriveEd25519StealthPrivateKeyV1,
33
34
  checkEd25519StealthAddress,
35
+ checkEd25519StealthAddressV1,
34
36
  } from './ed25519'
35
37
 
36
38
  import {
37
39
  generateSecp256k1StealthMetaAddress,
38
40
  generateSecp256k1StealthAddress,
39
41
  deriveSecp256k1StealthPrivateKey,
42
+ deriveSecp256k1StealthPrivateKeyV1,
40
43
  checkSecp256k1StealthAddress,
44
+ checkSecp256k1StealthAddressV1,
41
45
  publicKeyToEthAddress,
42
46
  validateSecp256k1StealthMetaAddress,
43
47
  validateSecp256k1StealthAddress,
@@ -166,32 +170,63 @@ export function deriveStealthPrivateKey(
166
170
  return deriveSecp256k1StealthPrivateKey(stealthAddress, spendingPrivateKey, viewingPrivateKey)
167
171
  }
168
172
 
173
+ /**
174
+ * Derive the stealth private key for a LEGACY SIP:1 announcement (back-compat)
175
+ *
176
+ * Routes to the pre-flip swapped-scheme derivation. Use only when claiming funds
177
+ * announced before the canonical EIP-5564 flip; new (SIP:2) payments use
178
+ * {@link deriveStealthPrivateKey}.
179
+ *
180
+ * @param stealthAddress - The legacy stealth address to recover
181
+ * @param spendingPrivateKey - Recipient's spending private key
182
+ * @param viewingPrivateKey - Recipient's viewing private key
183
+ * @returns Recovery data including the derived private key
184
+ */
185
+ export function deriveStealthPrivateKeyV1(
186
+ stealthAddress: StealthAddress,
187
+ spendingPrivateKey: HexString,
188
+ viewingPrivateKey: HexString,
189
+ ): StealthAddressRecovery {
190
+ const addressHex = stealthAddress.address.slice(2)
191
+
192
+ if (addressHex.length === 64) {
193
+ // 32 bytes = ed25519
194
+ return deriveEd25519StealthPrivateKeyV1(stealthAddress, spendingPrivateKey, viewingPrivateKey)
195
+ }
196
+
197
+ // Default to secp256k1
198
+ return deriveSecp256k1StealthPrivateKeyV1(stealthAddress, spendingPrivateKey, viewingPrivateKey)
199
+ }
200
+
169
201
  /**
170
202
  * Check if a stealth address was intended for this recipient
171
203
  *
172
204
  * Automatically dispatches to the correct curve implementation.
173
205
  *
206
+ * Canonical EIP-5564 view-only check: requires only the recipient's viewing
207
+ * private key plus their spending PUBLIC key (no spending private key needed).
208
+ *
174
209
  * @param stealthAddress - Stealth address to check
175
- * @param spendingPrivateKey - Recipient's spending private key
176
210
  * @param viewingPrivateKey - Recipient's viewing private key
211
+ * @param spendingPublicKey - Recipient's spending public key (meta-address spendingKey)
177
212
  * @returns true if this address belongs to the recipient
178
213
  * @throws {ValidationError} If any input is invalid
179
214
  */
180
215
  export function checkStealthAddress(
181
216
  stealthAddress: StealthAddress,
182
- spendingPrivateKey: HexString,
183
217
  viewingPrivateKey: HexString,
218
+ spendingPublicKey: HexString,
184
219
  ): boolean {
185
220
  // Try to detect curve from address length
186
221
  const addressHex = stealthAddress.address.slice(2)
187
222
 
188
223
  if (addressHex.length === 64) {
189
224
  // 32 bytes = ed25519
190
- return checkEd25519StealthAddress(stealthAddress, spendingPrivateKey, viewingPrivateKey)
225
+ return checkEd25519StealthAddress(stealthAddress, viewingPrivateKey, spendingPublicKey)
191
226
  }
192
227
 
193
228
  // Default to secp256k1
194
- return checkSecp256k1StealthAddress(stealthAddress, spendingPrivateKey, viewingPrivateKey)
229
+ return checkSecp256k1StealthAddress(stealthAddress, viewingPrivateKey, spendingPublicKey)
195
230
  }
196
231
 
197
232
  // ─── Re-exports ─────────────────────────────────────────────────────────────
@@ -207,6 +242,14 @@ export {
207
242
  checkEd25519StealthAddress,
208
243
  }
209
244
 
245
+ // Legacy SIP:1 back-compat (claim/scan of pre-flip announcements)
246
+ export {
247
+ deriveEd25519StealthPrivateKeyV1,
248
+ checkEd25519StealthAddressV1,
249
+ deriveSecp256k1StealthPrivateKeyV1,
250
+ checkSecp256k1StealthAddressV1,
251
+ }
252
+
210
253
  // secp256k1 (Ethereum, Polygon, etc.)
211
254
  export { publicKeyToEthAddress }
212
255
 
@@ -163,21 +163,22 @@ export function generateSecp256k1StealthAddress(
163
163
  const spendingKeyBytes = hexToBytes(recipientMetaAddress.spendingKey.slice(2))
164
164
  const viewingKeyBytes = hexToBytes(recipientMetaAddress.viewingKey.slice(2))
165
165
 
166
- // Compute shared secret: S = r * P (ephemeral private * spending public)
166
+ // Compute shared secret: S = r * K_view (ephemeral private * viewing public)
167
+ // Canonical EIP-5564: ECDH is on the VIEWING key.
167
168
  const sharedSecretPoint = secp256k1.getSharedSecret(
168
169
  ephemeralPrivateKey,
169
- spendingKeyBytes,
170
+ viewingKeyBytes,
170
171
  )
171
172
 
172
173
  // Hash the shared secret for use as a scalar
173
174
  const sharedSecretHash = sha256(sharedSecretPoint)
174
175
 
175
- // Compute stealth address: A = Q + hash(S)*G
176
+ // Compute stealth address: A = K_spend + hash(S)*G
176
177
  const hashTimesG = secp256k1.getPublicKey(sharedSecretHash, true)
177
178
 
178
- const viewingKeyPoint = secp256k1.ProjectivePoint.fromHex(viewingKeyBytes)
179
+ const spendingKeyPoint = secp256k1.ProjectivePoint.fromHex(spendingKeyBytes)
179
180
  const hashTimesGPoint = secp256k1.ProjectivePoint.fromHex(hashTimesG)
180
- const stealthPoint = viewingKeyPoint.add(hashTimesGPoint)
181
+ const stealthPoint = spendingKeyPoint.add(hashTimesGPoint)
181
182
  const stealthAddressBytes = stealthPoint.toRawBytes(true)
182
183
 
183
184
  // Compute view tag (first byte of hash for efficient scanning)
@@ -199,7 +200,9 @@ export function generateSecp256k1StealthAddress(
199
200
  // ─── Private Key Derivation ─────────────────────────────────────────────────
200
201
 
201
202
  /**
202
- * Derive the private key for a secp256k1 stealth address
203
+ * Derive the private key for a secp256k1 stealth address (canonical EIP-5564)
204
+ *
205
+ * Requires BOTH the spending and viewing private keys (spending authority).
203
206
  */
204
207
  export function deriveSecp256k1StealthPrivateKey(
205
208
  stealthAddress: StealthAddress,
@@ -226,6 +229,71 @@ export function deriveSecp256k1StealthPrivateKey(
226
229
  const viewingPrivBytes = hexToBytes(viewingPrivateKey.slice(2))
227
230
  const ephemeralPubBytes = hexToBytes(stealthAddress.ephemeralPublicKey.slice(2))
228
231
 
232
+ try {
233
+ // Compute shared secret: S = k_view * R (viewing private * ephemeral public)
234
+ const sharedSecretPoint = secp256k1.getSharedSecret(
235
+ viewingPrivBytes,
236
+ ephemeralPubBytes,
237
+ )
238
+
239
+ // Hash the shared secret
240
+ const sharedSecretHash = sha256(sharedSecretPoint)
241
+
242
+ // Derive stealth private key: k_spend + hash(S) mod n (canonical)
243
+ const spendingScalar = bytesToBigInt(spendingPrivBytes)
244
+ const hashScalar = bytesToBigInt(sharedSecretHash)
245
+ const stealthPrivateScalar = (spendingScalar + hashScalar) % secp256k1.CURVE.n
246
+
247
+ // Convert back to bytes
248
+ const stealthPrivateKey = bigIntToBytes(stealthPrivateScalar, 32)
249
+
250
+ const result = {
251
+ stealthAddress: stealthAddress.address,
252
+ ephemeralPublicKey: stealthAddress.ephemeralPublicKey,
253
+ privateKey: `0x${bytesToHex(stealthPrivateKey)}` as HexString,
254
+ }
255
+
256
+ secureWipe(stealthPrivateKey)
257
+
258
+ return result
259
+ } finally {
260
+ secureWipeAll(spendingPrivBytes, viewingPrivBytes)
261
+ }
262
+ }
263
+
264
+ /**
265
+ * @deprecated Legacy SIP:1 swapped-scheme derivation — claim-side back-compat ONLY.
266
+ *
267
+ * Recovers funds sent to secp256k1 stealth addresses generated before the
268
+ * canonical EIP-5564 flip (legacy scheme: `S = k_spend * R`, `p = k_view + H(S)`).
269
+ * Used only when claiming a `SIP:1` announcement. New (SIP:2) sends use
270
+ * {@link deriveSecp256k1StealthPrivateKey}.
271
+ */
272
+ export function deriveSecp256k1StealthPrivateKeyV1(
273
+ stealthAddress: StealthAddress,
274
+ spendingPrivateKey: HexString,
275
+ viewingPrivateKey: HexString,
276
+ ): StealthAddressRecovery {
277
+ validateSecp256k1StealthAddress(stealthAddress)
278
+
279
+ if (!isValidPrivateKey(spendingPrivateKey)) {
280
+ throw new ValidationError(
281
+ 'must be a valid 32-byte hex string',
282
+ 'spendingPrivateKey'
283
+ )
284
+ }
285
+
286
+ if (!isValidPrivateKey(viewingPrivateKey)) {
287
+ throw new ValidationError(
288
+ 'must be a valid 32-byte hex string',
289
+ 'viewingPrivateKey'
290
+ )
291
+ }
292
+
293
+ const spendingPrivBytes = hexToBytes(spendingPrivateKey.slice(2))
294
+ const viewingPrivBytes = hexToBytes(viewingPrivateKey.slice(2))
295
+ const ephemeralPubBytes = hexToBytes(stealthAddress.ephemeralPublicKey.slice(2))
296
+
229
297
  try {
230
298
  // Compute shared secret: S = p * R (spending private * ephemeral public)
231
299
  const sharedSecretPoint = secp256k1.getSharedSecret(
@@ -261,9 +329,78 @@ export function deriveSecp256k1StealthPrivateKey(
261
329
  // ─── Address Checking ───────────────────────────────────────────────────────
262
330
 
263
331
  /**
264
- * Check if a secp256k1 stealth address belongs to this recipient
332
+ * Check if a secp256k1 stealth address is ours canonical EIP-5564 view-only.
333
+ *
334
+ * Requires only the viewing PRIVATE key + the spending PUBLIC key, so a viewing
335
+ * key can be delegated for scanning without granting spend authority. Never
336
+ * touches the spending private key.
337
+ *
338
+ * @param stealthAddress - Stealth address to check
339
+ * @param viewingPrivateKey - Recipient's viewing private key
340
+ * @param spendingPublicKey - Recipient's compressed spending PUBLIC key (meta-address spendingKey)
341
+ * @returns true if this address belongs to the recipient
265
342
  */
266
343
  export function checkSecp256k1StealthAddress(
344
+ stealthAddress: StealthAddress,
345
+ viewingPrivateKey: HexString,
346
+ spendingPublicKey: HexString,
347
+ ): boolean {
348
+ validateSecp256k1StealthAddress(stealthAddress)
349
+
350
+ if (!isValidPrivateKey(viewingPrivateKey)) {
351
+ throw new ValidationError(
352
+ 'must be a valid 32-byte hex string',
353
+ 'viewingPrivateKey'
354
+ )
355
+ }
356
+
357
+ if (!isValidCompressedPublicKey(spendingPublicKey)) {
358
+ throw new ValidationError(
359
+ 'must be a valid compressed secp256k1 public key (33 bytes, starting with 02 or 03)',
360
+ 'spendingPublicKey'
361
+ )
362
+ }
363
+
364
+ const viewingPrivBytes = hexToBytes(viewingPrivateKey.slice(2))
365
+ const spendingPubBytes = hexToBytes(spendingPublicKey.slice(2))
366
+ const ephemeralPubBytes = hexToBytes(stealthAddress.ephemeralPublicKey.slice(2))
367
+
368
+ try {
369
+ // Compute shared secret: S = k_view * R (canonical: ECDH on the viewing key)
370
+ const sharedSecretPoint = secp256k1.getSharedSecret(
371
+ viewingPrivBytes,
372
+ ephemeralPubBytes,
373
+ )
374
+ const sharedSecretHash = sha256(sharedSecretPoint)
375
+
376
+ // View tag check (fast reject)
377
+ if (sharedSecretHash[0] !== stealthAddress.viewTag) {
378
+ return false
379
+ }
380
+
381
+ // Expected address: A = K_spend + hash(S)*G (no spending private key needed)
382
+ const hashTimesG = secp256k1.getPublicKey(sharedSecretHash, true)
383
+ const expectedPoint = secp256k1.ProjectivePoint.fromHex(spendingPubBytes).add(
384
+ secp256k1.ProjectivePoint.fromHex(hashTimesG),
385
+ )
386
+
387
+ // Compare with provided stealth address
388
+ const providedAddress = hexToBytes(stealthAddress.address.slice(2))
389
+
390
+ return bytesToHex(expectedPoint.toRawBytes(true)) === bytesToHex(providedAddress)
391
+ } finally {
392
+ secureWipe(viewingPrivBytes)
393
+ }
394
+ }
395
+
396
+ /**
397
+ * @deprecated Legacy SIP:1 full-wallet check — requires BOTH private keys.
398
+ *
399
+ * For detecting/claiming pre-flip (SIP:1) announcements only (legacy swapped
400
+ * scheme: `S = k_spend * R`, address built on the viewing key). New code should
401
+ * use the view-only {@link checkSecp256k1StealthAddress}.
402
+ */
403
+ export function checkSecp256k1StealthAddressV1(
267
404
  stealthAddress: StealthAddress,
268
405
  spendingPrivateKey: HexString,
269
406
  viewingPrivateKey: HexString,
package/src/stealth.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  generateStealthMetaAddress,
18
18
  generateStealthAddress,
19
19
  deriveStealthPrivateKey,
20
+ deriveStealthPrivateKeyV1,
20
21
  checkStealthAddress,
21
22
 
22
23
  // Chain detection
@@ -27,11 +28,17 @@ export {
27
28
  generateEd25519StealthMetaAddress,
28
29
  generateEd25519StealthAddress,
29
30
  deriveEd25519StealthPrivateKey,
31
+ deriveEd25519StealthPrivateKeyV1,
30
32
  checkEd25519StealthAddress,
33
+ checkEd25519StealthAddressV1,
31
34
 
32
35
  // secp256k1 (Ethereum, Polygon, etc.)
33
36
  publicKeyToEthAddress,
34
37
 
38
+ // Legacy SIP:1 back-compat (claim/scan of pre-flip announcements)
39
+ deriveSecp256k1StealthPrivateKeyV1,
40
+ checkSecp256k1StealthAddressV1,
41
+
35
42
  // Meta-address encoding
36
43
  encodeStealthMetaAddress,
37
44
  decodeStealthMetaAddress,
@@ -337,8 +337,8 @@ export class PrivacyEthereumWalletAdapter extends EthereumWalletAdapter {
337
337
  try {
338
338
  const isOwned = checkEthereumStealthAddress(
339
339
  announcement,
340
- this.stealthKeys.metaAddress.spendingKey,
341
340
  this.stealthKeys.viewingPrivateKey,
341
+ this.stealthKeys.metaAddress.spendingKey,
342
342
  )
343
343
 
344
344
  let ethAddress: HexString
@@ -247,8 +247,8 @@ export class LedgerPrivacyAdapter extends LedgerWalletAdapter {
247
247
  // Check if this payment belongs to us using viewing key
248
248
  const isOurs = checkEthereumStealthAddress(
249
249
  announcement,
250
- this.stealthKeys!.spendingPrivateKey,
251
- this.stealthKeys!.viewingPrivateKey
250
+ this.stealthKeys!.viewingPrivateKey,
251
+ this.stealthKeys!.metaAddress.spendingKey
252
252
  )
253
253
 
254
254
  if (isOurs) {
@@ -441,8 +441,8 @@ export class NEARWalletAdapter extends BaseWalletAdapter {
441
441
 
442
442
  return checkNEARStealthAddress(
443
443
  stealthAddress,
444
- keys.spendingPublicKey,
445
- keys.viewingPrivateKey
444
+ keys.viewingPrivateKey,
445
+ keys.spendingPublicKey
446
446
  )
447
447
  }
448
448
 
@@ -710,8 +710,8 @@ export class MeteorWalletPrivacy {
710
710
 
711
711
  return checkNEARStealthAddress(
712
712
  stealthAddress,
713
- this.privacyKeys.spendingPrivateKey,
714
- this.privacyKeys.viewingPrivateKey
713
+ this.privacyKeys.viewingPrivateKey,
714
+ this.privacyKeys.spendingPublicKey
715
715
  )
716
716
  }
717
717
 
@@ -448,8 +448,8 @@ export class MyNearWalletPrivacy {
448
448
 
449
449
  return checkNEARStealthAddress(
450
450
  stealthAddress,
451
- this.privacyKeys.spendingPrivateKey,
452
- this.privacyKeys.viewingPrivateKey
451
+ this.privacyKeys.viewingPrivateKey,
452
+ this.privacyKeys.spendingPublicKey
453
453
  )
454
454
  }
455
455