@sip-protocol/sdk 0.1.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 ADDED
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Stealth Address Generation for SIP Protocol
3
+ *
4
+ * Implements EIP-5564 style stealth addresses using secp256k1.
5
+ * Provides unlinkable one-time addresses for privacy-preserving transactions.
6
+ *
7
+ * Flow:
8
+ * 1. Recipient generates stealth meta-address (spending key P, viewing key Q)
9
+ * 2. Sender generates ephemeral keypair (r, R = r*G)
10
+ * 3. Sender computes shared secret: S = r * P
11
+ * 4. Sender derives stealth address: A = Q + hash(S)*G
12
+ * 5. Recipient scans: for each R, compute S = p * R, check if A matches
13
+ */
14
+
15
+ import { secp256k1 } from '@noble/curves/secp256k1'
16
+ import { sha256 } from '@noble/hashes/sha256'
17
+ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils'
18
+ import type {
19
+ StealthMetaAddress,
20
+ StealthAddress,
21
+ StealthAddressRecovery,
22
+ ChainId,
23
+ HexString,
24
+ } from '@sip-protocol/types'
25
+ import { ValidationError } from './errors'
26
+ import {
27
+ isValidChainId,
28
+ isValidHex,
29
+ isValidCompressedPublicKey,
30
+ isValidPrivateKey,
31
+ } from './validation'
32
+
33
+ /**
34
+ * Generate a new stealth meta-address keypair
35
+ *
36
+ * @param chain - Target chain for the addresses
37
+ * @param label - Optional human-readable label
38
+ * @returns Stealth meta-address and private keys
39
+ * @throws {ValidationError} If chain is invalid
40
+ */
41
+ export function generateStealthMetaAddress(
42
+ chain: ChainId,
43
+ label?: string,
44
+ ): {
45
+ metaAddress: StealthMetaAddress
46
+ spendingPrivateKey: HexString
47
+ viewingPrivateKey: HexString
48
+ } {
49
+ // Validate chain
50
+ if (!isValidChainId(chain)) {
51
+ throw new ValidationError(
52
+ `invalid chain '${chain}', must be one of: solana, ethereum, near, zcash, polygon, arbitrum, optimism, base`,
53
+ 'chain'
54
+ )
55
+ }
56
+
57
+ // Generate random private keys
58
+ const spendingPrivateKey = randomBytes(32)
59
+ const viewingPrivateKey = randomBytes(32)
60
+
61
+ // Derive public keys
62
+ const spendingKey = secp256k1.getPublicKey(spendingPrivateKey, true)
63
+ const viewingKey = secp256k1.getPublicKey(viewingPrivateKey, true)
64
+
65
+ return {
66
+ metaAddress: {
67
+ spendingKey: `0x${bytesToHex(spendingKey)}` as HexString,
68
+ viewingKey: `0x${bytesToHex(viewingKey)}` as HexString,
69
+ chain,
70
+ label,
71
+ },
72
+ spendingPrivateKey: `0x${bytesToHex(spendingPrivateKey)}` as HexString,
73
+ viewingPrivateKey: `0x${bytesToHex(viewingPrivateKey)}` as HexString,
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Validate a StealthMetaAddress object
79
+ */
80
+ function validateStealthMetaAddress(
81
+ metaAddress: StealthMetaAddress,
82
+ field: string = 'recipientMetaAddress'
83
+ ): void {
84
+ if (!metaAddress || typeof metaAddress !== 'object') {
85
+ throw new ValidationError('must be an object', field)
86
+ }
87
+
88
+ // Validate chain
89
+ if (!isValidChainId(metaAddress.chain)) {
90
+ throw new ValidationError(
91
+ `invalid chain '${metaAddress.chain}'`,
92
+ `${field}.chain`
93
+ )
94
+ }
95
+
96
+ // Validate spending key
97
+ if (!isValidCompressedPublicKey(metaAddress.spendingKey)) {
98
+ throw new ValidationError(
99
+ 'spendingKey must be a valid compressed secp256k1 public key (33 bytes, starting with 02 or 03)',
100
+ `${field}.spendingKey`
101
+ )
102
+ }
103
+
104
+ // Validate viewing key
105
+ if (!isValidCompressedPublicKey(metaAddress.viewingKey)) {
106
+ throw new ValidationError(
107
+ 'viewingKey must be a valid compressed secp256k1 public key (33 bytes, starting with 02 or 03)',
108
+ `${field}.viewingKey`
109
+ )
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Generate a one-time stealth address for a recipient
115
+ *
116
+ * @param recipientMetaAddress - Recipient's published stealth meta-address
117
+ * @returns Stealth address data (address + ephemeral key for publication)
118
+ * @throws {ValidationError} If recipientMetaAddress is invalid
119
+ */
120
+ export function generateStealthAddress(
121
+ recipientMetaAddress: StealthMetaAddress,
122
+ ): {
123
+ stealthAddress: StealthAddress
124
+ sharedSecret: HexString
125
+ } {
126
+ // Validate input
127
+ validateStealthMetaAddress(recipientMetaAddress)
128
+
129
+ // Generate ephemeral keypair
130
+ const ephemeralPrivateKey = randomBytes(32)
131
+ const ephemeralPublicKey = secp256k1.getPublicKey(ephemeralPrivateKey, true)
132
+
133
+ // Parse recipient's keys (remove 0x prefix)
134
+ const spendingKeyBytes = hexToBytes(recipientMetaAddress.spendingKey.slice(2))
135
+ const viewingKeyBytes = hexToBytes(recipientMetaAddress.viewingKey.slice(2))
136
+
137
+ // Compute shared secret: S = r * P (ephemeral private * spending public)
138
+ const sharedSecretPoint = secp256k1.getSharedSecret(
139
+ ephemeralPrivateKey,
140
+ spendingKeyBytes,
141
+ )
142
+
143
+ // Hash the shared secret for use as a scalar
144
+ const sharedSecretHash = sha256(sharedSecretPoint)
145
+
146
+ // Compute stealth address: A = Q + hash(S)*G
147
+ // First get hash(S)*G
148
+ const hashTimesG = secp256k1.getPublicKey(sharedSecretHash, true)
149
+
150
+ // Then add to viewing key Q
151
+ const viewingKeyPoint = secp256k1.ProjectivePoint.fromHex(viewingKeyBytes)
152
+ const hashTimesGPoint = secp256k1.ProjectivePoint.fromHex(hashTimesG)
153
+ const stealthPoint = viewingKeyPoint.add(hashTimesGPoint)
154
+ const stealthAddressBytes = stealthPoint.toRawBytes(true)
155
+
156
+ // Compute view tag (first byte of hash for efficient scanning)
157
+ const viewTag = sharedSecretHash[0]
158
+
159
+ return {
160
+ stealthAddress: {
161
+ address: `0x${bytesToHex(stealthAddressBytes)}` as HexString,
162
+ ephemeralPublicKey: `0x${bytesToHex(ephemeralPublicKey)}` as HexString,
163
+ viewTag,
164
+ },
165
+ sharedSecret: `0x${bytesToHex(sharedSecretHash)}` as HexString,
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Validate a StealthAddress object
171
+ */
172
+ function validateStealthAddress(
173
+ stealthAddress: StealthAddress,
174
+ field: string = 'stealthAddress'
175
+ ): void {
176
+ if (!stealthAddress || typeof stealthAddress !== 'object') {
177
+ throw new ValidationError('must be an object', field)
178
+ }
179
+
180
+ // Validate address (compressed public key)
181
+ if (!isValidCompressedPublicKey(stealthAddress.address)) {
182
+ throw new ValidationError(
183
+ 'address must be a valid compressed secp256k1 public key',
184
+ `${field}.address`
185
+ )
186
+ }
187
+
188
+ // Validate ephemeral public key
189
+ if (!isValidCompressedPublicKey(stealthAddress.ephemeralPublicKey)) {
190
+ throw new ValidationError(
191
+ 'ephemeralPublicKey must be a valid compressed secp256k1 public key',
192
+ `${field}.ephemeralPublicKey`
193
+ )
194
+ }
195
+
196
+ // Validate view tag (0-255)
197
+ if (typeof stealthAddress.viewTag !== 'number' ||
198
+ !Number.isInteger(stealthAddress.viewTag) ||
199
+ stealthAddress.viewTag < 0 ||
200
+ stealthAddress.viewTag > 255) {
201
+ throw new ValidationError(
202
+ 'viewTag must be an integer between 0 and 255',
203
+ `${field}.viewTag`
204
+ )
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Derive the private key for a stealth address (for recipient to claim funds)
210
+ *
211
+ * @param stealthAddress - The stealth address to recover
212
+ * @param spendingPrivateKey - Recipient's spending private key
213
+ * @param viewingPrivateKey - Recipient's viewing private key
214
+ * @returns Recovery data including derived private key
215
+ * @throws {ValidationError} If any input is invalid
216
+ */
217
+ export function deriveStealthPrivateKey(
218
+ stealthAddress: StealthAddress,
219
+ spendingPrivateKey: HexString,
220
+ viewingPrivateKey: HexString,
221
+ ): StealthAddressRecovery {
222
+ // Validate stealth address
223
+ validateStealthAddress(stealthAddress)
224
+
225
+ // Validate private keys
226
+ if (!isValidPrivateKey(spendingPrivateKey)) {
227
+ throw new ValidationError(
228
+ 'must be a valid 32-byte hex string',
229
+ 'spendingPrivateKey'
230
+ )
231
+ }
232
+
233
+ if (!isValidPrivateKey(viewingPrivateKey)) {
234
+ throw new ValidationError(
235
+ 'must be a valid 32-byte hex string',
236
+ 'viewingPrivateKey'
237
+ )
238
+ }
239
+
240
+ // Parse keys
241
+ const spendingPrivBytes = hexToBytes(spendingPrivateKey.slice(2))
242
+ const viewingPrivBytes = hexToBytes(viewingPrivateKey.slice(2))
243
+ const ephemeralPubBytes = hexToBytes(stealthAddress.ephemeralPublicKey.slice(2))
244
+
245
+ // Compute shared secret: S = p * R (spending private * ephemeral public)
246
+ const sharedSecretPoint = secp256k1.getSharedSecret(
247
+ spendingPrivBytes,
248
+ ephemeralPubBytes,
249
+ )
250
+
251
+ // Hash the shared secret
252
+ const sharedSecretHash = sha256(sharedSecretPoint)
253
+
254
+ // Derive stealth private key: q + hash(S) mod n
255
+ // Where q is the viewing private key
256
+ const viewingScalar = bytesToBigInt(viewingPrivBytes)
257
+ const hashScalar = bytesToBigInt(sharedSecretHash)
258
+ const stealthPrivateScalar = (viewingScalar + hashScalar) % secp256k1.CURVE.n
259
+
260
+ // Convert back to bytes
261
+ const stealthPrivateKey = bigIntToBytes(stealthPrivateScalar, 32)
262
+
263
+ return {
264
+ stealthAddress: stealthAddress.address,
265
+ ephemeralPublicKey: stealthAddress.ephemeralPublicKey,
266
+ privateKey: `0x${bytesToHex(stealthPrivateKey)}` as HexString,
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Check if a stealth address was intended for this recipient
272
+ * Uses view tag for efficient filtering before full computation
273
+ *
274
+ * @param stealthAddress - Stealth address to check
275
+ * @param spendingPrivateKey - Recipient's spending private key
276
+ * @param viewingPrivateKey - Recipient's viewing private key
277
+ * @returns true if this address belongs to the recipient
278
+ * @throws {ValidationError} If any input is invalid
279
+ */
280
+ export function checkStealthAddress(
281
+ stealthAddress: StealthAddress,
282
+ spendingPrivateKey: HexString,
283
+ viewingPrivateKey: HexString,
284
+ ): boolean {
285
+ // Validate stealth address
286
+ validateStealthAddress(stealthAddress)
287
+
288
+ // Validate private keys
289
+ if (!isValidPrivateKey(spendingPrivateKey)) {
290
+ throw new ValidationError(
291
+ 'must be a valid 32-byte hex string',
292
+ 'spendingPrivateKey'
293
+ )
294
+ }
295
+
296
+ if (!isValidPrivateKey(viewingPrivateKey)) {
297
+ throw new ValidationError(
298
+ 'must be a valid 32-byte hex string',
299
+ 'viewingPrivateKey'
300
+ )
301
+ }
302
+
303
+ // Parse keys
304
+ const spendingPrivBytes = hexToBytes(spendingPrivateKey.slice(2))
305
+ const viewingPrivBytes = hexToBytes(viewingPrivateKey.slice(2))
306
+ const ephemeralPubBytes = hexToBytes(stealthAddress.ephemeralPublicKey.slice(2))
307
+
308
+ // Quick check: compute shared secret and verify view tag first
309
+ const sharedSecretPoint = secp256k1.getSharedSecret(
310
+ spendingPrivBytes,
311
+ ephemeralPubBytes,
312
+ )
313
+ const sharedSecretHash = sha256(sharedSecretPoint)
314
+
315
+ // View tag check (optimization - reject quickly if doesn't match)
316
+ if (sharedSecretHash[0] !== stealthAddress.viewTag) {
317
+ return false
318
+ }
319
+
320
+ // Full check: derive the expected stealth address
321
+ const viewingScalar = bytesToBigInt(viewingPrivBytes)
322
+ const hashScalar = bytesToBigInt(sharedSecretHash)
323
+ const stealthPrivateScalar = (viewingScalar + hashScalar) % secp256k1.CURVE.n
324
+
325
+ // Compute expected public key from derived private key
326
+ const expectedPubKey = secp256k1.getPublicKey(
327
+ bigIntToBytes(stealthPrivateScalar, 32),
328
+ true,
329
+ )
330
+
331
+ // Compare with provided stealth address
332
+ const providedAddress = hexToBytes(stealthAddress.address.slice(2))
333
+
334
+ return bytesToHex(expectedPubKey) === bytesToHex(providedAddress)
335
+ }
336
+
337
+ /**
338
+ * Encode a stealth meta-address as a string
339
+ * Format: sip:{chain}:{spendingKey}:{viewingKey}
340
+ */
341
+ export function encodeStealthMetaAddress(metaAddress: StealthMetaAddress): string {
342
+ return `sip:${metaAddress.chain}:${metaAddress.spendingKey}:${metaAddress.viewingKey}`
343
+ }
344
+
345
+ /**
346
+ * Decode a stealth meta-address from a string
347
+ *
348
+ * @param encoded - Encoded stealth meta-address (format: sip:<chain>:<spendingKey>:<viewingKey>)
349
+ * @returns Decoded StealthMetaAddress
350
+ * @throws {ValidationError} If format is invalid or keys are malformed
351
+ */
352
+ export function decodeStealthMetaAddress(encoded: string): StealthMetaAddress {
353
+ if (typeof encoded !== 'string') {
354
+ throw new ValidationError('must be a string', 'encoded')
355
+ }
356
+
357
+ const parts = encoded.split(':')
358
+ if (parts.length < 4 || parts[0] !== 'sip') {
359
+ throw new ValidationError(
360
+ 'invalid format, expected: sip:<chain>:<spendingKey>:<viewingKey>',
361
+ 'encoded'
362
+ )
363
+ }
364
+
365
+ const [, chain, spendingKey, viewingKey] = parts
366
+
367
+ // Validate chain
368
+ if (!isValidChainId(chain)) {
369
+ throw new ValidationError(
370
+ `invalid chain '${chain}'`,
371
+ 'encoded.chain'
372
+ )
373
+ }
374
+
375
+ // Validate keys
376
+ if (!isValidCompressedPublicKey(spendingKey)) {
377
+ throw new ValidationError(
378
+ 'spendingKey must be a valid compressed secp256k1 public key',
379
+ 'encoded.spendingKey'
380
+ )
381
+ }
382
+
383
+ if (!isValidCompressedPublicKey(viewingKey)) {
384
+ throw new ValidationError(
385
+ 'viewingKey must be a valid compressed secp256k1 public key',
386
+ 'encoded.viewingKey'
387
+ )
388
+ }
389
+
390
+ return {
391
+ chain: chain as ChainId,
392
+ spendingKey: spendingKey as HexString,
393
+ viewingKey: viewingKey as HexString,
394
+ }
395
+ }
396
+
397
+ // ─── Utility Functions ──────────────────────────────────────────────────────
398
+
399
+ function bytesToBigInt(bytes: Uint8Array): bigint {
400
+ let result = 0n
401
+ for (const byte of bytes) {
402
+ result = (result << 8n) + BigInt(byte)
403
+ }
404
+ return result
405
+ }
406
+
407
+ function bigIntToBytes(value: bigint, length: number): Uint8Array {
408
+ const bytes = new Uint8Array(length)
409
+ for (let i = length - 1; i >= 0; i--) {
410
+ bytes[i] = Number(value & 0xffn)
411
+ value >>= 8n
412
+ }
413
+ return bytes
414
+ }