@sip-protocol/sdk 0.1.9 → 0.2.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.
package/src/stealth.ts CHANGED
@@ -13,7 +13,9 @@
13
13
  */
14
14
 
15
15
  import { secp256k1 } from '@noble/curves/secp256k1'
16
+ import { ed25519 } from '@noble/curves/ed25519'
16
17
  import { sha256 } from '@noble/hashes/sha256'
18
+ import { sha512 } from '@noble/hashes/sha512'
17
19
  import { keccak_256 } from '@noble/hashes/sha3'
18
20
  import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils'
19
21
  import type {
@@ -28,6 +30,7 @@ import {
28
30
  isValidChainId,
29
31
  isValidHex,
30
32
  isValidCompressedPublicKey,
33
+ isValidEd25519PublicKey,
31
34
  isValidPrivateKey,
32
35
  } from './validation'
33
36
  import { secureWipe, secureWipeAll } from './secure-memory'
@@ -405,19 +408,38 @@ export function decodeStealthMetaAddress(encoded: string): StealthMetaAddress {
405
408
  )
406
409
  }
407
410
 
408
- // Validate keys
409
- if (!isValidCompressedPublicKey(spendingKey)) {
410
- throw new ValidationError(
411
- 'spendingKey must be a valid compressed secp256k1 public key',
412
- 'encoded.spendingKey'
413
- )
414
- }
411
+ // Validate keys based on chain's curve type
412
+ const chainId = chain as ChainId
413
+ if (isEd25519Chain(chainId)) {
414
+ // Ed25519 chains (Solana, NEAR) use 32-byte public keys
415
+ if (!isValidEd25519PublicKey(spendingKey)) {
416
+ throw new ValidationError(
417
+ 'spendingKey must be a valid 32-byte ed25519 public key',
418
+ 'encoded.spendingKey'
419
+ )
420
+ }
415
421
 
416
- if (!isValidCompressedPublicKey(viewingKey)) {
417
- throw new ValidationError(
418
- 'viewingKey must be a valid compressed secp256k1 public key',
419
- 'encoded.viewingKey'
420
- )
422
+ if (!isValidEd25519PublicKey(viewingKey)) {
423
+ throw new ValidationError(
424
+ 'viewingKey must be a valid 32-byte ed25519 public key',
425
+ 'encoded.viewingKey'
426
+ )
427
+ }
428
+ } else {
429
+ // secp256k1 chains (Ethereum, etc.) use 33-byte compressed public keys
430
+ if (!isValidCompressedPublicKey(spendingKey)) {
431
+ throw new ValidationError(
432
+ 'spendingKey must be a valid compressed secp256k1 public key',
433
+ 'encoded.spendingKey'
434
+ )
435
+ }
436
+
437
+ if (!isValidCompressedPublicKey(viewingKey)) {
438
+ throw new ValidationError(
439
+ 'viewingKey must be a valid compressed secp256k1 public key',
440
+ 'encoded.viewingKey'
441
+ )
442
+ }
421
443
  }
422
444
 
423
445
  return {
@@ -510,3 +532,837 @@ function toChecksumAddress(address: string): HexString {
510
532
 
511
533
  return checksummed as HexString
512
534
  }
535
+
536
+ // ═══════════════════════════════════════════════════════════════════════════════
537
+ // ED25519 STEALTH ADDRESSES
538
+ // ═══════════════════════════════════════════════════════════════════════════════
539
+ //
540
+ // ed25519 stealth address implementation for Solana and NEAR chains.
541
+ // Uses DKSAP (Dual-Key Stealth Address Protocol) pattern adapted for ed25519.
542
+ //
543
+ // Key differences from secp256k1:
544
+ // - Public keys are 32 bytes (not 33 compressed)
545
+ // - Uses SHA-512 for key derivation (matches ed25519 spec)
546
+ // - Scalar arithmetic modulo ed25519 curve order (L)
547
+ // ═══════════════════════════════════════════════════════════════════════════════
548
+
549
+ /**
550
+ * ed25519 curve order (L) - the order of the base point
551
+ */
552
+ const ED25519_ORDER = 2n ** 252n + 27742317777372353535851937790883648493n
553
+
554
+ /**
555
+ * Chains that use ed25519 for stealth addresses
556
+ */
557
+ const ED25519_CHAINS: ChainId[] = ['solana', 'near']
558
+
559
+ /**
560
+ * Check if a chain uses ed25519 for stealth addresses
561
+ */
562
+ export function isEd25519Chain(chain: ChainId): boolean {
563
+ return ED25519_CHAINS.includes(chain)
564
+ }
565
+
566
+ /**
567
+ * Curve type used for stealth addresses
568
+ */
569
+ export type StealthCurve = 'secp256k1' | 'ed25519'
570
+
571
+ /**
572
+ * Get the curve type used by a chain for stealth addresses
573
+ *
574
+ * @param chain - Chain identifier
575
+ * @returns 'ed25519' for Solana/NEAR, 'secp256k1' for EVM chains
576
+ */
577
+ export function getCurveForChain(chain: ChainId): StealthCurve {
578
+ return isEd25519Chain(chain) ? 'ed25519' : 'secp256k1'
579
+ }
580
+
581
+ /**
582
+ * Validate an ed25519 StealthMetaAddress object
583
+ */
584
+ function validateEd25519StealthMetaAddress(
585
+ metaAddress: StealthMetaAddress,
586
+ field: string = 'recipientMetaAddress'
587
+ ): void {
588
+ if (!metaAddress || typeof metaAddress !== 'object') {
589
+ throw new ValidationError('must be an object', field)
590
+ }
591
+
592
+ // Validate chain is ed25519-compatible
593
+ if (!isValidChainId(metaAddress.chain)) {
594
+ throw new ValidationError(
595
+ `invalid chain '${metaAddress.chain}'`,
596
+ `${field}.chain`
597
+ )
598
+ }
599
+
600
+ if (!isEd25519Chain(metaAddress.chain)) {
601
+ throw new ValidationError(
602
+ `chain '${metaAddress.chain}' does not use ed25519, use secp256k1 functions instead`,
603
+ `${field}.chain`
604
+ )
605
+ }
606
+
607
+ // Validate spending key (32 bytes for ed25519)
608
+ if (!isValidEd25519PublicKey(metaAddress.spendingKey)) {
609
+ throw new ValidationError(
610
+ 'spendingKey must be a valid ed25519 public key (32 bytes)',
611
+ `${field}.spendingKey`
612
+ )
613
+ }
614
+
615
+ // Validate viewing key (32 bytes for ed25519)
616
+ if (!isValidEd25519PublicKey(metaAddress.viewingKey)) {
617
+ throw new ValidationError(
618
+ 'viewingKey must be a valid ed25519 public key (32 bytes)',
619
+ `${field}.viewingKey`
620
+ )
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Validate an ed25519 StealthAddress object
626
+ */
627
+ function validateEd25519StealthAddress(
628
+ stealthAddress: StealthAddress,
629
+ field: string = 'stealthAddress'
630
+ ): void {
631
+ if (!stealthAddress || typeof stealthAddress !== 'object') {
632
+ throw new ValidationError('must be an object', field)
633
+ }
634
+
635
+ // Validate address (32-byte ed25519 public key)
636
+ if (!isValidEd25519PublicKey(stealthAddress.address)) {
637
+ throw new ValidationError(
638
+ 'address must be a valid ed25519 public key (32 bytes)',
639
+ `${field}.address`
640
+ )
641
+ }
642
+
643
+ // Validate ephemeral public key (32 bytes for ed25519)
644
+ if (!isValidEd25519PublicKey(stealthAddress.ephemeralPublicKey)) {
645
+ throw new ValidationError(
646
+ 'ephemeralPublicKey must be a valid ed25519 public key (32 bytes)',
647
+ `${field}.ephemeralPublicKey`
648
+ )
649
+ }
650
+
651
+ // Validate view tag (0-255)
652
+ if (typeof stealthAddress.viewTag !== 'number' ||
653
+ !Number.isInteger(stealthAddress.viewTag) ||
654
+ stealthAddress.viewTag < 0 ||
655
+ stealthAddress.viewTag > 255) {
656
+ throw new ValidationError(
657
+ 'viewTag must be an integer between 0 and 255',
658
+ `${field}.viewTag`
659
+ )
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Get the scalar from an ed25519 private key
665
+ *
666
+ * ed25519 key derivation:
667
+ * 1. Hash the 32-byte seed with SHA-512 to get 64 bytes
668
+ * 2. First 32 bytes are the scalar (after clamping)
669
+ * 3. Last 32 bytes are used for nonce generation (not needed here)
670
+ */
671
+ function getEd25519Scalar(privateKey: Uint8Array): bigint {
672
+ // Hash the private key seed with SHA-512
673
+ const hash = sha512(privateKey)
674
+
675
+ // Take first 32 bytes and clamp as per ed25519 spec
676
+ const scalar = hash.slice(0, 32)
677
+
678
+ // Clamp: clear lowest 3 bits, clear highest bit, set second highest bit
679
+ scalar[0] &= 248
680
+ scalar[31] &= 127
681
+ scalar[31] |= 64
682
+
683
+ // Convert to bigint (little-endian for ed25519)
684
+ return bytesToBigIntLE(scalar)
685
+ }
686
+
687
+ /**
688
+ * Convert bytes to bigint (little-endian, used by ed25519)
689
+ */
690
+ function bytesToBigIntLE(bytes: Uint8Array): bigint {
691
+ let result = 0n
692
+ for (let i = bytes.length - 1; i >= 0; i--) {
693
+ result = (result << 8n) + BigInt(bytes[i])
694
+ }
695
+ return result
696
+ }
697
+
698
+ /**
699
+ * Convert bigint to bytes (little-endian, used by ed25519)
700
+ */
701
+ function bigIntToBytesLE(value: bigint, length: number): Uint8Array {
702
+ const bytes = new Uint8Array(length)
703
+ for (let i = 0; i < length; i++) {
704
+ bytes[i] = Number(value & 0xffn)
705
+ value >>= 8n
706
+ }
707
+ return bytes
708
+ }
709
+
710
+ /**
711
+ * Generate a new ed25519 stealth meta-address keypair
712
+ *
713
+ * @param chain - Target chain (must be ed25519-compatible: solana, near)
714
+ * @param label - Optional human-readable label
715
+ * @returns Stealth meta-address and private keys
716
+ * @throws {ValidationError} If chain is invalid or not ed25519-compatible
717
+ */
718
+ export function generateEd25519StealthMetaAddress(
719
+ chain: ChainId,
720
+ label?: string,
721
+ ): {
722
+ metaAddress: StealthMetaAddress
723
+ spendingPrivateKey: HexString
724
+ viewingPrivateKey: HexString
725
+ } {
726
+ // Validate chain
727
+ if (!isValidChainId(chain)) {
728
+ throw new ValidationError(
729
+ `invalid chain '${chain}', must be one of: solana, ethereum, near, zcash, polygon, arbitrum, optimism, base`,
730
+ 'chain'
731
+ )
732
+ }
733
+
734
+ if (!isEd25519Chain(chain)) {
735
+ throw new ValidationError(
736
+ `chain '${chain}' does not use ed25519, use generateStealthMetaAddress() for secp256k1 chains`,
737
+ 'chain'
738
+ )
739
+ }
740
+
741
+ // Generate random private keys (32-byte seeds)
742
+ const spendingPrivateKey = randomBytes(32)
743
+ const viewingPrivateKey = randomBytes(32)
744
+
745
+ try {
746
+ // Derive public keys using ed25519
747
+ const spendingKey = ed25519.getPublicKey(spendingPrivateKey)
748
+ const viewingKey = ed25519.getPublicKey(viewingPrivateKey)
749
+
750
+ // Convert to hex strings before wiping buffers
751
+ const result = {
752
+ metaAddress: {
753
+ spendingKey: `0x${bytesToHex(spendingKey)}` as HexString,
754
+ viewingKey: `0x${bytesToHex(viewingKey)}` as HexString,
755
+ chain,
756
+ label,
757
+ },
758
+ spendingPrivateKey: `0x${bytesToHex(spendingPrivateKey)}` as HexString,
759
+ viewingPrivateKey: `0x${bytesToHex(viewingPrivateKey)}` as HexString,
760
+ }
761
+
762
+ return result
763
+ } finally {
764
+ // Securely wipe private key buffers
765
+ secureWipeAll(spendingPrivateKey, viewingPrivateKey)
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Generate a one-time ed25519 stealth address for a recipient
771
+ *
772
+ * Algorithm (DKSAP for ed25519):
773
+ * 1. Generate ephemeral keypair (r, R = r*G)
774
+ * 2. Compute shared secret: S = r * P_spend (ephemeral scalar * spending public)
775
+ * 3. Hash shared secret: h = SHA256(S)
776
+ * 4. Derive stealth public key: P_stealth = P_view + h*G
777
+ *
778
+ * @param recipientMetaAddress - Recipient's published stealth meta-address
779
+ * @returns Stealth address data (address + ephemeral key for publication)
780
+ * @throws {ValidationError} If recipientMetaAddress is invalid
781
+ */
782
+ export function generateEd25519StealthAddress(
783
+ recipientMetaAddress: StealthMetaAddress,
784
+ ): {
785
+ stealthAddress: StealthAddress
786
+ sharedSecret: HexString
787
+ } {
788
+ // Validate input
789
+ validateEd25519StealthMetaAddress(recipientMetaAddress)
790
+
791
+ // Generate ephemeral keypair
792
+ const ephemeralPrivateKey = randomBytes(32)
793
+
794
+ try {
795
+ const ephemeralPublicKey = ed25519.getPublicKey(ephemeralPrivateKey)
796
+
797
+ // Parse recipient's keys (remove 0x prefix)
798
+ const spendingKeyBytes = hexToBytes(recipientMetaAddress.spendingKey.slice(2))
799
+ const viewingKeyBytes = hexToBytes(recipientMetaAddress.viewingKey.slice(2))
800
+
801
+ // Get ephemeral scalar from private key and reduce mod L
802
+ // ed25519 clamping produces values that may exceed L, so we reduce
803
+ const rawEphemeralScalar = getEd25519Scalar(ephemeralPrivateKey)
804
+ const ephemeralScalar = rawEphemeralScalar % ED25519_ORDER
805
+ if (ephemeralScalar === 0n) {
806
+ throw new Error('CRITICAL: Zero ephemeral scalar after reduction - investigate RNG')
807
+ }
808
+
809
+ // Convert spending public key to extended point and multiply by ephemeral scalar
810
+ // S = ephemeral_scalar * P_spend
811
+ const spendingPoint = ed25519.ExtendedPoint.fromHex(spendingKeyBytes)
812
+ const sharedSecretPoint = spendingPoint.multiply(ephemeralScalar)
813
+
814
+ // Hash the shared secret point (compress to bytes first)
815
+ const sharedSecretHash = sha256(sharedSecretPoint.toRawBytes())
816
+
817
+ // Derive stealth public key: P_stealth = P_view + hash(S)*G
818
+ // Convert hash to scalar (mod L to ensure it's valid and non-zero)
819
+ const hashScalar = bytesToBigInt(sharedSecretHash) % ED25519_ORDER
820
+ if (hashScalar === 0n) {
821
+ throw new Error('CRITICAL: Zero hash scalar after reduction - investigate hash computation')
822
+ }
823
+
824
+ // Compute hash(S) * G
825
+ const hashTimesG = ed25519.ExtendedPoint.BASE.multiply(hashScalar)
826
+
827
+ // Add to viewing key: P_stealth = P_view + hash(S)*G
828
+ const viewingPoint = ed25519.ExtendedPoint.fromHex(viewingKeyBytes)
829
+ const stealthPoint = viewingPoint.add(hashTimesG)
830
+ const stealthAddressBytes = stealthPoint.toRawBytes()
831
+
832
+ // Compute view tag (first byte of hash for efficient scanning)
833
+ const viewTag = sharedSecretHash[0]
834
+
835
+ return {
836
+ stealthAddress: {
837
+ address: `0x${bytesToHex(stealthAddressBytes)}` as HexString,
838
+ ephemeralPublicKey: `0x${bytesToHex(ephemeralPublicKey)}` as HexString,
839
+ viewTag,
840
+ },
841
+ sharedSecret: `0x${bytesToHex(sharedSecretHash)}` as HexString,
842
+ }
843
+ } finally {
844
+ // Securely wipe ephemeral private key
845
+ secureWipe(ephemeralPrivateKey)
846
+ }
847
+ }
848
+
849
+ /**
850
+ * Derive the private key for an ed25519 stealth address (for recipient to claim funds)
851
+ *
852
+ * Algorithm:
853
+ * 1. Compute shared secret: S = spend_scalar * R (spending scalar * ephemeral public)
854
+ * 2. Hash shared secret: h = SHA256(S)
855
+ * 3. Derive stealth private key: s_stealth = s_view + h (mod L)
856
+ *
857
+ * **IMPORTANT: Derived Key Format**
858
+ *
859
+ * The returned `privateKey` is a **raw scalar** in little-endian format, NOT a standard
860
+ * ed25519 seed. This is because the stealth private key is derived mathematically
861
+ * (s_view + hash), not generated from a seed.
862
+ *
863
+ * To compute the public key from the derived private key:
864
+ * ```typescript
865
+ * // CORRECT: Direct scalar multiplication
866
+ * const scalar = bytesToBigIntLE(hexToBytes(privateKey.slice(2)))
867
+ * const publicKey = ed25519.ExtendedPoint.BASE.multiply(scalar)
868
+ *
869
+ * // WRONG: Do NOT use ed25519.getPublicKey() - it will hash and clamp the input,
870
+ * // producing a different (incorrect) public key
871
+ * ```
872
+ *
873
+ * @param stealthAddress - The stealth address to recover
874
+ * @param spendingPrivateKey - Recipient's spending private key
875
+ * @param viewingPrivateKey - Recipient's viewing private key
876
+ * @returns Recovery data including derived private key (raw scalar, little-endian)
877
+ * @throws {ValidationError} If any input is invalid
878
+ */
879
+ export function deriveEd25519StealthPrivateKey(
880
+ stealthAddress: StealthAddress,
881
+ spendingPrivateKey: HexString,
882
+ viewingPrivateKey: HexString,
883
+ ): StealthAddressRecovery {
884
+ // Validate stealth address
885
+ validateEd25519StealthAddress(stealthAddress)
886
+
887
+ // Validate private keys (32 bytes)
888
+ if (!isValidPrivateKey(spendingPrivateKey)) {
889
+ throw new ValidationError(
890
+ 'must be a valid 32-byte hex string',
891
+ 'spendingPrivateKey'
892
+ )
893
+ }
894
+
895
+ if (!isValidPrivateKey(viewingPrivateKey)) {
896
+ throw new ValidationError(
897
+ 'must be a valid 32-byte hex string',
898
+ 'viewingPrivateKey'
899
+ )
900
+ }
901
+
902
+ // Parse keys
903
+ const spendingPrivBytes = hexToBytes(spendingPrivateKey.slice(2))
904
+ const viewingPrivBytes = hexToBytes(viewingPrivateKey.slice(2))
905
+ const ephemeralPubBytes = hexToBytes(stealthAddress.ephemeralPublicKey.slice(2))
906
+
907
+ try {
908
+ // Get spending scalar from private key and reduce mod L
909
+ const rawSpendingScalar = getEd25519Scalar(spendingPrivBytes)
910
+ const spendingScalar = rawSpendingScalar % ED25519_ORDER
911
+ if (spendingScalar === 0n) {
912
+ throw new Error('CRITICAL: Zero spending scalar after reduction - investigate key derivation')
913
+ }
914
+
915
+ // Compute shared secret: S = spending_scalar * R
916
+ const ephemeralPoint = ed25519.ExtendedPoint.fromHex(ephemeralPubBytes)
917
+ const sharedSecretPoint = ephemeralPoint.multiply(spendingScalar)
918
+
919
+ // Hash the shared secret
920
+ const sharedSecretHash = sha256(sharedSecretPoint.toRawBytes())
921
+
922
+ // Get viewing scalar from private key and reduce mod L
923
+ const rawViewingScalar = getEd25519Scalar(viewingPrivBytes)
924
+ const viewingScalar = rawViewingScalar % ED25519_ORDER
925
+ if (viewingScalar === 0n) {
926
+ throw new Error('CRITICAL: Zero viewing scalar after reduction - investigate key derivation')
927
+ }
928
+
929
+ // Derive stealth private key: s_stealth = s_view + hash(S) mod L
930
+ const hashScalar = bytesToBigInt(sharedSecretHash) % ED25519_ORDER
931
+ if (hashScalar === 0n) {
932
+ throw new Error('CRITICAL: Zero hash scalar after reduction - investigate hash computation')
933
+ }
934
+ const stealthPrivateScalar = (viewingScalar + hashScalar) % ED25519_ORDER
935
+ if (stealthPrivateScalar === 0n) {
936
+ throw new Error('CRITICAL: Zero stealth scalar after reduction - investigate key derivation')
937
+ }
938
+
939
+ // Convert back to bytes (little-endian for ed25519)
940
+ // Note: We need to store this as a seed that will produce this scalar
941
+ // For simplicity, we store the scalar directly (32 bytes, little-endian)
942
+ const stealthPrivateKey = bigIntToBytesLE(stealthPrivateScalar, 32)
943
+
944
+ const result = {
945
+ stealthAddress: stealthAddress.address,
946
+ ephemeralPublicKey: stealthAddress.ephemeralPublicKey,
947
+ privateKey: `0x${bytesToHex(stealthPrivateKey)}` as HexString,
948
+ }
949
+
950
+ // Wipe derived key buffer after converting to hex
951
+ secureWipe(stealthPrivateKey)
952
+
953
+ return result
954
+ } finally {
955
+ // Securely wipe input private key buffers
956
+ secureWipeAll(spendingPrivBytes, viewingPrivBytes)
957
+ }
958
+ }
959
+
960
+ /**
961
+ * Check if an ed25519 stealth address was intended for this recipient
962
+ * Uses view tag for efficient filtering before full computation
963
+ *
964
+ * @param stealthAddress - Stealth address to check
965
+ * @param spendingPrivateKey - Recipient's spending private key
966
+ * @param viewingPrivateKey - Recipient's viewing private key
967
+ * @returns true if this address belongs to the recipient
968
+ * @throws {ValidationError} If any input is invalid
969
+ */
970
+ export function checkEd25519StealthAddress(
971
+ stealthAddress: StealthAddress,
972
+ spendingPrivateKey: HexString,
973
+ viewingPrivateKey: HexString,
974
+ ): boolean {
975
+ // Validate stealth address
976
+ validateEd25519StealthAddress(stealthAddress)
977
+
978
+ // Validate private keys
979
+ if (!isValidPrivateKey(spendingPrivateKey)) {
980
+ throw new ValidationError(
981
+ 'must be a valid 32-byte hex string',
982
+ 'spendingPrivateKey'
983
+ )
984
+ }
985
+
986
+ if (!isValidPrivateKey(viewingPrivateKey)) {
987
+ throw new ValidationError(
988
+ 'must be a valid 32-byte hex string',
989
+ 'viewingPrivateKey'
990
+ )
991
+ }
992
+
993
+ // Parse keys
994
+ const spendingPrivBytes = hexToBytes(spendingPrivateKey.slice(2))
995
+ const viewingPrivBytes = hexToBytes(viewingPrivateKey.slice(2))
996
+ const ephemeralPubBytes = hexToBytes(stealthAddress.ephemeralPublicKey.slice(2))
997
+
998
+ try {
999
+ // Get spending scalar from private key and reduce mod L
1000
+ const rawSpendingScalar = getEd25519Scalar(spendingPrivBytes)
1001
+ const spendingScalar = rawSpendingScalar % ED25519_ORDER
1002
+ if (spendingScalar === 0n) {
1003
+ throw new Error('CRITICAL: Zero spending scalar after reduction - investigate key derivation')
1004
+ }
1005
+
1006
+ // Compute shared secret: S = spending_scalar * R
1007
+ const ephemeralPoint = ed25519.ExtendedPoint.fromHex(ephemeralPubBytes)
1008
+ const sharedSecretPoint = ephemeralPoint.multiply(spendingScalar)
1009
+
1010
+ // Hash the shared secret
1011
+ const sharedSecretHash = sha256(sharedSecretPoint.toRawBytes())
1012
+
1013
+ // View tag check (optimization - reject quickly if doesn't match)
1014
+ if (sharedSecretHash[0] !== stealthAddress.viewTag) {
1015
+ return false
1016
+ }
1017
+
1018
+ // Full check: derive the expected stealth address
1019
+ const rawViewingScalar = getEd25519Scalar(viewingPrivBytes)
1020
+ const viewingScalar = rawViewingScalar % ED25519_ORDER
1021
+ if (viewingScalar === 0n) {
1022
+ throw new Error('CRITICAL: Zero viewing scalar after reduction - investigate key derivation')
1023
+ }
1024
+
1025
+ const hashScalar = bytesToBigInt(sharedSecretHash) % ED25519_ORDER
1026
+ if (hashScalar === 0n) {
1027
+ throw new Error('CRITICAL: Zero hash scalar after reduction - investigate hash computation')
1028
+ }
1029
+ const stealthPrivateScalar = (viewingScalar + hashScalar) % ED25519_ORDER
1030
+ if (stealthPrivateScalar === 0n) {
1031
+ throw new Error('CRITICAL: Zero stealth scalar after reduction - investigate key derivation')
1032
+ }
1033
+
1034
+ // Compute expected public key from derived scalar
1035
+ const expectedPubKey = ed25519.ExtendedPoint.BASE.multiply(stealthPrivateScalar)
1036
+ const expectedPubKeyBytes = expectedPubKey.toRawBytes()
1037
+
1038
+ // Compare with provided stealth address
1039
+ const providedAddress = hexToBytes(stealthAddress.address.slice(2))
1040
+
1041
+ return bytesToHex(expectedPubKeyBytes) === bytesToHex(providedAddress)
1042
+ } finally {
1043
+ // Securely wipe input private key buffers
1044
+ secureWipeAll(spendingPrivBytes, viewingPrivBytes)
1045
+ }
1046
+ }
1047
+
1048
+ // ─── Base58 Encoding for Solana ────────────────────────────────────────────────
1049
+
1050
+ /** Base58 alphabet (Bitcoin/Solana standard) */
1051
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
1052
+
1053
+ /**
1054
+ * Encode bytes to base58 string
1055
+ * Used for Solana address encoding
1056
+ */
1057
+ function bytesToBase58(bytes: Uint8Array): string {
1058
+ // Count leading zeros
1059
+ let leadingZeros = 0
1060
+ for (let i = 0; i < bytes.length && bytes[i] === 0; i++) {
1061
+ leadingZeros++
1062
+ }
1063
+
1064
+ // Convert bytes to bigint
1065
+ let value = 0n
1066
+ for (const byte of bytes) {
1067
+ value = value * 256n + BigInt(byte)
1068
+ }
1069
+
1070
+ // Convert to base58
1071
+ let result = ''
1072
+ while (value > 0n) {
1073
+ const remainder = value % 58n
1074
+ value = value / 58n
1075
+ result = BASE58_ALPHABET[Number(remainder)] + result
1076
+ }
1077
+
1078
+ // Add leading '1's for each leading zero byte
1079
+ return '1'.repeat(leadingZeros) + result
1080
+ }
1081
+
1082
+ /**
1083
+ * Decode base58 string to bytes
1084
+ * Used for Solana address validation
1085
+ */
1086
+ function base58ToBytes(str: string): Uint8Array {
1087
+ // Count leading '1's (they represent leading zero bytes)
1088
+ let leadingOnes = 0
1089
+ for (let i = 0; i < str.length && str[i] === '1'; i++) {
1090
+ leadingOnes++
1091
+ }
1092
+
1093
+ // Convert from base58 to bigint
1094
+ let value = 0n
1095
+ for (const char of str) {
1096
+ const index = BASE58_ALPHABET.indexOf(char)
1097
+ if (index === -1) {
1098
+ throw new ValidationError(`Invalid base58 character: ${char}`, 'address')
1099
+ }
1100
+ value = value * 58n + BigInt(index)
1101
+ }
1102
+
1103
+ // Convert bigint to bytes
1104
+ const bytes: number[] = []
1105
+ while (value > 0n) {
1106
+ bytes.unshift(Number(value % 256n))
1107
+ value = value / 256n
1108
+ }
1109
+
1110
+ // Add leading zeros
1111
+ const result = new Uint8Array(leadingOnes + bytes.length)
1112
+ for (let i = 0; i < leadingOnes; i++) {
1113
+ result[i] = 0
1114
+ }
1115
+ for (let i = 0; i < bytes.length; i++) {
1116
+ result[leadingOnes + i] = bytes[i]
1117
+ }
1118
+
1119
+ return result
1120
+ }
1121
+
1122
+ // ─── Solana Address Derivation ─────────────────────────────────────────────────
1123
+
1124
+ /**
1125
+ * Convert an ed25519 public key (hex) to a Solana address (base58)
1126
+ *
1127
+ * Solana addresses are base58-encoded 32-byte ed25519 public keys.
1128
+ *
1129
+ * @param publicKey - 32-byte ed25519 public key as hex string (with 0x prefix)
1130
+ * @returns Base58-encoded Solana address
1131
+ * @throws {ValidationError} If public key is invalid
1132
+ *
1133
+ * @example
1134
+ * ```typescript
1135
+ * const { stealthAddress } = generateEd25519StealthAddress(metaAddress)
1136
+ * const solanaAddress = ed25519PublicKeyToSolanaAddress(stealthAddress.address)
1137
+ * // Returns: "7Vbmv1jt4vyuqBZcpYPpnVhrqVe5e6ZPBJCyqLqzQPvN" (example)
1138
+ * ```
1139
+ */
1140
+ export function ed25519PublicKeyToSolanaAddress(publicKey: HexString): string {
1141
+ // Validate input
1142
+ if (!isValidHex(publicKey)) {
1143
+ throw new ValidationError(
1144
+ 'publicKey must be a valid hex string with 0x prefix',
1145
+ 'publicKey'
1146
+ )
1147
+ }
1148
+
1149
+ if (!isValidEd25519PublicKey(publicKey)) {
1150
+ throw new ValidationError(
1151
+ 'publicKey must be 32 bytes (64 hex characters)',
1152
+ 'publicKey'
1153
+ )
1154
+ }
1155
+
1156
+ // Convert hex to bytes (remove 0x prefix)
1157
+ const publicKeyBytes = hexToBytes(publicKey.slice(2))
1158
+
1159
+ // Encode as base58
1160
+ return bytesToBase58(publicKeyBytes)
1161
+ }
1162
+
1163
+ /**
1164
+ * Validate a Solana address format
1165
+ *
1166
+ * Checks that the address:
1167
+ * - Is a valid base58 string
1168
+ * - Decodes to exactly 32 bytes (ed25519 public key size)
1169
+ *
1170
+ * @param address - Base58-encoded Solana address
1171
+ * @returns true if valid, false otherwise
1172
+ *
1173
+ * @example
1174
+ * ```typescript
1175
+ * isValidSolanaAddress('7Vbmv1jt4vyuqBZcpYPpnVhrqVe5e6ZPBJCyqLqzQPvN') // true
1176
+ * isValidSolanaAddress('invalid') // false
1177
+ * ```
1178
+ */
1179
+ export function isValidSolanaAddress(address: string): boolean {
1180
+ if (typeof address !== 'string' || address.length === 0) {
1181
+ return false
1182
+ }
1183
+
1184
+ // Solana addresses are typically 32-44 characters
1185
+ if (address.length < 32 || address.length > 44) {
1186
+ return false
1187
+ }
1188
+
1189
+ try {
1190
+ const decoded = base58ToBytes(address)
1191
+ // Valid Solana address is exactly 32 bytes
1192
+ return decoded.length === 32
1193
+ } catch {
1194
+ return false
1195
+ }
1196
+ }
1197
+
1198
+ /**
1199
+ * Convert a Solana address (base58) back to ed25519 public key (hex)
1200
+ *
1201
+ * @param address - Base58-encoded Solana address
1202
+ * @returns 32-byte ed25519 public key as hex string (with 0x prefix)
1203
+ * @throws {ValidationError} If address is invalid
1204
+ *
1205
+ * @example
1206
+ * ```typescript
1207
+ * const publicKey = solanaAddressToEd25519PublicKey('7Vbmv1jt4vyuqBZcpYPpnVhrqVe5e6ZPBJCyqLqzQPvN')
1208
+ * // Returns: "0x..." (64 hex characters)
1209
+ * ```
1210
+ */
1211
+ export function solanaAddressToEd25519PublicKey(address: string): HexString {
1212
+ if (!isValidSolanaAddress(address)) {
1213
+ throw new ValidationError(
1214
+ 'Invalid Solana address format',
1215
+ 'address'
1216
+ )
1217
+ }
1218
+
1219
+ const decoded = base58ToBytes(address)
1220
+ return `0x${bytesToHex(decoded)}` as HexString
1221
+ }
1222
+
1223
+ // ─── NEAR Address Derivation ────────────────────────────────────────────────────
1224
+
1225
+ /**
1226
+ * Convert ed25519 public key to NEAR implicit account address
1227
+ *
1228
+ * NEAR implicit accounts are lowercase hex-encoded ed25519 public keys (64 characters).
1229
+ * No prefix, just raw 32 bytes as lowercase hex.
1230
+ *
1231
+ * @param publicKey - 32-byte ed25519 public key as hex string (with 0x prefix)
1232
+ * @returns NEAR implicit account address (64 lowercase hex characters, no prefix)
1233
+ * @throws {ValidationError} If public key is invalid
1234
+ *
1235
+ * @example
1236
+ * ```typescript
1237
+ * const { stealthAddress } = generateEd25519StealthAddress(metaAddress)
1238
+ * const nearAddress = ed25519PublicKeyToNearAddress(stealthAddress.address)
1239
+ * // Returns: "ab12cd34..." (64 hex chars)
1240
+ * ```
1241
+ */
1242
+ export function ed25519PublicKeyToNearAddress(publicKey: HexString): string {
1243
+ // Validate input
1244
+ if (!isValidHex(publicKey)) {
1245
+ throw new ValidationError(
1246
+ 'publicKey must be a valid hex string with 0x prefix',
1247
+ 'publicKey'
1248
+ )
1249
+ }
1250
+
1251
+ if (!isValidEd25519PublicKey(publicKey)) {
1252
+ throw new ValidationError(
1253
+ 'publicKey must be 32 bytes (64 hex characters)',
1254
+ 'publicKey'
1255
+ )
1256
+ }
1257
+
1258
+ // NEAR implicit accounts are lowercase hex without 0x prefix
1259
+ return publicKey.slice(2).toLowerCase()
1260
+ }
1261
+
1262
+ /**
1263
+ * Convert NEAR implicit account address back to ed25519 public key
1264
+ *
1265
+ * @param address - NEAR implicit account address (64 hex characters)
1266
+ * @returns ed25519 public key as HexString (with 0x prefix)
1267
+ * @throws {ValidationError} If address is invalid
1268
+ *
1269
+ * @example
1270
+ * ```typescript
1271
+ * const publicKey = nearAddressToEd25519PublicKey("ab12cd34...")
1272
+ * // Returns: "0xab12cd34..."
1273
+ * ```
1274
+ */
1275
+ export function nearAddressToEd25519PublicKey(address: string): HexString {
1276
+ if (!isValidNearImplicitAddress(address)) {
1277
+ throw new ValidationError(
1278
+ 'Invalid NEAR implicit address format',
1279
+ 'address'
1280
+ )
1281
+ }
1282
+
1283
+ return `0x${address.toLowerCase()}` as HexString
1284
+ }
1285
+
1286
+ /**
1287
+ * Validate a NEAR implicit account address
1288
+ *
1289
+ * NEAR implicit accounts are:
1290
+ * - Exactly 64 lowercase hex characters
1291
+ * - No prefix (no "0x")
1292
+ * - Represent a 32-byte ed25519 public key
1293
+ *
1294
+ * @param address - Address to validate
1295
+ * @returns true if valid NEAR implicit account address
1296
+ *
1297
+ * @example
1298
+ * ```typescript
1299
+ * isValidNearImplicitAddress("ab12cd34ef...") // true (64 hex chars)
1300
+ * isValidNearImplicitAddress("0xab12...") // false (has prefix)
1301
+ * isValidNearImplicitAddress("alice.near") // false (named account)
1302
+ * isValidNearImplicitAddress("AB12CD...") // false (uppercase)
1303
+ * ```
1304
+ */
1305
+ export function isValidNearImplicitAddress(address: string): boolean {
1306
+ // Must be a string
1307
+ if (typeof address !== 'string' || address.length === 0) {
1308
+ return false
1309
+ }
1310
+
1311
+ // Must be exactly 64 characters (32 bytes as hex)
1312
+ if (address.length !== 64) {
1313
+ return false
1314
+ }
1315
+
1316
+ // Must be lowercase hex only (no 0x prefix)
1317
+ return /^[0-9a-f]{64}$/.test(address)
1318
+ }
1319
+
1320
+ /**
1321
+ * Check if a string is a valid NEAR account ID (named or implicit)
1322
+ *
1323
+ * Supports both:
1324
+ * - Named accounts: alice.near, bob.testnet
1325
+ * - Implicit accounts: 64 hex characters
1326
+ *
1327
+ * @param accountId - Account ID to validate
1328
+ * @returns true if valid NEAR account ID
1329
+ *
1330
+ * @example
1331
+ * ```typescript
1332
+ * isValidNearAccountId("alice.near") // true
1333
+ * isValidNearAccountId("bob.testnet") // true
1334
+ * isValidNearAccountId("ab12cd34...") // true (64 hex chars)
1335
+ * isValidNearAccountId("ALICE.near") // false (uppercase)
1336
+ * isValidNearAccountId("a") // false (too short)
1337
+ * ```
1338
+ */
1339
+ export function isValidNearAccountId(accountId: string): boolean {
1340
+ // Must be a string
1341
+ if (typeof accountId !== 'string' || accountId.length === 0) {
1342
+ return false
1343
+ }
1344
+
1345
+ // Check if it's a valid implicit account (64 hex chars)
1346
+ if (isValidNearImplicitAddress(accountId)) {
1347
+ return true
1348
+ }
1349
+
1350
+ // Named accounts: 2-64 characters, lowercase alphanumeric with . _ -
1351
+ // Must start and end with alphanumeric
1352
+ if (accountId.length < 2 || accountId.length > 64) {
1353
+ return false
1354
+ }
1355
+
1356
+ // NEAR account ID pattern
1357
+ const nearAccountPattern = /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/
1358
+ if (!nearAccountPattern.test(accountId)) {
1359
+ return false
1360
+ }
1361
+
1362
+ // Cannot have consecutive dots
1363
+ if (accountId.includes('..')) {
1364
+ return false
1365
+ }
1366
+
1367
+ return true
1368
+ }