@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 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.29.5",
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.2.0",
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": "01daedc61270d2ef4e7b03f984c130346d06922f",
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
  },
@@ -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,
@@ -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://solana-clarity.a.exodus.io/api/v2/solana'
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(address, { cursor, limit, includeUnparsed = false } = Object.create(null)) {
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
- cursor,
57
+ before,
58
+ after,
55
59
  limit,
56
60
  includeUnparsed,
57
61
  })
@@ -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, stakingProviderClient }) => {
9
- const stakingProvider = stakingProviderClient ?? stakingProviderClientFactory()
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 shouldUpdateHistory =
136
+ const shouldUpdateLatestHistory =
141
137
  refresh || isHistoryUpdateTick || balanceChanged || hasUnconfirmedSentTx
142
- const shouldUpdateOnlyBalance = balanceChanged && !shouldUpdateHistory
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
- if (shouldUpdateHistory) {
154
- const { logItemsByAsset, cursorState } = await this.getHistory({
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
- accountState,
157
- walletAccount,
158
- refresh,
159
- tokenAccounts,
183
+ historyCursor: newCursorState.historyCursor,
160
184
  })
161
185
 
162
- const cursorChanged = this.hasNewCursor({ walletAccount, cursorState })
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({ walletAccount, logItemsByAsset })
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 getHistory({ address, accountState, refresh, tokenAccounts } = Object.create(null)) {
184
- const cursor = refresh ? '' : accountState.cursor
185
- const baseAsset = this.asset
221
+ async fetchOldHistory({ address, historyCursor }) {
222
+ if (!historyCursor) return { transactions: [], historyCursor: undefined }
186
223
 
187
- // TODO: switch to this.clarityApi.getTransactions once ready!!
188
- // and remove this.rpcApi from constructor and create-asset-utils.js
189
- const { transactions, newCursor } = await this.rpcApi.getTransactions(address, {
190
- cursor,
191
- includeUnparsed: this.includeUnparsed,
192
- limit: this.txsLimit,
193
- tokenAccounts,
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
- const logItemsByAsset = lodash.groupBy(mergeByTxId(mappedTransactions), 'coinName')
266
- return {
267
- logItemsByAsset,
268
- hasNewTxs: transactions.length > 0,
269
- cursorState: { cursor: newCursor },
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.earned.toBaseString()
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 {
@@ -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.rpcApi.getTokensBalancesAndAccounts({
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 WS_ENDPOINT = 'wss://solana-triton.a.exodus.io/whirligig' // pointing to: wss://exodus-solanama-6db3.mainnet.rpcpool.com/<token>/whirligig
11
- // Helius Advanced WebSocket
12
- // const WS_ENDPOINT = 'wss://solana-helius-wss.a.exodus.io/ws' // pointing to: wss://atlas-mainnet.helius-rpc.com/?api-key=<API_KEY>
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 || WS_ENDPOINT
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
- vote: false,
184
- accounts: {
185
- include: difference,
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
- transactionDetails: 'full',
192
- showRewards: false,
193
- maxSupportedTransactionVersion: 255,
244
+ commitment: 'confirmed',
245
+ filters: [{ memcmp: { offset: 32, bytes: owner } }],
194
246
  },
195
247
  ],
196
248
  })
197
- this.transactionSubscriptions[owner] = [...subscriptions, ...difference]
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
- const splTokenProgramId = TOKEN_PROGRAM_ID.toBase58()
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 isSolAccount = result.value.owner === '11111111111111111111111111111111' // System Program
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 = result.value.lamports
355
+ const amount = value.lamports
341
356
  return { solAddress: address, amount }
342
357
  }
343
358
 
344
- const isSplTokenAccount = result.value.owner === TOKEN_PROGRAM_ID.toBase58()
345
- const isSpl2022TokenAccount = result.value.owner === TOKEN_2022_PROGRAM_ID.toBase58()
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 (result.value.data?.parsed?.info) {
351
- const parsed = result.value.data.parsed.info
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(result.value.data[0], 'base64'))
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
- const rawTransaction = result?.value ? result.value.transaction : result.transaction // for Triton Whirligig OR Helius Advanced WS
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