@exodus/solana-api 3.29.5 → 3.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/package.json +3 -3
- package/src/account-state.js +1 -0
- package/src/clarity-api.js +7 -3
- package/src/staking/index.js +2 -2
- package/src/tx-log/clarity-monitor.js +186 -41
- package/src/tx-log/ws-monitor.js +1 -2
- package/src/ws-api.js +86 -66
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,32 @@
|
|
|
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.30.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.29.6...@exodus/solana-api@3.30.0) (2026-03-10)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
* feat: switch Solana txs to Clarity (#7449)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
* fix: helius vote false param (#7486)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## [3.29.6](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.29.5...@exodus/solana-api@3.29.6) (2026-02-23)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Bug Fixes
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
* fix: remove staking provider param (#7440)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
6
32
|
## [3.29.5](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.29.4...@exodus/solana-api@3.29.5) (2026-02-14)
|
|
7
33
|
|
|
8
34
|
**Note:** Version bump only for package @exodus/solana-api
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/solana-api",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.30.0",
|
|
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",
|
|
@@ -45,11 +45,11 @@
|
|
|
45
45
|
"url-join": "^4.0.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@exodus/asset": "^2.
|
|
48
|
+
"@exodus/asset": "^2.3.0",
|
|
49
49
|
"@exodus/assets-testing": "^1.0.0",
|
|
50
50
|
"@exodus/solana-web3.js": "^1.63.1-exodus.9-rc3"
|
|
51
51
|
},
|
|
52
|
-
"gitHead": "
|
|
52
|
+
"gitHead": "0076335abe2f542d07cc23704104c106ed38d5f3",
|
|
53
53
|
"bugs": {
|
|
54
54
|
"url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Asolana-api"
|
|
55
55
|
},
|
package/src/account-state.js
CHANGED
|
@@ -17,6 +17,7 @@ export const createAccountState = ({ assetList }) => {
|
|
|
17
17
|
return class SolanaAccountState extends AccountState {
|
|
18
18
|
static defaults = {
|
|
19
19
|
cursor: '',
|
|
20
|
+
historyCursor: '',
|
|
20
21
|
balance: asset.currency.ZERO,
|
|
21
22
|
tokenBalances: Object.create(null),
|
|
22
23
|
rentExemptAmount: asset.currency.ZERO,
|
package/src/clarity-api.js
CHANGED
|
@@ -7,7 +7,7 @@ import urljoin from 'url-join'
|
|
|
7
7
|
|
|
8
8
|
import { RpcApi } from './rpc-api.js'
|
|
9
9
|
|
|
10
|
-
const CLARITY_URL = 'https://
|
|
10
|
+
const CLARITY_URL = 'https://assets-gateway-clarity-api.a.exodus.io/assets/api/v2/solana'
|
|
11
11
|
|
|
12
12
|
// Filter out nil and empty string values to prevent API validation errors (e.g., empty cursor)
|
|
13
13
|
const cleanQuery = (obj) => omitBy(obj, (v) => isNil(v) || v === '')
|
|
@@ -47,11 +47,15 @@ export class ClarityApi extends RpcApi {
|
|
|
47
47
|
})
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
async getTransactions(
|
|
50
|
+
async getTransactions(
|
|
51
|
+
address,
|
|
52
|
+
{ after, before, limit, includeUnparsed = false } = Object.create(null)
|
|
53
|
+
) {
|
|
51
54
|
const result = await this.request(`/addresses/${encodeURIComponent(address)}/transactions`)
|
|
52
55
|
.query(
|
|
53
56
|
cleanQuery({
|
|
54
|
-
|
|
57
|
+
before,
|
|
58
|
+
after,
|
|
55
59
|
limit,
|
|
56
60
|
includeUnparsed,
|
|
57
61
|
})
|
package/src/staking/index.js
CHANGED
|
@@ -5,8 +5,8 @@ import { stakingProviderClientFactory } from './staking-provider-client.js'
|
|
|
5
5
|
|
|
6
6
|
export { getStakingInfo } from '../staking-utils.js'
|
|
7
7
|
|
|
8
|
-
export const stakingApiFactory = ({ assetName, assetClientInterface
|
|
9
|
-
const stakingProvider =
|
|
8
|
+
export const stakingApiFactory = ({ assetName, assetClientInterface }) => {
|
|
9
|
+
const stakingProvider = stakingProviderClientFactory()
|
|
10
10
|
|
|
11
11
|
async function sendStake({
|
|
12
12
|
address,
|
|
@@ -18,21 +18,21 @@ const TICKS_BETWEEN_STAKE_FETCHES = 5
|
|
|
18
18
|
const TX_STALE_AFTER = ms('2m') // mark txs as dropped after N minutes
|
|
19
19
|
|
|
20
20
|
export class SolanaClarityMonitor extends BaseMonitor {
|
|
21
|
+
#maxFetchLimitPerTick
|
|
22
|
+
|
|
21
23
|
constructor({
|
|
22
24
|
clarityApi,
|
|
23
|
-
rpcApi,
|
|
24
25
|
includeUnparsed = false,
|
|
25
26
|
ticksBetweenHistoryFetches = TICKS_BETWEEN_HISTORY_FETCHES,
|
|
26
27
|
ticksBetweenStakeFetches = TICKS_BETWEEN_STAKE_FETCHES,
|
|
27
28
|
txsLimit,
|
|
29
|
+
maxFetchLimitPerTick = 5,
|
|
28
30
|
shouldUpdateBalanceBeforeHistory = true,
|
|
29
31
|
...args
|
|
30
32
|
}) {
|
|
31
33
|
super(args)
|
|
32
34
|
assert(clarityApi, 'clarityApi is required')
|
|
33
35
|
this.clarityApi = clarityApi
|
|
34
|
-
this.rpcApi = rpcApi
|
|
35
|
-
this.cursors = Object.create(null)
|
|
36
36
|
this.assets = Object.create(null)
|
|
37
37
|
this.staking = DEFAULT_REMOTE_CONFIG.staking
|
|
38
38
|
this.ticksBetweenStakeFetches = ticksBetweenStakeFetches
|
|
@@ -40,6 +40,7 @@ export class SolanaClarityMonitor extends BaseMonitor {
|
|
|
40
40
|
this.shouldUpdateBalanceBeforeHistory = shouldUpdateBalanceBeforeHistory
|
|
41
41
|
this.includeUnparsed = includeUnparsed
|
|
42
42
|
this.txsLimit = txsLimit
|
|
43
|
+
this.#maxFetchLimitPerTick = maxFetchLimitPerTick
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
setServer(config = Object.create(null)) {
|
|
@@ -53,11 +54,6 @@ export class SolanaClarityMonitor extends BaseMonitor {
|
|
|
53
54
|
this.staking = staking
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
hasNewCursor({ walletAccount, cursorState }) {
|
|
57
|
-
const { cursor } = cursorState
|
|
58
|
-
return this.cursors[walletAccount] !== cursor
|
|
59
|
-
}
|
|
60
|
-
|
|
61
57
|
async emitUnknownTokensEvent({ tokenAccounts }) {
|
|
62
58
|
const tokensList = await this.clarityApi.getWalletTokensList({ tokenAccounts })
|
|
63
59
|
const unknownTokensList = tokensList.filter((mintAddress) => {
|
|
@@ -101,7 +97,7 @@ export class SolanaClarityMonitor extends BaseMonitor {
|
|
|
101
97
|
const txSet = await this.aci.getTxLog({ assetName, walletAccount })
|
|
102
98
|
const { stale } = this.getUnconfirmed({ txSet, staleTxAge: TX_STALE_AFTER })
|
|
103
99
|
if (stale.length > 0) {
|
|
104
|
-
clearedLogItems[assetName] = lodash.unionBy(logItemsByAsset[assetName], stale, 'txId')
|
|
100
|
+
clearedLogItems[assetName] = lodash.unionBy(logItemsByAsset[assetName] ?? [], stale, 'txId')
|
|
105
101
|
}
|
|
106
102
|
}
|
|
107
103
|
|
|
@@ -137,9 +133,9 @@ export class SolanaClarityMonitor extends BaseMonitor {
|
|
|
137
133
|
})
|
|
138
134
|
const hasUnconfirmedSentTx = [...baseAssetTxLog].some((tx) => tx.pending && tx.sent)
|
|
139
135
|
|
|
140
|
-
const
|
|
136
|
+
const shouldUpdateLatestHistory =
|
|
141
137
|
refresh || isHistoryUpdateTick || balanceChanged || hasUnconfirmedSentTx
|
|
142
|
-
const shouldUpdateOnlyBalance = balanceChanged && !
|
|
138
|
+
const shouldUpdateOnlyBalance = balanceChanged && !shouldUpdateLatestHistory
|
|
143
139
|
|
|
144
140
|
// start a batch
|
|
145
141
|
const batch = this.aci.createOperationsBatch()
|
|
@@ -150,50 +146,146 @@ export class SolanaClarityMonitor extends BaseMonitor {
|
|
|
150
146
|
await this.emitUnknownTokensEvent({ tokenAccounts })
|
|
151
147
|
}
|
|
152
148
|
|
|
153
|
-
|
|
154
|
-
|
|
149
|
+
const newCursorState = {
|
|
150
|
+
cursor: accountState.cursor,
|
|
151
|
+
historyCursor: accountState.historyCursor,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const newTransactions = []
|
|
155
|
+
|
|
156
|
+
if (shouldUpdateLatestHistory) {
|
|
157
|
+
const { transactions: latestTransactions, cursorState: latestHistoryCursorState } =
|
|
158
|
+
await this.getLatestHistory({
|
|
159
|
+
address,
|
|
160
|
+
accountState,
|
|
161
|
+
walletAccount,
|
|
162
|
+
refresh,
|
|
163
|
+
tokenAccounts,
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
if (latestHistoryCursorState.cursor) {
|
|
167
|
+
newCursorState.cursor = latestHistoryCursorState.cursor
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (latestHistoryCursorState.historyCursor) {
|
|
171
|
+
// refresh case will have historyCursor available
|
|
172
|
+
// but if user has 0 txs, it won't be available, therefore need to check
|
|
173
|
+
newCursorState.historyCursor = latestHistoryCursorState.historyCursor
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
newTransactions.push(...latestTransactions)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const shouldFetchOldHistory = !refresh && newCursorState.historyCursor // on refresh request, wait until next tick to fetch old history
|
|
180
|
+
if (shouldFetchOldHistory) {
|
|
181
|
+
const { transactions: historyTransactions, historyCursor } = await this.fetchOldHistory({
|
|
155
182
|
address,
|
|
156
|
-
|
|
157
|
-
walletAccount,
|
|
158
|
-
refresh,
|
|
159
|
-
tokenAccounts,
|
|
183
|
+
historyCursor: newCursorState.historyCursor,
|
|
160
184
|
})
|
|
161
185
|
|
|
162
|
-
|
|
186
|
+
newTransactions.push(...historyTransactions)
|
|
187
|
+
// Always update so we persist historyCursor = undefined when history is exhausted
|
|
188
|
+
newCursorState.historyCursor = historyCursor
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await this.emitUnknownTokensEvent({ tokenAccounts })
|
|
163
192
|
|
|
193
|
+
if (newTransactions.length > 0) {
|
|
164
194
|
// update all state at once
|
|
165
|
-
const clearedLogItems = await this.markStaleTransactions({
|
|
195
|
+
const clearedLogItems = await this.markStaleTransactions({
|
|
196
|
+
walletAccount,
|
|
197
|
+
logItemsByAsset: this.mapTransactionsToLogItems({ transactions: newTransactions, address }),
|
|
198
|
+
})
|
|
199
|
+
|
|
166
200
|
this.updateTxLogByAssetBatch({
|
|
167
201
|
logItemsByAsset: clearedLogItems,
|
|
168
202
|
walletAccount,
|
|
169
203
|
refresh,
|
|
170
204
|
batch,
|
|
171
205
|
})
|
|
172
|
-
this.updateState({ account, cursorState, walletAccount, staking, batch })
|
|
173
|
-
await this.emitUnknownTokensEvent({ tokenAccounts })
|
|
174
|
-
if (refresh || cursorChanged) {
|
|
175
|
-
this.cursors[walletAccount] = cursorState.cursor
|
|
176
|
-
}
|
|
177
206
|
}
|
|
178
207
|
|
|
208
|
+
// Always persist cursor state so historyCursor advances (and is cleared when history exhausted)
|
|
209
|
+
this.updateState({
|
|
210
|
+
account,
|
|
211
|
+
cursorState: newCursorState,
|
|
212
|
+
walletAccount,
|
|
213
|
+
staking,
|
|
214
|
+
batch,
|
|
215
|
+
})
|
|
216
|
+
|
|
179
217
|
// close batch
|
|
180
218
|
await this.aci.executeOperationsBatch(batch)
|
|
181
219
|
}
|
|
182
220
|
|
|
183
|
-
async
|
|
184
|
-
|
|
185
|
-
const baseAsset = this.asset
|
|
221
|
+
async fetchOldHistory({ address, historyCursor }) {
|
|
222
|
+
if (!historyCursor) return { transactions: [], historyCursor: undefined }
|
|
186
223
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
224
|
+
try {
|
|
225
|
+
const { transactions, before } = await this.clarityApi.getTransactions(address, {
|
|
226
|
+
before: historyCursor,
|
|
227
|
+
includeUnparsed: this.includeUnparsed,
|
|
228
|
+
limit: this.txsLimit,
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
if (!transactions || transactions.length === 0 || !before) {
|
|
232
|
+
// no more transactions to fetch, return undefined to stop fetching old history
|
|
233
|
+
return { transactions: [], historyCursor: undefined }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const transactionsWithSilentSound = transactions.map((tx) => ({
|
|
237
|
+
...tx,
|
|
238
|
+
data: { ...tx.data, silentSound: true },
|
|
239
|
+
}))
|
|
240
|
+
|
|
241
|
+
return { transactions: transactionsWithSilentSound, historyCursor: before }
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.warn('SolanaClarityMonitor fetchOldHistory failed', {
|
|
244
|
+
address,
|
|
245
|
+
historyCursor,
|
|
246
|
+
error,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
return { transactions: [], historyCursor: undefined }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async getLatestHistory({ address, accountState, refresh, tokenAccounts } = Object.create(null)) {
|
|
254
|
+
if (refresh || !accountState.cursor) {
|
|
255
|
+
const { transactions, before, after } = await this.clarityApi.getTransactions(address, {
|
|
256
|
+
after: undefined,
|
|
257
|
+
includeUnparsed: this.includeUnparsed,
|
|
258
|
+
limit: this.txsLimit,
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
transactions,
|
|
263
|
+
hasNewTxs: transactions.length > 0,
|
|
264
|
+
cursorState: {
|
|
265
|
+
cursor: after,
|
|
266
|
+
historyCursor: before,
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const { transactions, after: newAfterCursor } = await this.fetchLatestTransactions({
|
|
272
|
+
address,
|
|
273
|
+
afterCursor: accountState.cursor,
|
|
194
274
|
})
|
|
195
275
|
|
|
276
|
+
return {
|
|
277
|
+
transactions,
|
|
278
|
+
hasNewTxs: transactions.length > 0,
|
|
279
|
+
cursorState: {
|
|
280
|
+
cursor: newAfterCursor,
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
mapTransactionsToLogItems({ transactions, address }) {
|
|
286
|
+
const baseAsset = this.asset
|
|
196
287
|
const mappedTransactions = []
|
|
288
|
+
|
|
197
289
|
for (const tx of transactions) {
|
|
198
290
|
// we get the token name using the token.mintAddress
|
|
199
291
|
const assetName = tx.token
|
|
@@ -218,6 +310,7 @@ export class SolanaClarityMonitor extends BaseMonitor {
|
|
|
218
310
|
staking: tx.staking || null,
|
|
219
311
|
unparsed: !!tx.unparsed,
|
|
220
312
|
swapTx: !!(tx.data && tx.data.inner),
|
|
313
|
+
silentSound: !!tx.data?.silentSound,
|
|
221
314
|
},
|
|
222
315
|
currencies: { [assetName]: asset.currency, [feeAsset.name]: feeAsset.currency },
|
|
223
316
|
}
|
|
@@ -262,12 +355,63 @@ export class SolanaClarityMonitor extends BaseMonitor {
|
|
|
262
355
|
mappedTransactions.push(item)
|
|
263
356
|
}
|
|
264
357
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
358
|
+
return lodash.groupBy(mergeByTxId(mappedTransactions), 'coinName')
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async fetchLatestTransactions({ address, afterCursor }) {
|
|
362
|
+
let cursor = afterCursor
|
|
363
|
+
let allTransactions = []
|
|
364
|
+
let newAfterCursor = afterCursor
|
|
365
|
+
let fetchCount = 0
|
|
366
|
+
|
|
367
|
+
while (fetchCount < this.#maxFetchLimitPerTick) {
|
|
368
|
+
try {
|
|
369
|
+
const { transactions, after } = await this.clarityApi.getTransactions(address, {
|
|
370
|
+
after: cursor,
|
|
371
|
+
includeUnparsed: this.includeUnparsed,
|
|
372
|
+
limit: this.txsLimit,
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
fetchCount += 1
|
|
376
|
+
|
|
377
|
+
if (!transactions || transactions.length === 0) {
|
|
378
|
+
break
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!after) {
|
|
382
|
+
// guard against missing cursor on non-empty transactions
|
|
383
|
+
console.warn('SolanaClarityMonitor missing cursor with transactions', {
|
|
384
|
+
address,
|
|
385
|
+
cursor,
|
|
386
|
+
afterCursor,
|
|
387
|
+
fetchCount,
|
|
388
|
+
transactionsCount: transactions.length,
|
|
389
|
+
})
|
|
390
|
+
break
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
allTransactions = [...allTransactions, ...transactions]
|
|
394
|
+
|
|
395
|
+
if (after === cursor) {
|
|
396
|
+
newAfterCursor = after
|
|
397
|
+
break
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
cursor = after
|
|
401
|
+
newAfterCursor = after
|
|
402
|
+
} catch (error) {
|
|
403
|
+
console.warn('SolanaClarityMonitor fetchLatestTransactions failed', {
|
|
404
|
+
address,
|
|
405
|
+
cursor,
|
|
406
|
+
afterCursor,
|
|
407
|
+
fetchCount,
|
|
408
|
+
error,
|
|
409
|
+
})
|
|
410
|
+
break
|
|
411
|
+
}
|
|
270
412
|
}
|
|
413
|
+
|
|
414
|
+
return { transactions: allTransactions, after: newAfterCursor }
|
|
271
415
|
}
|
|
272
416
|
|
|
273
417
|
async getAccountsAndBalances({ refresh, address, accountState, walletAccount }) {
|
|
@@ -367,17 +511,18 @@ export class SolanaClarityMonitor extends BaseMonitor {
|
|
|
367
511
|
stakingInfo: staking,
|
|
368
512
|
...cursorState,
|
|
369
513
|
}
|
|
514
|
+
|
|
370
515
|
return this.updateAccountStateBatch({ assetName, walletAccount, newData, batch })
|
|
371
516
|
}
|
|
372
517
|
|
|
373
518
|
async getStakingInfo({ address, accountState, walletAccount }) {
|
|
374
519
|
const stakingInfo = await this.clarityApi.getStakeAccountsInfo(address)
|
|
375
|
-
let earned = accountState.stakingInfo
|
|
520
|
+
let earned = accountState.stakingInfo?.earned?.toBaseString() ?? '0'
|
|
376
521
|
try {
|
|
377
522
|
const rewards = await this.clarityApi.getRewards(address)
|
|
378
523
|
earned = rewards
|
|
379
524
|
} catch (error) {
|
|
380
|
-
console.warn(error)
|
|
525
|
+
console.warn('SolanaClarityMonitor getStakingInfo failed', { address, error })
|
|
381
526
|
}
|
|
382
527
|
|
|
383
528
|
return {
|
package/src/tx-log/ws-monitor.js
CHANGED
|
@@ -26,7 +26,6 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
26
26
|
async beforeStart() {
|
|
27
27
|
this.assets = await this.aci.getAssetsForNetwork({ baseAssetName: this.asset.name })
|
|
28
28
|
this.clarityApi.setTokens(this.assets)
|
|
29
|
-
this.rpcApi.setTokens(this.assets)
|
|
30
29
|
|
|
31
30
|
const walletAccounts = await this.aci.getWalletAccounts({ assetName: this.asset.name })
|
|
32
31
|
await Promise.all(
|
|
@@ -50,7 +49,7 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
50
49
|
useCache: true,
|
|
51
50
|
})
|
|
52
51
|
|
|
53
|
-
const { accounts: tokenAccountsByOwner } = await this.
|
|
52
|
+
const { accounts: tokenAccountsByOwner } = await this.clarityApi.getTokensBalancesAndAccounts({
|
|
54
53
|
address,
|
|
55
54
|
})
|
|
56
55
|
this.tokenAccountsByOwner[walletAccount] = tokenAccountsByOwner
|
package/src/ws-api.js
CHANGED
|
@@ -7,9 +7,9 @@ import { parseTransaction } from './tx-parser.js'
|
|
|
7
7
|
import { isSolAddressPoisoningTx } from './txs-utils.js'
|
|
8
8
|
|
|
9
9
|
// Triton Whirligig WebSocket
|
|
10
|
-
const
|
|
11
|
-
// Helius
|
|
12
|
-
|
|
10
|
+
// const TRITON_WS_ENDPOINT = 'wss://solana-triton.a.exodus.io/whirligig' // pointing to: wss://exodus-solanama-6db3.mainnet.rpcpool.com/<token>/whirligig
|
|
11
|
+
// Helius Enhanced WebSocket (https://www.helius.dev/docs/enhanced-websockets)
|
|
12
|
+
const HELIUS_WS_URL = 'wss://solana-helius-wss.a.exodus.io/ws'
|
|
13
13
|
|
|
14
14
|
export class WsApi {
|
|
15
15
|
constructor({ rpcUrl, wsUrl, assets }) {
|
|
@@ -23,6 +23,7 @@ export class WsApi {
|
|
|
23
23
|
#resetSubscriptionState() {
|
|
24
24
|
this.accountSubscriptions = Object.create(null)
|
|
25
25
|
this.transactionSubscriptions = Object.create(null)
|
|
26
|
+
this.programSubscriptions = Object.create(null)
|
|
26
27
|
// subscription id (from RPC response) -> { owner, type: 'account'|'transaction'|'program' }
|
|
27
28
|
this.subscriptionIdToMeta = Object.create(null)
|
|
28
29
|
// request id (our conn.seq) -> { owner, type }, until we receive the subscription id
|
|
@@ -30,7 +31,13 @@ export class WsApi {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
setWsEndpoint(wsUrl) {
|
|
33
|
-
this.wsUrl = wsUrl ||
|
|
34
|
+
this.wsUrl = wsUrl || HELIUS_WS_URL
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when using Helius Enhanced WebSocket (different transactionSubscribe params and notification shape). */
|
|
38
|
+
#isHelius() {
|
|
39
|
+
if (!this.wsUrl) return false
|
|
40
|
+
return this.wsUrl.includes('helius') || this.wsUrl === HELIUS_WS_URL
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
/**
|
|
@@ -174,36 +181,79 @@ export class WsApi {
|
|
|
174
181
|
|
|
175
182
|
const id = ++conn.seq
|
|
176
183
|
this.pendingSubscriptionRequests[id] = { owner, type: 'transaction' }
|
|
184
|
+
const options = {
|
|
185
|
+
commitment: 'confirmed',
|
|
186
|
+
encoding: 'jsonParsed',
|
|
187
|
+
transactionDetails: 'full',
|
|
188
|
+
showRewards: false,
|
|
189
|
+
maxSupportedTransactionVersion: 255,
|
|
190
|
+
}
|
|
191
|
+
const filter = this.#isHelius()
|
|
192
|
+
? { vote: false, accountInclude: difference }
|
|
193
|
+
: { vote: false, accounts: { include: difference } }
|
|
177
194
|
conn.send({
|
|
178
195
|
jsonrpc: '2.0',
|
|
179
196
|
id,
|
|
180
197
|
method: 'transactionSubscribe',
|
|
198
|
+
params: [filter, options],
|
|
199
|
+
})
|
|
200
|
+
this.transactionSubscriptions[owner] = [...subscriptions, ...difference]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async programSubscribe({ owner }) {
|
|
204
|
+
const conn = this.connection
|
|
205
|
+
if (!conn || !conn.isOpen) {
|
|
206
|
+
console.warn('SOL Connection is not open, cannot programSubscribe for', owner)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (this.programSubscriptions[owner]) return // already subscribed (SPL + Token-2022)
|
|
211
|
+
|
|
212
|
+
const splTokenProgramId = TOKEN_PROGRAM_ID.toBase58()
|
|
213
|
+
const token2022ProgramId = TOKEN_2022_PROGRAM_ID.toBase58()
|
|
214
|
+
const tokenAccountDataSize = 165
|
|
215
|
+
|
|
216
|
+
// SPL Token: fixed 165-byte account size
|
|
217
|
+
const id1 = ++conn.seq
|
|
218
|
+
this.pendingSubscriptionRequests[id1] = { owner, type: 'program' }
|
|
219
|
+
conn.send({
|
|
220
|
+
jsonrpc: '2.0',
|
|
221
|
+
id: id1,
|
|
222
|
+
method: 'programSubscribe',
|
|
181
223
|
params: [
|
|
224
|
+
splTokenProgramId,
|
|
182
225
|
{
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
},
|
|
226
|
+
encoding: 'jsonParsed',
|
|
227
|
+
commitment: 'confirmed',
|
|
228
|
+
filters: [{ dataSize: tokenAccountDataSize }, { memcmp: { offset: 32, bytes: owner } }],
|
|
187
229
|
},
|
|
230
|
+
],
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// Token-2022: no dataSize filter (accounts can have extensions and be larger than 165 bytes)
|
|
234
|
+
const id2 = ++conn.seq
|
|
235
|
+
this.pendingSubscriptionRequests[id2] = { owner, type: 'program' }
|
|
236
|
+
conn.send({
|
|
237
|
+
jsonrpc: '2.0',
|
|
238
|
+
id: id2,
|
|
239
|
+
method: 'programSubscribe',
|
|
240
|
+
params: [
|
|
241
|
+
token2022ProgramId,
|
|
188
242
|
{
|
|
189
|
-
commitment: 'confirmed',
|
|
190
243
|
encoding: 'jsonParsed',
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
maxSupportedTransactionVersion: 255,
|
|
244
|
+
commitment: 'confirmed',
|
|
245
|
+
filters: [{ memcmp: { offset: 32, bytes: owner } }],
|
|
194
246
|
},
|
|
195
247
|
],
|
|
196
248
|
})
|
|
197
|
-
|
|
249
|
+
|
|
250
|
+
this.programSubscriptions[owner] = true
|
|
198
251
|
}
|
|
199
252
|
|
|
200
253
|
async sendSubscriptions({ address, tokensAddresses = [] }) {
|
|
201
|
-
const conn = this.connection
|
|
202
|
-
|
|
203
254
|
const addresses = [address, ...tokensAddresses]
|
|
204
255
|
|
|
205
256
|
// 1. subscribe to each addresses (SOL and Token addresses) balance events
|
|
206
|
-
// transform this forEach in a for of to await each subscription:
|
|
207
257
|
for (const addr of addresses) {
|
|
208
258
|
await this.accountSubscribe({ owner: address, account: addr })
|
|
209
259
|
}
|
|
@@ -212,48 +262,7 @@ export class WsApi {
|
|
|
212
262
|
await this.transactionSubscribe({ owner: address, accounts: addresses })
|
|
213
263
|
|
|
214
264
|
// 3. subscribe to SPL Token and Token-2022 program account changes to detect new token accounts for this wallet
|
|
215
|
-
|
|
216
|
-
const token2022ProgramId = TOKEN_2022_PROGRAM_ID.toBase58()
|
|
217
|
-
const tokenAccountDataSize = 165
|
|
218
|
-
|
|
219
|
-
if (conn?.isOpen) {
|
|
220
|
-
// SPL Token: fixed 165-byte account size
|
|
221
|
-
const id1 = ++conn.seq
|
|
222
|
-
this.pendingSubscriptionRequests[id1] = { owner: address, type: 'program' }
|
|
223
|
-
conn.send({
|
|
224
|
-
jsonrpc: '2.0',
|
|
225
|
-
id: id1,
|
|
226
|
-
method: 'programSubscribe',
|
|
227
|
-
params: [
|
|
228
|
-
splTokenProgramId,
|
|
229
|
-
{
|
|
230
|
-
encoding: 'jsonParsed',
|
|
231
|
-
commitment: 'confirmed',
|
|
232
|
-
filters: [
|
|
233
|
-
{ dataSize: tokenAccountDataSize },
|
|
234
|
-
{ memcmp: { offset: 32, bytes: address } },
|
|
235
|
-
],
|
|
236
|
-
},
|
|
237
|
-
],
|
|
238
|
-
})
|
|
239
|
-
|
|
240
|
-
// Token-2022: no dataSize filter (accounts can have extensions and be larger than 165 bytes)
|
|
241
|
-
const id2 = ++conn.seq
|
|
242
|
-
this.pendingSubscriptionRequests[id2] = { owner: address, type: 'program' }
|
|
243
|
-
conn.send({
|
|
244
|
-
jsonrpc: '2.0',
|
|
245
|
-
id: id2,
|
|
246
|
-
method: 'programSubscribe',
|
|
247
|
-
params: [
|
|
248
|
-
token2022ProgramId,
|
|
249
|
-
{
|
|
250
|
-
encoding: 'jsonParsed',
|
|
251
|
-
commitment: 'confirmed',
|
|
252
|
-
filters: [{ memcmp: { offset: 32, bytes: address } }],
|
|
253
|
-
},
|
|
254
|
-
],
|
|
255
|
-
})
|
|
256
|
-
}
|
|
265
|
+
await this.programSubscribe({ owner: address })
|
|
257
266
|
}
|
|
258
267
|
|
|
259
268
|
async unwatchAddress({ address }) {
|
|
@@ -283,6 +292,7 @@ export class WsApi {
|
|
|
283
292
|
delete this.watchedAddresses[address]
|
|
284
293
|
delete this.accountSubscriptions[address]
|
|
285
294
|
delete this.transactionSubscriptions[address]
|
|
295
|
+
delete this.programSubscriptions[address]
|
|
286
296
|
|
|
287
297
|
if (Object.keys(this.watchedAddresses).length === 0 && conn) {
|
|
288
298
|
await conn.stop()
|
|
@@ -333,22 +343,27 @@ export class WsApi {
|
|
|
333
343
|
}
|
|
334
344
|
}
|
|
335
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Parse accountNotification from Triton or Helius.
|
|
348
|
+
* Triton: params.result = { context, value }. Helius: same shape (result.value = account).
|
|
349
|
+
*/
|
|
336
350
|
parseAccountNotification({ address, walletAccount, tokenAccountsByOwner, result }) {
|
|
337
|
-
const
|
|
351
|
+
const value = result?.value ?? result // support both { context, value } and flat result
|
|
352
|
+
const isSolAccount = value.owner === '11111111111111111111111111111111' // System Program
|
|
338
353
|
if (isSolAccount) {
|
|
339
354
|
// SOL balance changed
|
|
340
|
-
const amount =
|
|
355
|
+
const amount = value.lamports
|
|
341
356
|
return { solAddress: address, amount }
|
|
342
357
|
}
|
|
343
358
|
|
|
344
|
-
const isSplTokenAccount =
|
|
345
|
-
const isSpl2022TokenAccount =
|
|
359
|
+
const isSplTokenAccount = value.owner === TOKEN_PROGRAM_ID.toBase58()
|
|
360
|
+
const isSpl2022TokenAccount = value.owner === TOKEN_2022_PROGRAM_ID.toBase58()
|
|
346
361
|
|
|
347
362
|
// SPL Token balance changed (both spl-token and spl-2022 have the first 165 bytes the same)
|
|
348
363
|
if (isSplTokenAccount || isSpl2022TokenAccount) {
|
|
349
364
|
// Handle jsonParsed encoding (data is an object with parsed info)
|
|
350
|
-
if (
|
|
351
|
-
const parsed =
|
|
365
|
+
if (value.data?.parsed?.info) {
|
|
366
|
+
const parsed = value.data.parsed.info
|
|
352
367
|
return {
|
|
353
368
|
solAddress: parsed.owner,
|
|
354
369
|
amount: parsed.tokenAmount.amount,
|
|
@@ -357,7 +372,7 @@ export class WsApi {
|
|
|
357
372
|
}
|
|
358
373
|
|
|
359
374
|
// Handle base64 encoding (data is an array: [base64_string, "base64"])
|
|
360
|
-
const decoded = Token.decode(Buffer.from(
|
|
375
|
+
const decoded = Token.decode(Buffer.from(value.data[0], 'base64'))
|
|
361
376
|
const tokenMintAddress = new PublicKey(decoded.mint).toBase58()
|
|
362
377
|
const solAddress = new PublicKey(decoded.owner).toBase58()
|
|
363
378
|
const amount = U64.fromBuffer(decoded.amount).toString()
|
|
@@ -407,7 +422,12 @@ export class WsApi {
|
|
|
407
422
|
tokenAccountsByOwner,
|
|
408
423
|
result,
|
|
409
424
|
}) {
|
|
410
|
-
|
|
425
|
+
// Triton: result = { context, value: { transaction } }. Helius: result = { slot, signature, transaction, transactionIndex }.
|
|
426
|
+
const rawTransaction = result?.value?.transaction ?? result?.transaction
|
|
427
|
+
if (rawTransaction && result?.slot != null && rawTransaction.slot == null) {
|
|
428
|
+
rawTransaction.slot = result.slot // Helius puts slot on result, parser expects it on tx
|
|
429
|
+
}
|
|
430
|
+
|
|
411
431
|
const parsedTx = parseTransaction(address, rawTransaction, tokenAccountsByOwner || [])
|
|
412
432
|
const timestamp = Date.now() // the notification event has no blockTime
|
|
413
433
|
|