@sip-protocol/sdk 0.7.0 → 0.7.1

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.
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Solana Stealth Payment Scanning and Claiming
3
+ *
4
+ * Scan the blockchain for incoming stealth payments and claim them.
5
+ */
6
+
7
+ import {
8
+ PublicKey,
9
+ Transaction,
10
+ Keypair,
11
+ } from '@solana/web3.js'
12
+ import {
13
+ getAssociatedTokenAddress,
14
+ createTransferInstruction,
15
+ getAccount,
16
+ TOKEN_PROGRAM_ID,
17
+ } from '@solana/spl-token'
18
+ import {
19
+ checkEd25519StealthAddress,
20
+ deriveEd25519StealthPrivateKey,
21
+ solanaAddressToEd25519PublicKey,
22
+ } from '../../stealth'
23
+ import type { StealthAddress, HexString } from '@sip-protocol/types'
24
+ import type {
25
+ SolanaScanParams,
26
+ SolanaScanResult,
27
+ SolanaClaimParams,
28
+ SolanaClaimResult,
29
+ } from './types'
30
+ import { parseAnnouncement } from './types'
31
+ import {
32
+ SIP_MEMO_PREFIX,
33
+ MEMO_PROGRAM_ID,
34
+ getExplorerUrl,
35
+ SOLANA_TOKEN_MINTS,
36
+ type SolanaCluster,
37
+ } from './constants'
38
+ import { hexToBytes, bytesToHex } from '@noble/hashes/utils'
39
+
40
+ /**
41
+ * Scan for incoming stealth payments
42
+ *
43
+ * Queries the Solana blockchain for transactions containing SIP announcements,
44
+ * then checks if any match the recipient's viewing key.
45
+ *
46
+ * @param params - Scanning parameters
47
+ * @returns Array of detected payments
48
+ *
49
+ * @example
50
+ * ```typescript
51
+ * const payments = await scanForPayments({
52
+ * connection,
53
+ * viewingPrivateKey: '0x...',
54
+ * spendingPublicKey: '0x...',
55
+ * fromSlot: 250000000,
56
+ * })
57
+ *
58
+ * for (const payment of payments) {
59
+ * console.log(`Found ${payment.amount} at ${payment.stealthAddress}`)
60
+ * }
61
+ * ```
62
+ */
63
+ export async function scanForPayments(
64
+ params: SolanaScanParams
65
+ ): Promise<SolanaScanResult[]> {
66
+ const {
67
+ connection,
68
+ viewingPrivateKey,
69
+ spendingPublicKey,
70
+ fromSlot,
71
+ toSlot,
72
+ limit = 100,
73
+ } = params
74
+
75
+ const results: SolanaScanResult[] = []
76
+
77
+ // Get recent signatures for the memo program
78
+ // This is a simplified approach - in production, you'd want to
79
+ // index announcements more efficiently
80
+ const memoProgram = new PublicKey(MEMO_PROGRAM_ID)
81
+
82
+ try {
83
+ // Get recent transactions mentioning the memo program
84
+ // Note: This is limited - a production implementation would use
85
+ // a dedicated indexer or getProgramAccounts
86
+ const signatures = await connection.getSignaturesForAddress(
87
+ memoProgram,
88
+ {
89
+ limit,
90
+ minContextSlot: fromSlot,
91
+ }
92
+ )
93
+
94
+ // Filter by slot range if specified
95
+ const filteredSignatures = toSlot
96
+ ? signatures.filter((s) => s.slot <= toSlot)
97
+ : signatures
98
+
99
+ // Process each transaction
100
+ for (const sigInfo of filteredSignatures) {
101
+ try {
102
+ const tx = await connection.getTransaction(sigInfo.signature, {
103
+ maxSupportedTransactionVersion: 0,
104
+ })
105
+
106
+ if (!tx?.meta?.logMessages) continue
107
+
108
+ // Look for SIP announcement in logs
109
+ for (const log of tx.meta.logMessages) {
110
+ if (!log.includes(SIP_MEMO_PREFIX)) continue
111
+
112
+ // Extract memo content from log
113
+ const memoMatch = log.match(/Program log: (.+)/)
114
+ if (!memoMatch) continue
115
+
116
+ const memoContent = memoMatch[1]
117
+ const announcement = parseAnnouncement(memoContent)
118
+ if (!announcement) continue
119
+
120
+ // Check if this payment is for us using view tag first
121
+ const ephemeralPubKeyHex = solanaAddressToEd25519PublicKey(
122
+ announcement.ephemeralPublicKey
123
+ )
124
+
125
+ // Construct stealth address object for checking
126
+ // viewTag is a number (0-255), parse from hex string
127
+ const viewTagNumber = parseInt(announcement.viewTag, 16)
128
+ const stealthAddressToCheck: StealthAddress = {
129
+ address: announcement.stealthAddress
130
+ ? solanaAddressToEd25519PublicKey(announcement.stealthAddress)
131
+ : ('0x' + '00'.repeat(32)) as HexString, // Will be computed
132
+ ephemeralPublicKey: ephemeralPubKeyHex,
133
+ viewTag: viewTagNumber,
134
+ }
135
+
136
+ // Check if this is our payment
137
+ const isOurs = checkEd25519StealthAddress(
138
+ stealthAddressToCheck,
139
+ viewingPrivateKey,
140
+ spendingPublicKey
141
+ )
142
+
143
+ if (isOurs) {
144
+ // Parse token transfer from transaction
145
+ const transferInfo = parseTokenTransfer(tx)
146
+ if (transferInfo) {
147
+ results.push({
148
+ stealthAddress: announcement.stealthAddress || '',
149
+ ephemeralPublicKey: announcement.ephemeralPublicKey,
150
+ amount: transferInfo.amount,
151
+ mint: transferInfo.mint,
152
+ tokenSymbol: getTokenSymbol(transferInfo.mint),
153
+ txSignature: sigInfo.signature,
154
+ slot: sigInfo.slot,
155
+ timestamp: sigInfo.blockTime || 0,
156
+ })
157
+ }
158
+ }
159
+ }
160
+ } catch (err) {
161
+ // Skip failed transaction parsing
162
+ console.warn(`Failed to parse tx ${sigInfo.signature}:`, err)
163
+ }
164
+ }
165
+ } catch (err) {
166
+ console.error('Scan failed:', err)
167
+ throw new Error(`Failed to scan for payments: ${err}`)
168
+ }
169
+
170
+ return results
171
+ }
172
+
173
+ /**
174
+ * Claim a stealth payment
175
+ *
176
+ * Derives the stealth private key and transfers funds to the destination.
177
+ *
178
+ * @param params - Claim parameters
179
+ * @returns Claim result with transaction signature
180
+ *
181
+ * @example
182
+ * ```typescript
183
+ * const result = await claimStealthPayment({
184
+ * connection,
185
+ * stealthAddress: '7xK9...',
186
+ * ephemeralPublicKey: '8yL0...',
187
+ * viewingPrivateKey: '0x...',
188
+ * spendingPrivateKey: '0x...',
189
+ * destinationAddress: 'myWallet...',
190
+ * mint: new PublicKey('EPjFWdd5...'),
191
+ * })
192
+ *
193
+ * console.log('Claimed! Tx:', result.txSignature)
194
+ * ```
195
+ */
196
+ export async function claimStealthPayment(
197
+ params: SolanaClaimParams
198
+ ): Promise<SolanaClaimResult> {
199
+ const {
200
+ connection,
201
+ stealthAddress,
202
+ ephemeralPublicKey,
203
+ viewingPrivateKey,
204
+ spendingPrivateKey,
205
+ destinationAddress,
206
+ mint,
207
+ } = params
208
+
209
+ // Convert addresses to hex for SDK functions
210
+ const stealthAddressHex = solanaAddressToEd25519PublicKey(stealthAddress)
211
+ const ephemeralPubKeyHex = solanaAddressToEd25519PublicKey(ephemeralPublicKey)
212
+
213
+ // Construct stealth address object
214
+ const stealthAddressObj: StealthAddress = {
215
+ address: stealthAddressHex,
216
+ ephemeralPublicKey: ephemeralPubKeyHex,
217
+ viewTag: 0, // Not needed for derivation
218
+ }
219
+
220
+ // Derive stealth private key
221
+ const recovery = deriveEd25519StealthPrivateKey(
222
+ stealthAddressObj,
223
+ spendingPrivateKey,
224
+ viewingPrivateKey
225
+ )
226
+
227
+ // Create Solana keypair from derived private key
228
+ // Note: ed25519 private keys in Solana are seeds, not raw scalars
229
+ // The SDK returns a scalar, so we need to handle this carefully
230
+ const stealthPrivKeyBytes = hexToBytes(recovery.privateKey.slice(2))
231
+
232
+ // Solana keypairs expect 64 bytes (32 byte seed + 32 byte public key)
233
+ // We construct this from the derived scalar
234
+ const stealthPubkey = new PublicKey(stealthAddress)
235
+ const stealthKeypair = Keypair.fromSecretKey(
236
+ new Uint8Array([...stealthPrivKeyBytes, ...stealthPubkey.toBytes()])
237
+ )
238
+
239
+ // Get token accounts
240
+ const stealthATA = await getAssociatedTokenAddress(
241
+ mint,
242
+ stealthPubkey,
243
+ true
244
+ )
245
+
246
+ const destinationPubkey = new PublicKey(destinationAddress)
247
+ const destinationATA = await getAssociatedTokenAddress(
248
+ mint,
249
+ destinationPubkey
250
+ )
251
+
252
+ // Get balance
253
+ const stealthAccount = await getAccount(connection, stealthATA)
254
+ const amount = stealthAccount.amount
255
+
256
+ // Build transfer transaction
257
+ const transaction = new Transaction()
258
+
259
+ transaction.add(
260
+ createTransferInstruction(
261
+ stealthATA,
262
+ destinationATA,
263
+ stealthPubkey,
264
+ amount
265
+ )
266
+ )
267
+
268
+ // Get blockhash
269
+ const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash()
270
+ transaction.recentBlockhash = blockhash
271
+ transaction.lastValidBlockHeight = lastValidBlockHeight
272
+ transaction.feePayer = stealthPubkey // Stealth address pays fee
273
+
274
+ // Sign with stealth keypair
275
+ transaction.sign(stealthKeypair)
276
+
277
+ // Send transaction
278
+ const txSignature = await connection.sendRawTransaction(
279
+ transaction.serialize(),
280
+ {
281
+ skipPreflight: false,
282
+ preflightCommitment: 'confirmed',
283
+ }
284
+ )
285
+
286
+ // Wait for confirmation
287
+ await connection.confirmTransaction(
288
+ {
289
+ signature: txSignature,
290
+ blockhash,
291
+ lastValidBlockHeight,
292
+ },
293
+ 'confirmed'
294
+ )
295
+
296
+ // Detect cluster
297
+ const cluster = detectCluster(connection.rpcEndpoint)
298
+
299
+ return {
300
+ txSignature,
301
+ destinationAddress,
302
+ amount,
303
+ explorerUrl: getExplorerUrl(txSignature, cluster),
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Get token balance for a stealth address
309
+ */
310
+ export async function getStealthBalance(
311
+ connection: SolanaScanParams['connection'],
312
+ stealthAddress: string,
313
+ mint: PublicKey
314
+ ): Promise<bigint> {
315
+ try {
316
+ const stealthPubkey = new PublicKey(stealthAddress)
317
+ const ata = await getAssociatedTokenAddress(mint, stealthPubkey, true)
318
+ const account = await getAccount(connection, ata)
319
+ return account.amount
320
+ } catch {
321
+ return 0n
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Parse token transfer info from a transaction
327
+ */
328
+ function parseTokenTransfer(
329
+ tx: Awaited<ReturnType<typeof import('@solana/web3.js').Connection.prototype.getTransaction>>
330
+ ): { mint: string; amount: bigint } | null {
331
+ if (!tx?.meta?.postTokenBalances || !tx.meta.preTokenBalances) {
332
+ return null
333
+ }
334
+
335
+ // Find token balance changes
336
+ for (let i = 0; i < tx.meta.postTokenBalances.length; i++) {
337
+ const post = tx.meta.postTokenBalances[i]
338
+ const pre = tx.meta.preTokenBalances.find(
339
+ (p) => p.accountIndex === post.accountIndex
340
+ )
341
+
342
+ const postAmount = BigInt(post.uiTokenAmount.amount)
343
+ const preAmount = pre ? BigInt(pre.uiTokenAmount.amount) : 0n
344
+
345
+ if (postAmount > preAmount) {
346
+ return {
347
+ mint: post.mint,
348
+ amount: postAmount - preAmount,
349
+ }
350
+ }
351
+ }
352
+
353
+ return null
354
+ }
355
+
356
+ /**
357
+ * Get token symbol from mint address
358
+ */
359
+ function getTokenSymbol(mint: string): string | undefined {
360
+ for (const [symbol, address] of Object.entries(SOLANA_TOKEN_MINTS)) {
361
+ if (address === mint) {
362
+ return symbol
363
+ }
364
+ }
365
+ return undefined
366
+ }
367
+
368
+ /**
369
+ * Detect Solana cluster from RPC endpoint
370
+ */
371
+ function detectCluster(endpoint: string): SolanaCluster {
372
+ if (endpoint.includes('devnet')) {
373
+ return 'devnet'
374
+ }
375
+ if (endpoint.includes('testnet')) {
376
+ return 'testnet'
377
+ }
378
+ if (endpoint.includes('localhost') || endpoint.includes('127.0.0.1')) {
379
+ return 'localnet'
380
+ }
381
+ return 'mainnet-beta'
382
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Solana Private SPL Token Transfer
3
+ *
4
+ * Send SPL tokens to a stealth address with on-chain announcement.
5
+ * Uses ed25519 stealth addresses for Solana-native privacy.
6
+ */
7
+
8
+ import {
9
+ PublicKey,
10
+ Transaction,
11
+ TransactionInstruction,
12
+ SystemProgram,
13
+ LAMPORTS_PER_SOL,
14
+ } from '@solana/web3.js'
15
+ import {
16
+ getAssociatedTokenAddress,
17
+ createAssociatedTokenAccountInstruction,
18
+ createTransferInstruction,
19
+ TOKEN_PROGRAM_ID,
20
+ ASSOCIATED_TOKEN_PROGRAM_ID,
21
+ getAccount,
22
+ } from '@solana/spl-token'
23
+ import {
24
+ generateEd25519StealthAddress,
25
+ ed25519PublicKeyToSolanaAddress,
26
+ } from '../../stealth'
27
+ import type { StealthMetaAddress } from '@sip-protocol/types'
28
+ import type {
29
+ SolanaPrivateTransferParams,
30
+ SolanaPrivateTransferResult,
31
+ } from './types'
32
+ import { createAnnouncementMemo } from './types'
33
+ import {
34
+ MEMO_PROGRAM_ID,
35
+ getExplorerUrl,
36
+ ESTIMATED_TX_FEE_LAMPORTS,
37
+ type SolanaCluster,
38
+ } from './constants'
39
+ import { bytesToHex } from '@noble/hashes/utils'
40
+
41
+ /**
42
+ * Send SPL tokens privately to a stealth address
43
+ *
44
+ * This function:
45
+ * 1. Generates a one-time stealth address from recipient's meta-address
46
+ * 2. Creates/gets Associated Token Account for the stealth address
47
+ * 3. Transfers tokens to the stealth address
48
+ * 4. Adds memo with ephemeral key for recipient scanning
49
+ *
50
+ * @param params - Transfer parameters
51
+ * @returns Transfer result with stealth address and explorer URL
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * import { sendPrivateSPLTransfer } from '@sip-protocol/sdk'
56
+ *
57
+ * const result = await sendPrivateSPLTransfer({
58
+ * connection,
59
+ * sender: wallet.publicKey,
60
+ * senderTokenAccount: senderATA,
61
+ * recipientMetaAddress: {
62
+ * chain: 'solana',
63
+ * spendingKey: '0x...',
64
+ * viewingKey: '0x...',
65
+ * },
66
+ * mint: new PublicKey('EPjFWdd5...'), // USDC
67
+ * amount: 5_000_000n, // 5 USDC (6 decimals)
68
+ * signTransaction: wallet.signTransaction,
69
+ * })
70
+ *
71
+ * console.log('Sent to:', result.stealthAddress)
72
+ * console.log('View on Solscan:', result.explorerUrl)
73
+ * ```
74
+ */
75
+ export async function sendPrivateSPLTransfer(
76
+ params: SolanaPrivateTransferParams
77
+ ): Promise<SolanaPrivateTransferResult> {
78
+ const {
79
+ connection,
80
+ sender,
81
+ senderTokenAccount,
82
+ recipientMetaAddress,
83
+ mint,
84
+ amount,
85
+ signTransaction,
86
+ } = params
87
+
88
+ // Validate recipient meta-address is for Solana
89
+ if (recipientMetaAddress.chain !== 'solana') {
90
+ throw new Error(
91
+ `Invalid chain: expected 'solana', got '${recipientMetaAddress.chain}'`
92
+ )
93
+ }
94
+
95
+ // 1. Generate stealth address from recipient's meta-address
96
+ const { stealthAddress } = generateEd25519StealthAddress(recipientMetaAddress)
97
+
98
+ // Convert to Solana PublicKey (base58)
99
+ const stealthAddressBase58 = ed25519PublicKeyToSolanaAddress(stealthAddress.address)
100
+ const stealthPubkey = new PublicKey(stealthAddressBase58)
101
+
102
+ // Convert ephemeral public key to base58
103
+ const ephemeralPubkeyBase58 = ed25519PublicKeyToSolanaAddress(
104
+ stealthAddress.ephemeralPublicKey
105
+ )
106
+
107
+ // 2. Get or create Associated Token Account for stealth address
108
+ const stealthATA = await getAssociatedTokenAddress(
109
+ mint,
110
+ stealthPubkey,
111
+ true // allowOwnerOffCurve - stealth addresses may be off-curve
112
+ )
113
+
114
+ // Build transaction
115
+ const transaction = new Transaction()
116
+
117
+ // Check if stealth ATA exists
118
+ let stealthATAExists = false
119
+ try {
120
+ await getAccount(connection, stealthATA)
121
+ stealthATAExists = true
122
+ } catch {
123
+ // Account doesn't exist, we'll create it
124
+ stealthATAExists = false
125
+ }
126
+
127
+ // Create ATA if it doesn't exist
128
+ if (!stealthATAExists) {
129
+ transaction.add(
130
+ createAssociatedTokenAccountInstruction(
131
+ sender, // payer
132
+ stealthATA, // associatedToken
133
+ stealthPubkey, // owner
134
+ mint, // mint
135
+ TOKEN_PROGRAM_ID,
136
+ ASSOCIATED_TOKEN_PROGRAM_ID
137
+ )
138
+ )
139
+ }
140
+
141
+ // 3. Add SPL transfer instruction
142
+ transaction.add(
143
+ createTransferInstruction(
144
+ senderTokenAccount, // source
145
+ stealthATA, // destination
146
+ sender, // owner
147
+ amount // amount
148
+ )
149
+ )
150
+
151
+ // 4. Add memo with announcement for recipient scanning
152
+ // Format: SIP:1:<ephemeral_pubkey_base58>:<view_tag_hex>
153
+ // viewTag is a number (0-255), convert to 2-char hex
154
+ const viewTagHex = stealthAddress.viewTag.toString(16).padStart(2, '0')
155
+ const memoContent = createAnnouncementMemo(
156
+ ephemeralPubkeyBase58,
157
+ viewTagHex,
158
+ stealthAddressBase58
159
+ )
160
+
161
+ const memoInstruction = new TransactionInstruction({
162
+ keys: [],
163
+ programId: new PublicKey(MEMO_PROGRAM_ID),
164
+ data: Buffer.from(memoContent, 'utf-8'),
165
+ })
166
+ transaction.add(memoInstruction)
167
+
168
+ // 5. Get recent blockhash and sign
169
+ const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash()
170
+ transaction.recentBlockhash = blockhash
171
+ transaction.lastValidBlockHeight = lastValidBlockHeight
172
+ transaction.feePayer = sender
173
+
174
+ // Sign the transaction
175
+ const signedTx = await signTransaction(transaction)
176
+
177
+ // 6. Send and confirm
178
+ const txSignature = await connection.sendRawTransaction(signedTx.serialize(), {
179
+ skipPreflight: false,
180
+ preflightCommitment: 'confirmed',
181
+ })
182
+
183
+ // Wait for confirmation
184
+ await connection.confirmTransaction(
185
+ {
186
+ signature: txSignature,
187
+ blockhash,
188
+ lastValidBlockHeight,
189
+ },
190
+ 'confirmed'
191
+ )
192
+
193
+ // Determine cluster from connection endpoint
194
+ const cluster = detectCluster(connection.rpcEndpoint)
195
+
196
+ return {
197
+ txSignature,
198
+ stealthAddress: stealthAddressBase58,
199
+ ephemeralPublicKey: ephemeralPubkeyBase58,
200
+ viewTag: viewTagHex,
201
+ explorerUrl: getExplorerUrl(txSignature, cluster),
202
+ cluster,
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Estimate transaction fee for a private transfer
208
+ *
209
+ * @param connection - Solana RPC connection
210
+ * @param needsATACreation - Whether ATA needs to be created
211
+ * @returns Estimated fee in lamports
212
+ */
213
+ export async function estimatePrivateTransferFee(
214
+ connection: Parameters<typeof sendPrivateSPLTransfer>[0]['connection'],
215
+ needsATACreation: boolean = true
216
+ ): Promise<bigint> {
217
+ // Base fee for transaction
218
+ let fee = ESTIMATED_TX_FEE_LAMPORTS
219
+
220
+ // Add rent for ATA creation if needed
221
+ if (needsATACreation) {
222
+ const rentExemption = await connection.getMinimumBalanceForRentExemption(165)
223
+ fee += BigInt(rentExemption)
224
+ }
225
+
226
+ return fee
227
+ }
228
+
229
+ /**
230
+ * Check if a stealth address already has an Associated Token Account
231
+ *
232
+ * @param connection - Solana RPC connection
233
+ * @param stealthAddress - Stealth address (base58)
234
+ * @param mint - Token mint
235
+ * @returns True if ATA exists
236
+ */
237
+ export async function hasTokenAccount(
238
+ connection: Parameters<typeof sendPrivateSPLTransfer>[0]['connection'],
239
+ stealthAddress: string,
240
+ mint: PublicKey
241
+ ): Promise<boolean> {
242
+ try {
243
+ const stealthPubkey = new PublicKey(stealthAddress)
244
+ const ata = await getAssociatedTokenAddress(mint, stealthPubkey, true)
245
+ await getAccount(connection, ata)
246
+ return true
247
+ } catch {
248
+ return false
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Detect Solana cluster from RPC endpoint
254
+ */
255
+ function detectCluster(endpoint: string): SolanaCluster {
256
+ if (endpoint.includes('devnet')) {
257
+ return 'devnet'
258
+ }
259
+ if (endpoint.includes('testnet')) {
260
+ return 'testnet'
261
+ }
262
+ if (endpoint.includes('localhost') || endpoint.includes('127.0.0.1')) {
263
+ return 'localnet'
264
+ }
265
+ return 'mainnet-beta'
266
+ }