@exodus/solana-api 3.20.8 → 3.20.10

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/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [3.20.10](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.20.8...@exodus/solana-api@3.20.10) (2025-10-14)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: point SOL clarity to prod (#6672)
13
+
14
+
15
+
16
+ ## [3.20.9](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.20.8...@exodus/solana-api@3.20.9) (2025-10-09)
17
+
18
+ **Note:** Version bump only for package @exodus/solana-api
19
+
20
+
21
+
22
+
23
+
6
24
  ## [3.20.8](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.20.7...@exodus/solana-api@3.20.8) (2025-09-18)
7
25
 
8
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-api",
3
- "version": "3.20.8",
3
+ "version": "3.20.10",
4
4
  "description": "Transaction monitors, fee monitors, RPC with the blockchain node, and other networking code for Solana",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -14,7 +14,8 @@
14
14
  "author": "Exodus Movement, Inc.",
15
15
  "license": "MIT",
16
16
  "publishConfig": {
17
- "access": "public"
17
+ "access": "public",
18
+ "provenance": false
18
19
  },
19
20
  "scripts": {
20
21
  "test": "run -T exodus-test --jest",
@@ -46,7 +47,7 @@
46
47
  "@exodus/assets-testing": "^1.0.0",
47
48
  "@exodus/solana-web3.js": "^1.63.1-exodus.9-rc3"
48
49
  },
49
- "gitHead": "b94e40bcbccd75eab27cd08988ea039a597b0c42",
50
+ "gitHead": "b0f1b6b69b7a6f7e70eee7e03db7bf71ba6be92a",
50
51
  "bugs": {
51
52
  "url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Asolana-api"
52
53
  },
@@ -0,0 +1,141 @@
1
+ import { memoizeLruCache } from '@exodus/asset-lib'
2
+ import { memoize, omitBy } from '@exodus/basic-utils'
3
+ import wretch from '@exodus/fetch/wretch'
4
+ import { SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@exodus/solana-lib'
5
+ import ms from 'ms'
6
+ import urljoin from 'url-join'
7
+
8
+ import { RpcApi } from './rpc-api.js'
9
+
10
+ const CLARITY_URL = 'https://solana-clarity.a.exodus.io/api/v2/solana'
11
+
12
+ const cleanQuery = (obj) => omitBy(obj, (v) => v === undefined)
13
+
14
+ // Tokens + SOL api support
15
+ export class ClarityApi extends RpcApi {
16
+ getSupply = memoize(async (mintAddress) => {
17
+ // cached getSupply
18
+ return this.request(`/util/get-token-supply/${encodeURIComponent(mintAddress)}`)
19
+ .get()
20
+ .json()
21
+ })
22
+
23
+ getMinimumBalanceForRentExemption = memoize(
24
+ (accountSize) =>
25
+ this.request(`/util/min-balance-for-rent-exemption/${encodeURIComponent(accountSize)}`)
26
+ .get()
27
+ .json(),
28
+ (accountSize) => accountSize,
29
+ ms('15m')
30
+ )
31
+
32
+ constructor({ rpcUrl, clarityUrl, assets, txsLimit }) {
33
+ super({ rpcUrl, assets, txsLimit })
34
+ this.setClarityServer(clarityUrl)
35
+ }
36
+
37
+ setClarityServer(clarityUrl) {
38
+ this.clarityUrl = clarityUrl || CLARITY_URL
39
+ }
40
+
41
+ request(path, contentType = 'application/json') {
42
+ return wretch(urljoin(this.clarityUrl, path)).headers({
43
+ 'Content-Type': contentType,
44
+ })
45
+ }
46
+
47
+ async getTransactions(address, { cursor, limit, includeUnparsed = false } = Object.create(null)) {
48
+ return this.request(`/addresses/${encodeURIComponent(address)}/transactions`)
49
+ .query(
50
+ cleanQuery({
51
+ cursor,
52
+ limit,
53
+ includeUnparsed,
54
+ })
55
+ )
56
+ .get()
57
+ .json()
58
+ }
59
+
60
+ async getTransactionById(txId) {
61
+ return this.request(`/transaction/${encodeURIComponent(txId)}`)
62
+ .get()
63
+ .json()
64
+ }
65
+
66
+ async getTokensBalancesAndAccounts({ address }) {
67
+ return this.request(`/addresses/${encodeURIComponent(address)}/tokens-balance`)
68
+ .get()
69
+ .json()
70
+ }
71
+
72
+ async getTokenAccountsByOwner(address, tokenTicker) {
73
+ const { accounts } = await this.getTokensBalancesAndAccounts({ address })
74
+ return accounts
75
+ }
76
+
77
+ async getAccountInfo(address) {
78
+ return this.request(`/addresses/${encodeURIComponent(address)}/account-info`)
79
+ .get()
80
+ .json()
81
+ }
82
+
83
+ async getStakeAccountsInfo(address) {
84
+ const { stakingBalances } = await this.request(
85
+ `/addresses/${encodeURIComponent(address)}/base-balance`
86
+ )
87
+ .get()
88
+ .json()
89
+ return stakingBalances
90
+ }
91
+
92
+ async getRewards(address) {
93
+ return this.request(`/addresses/${encodeURIComponent(address)}/staking-rewards`)
94
+ .get()
95
+ .json()
96
+ }
97
+
98
+ async getBalance(address) {
99
+ const { balance } = await this.request(`/addresses/${encodeURIComponent(address)}/base-balance`)
100
+ .get()
101
+ .json()
102
+ return balance
103
+ }
104
+
105
+ async getMintAddress(address) {
106
+ const value = await this.getAccountInfo(address)
107
+ // token mint
108
+ return value?.data?.parsed?.info?.mint ?? null
109
+ }
110
+
111
+ async getAddressMint(address) {
112
+ // alias
113
+ return this.getMintAddress(address)
114
+ }
115
+
116
+ async ataOwnershipChanged(address, tokenAddress) {
117
+ // associated token address ownership changed
118
+ const value = await this.getAccountInfo(tokenAddress)
119
+ const owner = value?.data?.parsed?.info?.owner
120
+ return owner && owner !== address
121
+ }
122
+
123
+ async ownerChanged(address, accountInfo) {
124
+ // method to check if the owner of the account has changed, compared to standard programs.
125
+ // as there could be malicious dapps that reassign the ownership of the account (see https://github.com/coinspect/solana-assign-test)
126
+ const value = accountInfo || (await this.getAccountInfo(address))
127
+ const owner = value?.owner // program owner
128
+ if (!owner) return false // not initialized account (or purged)
129
+ return ![
130
+ SYSTEM_PROGRAM_ID.toBase58(),
131
+ TOKEN_PROGRAM_ID.toBase58(),
132
+ TOKEN_2022_PROGRAM_ID.toBase58(),
133
+ ].includes(owner)
134
+ }
135
+
136
+ ataOwnershipChangedCached = memoizeLruCache(
137
+ (...args) => this.ataOwnershipChanged(...args),
138
+ (address, tokenAddress) => `${address}:${tokenAddress}`,
139
+ { max: 1000 }
140
+ )
141
+ }
@@ -64,7 +64,7 @@ export const createUnsignedTxForSend = async ({
64
64
  amount = asset.currency.baseUnit(1)
65
65
  }
66
66
 
67
- const isToken = asset.assetType === api.tokenAssetType
67
+ const isToken = asset.name !== asset.baseAsset.name
68
68
 
69
69
  // Check if receiver has address active when sending tokens.
70
70
  if (isToken) {
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ import assetsList from '@exodus/solana-meta'
5
5
  import { Api } from './api.js'
6
6
 
7
7
  export { SolanaMonitor } from './tx-log/index.js'
8
+ export { SolanaClarityMonitor } from './tx-log/index.js'
8
9
  export { createAccountState } from './account-state.js'
9
10
  export { getStakingInfo } from './staking-utils.js'
10
11
  export {
@@ -25,7 +26,8 @@ const assets = connectAssets(keyBy(assetsList, (asset) => asset.name))
25
26
 
26
27
  // At some point we would like to exclude this export. Default export should be the whole asset "plugin" ready to be injected.
27
28
  // Clients should not call an specific server api directly.
28
- const serverApi = new Api({ assets })
29
- export default serverApi
29
+ const serverApi = new Api({ assets }) // TODO: remove it, clean every use from platforms
30
+ export default serverApi // TODO: remove it
30
31
 
31
32
  export { Api } from './api.js'
33
+ export { ClarityApi } from './clarity-api.js'
package/src/rpc-api.js ADDED
@@ -0,0 +1,455 @@
1
+ import createApiCJS from '@exodus/asset-json-rpc'
2
+ import { memoize, pickBy } from '@exodus/basic-utils'
3
+ import { retry } from '@exodus/simple-retry'
4
+ import {
5
+ buildRawTransaction,
6
+ computeBalance,
7
+ deserializeMetaplexMetadata,
8
+ filterAccountsByOwner,
9
+ getMetadataAccount,
10
+ getTransactionSimulationParams,
11
+ SOL_DECIMAL,
12
+ SYSTEM_PROGRAM_ID as SYSTEM_PROGRAM_ID_KEY,
13
+ TOKEN_2022_PROGRAM_ID as TOKEN_2022_PROGRAM_ID_KEY,
14
+ TOKEN_PROGRAM_ID as TOKEN_PROGRAM_ID_KEY,
15
+ } from '@exodus/solana-lib'
16
+ import assert from 'minimalistic-assert'
17
+ import ms from 'ms'
18
+
19
+ import { getStakeActivation } from './get-stake-activation/index.js'
20
+
21
+ const [SYSTEM_PROGRAM_ID, TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID] = [
22
+ SYSTEM_PROGRAM_ID_KEY.toBase58(),
23
+ TOKEN_PROGRAM_ID_KEY.toBase58(),
24
+ TOKEN_2022_PROGRAM_ID_KEY.toBase58(),
25
+ ]
26
+ const createApi = createApiCJS.default || createApiCJS
27
+
28
+ const RPC_URL = 'https://solana-clarity.a.exodus.io/api/v2/solana/rpc' // Clarity proxied
29
+
30
+ // Doc: https://docs.solana.com/apps/jsonrpc-api
31
+
32
+ const errorMessagesToRetry = [
33
+ 'Blockhash not found',
34
+ 'Failed to query long-term storage; please try again',
35
+ ]
36
+
37
+ // Tokens + SOL api support
38
+ export class RpcApi {
39
+ getSupply = memoize(async (mintAddress) => {
40
+ // cached getSupply
41
+ const result = await this.rpcCall('getTokenSupply', [mintAddress])
42
+ return result?.value?.amount
43
+ })
44
+
45
+ getAccountSize = memoize(
46
+ (address) => this.getAccountInfo(address),
47
+ (address) => address,
48
+ ms('3m')
49
+ )
50
+
51
+ constructor({ rpcUrl, assets, txsLimit }) {
52
+ this.setRpcServer(rpcUrl)
53
+ this.setTokens(assets)
54
+ this.tokensToSkip = Object.create(null)
55
+ this.txsLimit = txsLimit
56
+ }
57
+
58
+ setRpcServer(rpcUrl = RPC_URL) {
59
+ assert(typeof rpcUrl === 'string' && rpcUrl.startsWith('http'), 'Invalid rpcUrl')
60
+ this.rpcUrl = rpcUrl
61
+ this.api = createApi(this.rpcUrl)
62
+ }
63
+
64
+ setTokens(assets) {
65
+ const solTokens = pickBy(assets, (asset) => asset.name !== asset.baseAsset.name)
66
+ this.tokens = new Map(Object.values(solTokens).map((v) => [v.mintAddress, v]))
67
+ }
68
+
69
+ async rpcCall(method, params = []) {
70
+ return this.api.post({ method, params })
71
+ }
72
+
73
+ getTokenByAddress(mint) {
74
+ return this.tokens.get(mint)
75
+ }
76
+
77
+ isTokenSupported(mint) {
78
+ return this.tokens.has(mint)
79
+ }
80
+
81
+ async getRentExemptionMinAmount(address) {
82
+ // minimum amount required for the destination account to be rent-exempt
83
+ const accountInfo = await this.getAccountSize(address).catch(() => {})
84
+ if (accountInfo?.space === 0) {
85
+ // no rent required
86
+ return 0
87
+ }
88
+
89
+ const accountSize = accountInfo?.space || 0
90
+
91
+ // Lamports number
92
+ return this.getMinimumBalanceForRentExemption(accountSize)
93
+ }
94
+
95
+ async getEpochInfo() {
96
+ const { epoch } = await this.rpcCall('getEpochInfo')
97
+ return Number(epoch)
98
+ }
99
+
100
+ async getStakeActivation(stakeAddress) {
101
+ const { status } = await getStakeActivation(this, stakeAddress)
102
+ return status
103
+ }
104
+
105
+ async getRecentBlockHash(commitment) {
106
+ const result = await this.rpcCall('getLatestBlockhash', [
107
+ {
108
+ commitment: commitment || 'confirmed',
109
+ encoding: 'jsonParsed',
110
+ },
111
+ ])
112
+ return result?.value?.blockhash
113
+ }
114
+
115
+ async getPriorityFee(transaction) {
116
+ // https://docs.helius.dev/solana-rpc-nodes/alpha-priority-fee-api
117
+ const result = await this.rpcCall('getPriorityFeeEstimate', [
118
+ { transaction, options: { recommended: true } },
119
+ ])
120
+ return result.priorityFeeEstimate
121
+ }
122
+
123
+ async getBlockTime(slot) {
124
+ // might result in error if executed on a validator with partial ledger (https://github.com/solana-labs/solana/issues/12413)
125
+ return this.rpcCall('getBlockTime', [slot])
126
+ }
127
+
128
+ async waitForTransactionStatus(txIds, status = 'finalized', timeoutMs = ms('1m')) {
129
+ if (!Array.isArray(txIds)) txIds = [txIds]
130
+ const startTime = Date.now()
131
+
132
+ while (true) {
133
+ const response = await this.rpcCall('getSignatureStatuses', [
134
+ txIds,
135
+ { searchTransactionHistory: true },
136
+ ])
137
+ const data = response.value
138
+ const allTxsAreConfirmed = data.every((elem) => elem?.confirmationStatus === status)
139
+ if (allTxsAreConfirmed) {
140
+ return true
141
+ }
142
+
143
+ // Check if the timeout has elapsed
144
+ if (Date.now() - startTime >= timeoutMs) {
145
+ // timeout
146
+ throw new Error('waitForTransactionStatus timeout')
147
+ }
148
+
149
+ // Wait for the specified interval before the next request
150
+ await new Promise((resolve) => setTimeout(resolve, ms('10s')))
151
+ }
152
+ }
153
+
154
+ async getWalletTokensList({ tokenAccounts }) {
155
+ const tokensMint = []
156
+ for (const account of tokenAccounts) {
157
+ const mint = account.mintAddress
158
+
159
+ // skip cached NFT
160
+ if (this.tokensToSkip[mint]) continue
161
+ // skip 0 balance
162
+ if (account.balance === '0') continue
163
+ // skip NFT
164
+ if (!this.tokens.has(mint)) {
165
+ const supply = await this.getSupply(mint)
166
+ if (supply === '1') {
167
+ this.tokensToSkip[mint] = true
168
+ continue
169
+ }
170
+ }
171
+
172
+ // OK
173
+ tokensMint.push(mint)
174
+ }
175
+
176
+ return tokensMint
177
+ }
178
+
179
+ async isSpl(address) {
180
+ const { owner } = await this.getAccountInfo(address)
181
+ return [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID].includes(owner)
182
+ }
183
+
184
+ async getTokenBalance(tokenAddress) {
185
+ // Returns account balance of a SPL Token account.
186
+ const result = await this.rpcCall('getTokenAccountBalance', [tokenAddress])
187
+ return result?.value?.amount
188
+ }
189
+
190
+ async isAssociatedTokenAccountActive(tokenAddress) {
191
+ try {
192
+ await this.getTokenBalance(tokenAddress)
193
+ return true
194
+ } catch {
195
+ return false
196
+ }
197
+ }
198
+
199
+ async getTokenFeeBasisPoints(address) {
200
+ // only for token-2022
201
+ const value = await this.getAccountInfo(address)
202
+
203
+ const transferFeeBasisPoints =
204
+ value?.data?.parsed?.info?.extensions?.[0]?.state?.newerTransferFee?.transferFeeBasisPoints ??
205
+ 0
206
+ const maximumFee =
207
+ value?.data?.parsed?.info?.extensions?.[0]?.state?.newerTransferFee?.maximumFee ?? 0
208
+
209
+ return { feeBasisPoints: transferFeeBasisPoints, maximumFee }
210
+ }
211
+
212
+ async getMetaplexMetadata(tokenMintAddress) {
213
+ const metaplexPDA = getMetadataAccount(tokenMintAddress)
214
+ const res = await this.getAccountInfo(metaplexPDA, 'base64')
215
+ const data = res?.data?.[0]
216
+ if (!data) return null
217
+
218
+ return deserializeMetaplexMetadata(Buffer.from(data, 'base64'))
219
+ }
220
+
221
+ async getDecimals(tokenMintAddress) {
222
+ const result = await this.rpcCall('getTokenSupply', [tokenMintAddress])
223
+ return result?.value?.decimals ?? null
224
+ }
225
+
226
+ async getAddressType(address) {
227
+ // solana, token or null (unknown), meaning address has never been initialized
228
+ const value = await this.getAccountInfo(address)
229
+ if (value === null) return null
230
+
231
+ const account = {
232
+ executable: value.executable,
233
+ owner: value.owner,
234
+ lamports: value.lamports,
235
+ }
236
+
237
+ if (account.owner === SYSTEM_PROGRAM_ID) return 'solana'
238
+ if (account.owner === TOKEN_PROGRAM_ID) return 'token'
239
+ if (account.owner === TOKEN_2022_PROGRAM_ID) return 'token-2022'
240
+ return null
241
+ }
242
+
243
+ async getTokenAddressOwner(address) {
244
+ const value = await this.getAccountInfo(address)
245
+ return value?.data?.parsed?.info?.owner ?? null
246
+ }
247
+
248
+ async isTokenAddress(address) {
249
+ const type = await this.getAddressType(address)
250
+ return ['token', 'token-2022'].includes(type)
251
+ }
252
+
253
+ async isSOLaddress(address) {
254
+ const type = await this.getAddressType(address)
255
+ return type === 'solana'
256
+ }
257
+
258
+ async getProgramAccounts(programId, config) {
259
+ return this.rpcCall('getProgramAccounts', [programId, config])
260
+ }
261
+
262
+ async getMultipleAccounts(pubkeys, config) {
263
+ const response = await this.rpcCall('getMultipleAccounts', [pubkeys, config])
264
+ return response && response.value ? response.value : []
265
+ }
266
+
267
+ async getFeeForMessage(message, commitment) {
268
+ const response = await this.rpcCall('getFeeForMessage', [
269
+ Buffer.from(message.serialize()).toString('base64'),
270
+ { commitment },
271
+ ])
272
+
273
+ return response?.value
274
+ }
275
+
276
+ /**
277
+ * Broadcast a signed transaction
278
+ */
279
+ broadcastTransaction = async (signedTx, options) => {
280
+ console.log('Solana broadcasting TX:', signedTx) // base64
281
+ const defaultOptions = { encoding: 'base64', preflightCommitment: 'confirmed' }
282
+
283
+ const params = [signedTx, { ...defaultOptions, ...options }]
284
+
285
+ const broadcastTxWithRetry = retry(
286
+ async () => {
287
+ try {
288
+ const result = await this.rpcCall('sendTransaction', params)
289
+ console.log(`tx ${JSON.stringify(result)} sent!`)
290
+
291
+ return result || null
292
+ } catch (error) {
293
+ if (
294
+ error.message &&
295
+ !errorMessagesToRetry.some((errorMessage) => error.message.includes(errorMessage))
296
+ ) {
297
+ error.finalError = true
298
+ }
299
+
300
+ console.warn(`Error broadcasting tx. Retrying...`, error)
301
+
302
+ throw error
303
+ }
304
+ },
305
+ { delayTimesMs: ['6s', '6s', '8s', '10s'] }
306
+ )
307
+
308
+ return broadcastTxWithRetry()
309
+ }
310
+
311
+ simulateTransaction = async (encodedTransaction, options) => {
312
+ const result = await this.rpcCall('simulateTransaction', [encodedTransaction, options])
313
+ const {
314
+ value: { accounts, unitsConsumed, err },
315
+ } = result
316
+
317
+ return { accounts, unitsConsumed, err }
318
+ }
319
+
320
+ resolveSimulationSideEffects = async (solAccounts, tokenAccounts) => {
321
+ const willReceive = []
322
+ const willSend = []
323
+
324
+ const resolveSols = solAccounts.map(async (account) => {
325
+ const currentAmount = await this.getBalance(account.address)
326
+ const balance = computeBalance(account.amount, currentAmount)
327
+ return {
328
+ name: 'SOL',
329
+ symbol: 'SOL',
330
+ balance,
331
+ decimal: SOL_DECIMAL,
332
+ type: 'SOL',
333
+ }
334
+ })
335
+
336
+ const _wrapAndHandleAccountNotFound = (fn, defaultValue) => {
337
+ return async (...params) => {
338
+ try {
339
+ return await fn.apply(this, params)
340
+ } catch (error) {
341
+ if (error.message && error.message.includes('could not find account')) {
342
+ return defaultValue
343
+ }
344
+
345
+ throw error
346
+ }
347
+ }
348
+ }
349
+
350
+ const _getTokenBalance = _wrapAndHandleAccountNotFound(this.getTokenBalance, '0')
351
+ const _getDecimals = _wrapAndHandleAccountNotFound(this.getDecimals, 0)
352
+ const _getSupply = _wrapAndHandleAccountNotFound(this.getSupply, '0')
353
+
354
+ const resolveTokens = tokenAccounts.map(async (account) => {
355
+ try {
356
+ const [_tokenMetaPlex, currentAmount, decimal] = await Promise.all([
357
+ this.getMetaplexMetadata(account.mint),
358
+ _getTokenBalance(account.address),
359
+ _getDecimals(account.mint),
360
+ ])
361
+
362
+ const tokenMetaPlex = _tokenMetaPlex || { name: null, symbol: null }
363
+ let nft = Object.create(null)
364
+
365
+ // Only perform an NFT check (getSupply) if decimal is zero
366
+ if (decimal === 0 && (await _getSupply(account.mint)) === '1') {
367
+ const compositeId = account.mint
368
+ nft = {
369
+ id: `solana:${compositeId}`,
370
+ compositeId,
371
+ }
372
+ }
373
+
374
+ const balance = computeBalance(account.amount, currentAmount)
375
+ return {
376
+ balance,
377
+ decimal,
378
+ nft,
379
+ address: account.address,
380
+ mint: account.mint,
381
+ name: tokenMetaPlex.name,
382
+ symbol: tokenMetaPlex.symbol,
383
+ type: 'TOKEN',
384
+ }
385
+ } catch (error) {
386
+ console.warn(error)
387
+ return {
388
+ balance: null,
389
+ }
390
+ }
391
+ })
392
+
393
+ const accounts = await Promise.all([...resolveSols, ...resolveTokens])
394
+ accounts.forEach((account) => {
395
+ if (account.balance === null) {
396
+ return
397
+ }
398
+
399
+ if (account.balance > 0) {
400
+ willReceive.push(account)
401
+ } else {
402
+ willSend.push(account)
403
+ }
404
+ })
405
+
406
+ return {
407
+ willReceive,
408
+ willSend,
409
+ }
410
+ }
411
+
412
+ simulateUnsignedTransaction = async ({ message, transactionMessage }) => {
413
+ const { config, accountAddresses } = getTransactionSimulationParams(
414
+ transactionMessage || message
415
+ )
416
+ // eslint-disable-next-line unicorn/no-new-array
417
+ const signatures = new Array(message.header.numRequiredSignatures || 1).fill(null)
418
+ const encodedTransaction = buildRawTransaction(
419
+ Buffer.from(message.serialize()),
420
+ signatures
421
+ ).toString('base64')
422
+ const { accounts, unitsConsumed, err } = await this.simulateTransaction(encodedTransaction, {
423
+ ...config,
424
+ replaceRecentBlockhash: false,
425
+ sigVerify: false,
426
+ })
427
+ return {
428
+ accounts,
429
+ accountAddresses,
430
+ unitsConsumed,
431
+ err,
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Simulate transaction and return side effects
437
+ */
438
+ simulateAndRetrieveSideEffects = async (
439
+ message,
440
+ publicKey,
441
+ transactionMessage // decompiled TransactionMessage
442
+ ) => {
443
+ const { accounts, accountAddresses } = await this.simulateUnsignedTransaction({
444
+ message,
445
+ transactionMessage,
446
+ })
447
+ const { solAccounts, tokenAccounts } = filterAccountsByOwner(
448
+ accounts,
449
+ accountAddresses,
450
+ publicKey
451
+ )
452
+
453
+ return this.resolveSimulationSideEffects(solAccounts, tokenAccounts)
454
+ }
455
+ }
@@ -0,0 +1,362 @@
1
+ import { BaseMonitor } from '@exodus/asset-lib'
2
+ import { omitBy } from '@exodus/basic-utils'
3
+ import lodash from 'lodash'
4
+ import assert from 'minimalistic-assert'
5
+ import ms from 'ms'
6
+
7
+ import { DEFAULT_POOL_ADDRESS } from '../account-state.js'
8
+
9
+ const DEFAULT_REMOTE_CONFIG = {
10
+ clarityUrl: [],
11
+ rpcUrl: [],
12
+ staking: { enabled: true, pool: DEFAULT_POOL_ADDRESS },
13
+ }
14
+
15
+ const TICKS_BETWEEN_HISTORY_FETCHES = 10
16
+ const TICKS_BETWEEN_STAKE_FETCHES = 5
17
+ const TX_STALE_AFTER = ms('2m') // mark txs as dropped after N minutes
18
+
19
+ export class SolanaClarityMonitor extends BaseMonitor {
20
+ constructor({
21
+ api,
22
+ includeUnparsed = false,
23
+ ticksBetweenHistoryFetches = TICKS_BETWEEN_HISTORY_FETCHES,
24
+ ticksBetweenStakeFetches = TICKS_BETWEEN_STAKE_FETCHES,
25
+ txsLimit,
26
+ shouldUpdateBalanceBeforeHistory = true,
27
+ ...args
28
+ }) {
29
+ super(args)
30
+ assert(api, 'api is required')
31
+ this.api = api
32
+ this.cursors = Object.create(null)
33
+ this.assets = Object.create(null)
34
+ this.staking = DEFAULT_REMOTE_CONFIG.staking
35
+ this.ticksBetweenStakeFetches = ticksBetweenStakeFetches
36
+ this.ticksBetweenHistoryFetches = ticksBetweenHistoryFetches
37
+ this.shouldUpdateBalanceBeforeHistory = shouldUpdateBalanceBeforeHistory
38
+ this.includeUnparsed = includeUnparsed
39
+ this.txsLimit = txsLimit
40
+ }
41
+
42
+ setServer(config = Object.create(null)) {
43
+ const {
44
+ rpcUrl,
45
+ clarityUrl,
46
+ staking = Object.create(null),
47
+ } = { ...DEFAULT_REMOTE_CONFIG, ...config }
48
+ this.api.setRpcServer(rpcUrl[0])
49
+ this.api.setClarityServer(clarityUrl[0])
50
+ this.staking = staking
51
+ }
52
+
53
+ hasNewCursor({ walletAccount, cursorState }) {
54
+ const { cursor } = cursorState
55
+ return this.cursors[walletAccount] !== cursor
56
+ }
57
+
58
+ async emitUnknownTokensEvent({ tokenAccounts }) {
59
+ const tokensList = await this.api.getWalletTokensList({ tokenAccounts })
60
+ const unknownTokensList = tokensList.filter((mintAddress) => {
61
+ return !this.api.tokens.has(mintAddress)
62
+ })
63
+ if (unknownTokensList.length > 0) {
64
+ this.emit('unknown-tokens', unknownTokensList)
65
+ }
66
+ }
67
+
68
+ async getStakingAddressesFromTxLog({ assetName, walletAccount }) {
69
+ const txLog = await this.aci.getTxLog({ assetName: this.asset.name, walletAccount })
70
+ const stakingAddresses = [...txLog]
71
+ .filter((tx) => tx?.data?.staking?.stakeAddresses)
72
+ .map((tx) => tx.data.staking.stakeAddresses)
73
+ return lodash.uniq(stakingAddresses.flat())
74
+ }
75
+
76
+ #balanceChanged({ account, newAccount }) {
77
+ const solBalanceChanged = !account.balance || !account.balance.equals(newAccount.balance)
78
+ if (solBalanceChanged) return true
79
+
80
+ // token balance changed
81
+ return (
82
+ !account.tokenBalances ||
83
+ Object.entries(newAccount.tokenBalances).some(
84
+ ([token, balance]) =>
85
+ !account.tokenBalances[token] || !account.tokenBalances[token].equals(balance)
86
+ )
87
+ )
88
+ }
89
+
90
+ async markStaleTransactions({ walletAccount, logItemsByAsset = Object.create(null) }) {
91
+ // mark stale txs as dropped in logItemsByAsset
92
+ const clearedLogItems = logItemsByAsset
93
+ const assetNames = Object.keys(this.assets)
94
+
95
+ for (const assetName of assetNames) {
96
+ const txSet = await this.aci.getTxLog({ assetName, walletAccount })
97
+ const { stale } = this.getUnconfirmed({ txSet, staleTxAge: TX_STALE_AFTER })
98
+ if (stale.length > 0) {
99
+ clearedLogItems[assetName] = lodash.unionBy(logItemsByAsset[assetName], stale, 'txId')
100
+ }
101
+ }
102
+
103
+ return clearedLogItems
104
+ }
105
+
106
+ isStakingEnabled() {
107
+ return true
108
+ }
109
+
110
+ async tick({ walletAccount, refresh }) {
111
+ const assetName = this.asset.name
112
+ this.assets = await this.aci.getAssetsForNetwork({ baseAssetName: assetName })
113
+ this.api.setTokens(this.assets)
114
+
115
+ const accountState = await this.aci.getAccountState({ assetName, walletAccount })
116
+ const address = await this.aci.getReceiveAddress({ assetName, walletAccount, useCache: true })
117
+
118
+ const { account, tokenAccounts, staking } = await this.getAccountsAndBalances({
119
+ refresh,
120
+ address,
121
+ accountState,
122
+ walletAccount,
123
+ })
124
+ const balanceChanged = this.#balanceChanged({ account: accountState, newAccount: account })
125
+
126
+ const isHistoryUpdateTick =
127
+ this.tickCount[walletAccount] % this.ticksBetweenHistoryFetches === 0
128
+
129
+ const shouldUpdateHistory = refresh || isHistoryUpdateTick || balanceChanged
130
+ const shouldUpdateOnlyBalance = balanceChanged && !shouldUpdateHistory
131
+
132
+ // getHistory is more likely to fail/be rate limited, so we want to update users balance only on a lot of ticks
133
+ if (this.shouldUpdateBalanceBeforeHistory || shouldUpdateOnlyBalance) {
134
+ // update all state at once
135
+ await this.updateState({ account, walletAccount, staking })
136
+ await this.emitUnknownTokensEvent({ tokenAccounts })
137
+ }
138
+
139
+ if (shouldUpdateHistory) {
140
+ const { logItemsByAsset, cursorState } = await this.getHistory({
141
+ address,
142
+ accountState,
143
+ walletAccount,
144
+ refresh,
145
+ tokenAccounts,
146
+ })
147
+
148
+ const cursorChanged = this.hasNewCursor({ walletAccount, cursorState })
149
+
150
+ // update all state at once
151
+ const clearedLogItems = await this.markStaleTransactions({ walletAccount, logItemsByAsset })
152
+ await this.updateTxLogByAsset({ walletAccount, logItemsByAsset: clearedLogItems, refresh })
153
+ await this.updateState({ account, cursorState, walletAccount, staking })
154
+ await this.emitUnknownTokensEvent({ tokenAccounts })
155
+ if (refresh || cursorChanged) {
156
+ this.cursors[walletAccount] = cursorState.cursor
157
+ }
158
+ }
159
+ }
160
+
161
+ async getHistory({ address, accountState, refresh, tokenAccounts } = Object.create(null)) {
162
+ const cursor = refresh ? '' : accountState.cursor
163
+ const baseAsset = this.asset
164
+
165
+ const { transactions, newCursor } = await this.api.getTransactions(address, {
166
+ cursor,
167
+ includeUnparsed: this.includeUnparsed,
168
+ limit: this.txsLimit,
169
+ tokenAccounts,
170
+ })
171
+
172
+ const mappedTransactions = []
173
+ for (const tx of transactions) {
174
+ // we get the token name using the token.mintAddress
175
+ let tokenName = this.api.tokens.get(tx.token?.mintAddress)?.name
176
+ if (tx.token && !tokenName) {
177
+ tokenName = 'unknown' // unknown token
178
+ }
179
+
180
+ const assetName = tokenName ?? baseAsset.name
181
+ const asset = this.assets[assetName]
182
+ if (assetName === 'unknown' || !asset) continue // skip unknown tokens
183
+ const feeAsset = asset.feeAsset
184
+
185
+ const coinAmount = tx.amount ? asset.currency.baseUnit(tx.amount) : asset.currency.ZERO
186
+
187
+ const item = {
188
+ coinName: assetName,
189
+ txId: tx.id,
190
+ from: [tx.from],
191
+ coinAmount,
192
+ confirmations: 1, // tx.confirmations, // avoid multiple notifications
193
+ date: tx.date,
194
+ error: tx.error,
195
+ data: {
196
+ staking: tx.staking || null,
197
+ unparsed: !!tx.unparsed,
198
+ swapTx: !!(tx.data && tx.data.inner),
199
+ },
200
+ currencies: { [assetName]: asset.currency, [feeAsset.name]: feeAsset.currency },
201
+ }
202
+
203
+ if (tx.owner === address) {
204
+ // send transaction
205
+ item.to = Array.isArray(tx.to) ? undefined : tx.to
206
+ item.feeAmount = baseAsset.currency.baseUnit(tx.fee) // in SOL
207
+ item.feeCoinName = baseAsset.name
208
+ item.coinAmount = item.coinAmount.negate()
209
+
210
+ if (tx.data?.sent) {
211
+ item.data.sent = tx.data.sent.map((s) => ({
212
+ address: s.address,
213
+ amount: asset.currency.baseUnit(s.amount).toDefaultString({ unit: true }),
214
+ }))
215
+ }
216
+
217
+ if (tx.to === tx.owner) {
218
+ item.selfSend = true
219
+ item.coinAmount = asset.currency.ZERO
220
+ }
221
+ } else if (tx.unparsed) {
222
+ if (tx.fee !== 0) {
223
+ item.feeAmount = baseAsset.currency.baseUnit(tx.fee) // in SOL
224
+ item.feeCoinName = baseAsset.name
225
+ }
226
+
227
+ item.data.meta = tx.data.meta
228
+ }
229
+
230
+ if (asset.name !== asset.baseAsset.name && item.feeAmount && item.feeAmount.isPositive) {
231
+ const feeItem = {
232
+ ...lodash.clone(item),
233
+ coinName: feeAsset.name,
234
+ tokens: [asset.name],
235
+ coinAmount: feeAsset.currency.ZERO,
236
+ }
237
+ mappedTransactions.push(feeItem)
238
+ }
239
+
240
+ mappedTransactions.push(item)
241
+ }
242
+
243
+ const logItemsByAsset = lodash.groupBy(mappedTransactions, (item) => item.coinName)
244
+ return {
245
+ logItemsByAsset,
246
+ hasNewTxs: transactions.length > 0,
247
+ cursorState: { cursor: newCursor },
248
+ }
249
+ }
250
+
251
+ async getAccountsAndBalances({ refresh, address, accountState, walletAccount }) {
252
+ const tokens = Object.keys(this.assets).filter((name) => name !== this.asset.name)
253
+ const [accountInfo, { balances: splBalances, accounts: tokenAccounts }] = await Promise.all([
254
+ this.api.getAccountInfo(address).catch(() => {}),
255
+ this.api.getTokensBalancesAndAccounts({
256
+ address,
257
+ filterByTokens: tokens,
258
+ }),
259
+ ])
260
+
261
+ const solBalance = accountInfo?.lamports || 0
262
+
263
+ const accountSize = accountInfo?.space || 0
264
+
265
+ const rentExemptAmount = this.asset.currency.baseUnit(
266
+ await this.api.getMinimumBalanceForRentExemption(accountSize)
267
+ )
268
+
269
+ const ownerChanged = await this.api.ownerChanged(address, accountInfo)
270
+
271
+ // we can have splBalances for tokens that are not in our asset list
272
+ const clientKnownTokens = omitBy(splBalances, (v, mintAddress) => {
273
+ const tokenName = this.api.tokens.get(mintAddress)?.name
274
+ return !this.assets[tokenName]
275
+ })
276
+ const tokenBalances = Object.fromEntries(
277
+ Object.entries(clientKnownTokens).map(([mintAddress, balance]) => {
278
+ const tokenName = this.api.tokens.get(mintAddress)?.name
279
+ return [tokenName, this.assets[tokenName].currency.baseUnit(balance)]
280
+ })
281
+ )
282
+
283
+ const solBalanceChanged = this.#balanceChanged({
284
+ account: accountState,
285
+ newAccount: {
286
+ balance: this.asset.currency.baseUnit(solBalance), // balance without staking
287
+ tokenBalances,
288
+ },
289
+ })
290
+ const fetchStakingInfo =
291
+ refresh ||
292
+ solBalanceChanged ||
293
+ this.tickCount[walletAccount] % this.ticksBetweenStakeFetches === 0
294
+
295
+ const staking =
296
+ this.isStakingEnabled() && fetchStakingInfo
297
+ ? await this.getStakingInfo({ address, accountState, walletAccount })
298
+ : { ...accountState.stakingInfo, staking: this.staking }
299
+
300
+ const stakedBalance = this.asset.currency.baseUnit(staking.locked)
301
+ const activatingBalance = this.asset.currency.baseUnit(staking.activating)
302
+ const withdrawableBalance = this.asset.currency.baseUnit(staking.withdrawable)
303
+ const pendingBalance = this.asset.currency.baseUnit(staking.pending)
304
+ const balance = this.asset.currency
305
+ .baseUnit(solBalance)
306
+ .add(stakedBalance)
307
+ .add(activatingBalance)
308
+ .add(withdrawableBalance)
309
+ .add(pendingBalance)
310
+
311
+ return {
312
+ account: {
313
+ balance,
314
+ tokenBalances,
315
+ rentExemptAmount,
316
+ accountSize,
317
+ ownerChanged,
318
+ },
319
+ staking,
320
+ tokenAccounts,
321
+ }
322
+ }
323
+
324
+ async updateState({ account, cursorState, walletAccount, staking }) {
325
+ const { balance, tokenBalances, rentExemptAmount, accountSize, ownerChanged } = account
326
+ const newData = {
327
+ balance,
328
+ rentExemptAmount,
329
+ accountSize,
330
+ ownerChanged,
331
+ tokenBalances,
332
+ stakingInfo: staking,
333
+ ...cursorState,
334
+ }
335
+ return this.updateAccountState({ newData, walletAccount })
336
+ }
337
+
338
+ async getStakingInfo({ address, accountState, walletAccount }) {
339
+ const stakingInfo = await this.api.getStakeAccountsInfo(address)
340
+ let earned = accountState.stakingInfo.earned.toBaseString()
341
+ try {
342
+ const rewards = await this.api.getRewards(address)
343
+ earned = rewards
344
+ } catch (error) {
345
+ console.warn(error)
346
+ }
347
+
348
+ return {
349
+ loaded: true,
350
+ staking: this.staking,
351
+ isDelegating: Object.values(stakingInfo.accounts).some(({ state }) =>
352
+ ['active', 'activating', 'inactive'].includes(state)
353
+ ), // true if at least 1 account is delegating
354
+ locked: this.asset.currency.baseUnit(stakingInfo.locked),
355
+ activating: this.asset.currency.baseUnit(stakingInfo.activating),
356
+ withdrawable: this.asset.currency.baseUnit(stakingInfo.withdrawable),
357
+ pending: this.asset.currency.baseUnit(stakingInfo.pending), // still undelegating (not yet available for withdraw)
358
+ earned: this.asset.currency.baseUnit(earned),
359
+ accounts: stakingInfo.accounts, // Obj
360
+ }
361
+ }
362
+ }
@@ -1 +1,2 @@
1
1
  export * from './solana-monitor.js'
2
+ export * from './clarity-monitor.js'
package/src/tx-send.js CHANGED
@@ -44,7 +44,7 @@ export const createAndBroadcastTXFactory =
44
44
 
45
45
  const txId = signedTx.txId
46
46
 
47
- const isToken = asset.assetType === api.tokenAssetType
47
+ const isToken = asset.name !== asset.baseAsset.name
48
48
 
49
49
  await baseAsset.api.broadcastTx(signedTx.rawTx)
50
50