@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.
- package/dist/browser.d.mts +2 -1
- package/dist/browser.d.ts +2 -1
- package/dist/browser.js +5717 -4963
- package/dist/browser.mjs +94 -46
- package/dist/chunk-6WGN57S2.mjs +218 -0
- package/dist/chunk-DLDWZFYC.mjs +1495 -0
- package/dist/chunk-E6SZWREQ.mjs +57 -0
- package/dist/chunk-G33LB27A.mjs +16166 -0
- package/dist/chunk-HOR7PM3M.mjs +15 -0
- package/dist/chunk-L2K34JCU.mjs +1496 -0
- package/dist/chunk-SN4ZDTVW.mjs +16166 -0
- package/dist/constants-VOI7BSLK.mjs +27 -0
- package/dist/index-CHB3KuOB.d.mts +11859 -0
- package/dist/index-CzWPI6Le.d.ts +11859 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5684 -4933
- package/dist/index.mjs +91 -43
- package/dist/proofs/noir.mjs +4 -2
- package/dist/solana-5EMCTPTS.mjs +46 -0
- package/dist/solana-Q4NAVBTS.mjs +46 -0
- package/package.json +18 -16
- package/src/chains/solana/constants.ts +101 -0
- package/src/chains/solana/index.ts +87 -0
- package/src/chains/solana/scan.ts +382 -0
- package/src/chains/solana/transfer.ts +266 -0
- package/src/chains/solana/types.ts +169 -0
- package/src/executors/index.ts +18 -0
- package/src/executors/same-chain.ts +154 -0
- package/src/index.ts +58 -1
- package/src/sip.ts +127 -0
- package/LICENSE +0 -21
|
@@ -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
|
+
}
|