@sip-protocol/sdk 0.7.2 → 0.7.3
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 +1 -1
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +2926 -341
- package/dist/browser.mjs +48 -2
- package/dist/chunk-2XIVXWHA.mjs +1930 -0
- package/dist/chunk-3M3HNQCW.mjs +18253 -0
- package/dist/chunk-7RFRWDCW.mjs +1504 -0
- package/dist/chunk-F6F73W35.mjs +16166 -0
- package/dist/chunk-OFDBEIEK.mjs +16166 -0
- package/dist/chunk-SF7YSLF5.mjs +1515 -0
- package/dist/chunk-WWUSGOXE.mjs +17129 -0
- package/dist/index-8MQz13eJ.d.mts +13746 -0
- package/dist/index-B71aXVzk.d.ts +13264 -0
- package/dist/index-DIBZHOOQ.d.ts +13746 -0
- package/dist/index-pOIIuwfV.d.mts +13264 -0
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2911 -326
- package/dist/index.mjs +48 -2
- package/dist/solana-4O4K45VU.mjs +46 -0
- package/dist/solana-NDABAZ6P.mjs +56 -0
- package/dist/solana-ZYO63LY5.mjs +46 -0
- package/package.json +2 -2
- package/src/chains/solana/index.ts +24 -0
- package/src/chains/solana/providers/generic.ts +160 -0
- package/src/chains/solana/providers/helius.ts +249 -0
- package/src/chains/solana/providers/index.ts +54 -0
- package/src/chains/solana/providers/interface.ts +178 -0
- package/src/chains/solana/providers/webhook.ts +519 -0
- package/src/chains/solana/scan.ts +88 -8
- package/src/chains/solana/types.ts +20 -1
- package/src/compliance/index.ts +14 -0
- package/src/compliance/range-sas.ts +591 -0
- package/src/index.ts +99 -0
- package/src/privacy-backends/index.ts +86 -0
- package/src/privacy-backends/interface.ts +263 -0
- package/src/privacy-backends/privacycash-types.ts +278 -0
- package/src/privacy-backends/privacycash.ts +460 -0
- package/src/privacy-backends/registry.ts +278 -0
- package/src/privacy-backends/router.ts +346 -0
- package/src/privacy-backends/sip-native.ts +253 -0
- package/src/proofs/noir.ts +1 -1
- package/src/surveillance/algorithms/address-reuse.ts +143 -0
- package/src/surveillance/algorithms/cluster.ts +247 -0
- package/src/surveillance/algorithms/exchange.ts +295 -0
- package/src/surveillance/algorithms/temporal.ts +337 -0
- package/src/surveillance/analyzer.ts +442 -0
- package/src/surveillance/index.ts +64 -0
- package/src/surveillance/scoring.ts +372 -0
- package/src/surveillance/types.ts +264 -0
|
@@ -0,0 +1,1930 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ValidationError
|
|
3
|
+
} from "./chunk-6WGN57S2.mjs";
|
|
4
|
+
import {
|
|
5
|
+
ESTIMATED_TX_FEE_LAMPORTS,
|
|
6
|
+
MEMO_PROGRAM_ID,
|
|
7
|
+
SIP_MEMO_PREFIX,
|
|
8
|
+
SOLANA_TOKEN_MINTS,
|
|
9
|
+
getExplorerUrl
|
|
10
|
+
} from "./chunk-E6SZWREQ.mjs";
|
|
11
|
+
|
|
12
|
+
// src/chains/solana/types.ts
|
|
13
|
+
function parseAnnouncement(memo) {
|
|
14
|
+
if (!memo.startsWith("SIP:1:")) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const parts = memo.slice(6).split(":");
|
|
18
|
+
if (parts.length < 2) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
ephemeralPublicKey: parts[0],
|
|
23
|
+
viewTag: parts[1],
|
|
24
|
+
stealthAddress: parts[2]
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function createAnnouncementMemo(ephemeralPublicKey, viewTag, stealthAddress) {
|
|
28
|
+
const parts = ["SIP:1", ephemeralPublicKey, viewTag];
|
|
29
|
+
if (stealthAddress) {
|
|
30
|
+
parts.push(stealthAddress);
|
|
31
|
+
}
|
|
32
|
+
return parts.join(":");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/chains/solana/transfer.ts
|
|
36
|
+
import {
|
|
37
|
+
PublicKey,
|
|
38
|
+
Transaction,
|
|
39
|
+
TransactionInstruction
|
|
40
|
+
} from "@solana/web3.js";
|
|
41
|
+
import {
|
|
42
|
+
getAssociatedTokenAddress,
|
|
43
|
+
createAssociatedTokenAccountInstruction,
|
|
44
|
+
createTransferInstruction,
|
|
45
|
+
TOKEN_PROGRAM_ID,
|
|
46
|
+
ASSOCIATED_TOKEN_PROGRAM_ID,
|
|
47
|
+
getAccount
|
|
48
|
+
} from "@solana/spl-token";
|
|
49
|
+
|
|
50
|
+
// src/stealth.ts
|
|
51
|
+
import { secp256k1 } from "@noble/curves/secp256k1";
|
|
52
|
+
import { ed25519 } from "@noble/curves/ed25519";
|
|
53
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
54
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
55
|
+
import { keccak_256 } from "@noble/hashes/sha3";
|
|
56
|
+
import { bytesToHex, hexToBytes, randomBytes as randomBytes2 } from "@noble/hashes/utils";
|
|
57
|
+
|
|
58
|
+
// src/validation.ts
|
|
59
|
+
var VALID_CHAIN_IDS = [
|
|
60
|
+
"solana",
|
|
61
|
+
"ethereum",
|
|
62
|
+
"near",
|
|
63
|
+
"zcash",
|
|
64
|
+
"polygon",
|
|
65
|
+
"arbitrum",
|
|
66
|
+
"optimism",
|
|
67
|
+
"base",
|
|
68
|
+
"bitcoin",
|
|
69
|
+
"aptos",
|
|
70
|
+
"sui",
|
|
71
|
+
"cosmos",
|
|
72
|
+
"osmosis",
|
|
73
|
+
"injective",
|
|
74
|
+
"celestia",
|
|
75
|
+
"sei",
|
|
76
|
+
"dydx"
|
|
77
|
+
];
|
|
78
|
+
function isValidChainId(chain) {
|
|
79
|
+
return VALID_CHAIN_IDS.includes(chain);
|
|
80
|
+
}
|
|
81
|
+
function isValidPrivacyLevel(level) {
|
|
82
|
+
if (typeof level !== "string") return false;
|
|
83
|
+
return ["transparent", "shielded", "compliant"].includes(level);
|
|
84
|
+
}
|
|
85
|
+
function isValidHex(value) {
|
|
86
|
+
if (typeof value !== "string") return false;
|
|
87
|
+
if (!value.startsWith("0x")) return false;
|
|
88
|
+
const hex = value.slice(2);
|
|
89
|
+
if (hex.length === 0) return false;
|
|
90
|
+
return /^[0-9a-fA-F]+$/.test(hex);
|
|
91
|
+
}
|
|
92
|
+
function isValidHexLength(value, byteLength) {
|
|
93
|
+
if (!isValidHex(value)) return false;
|
|
94
|
+
const hex = value.slice(2);
|
|
95
|
+
return hex.length === byteLength * 2;
|
|
96
|
+
}
|
|
97
|
+
function isValidAmount(value) {
|
|
98
|
+
return typeof value === "bigint" && value > 0n;
|
|
99
|
+
}
|
|
100
|
+
function isNonNegativeAmount(value) {
|
|
101
|
+
return typeof value === "bigint" && value >= 0n;
|
|
102
|
+
}
|
|
103
|
+
function isValidSlippage(value) {
|
|
104
|
+
return typeof value === "number" && !isNaN(value) && value >= 0 && value < 1;
|
|
105
|
+
}
|
|
106
|
+
var STEALTH_META_ADDRESS_REGEX = /^sip:[a-z]+:0x[0-9a-fA-F]{64,66}:0x[0-9a-fA-F]{64,66}$/;
|
|
107
|
+
function isValidStealthMetaAddress(addr) {
|
|
108
|
+
if (typeof addr !== "string") return false;
|
|
109
|
+
return STEALTH_META_ADDRESS_REGEX.test(addr);
|
|
110
|
+
}
|
|
111
|
+
function isValidCompressedPublicKey(key) {
|
|
112
|
+
if (!isValidHexLength(key, 33)) return false;
|
|
113
|
+
const prefix = key.slice(2, 4);
|
|
114
|
+
return prefix === "02" || prefix === "03";
|
|
115
|
+
}
|
|
116
|
+
function isValidEd25519PublicKey(key) {
|
|
117
|
+
return isValidHexLength(key, 32);
|
|
118
|
+
}
|
|
119
|
+
function isValidPrivateKey(key) {
|
|
120
|
+
return isValidHexLength(key, 32);
|
|
121
|
+
}
|
|
122
|
+
function validateAsset(asset, field) {
|
|
123
|
+
if (!asset || typeof asset !== "object") {
|
|
124
|
+
throw new ValidationError("must be an object", field);
|
|
125
|
+
}
|
|
126
|
+
const a = asset;
|
|
127
|
+
if (!a.chain || !isValidChainId(a.chain)) {
|
|
128
|
+
throw new ValidationError(
|
|
129
|
+
`invalid chain '${a.chain}', must be one of: ${VALID_CHAIN_IDS.join(", ")}`,
|
|
130
|
+
`${field}.chain`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (typeof a.symbol !== "string" || a.symbol.length === 0) {
|
|
134
|
+
throw new ValidationError("symbol must be a non-empty string", `${field}.symbol`);
|
|
135
|
+
}
|
|
136
|
+
if (a.address !== null && !isValidHex(a.address)) {
|
|
137
|
+
throw new ValidationError("address must be null or valid hex string", `${field}.address`);
|
|
138
|
+
}
|
|
139
|
+
if (typeof a.decimals !== "number" || !Number.isInteger(a.decimals) || a.decimals < 0) {
|
|
140
|
+
throw new ValidationError("decimals must be a non-negative integer", `${field}.decimals`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function validateIntentInput(input, field = "input") {
|
|
144
|
+
if (!input || typeof input !== "object") {
|
|
145
|
+
throw new ValidationError("must be an object", field);
|
|
146
|
+
}
|
|
147
|
+
const i = input;
|
|
148
|
+
validateAsset(i.asset, `${field}.asset`);
|
|
149
|
+
if (!isValidAmount(i.amount)) {
|
|
150
|
+
throw new ValidationError(
|
|
151
|
+
"amount must be a positive bigint",
|
|
152
|
+
`${field}.amount`,
|
|
153
|
+
{ received: typeof i.amount, value: String(i.amount) }
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function validateIntentOutput(output, field = "output") {
|
|
158
|
+
if (!output || typeof output !== "object") {
|
|
159
|
+
throw new ValidationError("must be an object", field);
|
|
160
|
+
}
|
|
161
|
+
const o = output;
|
|
162
|
+
validateAsset(o.asset, `${field}.asset`);
|
|
163
|
+
if (!isNonNegativeAmount(o.minAmount)) {
|
|
164
|
+
throw new ValidationError(
|
|
165
|
+
"minAmount must be a non-negative bigint",
|
|
166
|
+
`${field}.minAmount`,
|
|
167
|
+
{ received: typeof o.minAmount, value: String(o.minAmount) }
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (!isValidSlippage(o.maxSlippage)) {
|
|
171
|
+
throw new ValidationError(
|
|
172
|
+
"maxSlippage must be a number between 0 and 1 (exclusive)",
|
|
173
|
+
`${field}.maxSlippage`,
|
|
174
|
+
{ received: o.maxSlippage }
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function validateCreateIntentParams(params) {
|
|
179
|
+
if (!params || typeof params !== "object") {
|
|
180
|
+
throw new ValidationError("params must be an object");
|
|
181
|
+
}
|
|
182
|
+
const p = params;
|
|
183
|
+
if (!p.input) {
|
|
184
|
+
throw new ValidationError("input is required", "input");
|
|
185
|
+
}
|
|
186
|
+
validateIntentInput(p.input, "input");
|
|
187
|
+
if (!p.output) {
|
|
188
|
+
throw new ValidationError("output is required", "output");
|
|
189
|
+
}
|
|
190
|
+
validateIntentOutput(p.output, "output");
|
|
191
|
+
if (!p.privacy) {
|
|
192
|
+
throw new ValidationError("privacy is required", "privacy");
|
|
193
|
+
}
|
|
194
|
+
if (!isValidPrivacyLevel(p.privacy)) {
|
|
195
|
+
throw new ValidationError(
|
|
196
|
+
`invalid privacy level '${p.privacy}', must be one of: transparent, shielded, compliant`,
|
|
197
|
+
"privacy"
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if ((p.privacy === "shielded" || p.privacy === "compliant") && p.recipientMetaAddress) {
|
|
201
|
+
if (!isValidStealthMetaAddress(p.recipientMetaAddress)) {
|
|
202
|
+
throw new ValidationError(
|
|
203
|
+
"invalid stealth meta-address format, expected: sip:<chain>:<spendingKey>:<viewingKey>",
|
|
204
|
+
"recipientMetaAddress"
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (p.privacy === "compliant" && !p.viewingKey) {
|
|
209
|
+
throw new ValidationError(
|
|
210
|
+
"viewingKey is required for compliant mode",
|
|
211
|
+
"viewingKey"
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (p.viewingKey && !isValidHex(p.viewingKey)) {
|
|
215
|
+
throw new ValidationError(
|
|
216
|
+
"viewingKey must be a valid hex string",
|
|
217
|
+
"viewingKey"
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (p.ttl !== void 0) {
|
|
221
|
+
if (typeof p.ttl !== "number" || !Number.isInteger(p.ttl) || p.ttl <= 0) {
|
|
222
|
+
throw new ValidationError(
|
|
223
|
+
"ttl must be a positive integer (seconds)",
|
|
224
|
+
"ttl",
|
|
225
|
+
{ received: p.ttl }
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function validateViewingKey(key, field = "viewingKey") {
|
|
231
|
+
if (!key || typeof key !== "string") {
|
|
232
|
+
throw new ValidationError("must be a string", field);
|
|
233
|
+
}
|
|
234
|
+
if (!isValidHex(key)) {
|
|
235
|
+
throw new ValidationError("must be a valid hex string with 0x prefix", field);
|
|
236
|
+
}
|
|
237
|
+
if (!isValidHexLength(key, 32)) {
|
|
238
|
+
throw new ValidationError("must be 32 bytes (64 hex characters)", field);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
var SECP256K1_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n;
|
|
242
|
+
function isValidScalar(value) {
|
|
243
|
+
return value > 0n && value < SECP256K1_ORDER;
|
|
244
|
+
}
|
|
245
|
+
function validateScalar(value, field) {
|
|
246
|
+
if (typeof value !== "bigint") {
|
|
247
|
+
throw new ValidationError("must be a bigint", field);
|
|
248
|
+
}
|
|
249
|
+
if (!isValidScalar(value)) {
|
|
250
|
+
throw new ValidationError(
|
|
251
|
+
"must be in range (0, curve order)",
|
|
252
|
+
field,
|
|
253
|
+
{ curveOrder: SECP256K1_ORDER.toString(16) }
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function isValidEvmAddress(address) {
|
|
258
|
+
if (typeof address !== "string") return false;
|
|
259
|
+
return /^0x[0-9a-fA-F]{40}$/.test(address);
|
|
260
|
+
}
|
|
261
|
+
function isValidSolanaAddressFormat(address) {
|
|
262
|
+
if (typeof address !== "string") return false;
|
|
263
|
+
return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(address);
|
|
264
|
+
}
|
|
265
|
+
function isValidNearAddressFormat(address) {
|
|
266
|
+
if (typeof address !== "string") return false;
|
|
267
|
+
if (/^[0-9a-f]{64}$/.test(address)) return true;
|
|
268
|
+
if (address.length < 2 || address.length > 64) return false;
|
|
269
|
+
if (!/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/.test(address)) return false;
|
|
270
|
+
if (address.includes("..")) return false;
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
function isValidCosmosAddressFormat(address) {
|
|
274
|
+
if (typeof address !== "string") return false;
|
|
275
|
+
if (address.length < 39 || address.length > 90) return false;
|
|
276
|
+
const bech32Pattern = /^[a-z]+1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{38,}$/;
|
|
277
|
+
return bech32Pattern.test(address);
|
|
278
|
+
}
|
|
279
|
+
function getChainAddressType(chain) {
|
|
280
|
+
switch (chain) {
|
|
281
|
+
case "ethereum":
|
|
282
|
+
case "polygon":
|
|
283
|
+
case "arbitrum":
|
|
284
|
+
case "optimism":
|
|
285
|
+
case "base":
|
|
286
|
+
return "evm";
|
|
287
|
+
case "solana":
|
|
288
|
+
return "solana";
|
|
289
|
+
case "near":
|
|
290
|
+
return "near";
|
|
291
|
+
case "zcash":
|
|
292
|
+
return "zcash";
|
|
293
|
+
case "cosmos":
|
|
294
|
+
case "osmosis":
|
|
295
|
+
case "injective":
|
|
296
|
+
case "celestia":
|
|
297
|
+
case "sei":
|
|
298
|
+
case "dydx":
|
|
299
|
+
return "cosmos";
|
|
300
|
+
default:
|
|
301
|
+
return "unknown";
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function validateAddressForChain(address, chain, field = "address") {
|
|
305
|
+
const addressType = getChainAddressType(chain);
|
|
306
|
+
switch (addressType) {
|
|
307
|
+
case "evm":
|
|
308
|
+
if (!isValidEvmAddress(address)) {
|
|
309
|
+
throw new ValidationError(
|
|
310
|
+
`Invalid address format for ${chain}. Expected EVM address (0x + 40 hex chars), got: ${address.slice(0, 20)}...`,
|
|
311
|
+
field,
|
|
312
|
+
{ chain, expectedFormat: "0x...", receivedFormat: address.startsWith("0x") ? "hex but wrong length" : "not hex" }
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
case "solana":
|
|
317
|
+
if (!isValidSolanaAddressFormat(address)) {
|
|
318
|
+
throw new ValidationError(
|
|
319
|
+
`Invalid address format for ${chain}. Expected Solana address (base58, 32-44 chars), got: ${address.slice(0, 20)}...`,
|
|
320
|
+
field,
|
|
321
|
+
{ chain, expectedFormat: "base58", receivedFormat: address.startsWith("0x") ? "looks like EVM" : "unknown" }
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
break;
|
|
325
|
+
case "near":
|
|
326
|
+
if (!isValidNearAddressFormat(address)) {
|
|
327
|
+
throw new ValidationError(
|
|
328
|
+
`Invalid address format for ${chain}. Expected NEAR account ID (named or implicit), got: ${address.slice(0, 20)}...`,
|
|
329
|
+
field,
|
|
330
|
+
{ chain, expectedFormat: "alice.near or 64 hex chars" }
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
break;
|
|
334
|
+
case "zcash":
|
|
335
|
+
if (!address || address.length === 0) {
|
|
336
|
+
throw new ValidationError(
|
|
337
|
+
`Invalid address format for ${chain}. Expected Zcash address.`,
|
|
338
|
+
field,
|
|
339
|
+
{ chain }
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
break;
|
|
343
|
+
case "cosmos":
|
|
344
|
+
if (!isValidCosmosAddressFormat(address)) {
|
|
345
|
+
throw new ValidationError(
|
|
346
|
+
`Invalid address format for ${chain}. Expected Cosmos bech32 address, got: ${address.slice(0, 20)}...`,
|
|
347
|
+
field,
|
|
348
|
+
{ chain, expectedFormat: "bech32" }
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
break;
|
|
352
|
+
default:
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function isAddressValidForChain(address, chain) {
|
|
357
|
+
try {
|
|
358
|
+
validateAddressForChain(address, chain);
|
|
359
|
+
return true;
|
|
360
|
+
} catch {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// src/secure-memory.ts
|
|
366
|
+
import { randomBytes } from "@noble/hashes/utils";
|
|
367
|
+
function secureWipe(buffer) {
|
|
368
|
+
if (!buffer || buffer.length === 0) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const random = randomBytes(buffer.length);
|
|
372
|
+
buffer.set(random);
|
|
373
|
+
buffer.fill(0);
|
|
374
|
+
}
|
|
375
|
+
async function withSecureBuffer(createSecret, useSecret) {
|
|
376
|
+
const secret = createSecret();
|
|
377
|
+
try {
|
|
378
|
+
return await useSecret(secret);
|
|
379
|
+
} finally {
|
|
380
|
+
secureWipe(secret);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function withSecureBufferSync(createSecret, useSecret) {
|
|
384
|
+
const secret = createSecret();
|
|
385
|
+
try {
|
|
386
|
+
return useSecret(secret);
|
|
387
|
+
} finally {
|
|
388
|
+
secureWipe(secret);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function secureWipeAll(...buffers) {
|
|
392
|
+
for (const buffer of buffers) {
|
|
393
|
+
if (buffer) {
|
|
394
|
+
secureWipe(buffer);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/stealth.ts
|
|
400
|
+
function generateStealthMetaAddress(chain, label) {
|
|
401
|
+
if (!isValidChainId(chain)) {
|
|
402
|
+
throw new ValidationError(
|
|
403
|
+
`invalid chain '${chain}', must be one of: solana, ethereum, near, zcash, polygon, arbitrum, optimism, base, bitcoin, aptos, sui, cosmos, osmosis, injective, celestia, sei, dydx`,
|
|
404
|
+
"chain"
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
if (isEd25519Chain(chain)) {
|
|
408
|
+
return generateEd25519StealthMetaAddress(chain, label);
|
|
409
|
+
}
|
|
410
|
+
const spendingPrivateKey = randomBytes2(32);
|
|
411
|
+
const viewingPrivateKey = randomBytes2(32);
|
|
412
|
+
try {
|
|
413
|
+
const spendingKey = secp256k1.getPublicKey(spendingPrivateKey, true);
|
|
414
|
+
const viewingKey = secp256k1.getPublicKey(viewingPrivateKey, true);
|
|
415
|
+
const result = {
|
|
416
|
+
metaAddress: {
|
|
417
|
+
spendingKey: `0x${bytesToHex(spendingKey)}`,
|
|
418
|
+
viewingKey: `0x${bytesToHex(viewingKey)}`,
|
|
419
|
+
chain,
|
|
420
|
+
label
|
|
421
|
+
},
|
|
422
|
+
spendingPrivateKey: `0x${bytesToHex(spendingPrivateKey)}`,
|
|
423
|
+
viewingPrivateKey: `0x${bytesToHex(viewingPrivateKey)}`
|
|
424
|
+
};
|
|
425
|
+
return result;
|
|
426
|
+
} finally {
|
|
427
|
+
secureWipeAll(spendingPrivateKey, viewingPrivateKey);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function validateStealthMetaAddress(metaAddress, field = "recipientMetaAddress") {
|
|
431
|
+
if (!metaAddress || typeof metaAddress !== "object") {
|
|
432
|
+
throw new ValidationError("must be an object", field);
|
|
433
|
+
}
|
|
434
|
+
if (!isValidChainId(metaAddress.chain)) {
|
|
435
|
+
throw new ValidationError(
|
|
436
|
+
`invalid chain '${metaAddress.chain}'`,
|
|
437
|
+
`${field}.chain`
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
const isEd25519 = isEd25519Chain(metaAddress.chain);
|
|
441
|
+
if (isEd25519) {
|
|
442
|
+
if (!isValidEd25519PublicKey(metaAddress.spendingKey)) {
|
|
443
|
+
throw new ValidationError(
|
|
444
|
+
"spendingKey must be a valid ed25519 public key (32 bytes)",
|
|
445
|
+
`${field}.spendingKey`
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
if (!isValidEd25519PublicKey(metaAddress.viewingKey)) {
|
|
449
|
+
throw new ValidationError(
|
|
450
|
+
"viewingKey must be a valid ed25519 public key (32 bytes)",
|
|
451
|
+
`${field}.viewingKey`
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
} else {
|
|
455
|
+
if (!isValidCompressedPublicKey(metaAddress.spendingKey)) {
|
|
456
|
+
throw new ValidationError(
|
|
457
|
+
"spendingKey must be a valid compressed secp256k1 public key (33 bytes, starting with 02 or 03)",
|
|
458
|
+
`${field}.spendingKey`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
if (!isValidCompressedPublicKey(metaAddress.viewingKey)) {
|
|
462
|
+
throw new ValidationError(
|
|
463
|
+
"viewingKey must be a valid compressed secp256k1 public key (33 bytes, starting with 02 or 03)",
|
|
464
|
+
`${field}.viewingKey`
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
function generateStealthAddress(recipientMetaAddress) {
|
|
470
|
+
validateStealthMetaAddress(recipientMetaAddress);
|
|
471
|
+
if (isEd25519Chain(recipientMetaAddress.chain)) {
|
|
472
|
+
return generateEd25519StealthAddress(recipientMetaAddress);
|
|
473
|
+
}
|
|
474
|
+
const ephemeralPrivateKey = randomBytes2(32);
|
|
475
|
+
try {
|
|
476
|
+
const ephemeralPublicKey = secp256k1.getPublicKey(ephemeralPrivateKey, true);
|
|
477
|
+
const spendingKeyBytes = hexToBytes(recipientMetaAddress.spendingKey.slice(2));
|
|
478
|
+
const viewingKeyBytes = hexToBytes(recipientMetaAddress.viewingKey.slice(2));
|
|
479
|
+
const sharedSecretPoint = secp256k1.getSharedSecret(
|
|
480
|
+
ephemeralPrivateKey,
|
|
481
|
+
spendingKeyBytes
|
|
482
|
+
);
|
|
483
|
+
const sharedSecretHash = sha256(sharedSecretPoint);
|
|
484
|
+
const hashTimesG = secp256k1.getPublicKey(sharedSecretHash, true);
|
|
485
|
+
const viewingKeyPoint = secp256k1.ProjectivePoint.fromHex(viewingKeyBytes);
|
|
486
|
+
const hashTimesGPoint = secp256k1.ProjectivePoint.fromHex(hashTimesG);
|
|
487
|
+
const stealthPoint = viewingKeyPoint.add(hashTimesGPoint);
|
|
488
|
+
const stealthAddressBytes = stealthPoint.toRawBytes(true);
|
|
489
|
+
const viewTag = sharedSecretHash[0];
|
|
490
|
+
return {
|
|
491
|
+
stealthAddress: {
|
|
492
|
+
address: `0x${bytesToHex(stealthAddressBytes)}`,
|
|
493
|
+
ephemeralPublicKey: `0x${bytesToHex(ephemeralPublicKey)}`,
|
|
494
|
+
viewTag
|
|
495
|
+
},
|
|
496
|
+
sharedSecret: `0x${bytesToHex(sharedSecretHash)}`
|
|
497
|
+
};
|
|
498
|
+
} finally {
|
|
499
|
+
secureWipe(ephemeralPrivateKey);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
function validateStealthAddress(stealthAddress, field = "stealthAddress") {
|
|
503
|
+
if (!stealthAddress || typeof stealthAddress !== "object") {
|
|
504
|
+
throw new ValidationError("must be an object", field);
|
|
505
|
+
}
|
|
506
|
+
if (!isValidCompressedPublicKey(stealthAddress.address)) {
|
|
507
|
+
throw new ValidationError(
|
|
508
|
+
"address must be a valid compressed secp256k1 public key",
|
|
509
|
+
`${field}.address`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
if (!isValidCompressedPublicKey(stealthAddress.ephemeralPublicKey)) {
|
|
513
|
+
throw new ValidationError(
|
|
514
|
+
"ephemeralPublicKey must be a valid compressed secp256k1 public key",
|
|
515
|
+
`${field}.ephemeralPublicKey`
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
if (typeof stealthAddress.viewTag !== "number" || !Number.isInteger(stealthAddress.viewTag) || stealthAddress.viewTag < 0 || stealthAddress.viewTag > 255) {
|
|
519
|
+
throw new ValidationError(
|
|
520
|
+
"viewTag must be an integer between 0 and 255",
|
|
521
|
+
`${field}.viewTag`
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
function deriveStealthPrivateKey(stealthAddress, spendingPrivateKey, viewingPrivateKey) {
|
|
526
|
+
validateStealthAddress(stealthAddress);
|
|
527
|
+
if (!isValidPrivateKey(spendingPrivateKey)) {
|
|
528
|
+
throw new ValidationError(
|
|
529
|
+
"must be a valid 32-byte hex string",
|
|
530
|
+
"spendingPrivateKey"
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
if (!isValidPrivateKey(viewingPrivateKey)) {
|
|
534
|
+
throw new ValidationError(
|
|
535
|
+
"must be a valid 32-byte hex string",
|
|
536
|
+
"viewingPrivateKey"
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
const spendingPrivBytes = hexToBytes(spendingPrivateKey.slice(2));
|
|
540
|
+
const viewingPrivBytes = hexToBytes(viewingPrivateKey.slice(2));
|
|
541
|
+
const ephemeralPubBytes = hexToBytes(stealthAddress.ephemeralPublicKey.slice(2));
|
|
542
|
+
try {
|
|
543
|
+
const sharedSecretPoint = secp256k1.getSharedSecret(
|
|
544
|
+
spendingPrivBytes,
|
|
545
|
+
ephemeralPubBytes
|
|
546
|
+
);
|
|
547
|
+
const sharedSecretHash = sha256(sharedSecretPoint);
|
|
548
|
+
const viewingScalar = bytesToBigInt(viewingPrivBytes);
|
|
549
|
+
const hashScalar = bytesToBigInt(sharedSecretHash);
|
|
550
|
+
const stealthPrivateScalar = (viewingScalar + hashScalar) % secp256k1.CURVE.n;
|
|
551
|
+
const stealthPrivateKey = bigIntToBytes(stealthPrivateScalar, 32);
|
|
552
|
+
const result = {
|
|
553
|
+
stealthAddress: stealthAddress.address,
|
|
554
|
+
ephemeralPublicKey: stealthAddress.ephemeralPublicKey,
|
|
555
|
+
privateKey: `0x${bytesToHex(stealthPrivateKey)}`
|
|
556
|
+
};
|
|
557
|
+
secureWipe(stealthPrivateKey);
|
|
558
|
+
return result;
|
|
559
|
+
} finally {
|
|
560
|
+
secureWipeAll(spendingPrivBytes, viewingPrivBytes);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
function checkStealthAddress(stealthAddress, spendingPrivateKey, viewingPrivateKey) {
|
|
564
|
+
validateStealthAddress(stealthAddress);
|
|
565
|
+
if (!isValidPrivateKey(spendingPrivateKey)) {
|
|
566
|
+
throw new ValidationError(
|
|
567
|
+
"must be a valid 32-byte hex string",
|
|
568
|
+
"spendingPrivateKey"
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
if (!isValidPrivateKey(viewingPrivateKey)) {
|
|
572
|
+
throw new ValidationError(
|
|
573
|
+
"must be a valid 32-byte hex string",
|
|
574
|
+
"viewingPrivateKey"
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
const spendingPrivBytes = hexToBytes(spendingPrivateKey.slice(2));
|
|
578
|
+
const viewingPrivBytes = hexToBytes(viewingPrivateKey.slice(2));
|
|
579
|
+
const ephemeralPubBytes = hexToBytes(stealthAddress.ephemeralPublicKey.slice(2));
|
|
580
|
+
try {
|
|
581
|
+
const sharedSecretPoint = secp256k1.getSharedSecret(
|
|
582
|
+
spendingPrivBytes,
|
|
583
|
+
ephemeralPubBytes
|
|
584
|
+
);
|
|
585
|
+
const sharedSecretHash = sha256(sharedSecretPoint);
|
|
586
|
+
if (sharedSecretHash[0] !== stealthAddress.viewTag) {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
const viewingScalar = bytesToBigInt(viewingPrivBytes);
|
|
590
|
+
const hashScalar = bytesToBigInt(sharedSecretHash);
|
|
591
|
+
const stealthPrivateScalar = (viewingScalar + hashScalar) % secp256k1.CURVE.n;
|
|
592
|
+
const derivedKeyBytes = bigIntToBytes(stealthPrivateScalar, 32);
|
|
593
|
+
const expectedPubKey = secp256k1.getPublicKey(derivedKeyBytes, true);
|
|
594
|
+
secureWipe(derivedKeyBytes);
|
|
595
|
+
const providedAddress = hexToBytes(stealthAddress.address.slice(2));
|
|
596
|
+
return bytesToHex(expectedPubKey) === bytesToHex(providedAddress);
|
|
597
|
+
} finally {
|
|
598
|
+
secureWipeAll(spendingPrivBytes, viewingPrivBytes);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
function encodeStealthMetaAddress(metaAddress) {
|
|
602
|
+
return `sip:${metaAddress.chain}:${metaAddress.spendingKey}:${metaAddress.viewingKey}`;
|
|
603
|
+
}
|
|
604
|
+
function decodeStealthMetaAddress(encoded) {
|
|
605
|
+
if (typeof encoded !== "string") {
|
|
606
|
+
throw new ValidationError("must be a string", "encoded");
|
|
607
|
+
}
|
|
608
|
+
const parts = encoded.split(":");
|
|
609
|
+
if (parts.length < 4 || parts[0] !== "sip") {
|
|
610
|
+
throw new ValidationError(
|
|
611
|
+
"invalid format, expected: sip:<chain>:<spendingKey>:<viewingKey>",
|
|
612
|
+
"encoded"
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
const [, chain, spendingKey, viewingKey] = parts;
|
|
616
|
+
if (!isValidChainId(chain)) {
|
|
617
|
+
throw new ValidationError(
|
|
618
|
+
`invalid chain '${chain}'`,
|
|
619
|
+
"encoded.chain"
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
const chainId = chain;
|
|
623
|
+
if (isEd25519Chain(chainId)) {
|
|
624
|
+
if (!isValidEd25519PublicKey(spendingKey)) {
|
|
625
|
+
throw new ValidationError(
|
|
626
|
+
"spendingKey must be a valid 32-byte ed25519 public key",
|
|
627
|
+
"encoded.spendingKey"
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
if (!isValidEd25519PublicKey(viewingKey)) {
|
|
631
|
+
throw new ValidationError(
|
|
632
|
+
"viewingKey must be a valid 32-byte ed25519 public key",
|
|
633
|
+
"encoded.viewingKey"
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
} else {
|
|
637
|
+
if (!isValidCompressedPublicKey(spendingKey)) {
|
|
638
|
+
throw new ValidationError(
|
|
639
|
+
"spendingKey must be a valid compressed secp256k1 public key",
|
|
640
|
+
"encoded.spendingKey"
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
if (!isValidCompressedPublicKey(viewingKey)) {
|
|
644
|
+
throw new ValidationError(
|
|
645
|
+
"viewingKey must be a valid compressed secp256k1 public key",
|
|
646
|
+
"encoded.viewingKey"
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
chain,
|
|
652
|
+
spendingKey,
|
|
653
|
+
viewingKey
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
function bytesToBigInt(bytes) {
|
|
657
|
+
let result = 0n;
|
|
658
|
+
for (const byte of bytes) {
|
|
659
|
+
result = (result << 8n) + BigInt(byte);
|
|
660
|
+
}
|
|
661
|
+
return result;
|
|
662
|
+
}
|
|
663
|
+
function bigIntToBytes(value, length) {
|
|
664
|
+
const bytes = new Uint8Array(length);
|
|
665
|
+
for (let i = length - 1; i >= 0; i--) {
|
|
666
|
+
bytes[i] = Number(value & 0xffn);
|
|
667
|
+
value >>= 8n;
|
|
668
|
+
}
|
|
669
|
+
return bytes;
|
|
670
|
+
}
|
|
671
|
+
function publicKeyToEthAddress(publicKey) {
|
|
672
|
+
const keyHex = publicKey.startsWith("0x") ? publicKey.slice(2) : publicKey;
|
|
673
|
+
const keyBytes = hexToBytes(keyHex);
|
|
674
|
+
let uncompressedBytes;
|
|
675
|
+
if (keyBytes.length === 33) {
|
|
676
|
+
const point = secp256k1.ProjectivePoint.fromHex(keyBytes);
|
|
677
|
+
uncompressedBytes = point.toRawBytes(false);
|
|
678
|
+
} else if (keyBytes.length === 65) {
|
|
679
|
+
uncompressedBytes = keyBytes;
|
|
680
|
+
} else {
|
|
681
|
+
throw new ValidationError(
|
|
682
|
+
`invalid public key length: ${keyBytes.length}, expected 33 (compressed) or 65 (uncompressed)`,
|
|
683
|
+
"publicKey"
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
const pubKeyWithoutPrefix = uncompressedBytes.slice(1);
|
|
687
|
+
const hash = keccak_256(pubKeyWithoutPrefix);
|
|
688
|
+
const addressBytes = hash.slice(-20);
|
|
689
|
+
return toChecksumAddress(`0x${bytesToHex(addressBytes)}`);
|
|
690
|
+
}
|
|
691
|
+
function toChecksumAddress(address) {
|
|
692
|
+
const addr = address.toLowerCase().replace("0x", "");
|
|
693
|
+
const hash = bytesToHex(keccak_256(new TextEncoder().encode(addr)));
|
|
694
|
+
let checksummed = "0x";
|
|
695
|
+
for (let i = 0; i < addr.length; i++) {
|
|
696
|
+
if (parseInt(hash[i], 16) >= 8) {
|
|
697
|
+
checksummed += addr[i].toUpperCase();
|
|
698
|
+
} else {
|
|
699
|
+
checksummed += addr[i];
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return checksummed;
|
|
703
|
+
}
|
|
704
|
+
var ED25519_ORDER = 2n ** 252n + 27742317777372353535851937790883648493n;
|
|
705
|
+
var ED25519_CHAINS = ["solana", "near", "aptos", "sui"];
|
|
706
|
+
function isEd25519Chain(chain) {
|
|
707
|
+
return ED25519_CHAINS.includes(chain);
|
|
708
|
+
}
|
|
709
|
+
function getCurveForChain(chain) {
|
|
710
|
+
return isEd25519Chain(chain) ? "ed25519" : "secp256k1";
|
|
711
|
+
}
|
|
712
|
+
function validateEd25519StealthMetaAddress(metaAddress, field = "recipientMetaAddress") {
|
|
713
|
+
if (!metaAddress || typeof metaAddress !== "object") {
|
|
714
|
+
throw new ValidationError("must be an object", field);
|
|
715
|
+
}
|
|
716
|
+
if (!isValidChainId(metaAddress.chain)) {
|
|
717
|
+
throw new ValidationError(
|
|
718
|
+
`invalid chain '${metaAddress.chain}'`,
|
|
719
|
+
`${field}.chain`
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
if (!isEd25519Chain(metaAddress.chain)) {
|
|
723
|
+
throw new ValidationError(
|
|
724
|
+
`chain '${metaAddress.chain}' does not use ed25519, use secp256k1 functions instead`,
|
|
725
|
+
`${field}.chain`
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
if (!isValidEd25519PublicKey(metaAddress.spendingKey)) {
|
|
729
|
+
throw new ValidationError(
|
|
730
|
+
"spendingKey must be a valid ed25519 public key (32 bytes)",
|
|
731
|
+
`${field}.spendingKey`
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
if (!isValidEd25519PublicKey(metaAddress.viewingKey)) {
|
|
735
|
+
throw new ValidationError(
|
|
736
|
+
"viewingKey must be a valid ed25519 public key (32 bytes)",
|
|
737
|
+
`${field}.viewingKey`
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
function validateEd25519StealthAddress(stealthAddress, field = "stealthAddress") {
|
|
742
|
+
if (!stealthAddress || typeof stealthAddress !== "object") {
|
|
743
|
+
throw new ValidationError("must be an object", field);
|
|
744
|
+
}
|
|
745
|
+
if (!isValidEd25519PublicKey(stealthAddress.address)) {
|
|
746
|
+
throw new ValidationError(
|
|
747
|
+
"address must be a valid ed25519 public key (32 bytes)",
|
|
748
|
+
`${field}.address`
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
if (!isValidEd25519PublicKey(stealthAddress.ephemeralPublicKey)) {
|
|
752
|
+
throw new ValidationError(
|
|
753
|
+
"ephemeralPublicKey must be a valid ed25519 public key (32 bytes)",
|
|
754
|
+
`${field}.ephemeralPublicKey`
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
if (typeof stealthAddress.viewTag !== "number" || !Number.isInteger(stealthAddress.viewTag) || stealthAddress.viewTag < 0 || stealthAddress.viewTag > 255) {
|
|
758
|
+
throw new ValidationError(
|
|
759
|
+
"viewTag must be an integer between 0 and 255",
|
|
760
|
+
`${field}.viewTag`
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
function getEd25519Scalar(privateKey) {
|
|
765
|
+
const hash = sha512(privateKey);
|
|
766
|
+
const scalar = hash.slice(0, 32);
|
|
767
|
+
scalar[0] &= 248;
|
|
768
|
+
scalar[31] &= 127;
|
|
769
|
+
scalar[31] |= 64;
|
|
770
|
+
return bytesToBigIntLE(scalar);
|
|
771
|
+
}
|
|
772
|
+
function bytesToBigIntLE(bytes) {
|
|
773
|
+
let result = 0n;
|
|
774
|
+
for (let i = bytes.length - 1; i >= 0; i--) {
|
|
775
|
+
result = (result << 8n) + BigInt(bytes[i]);
|
|
776
|
+
}
|
|
777
|
+
return result;
|
|
778
|
+
}
|
|
779
|
+
function bigIntToBytesLE(value, length) {
|
|
780
|
+
const bytes = new Uint8Array(length);
|
|
781
|
+
for (let i = 0; i < length; i++) {
|
|
782
|
+
bytes[i] = Number(value & 0xffn);
|
|
783
|
+
value >>= 8n;
|
|
784
|
+
}
|
|
785
|
+
return bytes;
|
|
786
|
+
}
|
|
787
|
+
function generateEd25519StealthMetaAddress(chain, label) {
|
|
788
|
+
if (!isValidChainId(chain)) {
|
|
789
|
+
throw new ValidationError(
|
|
790
|
+
`invalid chain '${chain}', must be one of: solana, ethereum, near, zcash, polygon, arbitrum, optimism, base, bitcoin, aptos, sui, cosmos, osmosis, injective, celestia, sei, dydx`,
|
|
791
|
+
"chain"
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
if (!isEd25519Chain(chain)) {
|
|
795
|
+
throw new ValidationError(
|
|
796
|
+
`chain '${chain}' does not use ed25519, use generateStealthMetaAddress() for secp256k1 chains`,
|
|
797
|
+
"chain"
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
const spendingPrivateKey = randomBytes2(32);
|
|
801
|
+
const viewingPrivateKey = randomBytes2(32);
|
|
802
|
+
try {
|
|
803
|
+
const spendingKey = ed25519.getPublicKey(spendingPrivateKey);
|
|
804
|
+
const viewingKey = ed25519.getPublicKey(viewingPrivateKey);
|
|
805
|
+
const result = {
|
|
806
|
+
metaAddress: {
|
|
807
|
+
spendingKey: `0x${bytesToHex(spendingKey)}`,
|
|
808
|
+
viewingKey: `0x${bytesToHex(viewingKey)}`,
|
|
809
|
+
chain,
|
|
810
|
+
label
|
|
811
|
+
},
|
|
812
|
+
spendingPrivateKey: `0x${bytesToHex(spendingPrivateKey)}`,
|
|
813
|
+
viewingPrivateKey: `0x${bytesToHex(viewingPrivateKey)}`
|
|
814
|
+
};
|
|
815
|
+
return result;
|
|
816
|
+
} finally {
|
|
817
|
+
secureWipeAll(spendingPrivateKey, viewingPrivateKey);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
function generateEd25519StealthAddress(recipientMetaAddress) {
|
|
821
|
+
validateEd25519StealthMetaAddress(recipientMetaAddress);
|
|
822
|
+
const ephemeralPrivateKey = randomBytes2(32);
|
|
823
|
+
try {
|
|
824
|
+
const ephemeralPublicKey = ed25519.getPublicKey(ephemeralPrivateKey);
|
|
825
|
+
const spendingKeyBytes = hexToBytes(recipientMetaAddress.spendingKey.slice(2));
|
|
826
|
+
const viewingKeyBytes = hexToBytes(recipientMetaAddress.viewingKey.slice(2));
|
|
827
|
+
const rawEphemeralScalar = getEd25519Scalar(ephemeralPrivateKey);
|
|
828
|
+
const ephemeralScalar = rawEphemeralScalar % ED25519_ORDER;
|
|
829
|
+
if (ephemeralScalar === 0n) {
|
|
830
|
+
throw new Error("CRITICAL: Zero ephemeral scalar after reduction - investigate RNG");
|
|
831
|
+
}
|
|
832
|
+
const spendingPoint = ed25519.ExtendedPoint.fromHex(spendingKeyBytes);
|
|
833
|
+
const sharedSecretPoint = spendingPoint.multiply(ephemeralScalar);
|
|
834
|
+
const sharedSecretHash = sha256(sharedSecretPoint.toRawBytes());
|
|
835
|
+
const hashScalar = bytesToBigInt(sharedSecretHash) % ED25519_ORDER;
|
|
836
|
+
if (hashScalar === 0n) {
|
|
837
|
+
throw new Error("CRITICAL: Zero hash scalar after reduction - investigate hash computation");
|
|
838
|
+
}
|
|
839
|
+
const hashTimesG = ed25519.ExtendedPoint.BASE.multiply(hashScalar);
|
|
840
|
+
const viewingPoint = ed25519.ExtendedPoint.fromHex(viewingKeyBytes);
|
|
841
|
+
const stealthPoint = viewingPoint.add(hashTimesG);
|
|
842
|
+
const stealthAddressBytes = stealthPoint.toRawBytes();
|
|
843
|
+
const viewTag = sharedSecretHash[0];
|
|
844
|
+
return {
|
|
845
|
+
stealthAddress: {
|
|
846
|
+
address: `0x${bytesToHex(stealthAddressBytes)}`,
|
|
847
|
+
ephemeralPublicKey: `0x${bytesToHex(ephemeralPublicKey)}`,
|
|
848
|
+
viewTag
|
|
849
|
+
},
|
|
850
|
+
sharedSecret: `0x${bytesToHex(sharedSecretHash)}`
|
|
851
|
+
};
|
|
852
|
+
} finally {
|
|
853
|
+
secureWipe(ephemeralPrivateKey);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
function deriveEd25519StealthPrivateKey(stealthAddress, spendingPrivateKey, viewingPrivateKey) {
|
|
857
|
+
validateEd25519StealthAddress(stealthAddress);
|
|
858
|
+
if (!isValidPrivateKey(spendingPrivateKey)) {
|
|
859
|
+
throw new ValidationError(
|
|
860
|
+
"must be a valid 32-byte hex string",
|
|
861
|
+
"spendingPrivateKey"
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
if (!isValidPrivateKey(viewingPrivateKey)) {
|
|
865
|
+
throw new ValidationError(
|
|
866
|
+
"must be a valid 32-byte hex string",
|
|
867
|
+
"viewingPrivateKey"
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
const spendingPrivBytes = hexToBytes(spendingPrivateKey.slice(2));
|
|
871
|
+
const viewingPrivBytes = hexToBytes(viewingPrivateKey.slice(2));
|
|
872
|
+
const ephemeralPubBytes = hexToBytes(stealthAddress.ephemeralPublicKey.slice(2));
|
|
873
|
+
try {
|
|
874
|
+
const rawSpendingScalar = getEd25519Scalar(spendingPrivBytes);
|
|
875
|
+
const spendingScalar = rawSpendingScalar % ED25519_ORDER;
|
|
876
|
+
if (spendingScalar === 0n) {
|
|
877
|
+
throw new Error("CRITICAL: Zero spending scalar after reduction - investigate key derivation");
|
|
878
|
+
}
|
|
879
|
+
const ephemeralPoint = ed25519.ExtendedPoint.fromHex(ephemeralPubBytes);
|
|
880
|
+
const sharedSecretPoint = ephemeralPoint.multiply(spendingScalar);
|
|
881
|
+
const sharedSecretHash = sha256(sharedSecretPoint.toRawBytes());
|
|
882
|
+
const rawViewingScalar = getEd25519Scalar(viewingPrivBytes);
|
|
883
|
+
const viewingScalar = rawViewingScalar % ED25519_ORDER;
|
|
884
|
+
if (viewingScalar === 0n) {
|
|
885
|
+
throw new Error("CRITICAL: Zero viewing scalar after reduction - investigate key derivation");
|
|
886
|
+
}
|
|
887
|
+
const hashScalar = bytesToBigInt(sharedSecretHash) % ED25519_ORDER;
|
|
888
|
+
if (hashScalar === 0n) {
|
|
889
|
+
throw new Error("CRITICAL: Zero hash scalar after reduction - investigate hash computation");
|
|
890
|
+
}
|
|
891
|
+
const stealthPrivateScalar = (viewingScalar + hashScalar) % ED25519_ORDER;
|
|
892
|
+
if (stealthPrivateScalar === 0n) {
|
|
893
|
+
throw new Error("CRITICAL: Zero stealth scalar after reduction - investigate key derivation");
|
|
894
|
+
}
|
|
895
|
+
const stealthPrivateKey = bigIntToBytesLE(stealthPrivateScalar, 32);
|
|
896
|
+
const result = {
|
|
897
|
+
stealthAddress: stealthAddress.address,
|
|
898
|
+
ephemeralPublicKey: stealthAddress.ephemeralPublicKey,
|
|
899
|
+
privateKey: `0x${bytesToHex(stealthPrivateKey)}`
|
|
900
|
+
};
|
|
901
|
+
secureWipe(stealthPrivateKey);
|
|
902
|
+
return result;
|
|
903
|
+
} finally {
|
|
904
|
+
secureWipeAll(spendingPrivBytes, viewingPrivBytes);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
function checkEd25519StealthAddress(stealthAddress, spendingPrivateKey, viewingPrivateKey) {
|
|
908
|
+
validateEd25519StealthAddress(stealthAddress);
|
|
909
|
+
if (!isValidPrivateKey(spendingPrivateKey)) {
|
|
910
|
+
throw new ValidationError(
|
|
911
|
+
"must be a valid 32-byte hex string",
|
|
912
|
+
"spendingPrivateKey"
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
if (!isValidPrivateKey(viewingPrivateKey)) {
|
|
916
|
+
throw new ValidationError(
|
|
917
|
+
"must be a valid 32-byte hex string",
|
|
918
|
+
"viewingPrivateKey"
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
const spendingPrivBytes = hexToBytes(spendingPrivateKey.slice(2));
|
|
922
|
+
const viewingPrivBytes = hexToBytes(viewingPrivateKey.slice(2));
|
|
923
|
+
const ephemeralPubBytes = hexToBytes(stealthAddress.ephemeralPublicKey.slice(2));
|
|
924
|
+
try {
|
|
925
|
+
const rawSpendingScalar = getEd25519Scalar(spendingPrivBytes);
|
|
926
|
+
const spendingScalar = rawSpendingScalar % ED25519_ORDER;
|
|
927
|
+
if (spendingScalar === 0n) {
|
|
928
|
+
throw new Error("CRITICAL: Zero spending scalar after reduction - investigate key derivation");
|
|
929
|
+
}
|
|
930
|
+
const ephemeralPoint = ed25519.ExtendedPoint.fromHex(ephemeralPubBytes);
|
|
931
|
+
const sharedSecretPoint = ephemeralPoint.multiply(spendingScalar);
|
|
932
|
+
const sharedSecretHash = sha256(sharedSecretPoint.toRawBytes());
|
|
933
|
+
if (sharedSecretHash[0] !== stealthAddress.viewTag) {
|
|
934
|
+
return false;
|
|
935
|
+
}
|
|
936
|
+
const rawViewingScalar = getEd25519Scalar(viewingPrivBytes);
|
|
937
|
+
const viewingScalar = rawViewingScalar % ED25519_ORDER;
|
|
938
|
+
if (viewingScalar === 0n) {
|
|
939
|
+
throw new Error("CRITICAL: Zero viewing scalar after reduction - investigate key derivation");
|
|
940
|
+
}
|
|
941
|
+
const hashScalar = bytesToBigInt(sharedSecretHash) % ED25519_ORDER;
|
|
942
|
+
if (hashScalar === 0n) {
|
|
943
|
+
throw new Error("CRITICAL: Zero hash scalar after reduction - investigate hash computation");
|
|
944
|
+
}
|
|
945
|
+
const stealthPrivateScalar = (viewingScalar + hashScalar) % ED25519_ORDER;
|
|
946
|
+
if (stealthPrivateScalar === 0n) {
|
|
947
|
+
throw new Error("CRITICAL: Zero stealth scalar after reduction - investigate key derivation");
|
|
948
|
+
}
|
|
949
|
+
const expectedPubKey = ed25519.ExtendedPoint.BASE.multiply(stealthPrivateScalar);
|
|
950
|
+
const expectedPubKeyBytes = expectedPubKey.toRawBytes();
|
|
951
|
+
const providedAddress = hexToBytes(stealthAddress.address.slice(2));
|
|
952
|
+
return bytesToHex(expectedPubKeyBytes) === bytesToHex(providedAddress);
|
|
953
|
+
} finally {
|
|
954
|
+
secureWipeAll(spendingPrivBytes, viewingPrivBytes);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
var BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
958
|
+
function bytesToBase58(bytes) {
|
|
959
|
+
let leadingZeros = 0;
|
|
960
|
+
for (let i = 0; i < bytes.length && bytes[i] === 0; i++) {
|
|
961
|
+
leadingZeros++;
|
|
962
|
+
}
|
|
963
|
+
let value = 0n;
|
|
964
|
+
for (const byte of bytes) {
|
|
965
|
+
value = value * 256n + BigInt(byte);
|
|
966
|
+
}
|
|
967
|
+
let result = "";
|
|
968
|
+
while (value > 0n) {
|
|
969
|
+
const remainder = value % 58n;
|
|
970
|
+
value = value / 58n;
|
|
971
|
+
result = BASE58_ALPHABET[Number(remainder)] + result;
|
|
972
|
+
}
|
|
973
|
+
return "1".repeat(leadingZeros) + result;
|
|
974
|
+
}
|
|
975
|
+
function base58ToBytes(str) {
|
|
976
|
+
let leadingOnes = 0;
|
|
977
|
+
for (let i = 0; i < str.length && str[i] === "1"; i++) {
|
|
978
|
+
leadingOnes++;
|
|
979
|
+
}
|
|
980
|
+
let value = 0n;
|
|
981
|
+
for (const char of str) {
|
|
982
|
+
const index = BASE58_ALPHABET.indexOf(char);
|
|
983
|
+
if (index === -1) {
|
|
984
|
+
throw new ValidationError(`Invalid base58 character: ${char}`, "address");
|
|
985
|
+
}
|
|
986
|
+
value = value * 58n + BigInt(index);
|
|
987
|
+
}
|
|
988
|
+
const bytes = [];
|
|
989
|
+
while (value > 0n) {
|
|
990
|
+
bytes.unshift(Number(value % 256n));
|
|
991
|
+
value = value / 256n;
|
|
992
|
+
}
|
|
993
|
+
const result = new Uint8Array(leadingOnes + bytes.length);
|
|
994
|
+
for (let i = 0; i < leadingOnes; i++) {
|
|
995
|
+
result[i] = 0;
|
|
996
|
+
}
|
|
997
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
998
|
+
result[leadingOnes + i] = bytes[i];
|
|
999
|
+
}
|
|
1000
|
+
return result;
|
|
1001
|
+
}
|
|
1002
|
+
function ed25519PublicKeyToSolanaAddress(publicKey) {
|
|
1003
|
+
if (!isValidHex(publicKey)) {
|
|
1004
|
+
throw new ValidationError(
|
|
1005
|
+
"publicKey must be a valid hex string with 0x prefix",
|
|
1006
|
+
"publicKey"
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
if (!isValidEd25519PublicKey(publicKey)) {
|
|
1010
|
+
throw new ValidationError(
|
|
1011
|
+
"publicKey must be 32 bytes (64 hex characters)",
|
|
1012
|
+
"publicKey"
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
const publicKeyBytes = hexToBytes(publicKey.slice(2));
|
|
1016
|
+
return bytesToBase58(publicKeyBytes);
|
|
1017
|
+
}
|
|
1018
|
+
function isValidSolanaAddress(address) {
|
|
1019
|
+
if (typeof address !== "string" || address.length === 0) {
|
|
1020
|
+
return false;
|
|
1021
|
+
}
|
|
1022
|
+
if (address.length < 32 || address.length > 44) {
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
try {
|
|
1026
|
+
const decoded = base58ToBytes(address);
|
|
1027
|
+
return decoded.length === 32;
|
|
1028
|
+
} catch {
|
|
1029
|
+
return false;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
function solanaAddressToEd25519PublicKey(address) {
|
|
1033
|
+
if (!isValidSolanaAddress(address)) {
|
|
1034
|
+
throw new ValidationError(
|
|
1035
|
+
"Invalid Solana address format",
|
|
1036
|
+
"address"
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
const decoded = base58ToBytes(address);
|
|
1040
|
+
return `0x${bytesToHex(decoded)}`;
|
|
1041
|
+
}
|
|
1042
|
+
function ed25519PublicKeyToNearAddress(publicKey) {
|
|
1043
|
+
if (!isValidHex(publicKey)) {
|
|
1044
|
+
throw new ValidationError(
|
|
1045
|
+
"publicKey must be a valid hex string with 0x prefix",
|
|
1046
|
+
"publicKey"
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
if (!isValidEd25519PublicKey(publicKey)) {
|
|
1050
|
+
throw new ValidationError(
|
|
1051
|
+
"publicKey must be 32 bytes (64 hex characters)",
|
|
1052
|
+
"publicKey"
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
return publicKey.slice(2).toLowerCase();
|
|
1056
|
+
}
|
|
1057
|
+
function nearAddressToEd25519PublicKey(address) {
|
|
1058
|
+
if (!isValidNearImplicitAddress(address)) {
|
|
1059
|
+
throw new ValidationError(
|
|
1060
|
+
"Invalid NEAR implicit address format",
|
|
1061
|
+
"address"
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
return `0x${address.toLowerCase()}`;
|
|
1065
|
+
}
|
|
1066
|
+
function isValidNearImplicitAddress(address) {
|
|
1067
|
+
if (typeof address !== "string" || address.length === 0) {
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
if (address.length !== 64) {
|
|
1071
|
+
return false;
|
|
1072
|
+
}
|
|
1073
|
+
return /^[0-9a-f]{64}$/.test(address);
|
|
1074
|
+
}
|
|
1075
|
+
function isValidNearAccountId(accountId) {
|
|
1076
|
+
if (typeof accountId !== "string" || accountId.length === 0) {
|
|
1077
|
+
return false;
|
|
1078
|
+
}
|
|
1079
|
+
if (isValidNearImplicitAddress(accountId)) {
|
|
1080
|
+
return true;
|
|
1081
|
+
}
|
|
1082
|
+
if (accountId.length < 2 || accountId.length > 64) {
|
|
1083
|
+
return false;
|
|
1084
|
+
}
|
|
1085
|
+
const nearAccountPattern = /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/;
|
|
1086
|
+
if (!nearAccountPattern.test(accountId)) {
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1089
|
+
if (accountId.includes("..")) {
|
|
1090
|
+
return false;
|
|
1091
|
+
}
|
|
1092
|
+
return true;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// src/chains/solana/transfer.ts
|
|
1096
|
+
async function sendPrivateSPLTransfer(params) {
|
|
1097
|
+
const {
|
|
1098
|
+
connection,
|
|
1099
|
+
sender,
|
|
1100
|
+
senderTokenAccount,
|
|
1101
|
+
recipientMetaAddress,
|
|
1102
|
+
mint,
|
|
1103
|
+
amount,
|
|
1104
|
+
signTransaction
|
|
1105
|
+
} = params;
|
|
1106
|
+
if (recipientMetaAddress.chain !== "solana") {
|
|
1107
|
+
throw new Error(
|
|
1108
|
+
`Invalid chain: expected 'solana', got '${recipientMetaAddress.chain}'`
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
const { stealthAddress } = generateEd25519StealthAddress(recipientMetaAddress);
|
|
1112
|
+
const stealthAddressBase58 = ed25519PublicKeyToSolanaAddress(stealthAddress.address);
|
|
1113
|
+
const stealthPubkey = new PublicKey(stealthAddressBase58);
|
|
1114
|
+
const ephemeralPubkeyBase58 = ed25519PublicKeyToSolanaAddress(
|
|
1115
|
+
stealthAddress.ephemeralPublicKey
|
|
1116
|
+
);
|
|
1117
|
+
const stealthATA = await getAssociatedTokenAddress(
|
|
1118
|
+
mint,
|
|
1119
|
+
stealthPubkey,
|
|
1120
|
+
true
|
|
1121
|
+
// allowOwnerOffCurve - stealth addresses may be off-curve
|
|
1122
|
+
);
|
|
1123
|
+
const transaction = new Transaction();
|
|
1124
|
+
let stealthATAExists = false;
|
|
1125
|
+
try {
|
|
1126
|
+
await getAccount(connection, stealthATA);
|
|
1127
|
+
stealthATAExists = true;
|
|
1128
|
+
} catch {
|
|
1129
|
+
stealthATAExists = false;
|
|
1130
|
+
}
|
|
1131
|
+
if (!stealthATAExists) {
|
|
1132
|
+
transaction.add(
|
|
1133
|
+
createAssociatedTokenAccountInstruction(
|
|
1134
|
+
sender,
|
|
1135
|
+
// payer
|
|
1136
|
+
stealthATA,
|
|
1137
|
+
// associatedToken
|
|
1138
|
+
stealthPubkey,
|
|
1139
|
+
// owner
|
|
1140
|
+
mint,
|
|
1141
|
+
// mint
|
|
1142
|
+
TOKEN_PROGRAM_ID,
|
|
1143
|
+
ASSOCIATED_TOKEN_PROGRAM_ID
|
|
1144
|
+
)
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
transaction.add(
|
|
1148
|
+
createTransferInstruction(
|
|
1149
|
+
senderTokenAccount,
|
|
1150
|
+
// source
|
|
1151
|
+
stealthATA,
|
|
1152
|
+
// destination
|
|
1153
|
+
sender,
|
|
1154
|
+
// owner
|
|
1155
|
+
amount
|
|
1156
|
+
// amount
|
|
1157
|
+
)
|
|
1158
|
+
);
|
|
1159
|
+
const viewTagHex = stealthAddress.viewTag.toString(16).padStart(2, "0");
|
|
1160
|
+
const memoContent = createAnnouncementMemo(
|
|
1161
|
+
ephemeralPubkeyBase58,
|
|
1162
|
+
viewTagHex,
|
|
1163
|
+
stealthAddressBase58
|
|
1164
|
+
);
|
|
1165
|
+
const memoInstruction = new TransactionInstruction({
|
|
1166
|
+
keys: [],
|
|
1167
|
+
programId: new PublicKey(MEMO_PROGRAM_ID),
|
|
1168
|
+
data: Buffer.from(memoContent, "utf-8")
|
|
1169
|
+
});
|
|
1170
|
+
transaction.add(memoInstruction);
|
|
1171
|
+
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
|
|
1172
|
+
transaction.recentBlockhash = blockhash;
|
|
1173
|
+
transaction.lastValidBlockHeight = lastValidBlockHeight;
|
|
1174
|
+
transaction.feePayer = sender;
|
|
1175
|
+
const signedTx = await signTransaction(transaction);
|
|
1176
|
+
const txSignature = await connection.sendRawTransaction(signedTx.serialize(), {
|
|
1177
|
+
skipPreflight: false,
|
|
1178
|
+
preflightCommitment: "confirmed"
|
|
1179
|
+
});
|
|
1180
|
+
await connection.confirmTransaction(
|
|
1181
|
+
{
|
|
1182
|
+
signature: txSignature,
|
|
1183
|
+
blockhash,
|
|
1184
|
+
lastValidBlockHeight
|
|
1185
|
+
},
|
|
1186
|
+
"confirmed"
|
|
1187
|
+
);
|
|
1188
|
+
const cluster = detectCluster(connection.rpcEndpoint);
|
|
1189
|
+
return {
|
|
1190
|
+
txSignature,
|
|
1191
|
+
stealthAddress: stealthAddressBase58,
|
|
1192
|
+
ephemeralPublicKey: ephemeralPubkeyBase58,
|
|
1193
|
+
viewTag: viewTagHex,
|
|
1194
|
+
explorerUrl: getExplorerUrl(txSignature, cluster),
|
|
1195
|
+
cluster
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
async function estimatePrivateTransferFee(connection, needsATACreation = true) {
|
|
1199
|
+
let fee = ESTIMATED_TX_FEE_LAMPORTS;
|
|
1200
|
+
if (needsATACreation) {
|
|
1201
|
+
const rentExemption = await connection.getMinimumBalanceForRentExemption(165);
|
|
1202
|
+
fee += BigInt(rentExemption);
|
|
1203
|
+
}
|
|
1204
|
+
return fee;
|
|
1205
|
+
}
|
|
1206
|
+
async function hasTokenAccount(connection, stealthAddress, mint) {
|
|
1207
|
+
try {
|
|
1208
|
+
const stealthPubkey = new PublicKey(stealthAddress);
|
|
1209
|
+
const ata = await getAssociatedTokenAddress(mint, stealthPubkey, true);
|
|
1210
|
+
await getAccount(connection, ata);
|
|
1211
|
+
return true;
|
|
1212
|
+
} catch {
|
|
1213
|
+
return false;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
function detectCluster(endpoint) {
|
|
1217
|
+
if (endpoint.includes("devnet")) {
|
|
1218
|
+
return "devnet";
|
|
1219
|
+
}
|
|
1220
|
+
if (endpoint.includes("testnet")) {
|
|
1221
|
+
return "testnet";
|
|
1222
|
+
}
|
|
1223
|
+
if (endpoint.includes("localhost") || endpoint.includes("127.0.0.1")) {
|
|
1224
|
+
return "localnet";
|
|
1225
|
+
}
|
|
1226
|
+
return "mainnet-beta";
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// src/chains/solana/scan.ts
|
|
1230
|
+
import {
|
|
1231
|
+
PublicKey as PublicKey2,
|
|
1232
|
+
Transaction as Transaction2,
|
|
1233
|
+
Keypair
|
|
1234
|
+
} from "@solana/web3.js";
|
|
1235
|
+
import {
|
|
1236
|
+
getAssociatedTokenAddress as getAssociatedTokenAddress2,
|
|
1237
|
+
createTransferInstruction as createTransferInstruction2,
|
|
1238
|
+
getAccount as getAccount2
|
|
1239
|
+
} from "@solana/spl-token";
|
|
1240
|
+
import { hexToBytes as hexToBytes2 } from "@noble/hashes/utils";
|
|
1241
|
+
import { ed25519 as ed255192 } from "@noble/curves/ed25519";
|
|
1242
|
+
async function scanForPayments(params) {
|
|
1243
|
+
const {
|
|
1244
|
+
connection,
|
|
1245
|
+
viewingPrivateKey,
|
|
1246
|
+
spendingPublicKey,
|
|
1247
|
+
fromSlot,
|
|
1248
|
+
toSlot,
|
|
1249
|
+
limit = 100,
|
|
1250
|
+
provider
|
|
1251
|
+
} = params;
|
|
1252
|
+
const results = [];
|
|
1253
|
+
const memoProgram = new PublicKey2(MEMO_PROGRAM_ID);
|
|
1254
|
+
try {
|
|
1255
|
+
const signatures = await connection.getSignaturesForAddress(
|
|
1256
|
+
memoProgram,
|
|
1257
|
+
{
|
|
1258
|
+
limit,
|
|
1259
|
+
minContextSlot: fromSlot
|
|
1260
|
+
}
|
|
1261
|
+
);
|
|
1262
|
+
const filteredSignatures = toSlot ? signatures.filter((s) => s.slot <= toSlot) : signatures;
|
|
1263
|
+
for (const sigInfo of filteredSignatures) {
|
|
1264
|
+
try {
|
|
1265
|
+
const tx = await connection.getTransaction(sigInfo.signature, {
|
|
1266
|
+
maxSupportedTransactionVersion: 0
|
|
1267
|
+
});
|
|
1268
|
+
if (!tx?.meta?.logMessages) continue;
|
|
1269
|
+
for (const log of tx.meta.logMessages) {
|
|
1270
|
+
if (!log.includes(SIP_MEMO_PREFIX)) continue;
|
|
1271
|
+
const memoMatch = log.match(/Program log: (.+)/);
|
|
1272
|
+
if (!memoMatch) continue;
|
|
1273
|
+
const memoContent = memoMatch[1];
|
|
1274
|
+
const announcement = parseAnnouncement(memoContent);
|
|
1275
|
+
if (!announcement) continue;
|
|
1276
|
+
const ephemeralPubKeyHex = solanaAddressToEd25519PublicKey(
|
|
1277
|
+
announcement.ephemeralPublicKey
|
|
1278
|
+
);
|
|
1279
|
+
const viewTagNumber = parseInt(announcement.viewTag, 16);
|
|
1280
|
+
const stealthAddressToCheck = {
|
|
1281
|
+
address: announcement.stealthAddress ? solanaAddressToEd25519PublicKey(announcement.stealthAddress) : "0x" + "00".repeat(32),
|
|
1282
|
+
// Will be computed
|
|
1283
|
+
ephemeralPublicKey: ephemeralPubKeyHex,
|
|
1284
|
+
viewTag: viewTagNumber
|
|
1285
|
+
};
|
|
1286
|
+
const isOurs = checkEd25519StealthAddress(
|
|
1287
|
+
stealthAddressToCheck,
|
|
1288
|
+
viewingPrivateKey,
|
|
1289
|
+
spendingPublicKey
|
|
1290
|
+
);
|
|
1291
|
+
if (isOurs) {
|
|
1292
|
+
const transferInfo = parseTokenTransfer(tx);
|
|
1293
|
+
if (transferInfo) {
|
|
1294
|
+
let amount = transferInfo.amount;
|
|
1295
|
+
const tokenSymbol = getTokenSymbol(transferInfo.mint);
|
|
1296
|
+
if (provider && announcement.stealthAddress) {
|
|
1297
|
+
try {
|
|
1298
|
+
const balance = await provider.getTokenBalance(
|
|
1299
|
+
announcement.stealthAddress,
|
|
1300
|
+
transferInfo.mint
|
|
1301
|
+
);
|
|
1302
|
+
if (balance > 0n) {
|
|
1303
|
+
amount = balance;
|
|
1304
|
+
}
|
|
1305
|
+
} catch {
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
results.push({
|
|
1309
|
+
stealthAddress: announcement.stealthAddress || "",
|
|
1310
|
+
ephemeralPublicKey: announcement.ephemeralPublicKey,
|
|
1311
|
+
amount,
|
|
1312
|
+
mint: transferInfo.mint,
|
|
1313
|
+
tokenSymbol,
|
|
1314
|
+
txSignature: sigInfo.signature,
|
|
1315
|
+
slot: sigInfo.slot,
|
|
1316
|
+
timestamp: sigInfo.blockTime || 0
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
} catch (err) {
|
|
1322
|
+
console.warn(`Failed to parse tx ${sigInfo.signature}:`, err);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
} catch (err) {
|
|
1326
|
+
console.error("Scan failed:", err);
|
|
1327
|
+
throw new Error(`Failed to scan for payments: ${err}`);
|
|
1328
|
+
}
|
|
1329
|
+
return results;
|
|
1330
|
+
}
|
|
1331
|
+
async function claimStealthPayment(params) {
|
|
1332
|
+
const {
|
|
1333
|
+
connection,
|
|
1334
|
+
stealthAddress,
|
|
1335
|
+
ephemeralPublicKey,
|
|
1336
|
+
viewingPrivateKey,
|
|
1337
|
+
spendingPrivateKey,
|
|
1338
|
+
destinationAddress,
|
|
1339
|
+
mint
|
|
1340
|
+
} = params;
|
|
1341
|
+
const stealthAddressHex = solanaAddressToEd25519PublicKey(stealthAddress);
|
|
1342
|
+
const ephemeralPubKeyHex = solanaAddressToEd25519PublicKey(ephemeralPublicKey);
|
|
1343
|
+
const stealthAddressObj = {
|
|
1344
|
+
address: stealthAddressHex,
|
|
1345
|
+
ephemeralPublicKey: ephemeralPubKeyHex,
|
|
1346
|
+
viewTag: 0
|
|
1347
|
+
// Not needed for derivation
|
|
1348
|
+
};
|
|
1349
|
+
const recovery = deriveEd25519StealthPrivateKey(
|
|
1350
|
+
stealthAddressObj,
|
|
1351
|
+
spendingPrivateKey,
|
|
1352
|
+
viewingPrivateKey
|
|
1353
|
+
);
|
|
1354
|
+
const stealthPrivKeyBytes = hexToBytes2(recovery.privateKey.slice(2));
|
|
1355
|
+
const stealthPubkey = new PublicKey2(stealthAddress);
|
|
1356
|
+
const expectedPubKeyBytes = stealthPubkey.toBytes();
|
|
1357
|
+
const scalarBigInt = bytesToBigIntLE2(stealthPrivKeyBytes);
|
|
1358
|
+
const ED25519_ORDER2 = 2n ** 252n + 27742317777372353535851937790883648493n;
|
|
1359
|
+
let validScalar = scalarBigInt % ED25519_ORDER2;
|
|
1360
|
+
if (validScalar === 0n) validScalar = 1n;
|
|
1361
|
+
const derivedPubKeyBytes = ed255192.ExtendedPoint.BASE.multiply(validScalar).toRawBytes();
|
|
1362
|
+
if (!derivedPubKeyBytes.every((b, i) => b === expectedPubKeyBytes[i])) {
|
|
1363
|
+
throw new Error(
|
|
1364
|
+
"Stealth key derivation failed: derived private key does not produce expected public key. This may indicate incorrect spending/viewing keys or corrupted announcement data."
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
const stealthKeypair = Keypair.fromSecretKey(
|
|
1368
|
+
new Uint8Array([...stealthPrivKeyBytes, ...expectedPubKeyBytes])
|
|
1369
|
+
);
|
|
1370
|
+
const stealthATA = await getAssociatedTokenAddress2(
|
|
1371
|
+
mint,
|
|
1372
|
+
stealthPubkey,
|
|
1373
|
+
true
|
|
1374
|
+
);
|
|
1375
|
+
const destinationPubkey = new PublicKey2(destinationAddress);
|
|
1376
|
+
const destinationATA = await getAssociatedTokenAddress2(
|
|
1377
|
+
mint,
|
|
1378
|
+
destinationPubkey
|
|
1379
|
+
);
|
|
1380
|
+
const stealthAccount = await getAccount2(connection, stealthATA);
|
|
1381
|
+
const amount = stealthAccount.amount;
|
|
1382
|
+
const transaction = new Transaction2();
|
|
1383
|
+
transaction.add(
|
|
1384
|
+
createTransferInstruction2(
|
|
1385
|
+
stealthATA,
|
|
1386
|
+
destinationATA,
|
|
1387
|
+
stealthPubkey,
|
|
1388
|
+
amount
|
|
1389
|
+
)
|
|
1390
|
+
);
|
|
1391
|
+
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
|
|
1392
|
+
transaction.recentBlockhash = blockhash;
|
|
1393
|
+
transaction.lastValidBlockHeight = lastValidBlockHeight;
|
|
1394
|
+
transaction.feePayer = stealthPubkey;
|
|
1395
|
+
transaction.sign(stealthKeypair);
|
|
1396
|
+
const txSignature = await connection.sendRawTransaction(
|
|
1397
|
+
transaction.serialize(),
|
|
1398
|
+
{
|
|
1399
|
+
skipPreflight: false,
|
|
1400
|
+
preflightCommitment: "confirmed"
|
|
1401
|
+
}
|
|
1402
|
+
);
|
|
1403
|
+
await connection.confirmTransaction(
|
|
1404
|
+
{
|
|
1405
|
+
signature: txSignature,
|
|
1406
|
+
blockhash,
|
|
1407
|
+
lastValidBlockHeight
|
|
1408
|
+
},
|
|
1409
|
+
"confirmed"
|
|
1410
|
+
);
|
|
1411
|
+
const cluster = detectCluster2(connection.rpcEndpoint);
|
|
1412
|
+
return {
|
|
1413
|
+
txSignature,
|
|
1414
|
+
destinationAddress,
|
|
1415
|
+
amount,
|
|
1416
|
+
explorerUrl: getExplorerUrl(txSignature, cluster)
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
async function getStealthBalance(connection, stealthAddress, mint, provider) {
|
|
1420
|
+
if (provider) {
|
|
1421
|
+
try {
|
|
1422
|
+
return await provider.getTokenBalance(stealthAddress, mint.toBase58());
|
|
1423
|
+
} catch {
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
try {
|
|
1427
|
+
const stealthPubkey = new PublicKey2(stealthAddress);
|
|
1428
|
+
const ata = await getAssociatedTokenAddress2(mint, stealthPubkey, true);
|
|
1429
|
+
const account = await getAccount2(connection, ata);
|
|
1430
|
+
return account.amount;
|
|
1431
|
+
} catch {
|
|
1432
|
+
return 0n;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
function parseTokenTransfer(tx) {
|
|
1436
|
+
if (!tx?.meta?.postTokenBalances || !tx.meta.preTokenBalances) {
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
for (let i = 0; i < tx.meta.postTokenBalances.length; i++) {
|
|
1440
|
+
const post = tx.meta.postTokenBalances[i];
|
|
1441
|
+
const pre = tx.meta.preTokenBalances.find(
|
|
1442
|
+
(p) => p.accountIndex === post.accountIndex
|
|
1443
|
+
);
|
|
1444
|
+
const postAmount = BigInt(post.uiTokenAmount.amount);
|
|
1445
|
+
const preAmount = pre ? BigInt(pre.uiTokenAmount.amount) : 0n;
|
|
1446
|
+
if (postAmount > preAmount) {
|
|
1447
|
+
return {
|
|
1448
|
+
mint: post.mint,
|
|
1449
|
+
amount: postAmount - preAmount
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
return null;
|
|
1454
|
+
}
|
|
1455
|
+
function getTokenSymbol(mint) {
|
|
1456
|
+
for (const [symbol, address] of Object.entries(SOLANA_TOKEN_MINTS)) {
|
|
1457
|
+
if (address === mint) {
|
|
1458
|
+
return symbol;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
return void 0;
|
|
1462
|
+
}
|
|
1463
|
+
function detectCluster2(endpoint) {
|
|
1464
|
+
if (endpoint.includes("devnet")) {
|
|
1465
|
+
return "devnet";
|
|
1466
|
+
}
|
|
1467
|
+
if (endpoint.includes("testnet")) {
|
|
1468
|
+
return "testnet";
|
|
1469
|
+
}
|
|
1470
|
+
if (endpoint.includes("localhost") || endpoint.includes("127.0.0.1")) {
|
|
1471
|
+
return "localnet";
|
|
1472
|
+
}
|
|
1473
|
+
return "mainnet-beta";
|
|
1474
|
+
}
|
|
1475
|
+
function bytesToBigIntLE2(bytes) {
|
|
1476
|
+
let result = 0n;
|
|
1477
|
+
for (let i = bytes.length - 1; i >= 0; i--) {
|
|
1478
|
+
result = result << 8n | BigInt(bytes[i]);
|
|
1479
|
+
}
|
|
1480
|
+
return result;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// src/chains/solana/providers/helius.ts
|
|
1484
|
+
var HeliusProvider = class {
|
|
1485
|
+
name = "helius";
|
|
1486
|
+
apiKey;
|
|
1487
|
+
cluster;
|
|
1488
|
+
rpcUrl;
|
|
1489
|
+
restUrl;
|
|
1490
|
+
constructor(config) {
|
|
1491
|
+
if (!config.apiKey) {
|
|
1492
|
+
throw new Error("Helius API key is required. Get one at https://dev.helius.xyz");
|
|
1493
|
+
}
|
|
1494
|
+
this.apiKey = config.apiKey;
|
|
1495
|
+
this.cluster = config.cluster ?? "mainnet-beta";
|
|
1496
|
+
this.rpcUrl = this.cluster === "devnet" ? `https://devnet.helius-rpc.com/?api-key=${this.apiKey}` : `https://mainnet.helius-rpc.com/?api-key=${this.apiKey}`;
|
|
1497
|
+
this.restUrl = this.cluster === "devnet" ? `https://api-devnet.helius.xyz/v0` : `https://api.helius.xyz/v0`;
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Get all token assets owned by an address using DAS API
|
|
1501
|
+
*
|
|
1502
|
+
* Uses getAssetsByOwner for comprehensive asset information including
|
|
1503
|
+
* NFTs and fungible tokens with metadata.
|
|
1504
|
+
*/
|
|
1505
|
+
async getAssetsByOwner(owner) {
|
|
1506
|
+
const assets = [];
|
|
1507
|
+
let page = 1;
|
|
1508
|
+
const limit = 1e3;
|
|
1509
|
+
let hasMore = true;
|
|
1510
|
+
while (hasMore) {
|
|
1511
|
+
const response = await fetch(this.rpcUrl, {
|
|
1512
|
+
method: "POST",
|
|
1513
|
+
headers: { "Content-Type": "application/json" },
|
|
1514
|
+
body: JSON.stringify({
|
|
1515
|
+
jsonrpc: "2.0",
|
|
1516
|
+
id: `sip-${Date.now()}`,
|
|
1517
|
+
method: "getAssetsByOwner",
|
|
1518
|
+
params: {
|
|
1519
|
+
ownerAddress: owner,
|
|
1520
|
+
page,
|
|
1521
|
+
limit,
|
|
1522
|
+
displayOptions: {
|
|
1523
|
+
showFungible: true,
|
|
1524
|
+
showNativeBalance: false
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
})
|
|
1528
|
+
});
|
|
1529
|
+
if (!response.ok) {
|
|
1530
|
+
throw new Error(`Helius API error: ${response.status} ${response.statusText}`);
|
|
1531
|
+
}
|
|
1532
|
+
const data = await response.json();
|
|
1533
|
+
if (data.error) {
|
|
1534
|
+
throw new Error(`Helius RPC error: ${data.error.message} (code: ${data.error.code})`);
|
|
1535
|
+
}
|
|
1536
|
+
if (data.result?.items) {
|
|
1537
|
+
for (const item of data.result.items) {
|
|
1538
|
+
if (item.interface !== "FungibleToken" && item.interface !== "FungibleAsset") {
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
const tokenInfo = item.token_info;
|
|
1542
|
+
if (!tokenInfo?.balance) continue;
|
|
1543
|
+
const balanceValue = typeof tokenInfo.balance === "string" ? BigInt(tokenInfo.balance) : BigInt(Math.floor(tokenInfo.balance));
|
|
1544
|
+
assets.push({
|
|
1545
|
+
mint: item.id,
|
|
1546
|
+
amount: balanceValue,
|
|
1547
|
+
decimals: tokenInfo.decimals ?? 0,
|
|
1548
|
+
symbol: tokenInfo.symbol ?? item.content?.metadata?.symbol,
|
|
1549
|
+
name: item.content?.metadata?.name,
|
|
1550
|
+
logoUri: item.content?.links?.image
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
hasMore = data.result?.items?.length === limit;
|
|
1555
|
+
page++;
|
|
1556
|
+
if (page > 100) {
|
|
1557
|
+
console.warn("[HeliusProvider] Reached page limit (100), stopping pagination");
|
|
1558
|
+
break;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
return assets;
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Get token balance for a specific mint using Balances API
|
|
1565
|
+
*
|
|
1566
|
+
* More efficient than getAssetsByOwner when you only need one token's balance.
|
|
1567
|
+
*/
|
|
1568
|
+
async getTokenBalance(owner, mint) {
|
|
1569
|
+
try {
|
|
1570
|
+
const url = `${this.restUrl}/addresses/${owner}/balances?api-key=${this.apiKey}`;
|
|
1571
|
+
const response = await fetch(url);
|
|
1572
|
+
if (!response.ok) {
|
|
1573
|
+
const assets = await this.getAssetsByOwner(owner);
|
|
1574
|
+
const asset = assets.find((a) => a.mint === mint);
|
|
1575
|
+
return asset?.amount ?? 0n;
|
|
1576
|
+
}
|
|
1577
|
+
const data = await response.json();
|
|
1578
|
+
const token = data.tokens?.find((t) => t.mint === mint);
|
|
1579
|
+
return token ? BigInt(token.amount) : 0n;
|
|
1580
|
+
} catch (error) {
|
|
1581
|
+
console.warn("[HeliusProvider] getTokenBalance error, falling back to DAS:", error);
|
|
1582
|
+
const assets = await this.getAssetsByOwner(owner);
|
|
1583
|
+
const asset = assets.find((a) => a.mint === mint);
|
|
1584
|
+
return asset?.amount ?? 0n;
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Check if provider supports real-time subscriptions
|
|
1589
|
+
*
|
|
1590
|
+
* Helius supports webhooks for real-time notifications,
|
|
1591
|
+
* but that requires server-side setup. Client-side subscriptions
|
|
1592
|
+
* are not directly supported.
|
|
1593
|
+
*/
|
|
1594
|
+
supportsSubscriptions() {
|
|
1595
|
+
return false;
|
|
1596
|
+
}
|
|
1597
|
+
};
|
|
1598
|
+
|
|
1599
|
+
// src/chains/solana/providers/generic.ts
|
|
1600
|
+
import {
|
|
1601
|
+
Connection,
|
|
1602
|
+
PublicKey as PublicKey3
|
|
1603
|
+
} from "@solana/web3.js";
|
|
1604
|
+
import {
|
|
1605
|
+
TOKEN_PROGRAM_ID as TOKEN_PROGRAM_ID2,
|
|
1606
|
+
getAssociatedTokenAddress as getAssociatedTokenAddress3,
|
|
1607
|
+
getAccount as getAccount3
|
|
1608
|
+
} from "@solana/spl-token";
|
|
1609
|
+
var CLUSTER_ENDPOINTS = {
|
|
1610
|
+
"mainnet-beta": "https://api.mainnet-beta.solana.com",
|
|
1611
|
+
devnet: "https://api.devnet.solana.com",
|
|
1612
|
+
testnet: "https://api.testnet.solana.com"
|
|
1613
|
+
};
|
|
1614
|
+
function validateSolanaAddress(address, paramName) {
|
|
1615
|
+
try {
|
|
1616
|
+
return new PublicKey3(address);
|
|
1617
|
+
} catch {
|
|
1618
|
+
throw new Error(`Invalid Solana address for ${paramName}: ${address}`);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
var GenericProvider = class {
|
|
1622
|
+
name = "generic";
|
|
1623
|
+
connection;
|
|
1624
|
+
constructor(config) {
|
|
1625
|
+
if (config.connection) {
|
|
1626
|
+
this.connection = config.connection;
|
|
1627
|
+
} else {
|
|
1628
|
+
const endpoint = config.endpoint ?? CLUSTER_ENDPOINTS[config.cluster ?? "mainnet-beta"];
|
|
1629
|
+
this.connection = new Connection(endpoint, "confirmed");
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* Get all token assets owned by an address using getParsedTokenAccountsByOwner
|
|
1634
|
+
*
|
|
1635
|
+
* Note: This is less efficient than Helius DAS API for large wallets,
|
|
1636
|
+
* but works with any RPC endpoint.
|
|
1637
|
+
*/
|
|
1638
|
+
async getAssetsByOwner(owner) {
|
|
1639
|
+
const ownerPubkey = validateSolanaAddress(owner, "owner");
|
|
1640
|
+
const accounts = await this.connection.getParsedTokenAccountsByOwner(
|
|
1641
|
+
ownerPubkey,
|
|
1642
|
+
{ programId: TOKEN_PROGRAM_ID2 }
|
|
1643
|
+
);
|
|
1644
|
+
const assets = [];
|
|
1645
|
+
for (const { account } of accounts.value) {
|
|
1646
|
+
const parsed = account.data.parsed;
|
|
1647
|
+
if (parsed.type !== "account") continue;
|
|
1648
|
+
const info = parsed.info;
|
|
1649
|
+
const amount = BigInt(info.tokenAmount.amount);
|
|
1650
|
+
if (amount === 0n) continue;
|
|
1651
|
+
assets.push({
|
|
1652
|
+
mint: info.mint,
|
|
1653
|
+
amount,
|
|
1654
|
+
decimals: info.tokenAmount.decimals,
|
|
1655
|
+
// Generic RPC doesn't provide symbol/name, those need metadata lookup
|
|
1656
|
+
symbol: void 0,
|
|
1657
|
+
name: void 0,
|
|
1658
|
+
logoUri: void 0
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
return assets;
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* Get token balance for a specific mint
|
|
1665
|
+
*
|
|
1666
|
+
* Uses getAccount on the associated token address.
|
|
1667
|
+
*/
|
|
1668
|
+
async getTokenBalance(owner, mint) {
|
|
1669
|
+
const ownerPubkey = validateSolanaAddress(owner, "owner");
|
|
1670
|
+
const mintPubkey = validateSolanaAddress(mint, "mint");
|
|
1671
|
+
try {
|
|
1672
|
+
const ata = await getAssociatedTokenAddress3(
|
|
1673
|
+
mintPubkey,
|
|
1674
|
+
ownerPubkey,
|
|
1675
|
+
true
|
|
1676
|
+
// allowOwnerOffCurve for PDAs
|
|
1677
|
+
);
|
|
1678
|
+
const account = await getAccount3(this.connection, ata);
|
|
1679
|
+
return account.amount;
|
|
1680
|
+
} catch {
|
|
1681
|
+
return 0n;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Check if provider supports real-time subscriptions
|
|
1686
|
+
*
|
|
1687
|
+
* Generic RPC supports WebSocket subscriptions but they're not
|
|
1688
|
+
* efficient for monitoring token transfers. Returns false.
|
|
1689
|
+
*/
|
|
1690
|
+
supportsSubscriptions() {
|
|
1691
|
+
return false;
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Get the underlying Connection object
|
|
1695
|
+
*
|
|
1696
|
+
* Useful for advanced operations that need direct RPC access.
|
|
1697
|
+
*/
|
|
1698
|
+
getConnection() {
|
|
1699
|
+
return this.connection;
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
|
|
1703
|
+
// src/chains/solana/providers/interface.ts
|
|
1704
|
+
function createProvider(type, config) {
|
|
1705
|
+
switch (type) {
|
|
1706
|
+
case "helius":
|
|
1707
|
+
return new HeliusProvider(config);
|
|
1708
|
+
case "generic":
|
|
1709
|
+
return new GenericProvider(config);
|
|
1710
|
+
case "quicknode":
|
|
1711
|
+
case "triton":
|
|
1712
|
+
throw new Error(
|
|
1713
|
+
`Provider '${type}' is not yet implemented. Use 'helius' or 'generic' for now. See https://github.com/sip-protocol/sip-protocol/issues/${type === "quicknode" ? "494" : "495"}`
|
|
1714
|
+
);
|
|
1715
|
+
default:
|
|
1716
|
+
throw new Error(`Unknown provider type: ${type}`);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// src/chains/solana/providers/webhook.ts
|
|
1721
|
+
function createWebhookHandler(config) {
|
|
1722
|
+
const { viewingPrivateKey, spendingPublicKey, onPaymentFound, onError } = config;
|
|
1723
|
+
if (!viewingPrivateKey || !viewingPrivateKey.startsWith("0x")) {
|
|
1724
|
+
throw new ValidationError("viewingPrivateKey must be a valid hex string starting with 0x", "viewingPrivateKey");
|
|
1725
|
+
}
|
|
1726
|
+
if (!spendingPublicKey || !spendingPublicKey.startsWith("0x")) {
|
|
1727
|
+
throw new ValidationError("spendingPublicKey must be a valid hex string starting with 0x", "spendingPublicKey");
|
|
1728
|
+
}
|
|
1729
|
+
if (typeof onPaymentFound !== "function") {
|
|
1730
|
+
throw new ValidationError("onPaymentFound callback is required", "onPaymentFound");
|
|
1731
|
+
}
|
|
1732
|
+
return async (payload) => {
|
|
1733
|
+
const transactions = Array.isArray(payload) ? payload : [payload];
|
|
1734
|
+
const results = [];
|
|
1735
|
+
for (const tx of transactions) {
|
|
1736
|
+
try {
|
|
1737
|
+
if (isRawTransaction(tx)) {
|
|
1738
|
+
const result = await processRawTransaction(
|
|
1739
|
+
tx,
|
|
1740
|
+
viewingPrivateKey,
|
|
1741
|
+
spendingPublicKey,
|
|
1742
|
+
onPaymentFound
|
|
1743
|
+
);
|
|
1744
|
+
results.push(result);
|
|
1745
|
+
} else {
|
|
1746
|
+
results.push({
|
|
1747
|
+
found: false,
|
|
1748
|
+
signature: tx.signature
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
} catch (error) {
|
|
1752
|
+
onError?.(error, isRawTransaction(tx) ? tx : void 0);
|
|
1753
|
+
results.push({
|
|
1754
|
+
found: false,
|
|
1755
|
+
signature: getSignature(tx)
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
return results;
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
async function processRawTransaction(tx, viewingPrivateKey, spendingPublicKey, onPaymentFound) {
|
|
1763
|
+
const signature = tx.transaction?.signatures?.[0] ?? "unknown";
|
|
1764
|
+
if (tx.meta?.err) {
|
|
1765
|
+
return { found: false, signature };
|
|
1766
|
+
}
|
|
1767
|
+
if (!tx.meta?.logMessages) {
|
|
1768
|
+
return { found: false, signature };
|
|
1769
|
+
}
|
|
1770
|
+
for (const log of tx.meta.logMessages) {
|
|
1771
|
+
if (!log.includes(SIP_MEMO_PREFIX)) continue;
|
|
1772
|
+
const memoMatch = log.match(/Program log: (.+)/);
|
|
1773
|
+
if (!memoMatch) continue;
|
|
1774
|
+
const memoContent = memoMatch[1];
|
|
1775
|
+
const announcement = parseAnnouncement(memoContent);
|
|
1776
|
+
if (!announcement) continue;
|
|
1777
|
+
const ephemeralPubKeyHex = solanaAddressToEd25519PublicKey(
|
|
1778
|
+
announcement.ephemeralPublicKey
|
|
1779
|
+
);
|
|
1780
|
+
const viewTagNumber = parseInt(announcement.viewTag, 16);
|
|
1781
|
+
if (!Number.isFinite(viewTagNumber) || viewTagNumber < 0 || viewTagNumber > 255) {
|
|
1782
|
+
continue;
|
|
1783
|
+
}
|
|
1784
|
+
const stealthAddressToCheck = {
|
|
1785
|
+
address: announcement.stealthAddress ? solanaAddressToEd25519PublicKey(announcement.stealthAddress) : "0x" + "00".repeat(32),
|
|
1786
|
+
ephemeralPublicKey: ephemeralPubKeyHex,
|
|
1787
|
+
viewTag: viewTagNumber
|
|
1788
|
+
};
|
|
1789
|
+
let isOurs = false;
|
|
1790
|
+
try {
|
|
1791
|
+
isOurs = checkEd25519StealthAddress(
|
|
1792
|
+
stealthAddressToCheck,
|
|
1793
|
+
viewingPrivateKey,
|
|
1794
|
+
spendingPublicKey
|
|
1795
|
+
);
|
|
1796
|
+
} catch {
|
|
1797
|
+
continue;
|
|
1798
|
+
}
|
|
1799
|
+
if (isOurs) {
|
|
1800
|
+
const transferInfo = parseTokenTransferFromWebhook(tx);
|
|
1801
|
+
const payment = {
|
|
1802
|
+
stealthAddress: announcement.stealthAddress || "",
|
|
1803
|
+
ephemeralPublicKey: announcement.ephemeralPublicKey,
|
|
1804
|
+
amount: transferInfo?.amount ?? 0n,
|
|
1805
|
+
mint: transferInfo?.mint ?? "",
|
|
1806
|
+
tokenSymbol: transferInfo?.mint ? getTokenSymbol2(transferInfo.mint) : void 0,
|
|
1807
|
+
txSignature: signature,
|
|
1808
|
+
slot: tx.slot,
|
|
1809
|
+
timestamp: tx.blockTime
|
|
1810
|
+
};
|
|
1811
|
+
try {
|
|
1812
|
+
await onPaymentFound(payment);
|
|
1813
|
+
} catch {
|
|
1814
|
+
}
|
|
1815
|
+
return { found: true, payment, signature };
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
return { found: false, signature };
|
|
1819
|
+
}
|
|
1820
|
+
function parseTokenTransferFromWebhook(tx) {
|
|
1821
|
+
const { preTokenBalances, postTokenBalances } = tx.meta;
|
|
1822
|
+
if (!postTokenBalances || !preTokenBalances) {
|
|
1823
|
+
return null;
|
|
1824
|
+
}
|
|
1825
|
+
for (const post of postTokenBalances) {
|
|
1826
|
+
const pre = preTokenBalances.find(
|
|
1827
|
+
(p) => p.accountIndex === post.accountIndex
|
|
1828
|
+
);
|
|
1829
|
+
const postAmount = BigInt(post.uiTokenAmount.amount);
|
|
1830
|
+
const preAmount = pre ? BigInt(pre.uiTokenAmount.amount) : 0n;
|
|
1831
|
+
if (postAmount > preAmount) {
|
|
1832
|
+
return {
|
|
1833
|
+
mint: post.mint,
|
|
1834
|
+
amount: postAmount - preAmount
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
return null;
|
|
1839
|
+
}
|
|
1840
|
+
function getTokenSymbol2(mint) {
|
|
1841
|
+
for (const [symbol, address] of Object.entries(SOLANA_TOKEN_MINTS)) {
|
|
1842
|
+
if (address === mint) {
|
|
1843
|
+
return symbol;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
return void 0;
|
|
1847
|
+
}
|
|
1848
|
+
function isRawTransaction(tx) {
|
|
1849
|
+
return typeof tx === "object" && tx !== null && "meta" in tx && "transaction" in tx && Array.isArray(tx.transaction?.signatures);
|
|
1850
|
+
}
|
|
1851
|
+
function getSignature(tx) {
|
|
1852
|
+
if ("signature" in tx && typeof tx.signature === "string") {
|
|
1853
|
+
return tx.signature;
|
|
1854
|
+
}
|
|
1855
|
+
if (isRawTransaction(tx)) {
|
|
1856
|
+
return tx.transaction?.signatures?.[0] ?? "unknown";
|
|
1857
|
+
}
|
|
1858
|
+
return "unknown";
|
|
1859
|
+
}
|
|
1860
|
+
async function processWebhookTransaction(transaction, viewingPrivateKey, spendingPublicKey) {
|
|
1861
|
+
const result = await processRawTransaction(
|
|
1862
|
+
transaction,
|
|
1863
|
+
viewingPrivateKey,
|
|
1864
|
+
spendingPublicKey,
|
|
1865
|
+
() => {
|
|
1866
|
+
}
|
|
1867
|
+
// No-op callback
|
|
1868
|
+
);
|
|
1869
|
+
return result.found ? result.payment ?? null : null;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
export {
|
|
1873
|
+
isValidChainId,
|
|
1874
|
+
isValidPrivacyLevel,
|
|
1875
|
+
isValidHex,
|
|
1876
|
+
isValidHexLength,
|
|
1877
|
+
isValidAmount,
|
|
1878
|
+
isNonNegativeAmount,
|
|
1879
|
+
isValidSlippage,
|
|
1880
|
+
isValidStealthMetaAddress,
|
|
1881
|
+
isValidCompressedPublicKey,
|
|
1882
|
+
isValidEd25519PublicKey,
|
|
1883
|
+
isValidPrivateKey,
|
|
1884
|
+
validateAsset,
|
|
1885
|
+
validateIntentInput,
|
|
1886
|
+
validateIntentOutput,
|
|
1887
|
+
validateCreateIntentParams,
|
|
1888
|
+
validateViewingKey,
|
|
1889
|
+
isValidScalar,
|
|
1890
|
+
validateScalar,
|
|
1891
|
+
getChainAddressType,
|
|
1892
|
+
isAddressValidForChain,
|
|
1893
|
+
secureWipe,
|
|
1894
|
+
withSecureBuffer,
|
|
1895
|
+
withSecureBufferSync,
|
|
1896
|
+
secureWipeAll,
|
|
1897
|
+
generateStealthMetaAddress,
|
|
1898
|
+
generateStealthAddress,
|
|
1899
|
+
deriveStealthPrivateKey,
|
|
1900
|
+
checkStealthAddress,
|
|
1901
|
+
encodeStealthMetaAddress,
|
|
1902
|
+
decodeStealthMetaAddress,
|
|
1903
|
+
publicKeyToEthAddress,
|
|
1904
|
+
isEd25519Chain,
|
|
1905
|
+
getCurveForChain,
|
|
1906
|
+
generateEd25519StealthMetaAddress,
|
|
1907
|
+
generateEd25519StealthAddress,
|
|
1908
|
+
deriveEd25519StealthPrivateKey,
|
|
1909
|
+
checkEd25519StealthAddress,
|
|
1910
|
+
ed25519PublicKeyToSolanaAddress,
|
|
1911
|
+
isValidSolanaAddress,
|
|
1912
|
+
solanaAddressToEd25519PublicKey,
|
|
1913
|
+
ed25519PublicKeyToNearAddress,
|
|
1914
|
+
nearAddressToEd25519PublicKey,
|
|
1915
|
+
isValidNearImplicitAddress,
|
|
1916
|
+
isValidNearAccountId,
|
|
1917
|
+
parseAnnouncement,
|
|
1918
|
+
createAnnouncementMemo,
|
|
1919
|
+
sendPrivateSPLTransfer,
|
|
1920
|
+
estimatePrivateTransferFee,
|
|
1921
|
+
hasTokenAccount,
|
|
1922
|
+
scanForPayments,
|
|
1923
|
+
claimStealthPayment,
|
|
1924
|
+
getStealthBalance,
|
|
1925
|
+
HeliusProvider,
|
|
1926
|
+
GenericProvider,
|
|
1927
|
+
createProvider,
|
|
1928
|
+
createWebhookHandler,
|
|
1929
|
+
processWebhookTransaction
|
|
1930
|
+
};
|