@exodus/solana-api 3.29.1 → 3.29.2

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,16 @@
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.29.2](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.29.1...@exodus/solana-api@3.29.2) (2026-02-11)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: getAccountInfo 3rd param (#7396)
13
+
14
+
15
+
6
16
  ## [3.29.1](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.29.0...@exodus/solana-api@3.29.1) (2026-02-03)
7
17
 
8
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-api",
3
- "version": "3.29.1",
3
+ "version": "3.29.2",
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",
@@ -49,7 +49,7 @@
49
49
  "@exodus/assets-testing": "^1.0.0",
50
50
  "@exodus/solana-web3.js": "^1.63.1-exodus.9-rc3"
51
51
  },
52
- "gitHead": "3679676ff106c29f167cab7f96d3ce8957299f9c",
52
+ "gitHead": "167b72157b276c512c14870fe16792cba83ea5e2",
53
53
  "bugs": {
54
54
  "url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Asolana-api"
55
55
  },
@@ -1,7 +1,5 @@
1
1
  async function fetchDelegatedAccountInfo({ rpcCall, delegatedAddress }) {
2
- return rpcCall('getAccountInfo', [delegatedAddress, { encoding: 'jsonParsed' }], {
3
- address: delegatedAddress,
4
- })
2
+ return rpcCall('getAccountInfo', [delegatedAddress, { encoding: 'jsonParsed' }])
5
3
  }
6
4
 
7
5
  function parseDelegationInfo({ accountInfo, expectedDelegate }) {
@@ -26,6 +26,7 @@ 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)
29
30
 
30
31
  const walletAccounts = await this.aci.getWalletAccounts({ assetName: this.asset.name })
31
32
  await Promise.all(
@@ -49,7 +50,7 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
49
50
  useCache: true,
50
51
  })
51
52
 
52
- const { accounts: tokenAccountsByOwner } = await this.clarityApi.getTokensBalancesAndAccounts({
53
+ const { accounts: tokenAccountsByOwner } = await this.rpcApi.getTokensBalancesAndAccounts({
53
54
  address,
54
55
  })
55
56
  this.tokenAccountsByOwner[walletAccount] = tokenAccountsByOwner
@@ -81,62 +82,11 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
81
82
  }
82
83
 
83
84
  async tick({ walletAccount, refresh }) {
84
- // we tick using Clarity only on startup, then we rely only on WS events and we periodically check for new tokens only.
85
+ // we tick using Clarity only on startup or explicit refresh; otherwise we rely on WS events
86
+ // and programSubscribe for new SPL / Token-2022 accounts (no periodic getTokensBalancesAndAccounts).
85
87
  if (refresh || this.tickCount[walletAccount] === 0) {
86
88
  return super.tick({ walletAccount, refresh }) // Clarity refresh or first tick
87
89
  }
88
-
89
- const assetName = this.asset.name
90
- this.assets = await this.aci.getAssetsForNetwork({ baseAssetName: assetName })
91
- this.clarityApi.setTokens(this.assets)
92
- const address = await this.aci.getReceiveAddress({
93
- assetName,
94
- walletAccount,
95
- useCache: true,
96
- })
97
-
98
- // we only update balance of the tokens we just subcribed to:
99
- const accountState = await this.aci.getAccountState({
100
- assetName: this.asset.name,
101
- walletAccount,
102
- })
103
-
104
- // we call this periodically to detect new token accounts created (there's not a WS event for this in Helius yet, we need programSubscribe)
105
- const { accounts: tokenAccounts, balances: splBalances } =
106
- await this.clarityApi.getTokensBalancesAndAccounts({
107
- address,
108
- })
109
- this.tokenAccountsByOwner[walletAccount] = tokenAccounts
110
-
111
- const unknownTokensList = await this.emitUnknownTokensEvent({
112
- tokenAccounts,
113
- })
114
-
115
- // subscribe to new tokenAccounts
116
- for (const mintAddress of unknownTokensList) {
117
- const tokenName = this.clarityApi.tokens.get(mintAddress)?.name
118
- if (!tokenName) {
119
- console.log(`Unknown token mint address: ${mintAddress}`)
120
- continue
121
- }
122
-
123
- const tokenAccountAddress = tokenAccounts.find(
124
- (acc) => acc.mintAddress === mintAddress
125
- )?.tokenAccountAddress
126
- await this.wsApi.accountSubscribe({ owner: address, account: tokenAccountAddress })
127
-
128
- // update only token balances for known tokens
129
- const amount = splBalances[mintAddress]
130
- const newData = {
131
- tokenBalances: {
132
- ...accountState.tokenBalances,
133
- [tokenName]: this.assets[tokenName].currency.baseUnit(amount),
134
- },
135
- }
136
- await this.#updateStateBatch({ newData, walletAccount })
137
- }
138
-
139
- // await this.updateState({ account, walletAccount, staking }) // we could update tokenBalances but we gotta test for race-conditions
140
90
  }
141
91
 
142
92
  async #handleMessage({ address, walletAccount, data }) {
@@ -153,11 +103,65 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
153
103
  4. After 2 seconds, close the window and execute the batch.
154
104
  */
155
105
 
156
- if (['accountNotification', 'transactionNotification'].includes(data?.method)) {
106
+ if (
107
+ ['accountNotification', 'transactionNotification', 'programNotification'].includes(
108
+ data?.method
109
+ )
110
+ ) {
157
111
  this.#ensureBatch(walletAccount)
158
112
  }
159
113
 
160
114
  switch (data?.method) {
115
+ case 'programNotification': {
116
+ // new or updated SPL / Token-2022 account for our wallet (from programSubscribe)
117
+ const parsed = this.wsApi.parseProgramNotification({
118
+ result: data.params?.result,
119
+ ownerAddress: address,
120
+ })
121
+ if (!parsed) return
122
+ const tokenAccountsByOwnerList = this.tokenAccountsByOwner[walletAccount] || []
123
+ const existing = tokenAccountsByOwnerList.find(
124
+ (acc) => acc.tokenAccountAddress === parsed.tokenAccountAddress
125
+ )
126
+ if (existing) {
127
+ // balance update only; accountNotification will also fire for this account
128
+ return
129
+ }
130
+
131
+ const tokenMeta = this.clarityApi.tokens.get(parsed.mintAddress)
132
+ const tokenName = tokenMeta?.name
133
+ const newAccount = {
134
+ tokenAccountAddress: parsed.tokenAccountAddress,
135
+ owner: parsed.owner,
136
+ tokenName: tokenName ?? 'unknown',
137
+ ticker: tokenMeta?.ticker ?? 'UNKNOWN',
138
+ balance: parsed.amount,
139
+ mintAddress: parsed.mintAddress,
140
+ tokenProgram: parsed.tokenProgram,
141
+ decimals: tokenMeta?.decimals ?? 0,
142
+ feeBasisPoints: 0,
143
+ maximumFee: 0,
144
+ }
145
+ this.tokenAccountsByOwner[walletAccount] = [...tokenAccountsByOwnerList, newAccount]
146
+
147
+ await this.wsApi.accountSubscribe({ owner: address, account: parsed.tokenAccountAddress })
148
+
149
+ const unknownTokensList = await this.emitUnknownTokensEvent({
150
+ tokenAccounts: this.tokenAccountsByOwner[walletAccount],
151
+ })
152
+ if (!unknownTokensList.includes(parsed.mintAddress) && tokenName) {
153
+ const newData = {
154
+ tokenBalances: {
155
+ ...accountState.tokenBalances,
156
+ [tokenName]: this.assets[tokenName].currency.baseUnit(parsed.amount),
157
+ },
158
+ }
159
+ await this.#updateStateBatch({ newData, walletAccount })
160
+ }
161
+
162
+ return
163
+ }
164
+
161
165
  case 'accountNotification':
162
166
  // balance changed events for known tokens or SOL address
163
167
 
@@ -224,13 +228,47 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
224
228
  }
225
229
 
226
230
  return
227
- case 'transactionNotification':
231
+ case 'transactionNotification': {
228
232
  // update tx-log with new txs and state with cursor
229
233
 
230
- // NB. if we receive a tx with a new "token", never received before,
231
- // this.clarityApi.tokens will be populated with new tokens only after we ran a monitor tick
232
- // since the unknown-tokens event is captured and processed in other places.
233
- // hence what happens is we skip the txLog update for that new token.
234
+ // Populate tokenAccountsByOwner from this tx first (e.g. new ATA created in same tx)
235
+ // so we can parse and update history; otherwise "cannot parse tx" would trigger for new receives
236
+ const rawTransaction =
237
+ data.params.result?.value?.transaction ?? data.params.result?.transaction
238
+ const txTokenAccounts = this.wsApi.getTokenAccountsFromTxMeta(rawTransaction, address)
239
+ let tokenAccountsByOwnerList = this.tokenAccountsByOwner[walletAccount] || []
240
+ for (const txAcc of txTokenAccounts) {
241
+ if (
242
+ tokenAccountsByOwnerList.some(
243
+ (a) => a.tokenAccountAddress === txAcc.tokenAccountAddress
244
+ )
245
+ ) {
246
+ continue
247
+ }
248
+
249
+ const tokenMeta = this.clarityApi.tokens.get(txAcc.mintAddress)
250
+ const newAccount = {
251
+ tokenAccountAddress: txAcc.tokenAccountAddress,
252
+ owner: txAcc.owner,
253
+ tokenName: tokenMeta?.name ?? 'unknown',
254
+ ticker: tokenMeta?.ticker ?? 'UNKNOWN',
255
+ balance: '0',
256
+ mintAddress: txAcc.mintAddress,
257
+ tokenProgram: null,
258
+ decimals: tokenMeta?.decimals ?? 0,
259
+ feeBasisPoints: 0,
260
+ maximumFee: 0,
261
+ }
262
+ tokenAccountsByOwnerList = [...tokenAccountsByOwnerList, newAccount]
263
+ await this.wsApi.accountSubscribe({ owner: address, account: txAcc.tokenAccountAddress })
264
+ // we need also to perform a transactionSubscribe to the new token account address
265
+ await this.wsApi.transactionSubscribe({
266
+ owner: address,
267
+ accounts: txAcc.tokenAccountAddress,
268
+ })
269
+ }
270
+
271
+ this.tokenAccountsByOwner[walletAccount] = tokenAccountsByOwnerList
234
272
 
235
273
  const { logItemsByAsset, cursorState = {} } = this.wsApi.parseTransactionNotification({
236
274
  address,
@@ -238,7 +276,7 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
238
276
  baseAsset: this.asset,
239
277
  assets: this.assets,
240
278
  tokens: this.clarityApi.tokens,
241
- tokenAccountsByOwner,
279
+ tokenAccountsByOwner: tokenAccountsByOwnerList,
242
280
  result: data.params.result, // raw tx
243
281
  })
244
282
 
@@ -262,10 +300,30 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
262
300
  refresh: false,
263
301
  })
264
302
 
265
- const newData = {
266
- ...cursorState,
303
+ // Update token balances from this tx's postTokenBalances so the first receive shows the
304
+ // correct balance even when accountNotification was missed (we subscribe after the tx is
305
+ // processed, so the balance-change notification can already have been sent).
306
+ const postTokenBalances = rawTransaction?.meta?.postTokenBalances ?? []
307
+ const tokenBalancesFromTx = {}
308
+ for (const b of postTokenBalances) {
309
+ if (b.owner !== address) continue
310
+ const tokenName = this.clarityApi.tokens.get(b.mint)?.name
311
+ if (!tokenName || !this.assets[tokenName]) continue
312
+ const amount = b.uiTokenAmount?.amount ?? b.amount ?? '0'
313
+ tokenBalancesFromTx[tokenName] = this.assets[tokenName].currency.baseUnit(amount)
267
314
  }
268
315
 
316
+ const newData =
317
+ Object.keys(tokenBalancesFromTx).length > 0
318
+ ? {
319
+ ...cursorState,
320
+ tokenBalances: {
321
+ ...accountState.tokenBalances,
322
+ ...tokenBalancesFromTx,
323
+ },
324
+ }
325
+ : { ...cursorState }
326
+
269
327
  if (stakingTx) {
270
328
  // for staking the balance is not updated by the balance handler
271
329
  // staking operations won't spend or modify the "total" wallet balance.
@@ -322,6 +380,8 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
322
380
  await this.#updateStateBatch({ newData, walletAccount })
323
381
 
324
382
  return
383
+ }
384
+
325
385
  default:
326
386
  if (data?.result && typeof data.result === 'number') {
327
387
  // subscription confirmation, skip
package/src/ws-api.js CHANGED
@@ -16,6 +16,7 @@ export class WsApi {
16
16
  this.setWsEndpoint(wsUrl)
17
17
  this.connections = Object.create(null)
18
18
  this.accountSubscriptions = Object.create(null)
19
+ this.transactionSubscriptions = Object.create(null)
19
20
  }
20
21
 
21
22
  setWsEndpoint(wsUrl) {
@@ -70,6 +71,45 @@ export class WsApi {
70
71
  this.accountSubscriptions[owner] = [...subscriptions, account]
71
72
  }
72
73
 
74
+ async transactionSubscribe({ owner, accounts }) {
75
+ if (!Array.isArray(accounts)) {
76
+ accounts = [accounts]
77
+ }
78
+
79
+ const conn = this.connections[owner]
80
+ if (!conn || !conn.isOpen) {
81
+ console.warn('SOL Connection is not open, cannot subscribe to', owner)
82
+ return
83
+ }
84
+
85
+ const subscriptions = this.transactionSubscriptions[owner] || []
86
+ // compute the difference between subscriptions and accounts
87
+ const difference = accounts.filter((account) => !subscriptions.includes(account))
88
+ if (difference.length === 0) return // already subscribed
89
+
90
+ conn.send({
91
+ jsonrpc: '2.0',
92
+ id: ++conn.seq,
93
+ method: 'transactionSubscribe',
94
+ params: [
95
+ {
96
+ vote: false,
97
+ accounts: {
98
+ include: difference,
99
+ },
100
+ },
101
+ {
102
+ commitment: 'confirmed',
103
+ encoding: 'jsonParsed',
104
+ transactionDetails: 'full',
105
+ showRewards: false,
106
+ maxSupportedTransactionVersion: 255,
107
+ },
108
+ ],
109
+ })
110
+ this.transactionSubscriptions[owner] = [...subscriptions, ...difference]
111
+ }
112
+
73
113
  async sendSubscriptions({ address, tokensAddresses = [] }) {
74
114
  const conn = this.connections[address]
75
115
 
@@ -82,33 +122,47 @@ export class WsApi {
82
122
  }
83
123
 
84
124
  // 2. subscribe to transactions involving the addresses
125
+ await this.transactionSubscribe({ owner: address, accounts: addresses })
126
+
127
+ // 3. subscribe to SPL Token and Token-2022 program account changes to detect new token accounts for this wallet
128
+ const splTokenProgramId = TOKEN_PROGRAM_ID.toBase58()
129
+ const token2022ProgramId = TOKEN_2022_PROGRAM_ID.toBase58()
130
+ const tokenAccountDataSize = 165
131
+
85
132
  if (conn) {
133
+ // SPL Token: fixed 165-byte account size
86
134
  conn.send({
87
135
  jsonrpc: '2.0',
88
136
  id: ++conn.seq,
89
- method: 'transactionSubscribe',
137
+ method: 'programSubscribe',
90
138
  params: [
139
+ splTokenProgramId,
91
140
  {
92
- vote: false,
93
- // failed: true,
94
- accounts: {
95
- include: addresses,
96
- },
97
- // accountInclude: addresses, // Helius
141
+ encoding: 'jsonParsed',
142
+ commitment: 'confirmed',
143
+ filters: [
144
+ { dataSize: tokenAccountDataSize },
145
+ { memcmp: { offset: 32, bytes: address } },
146
+ ],
98
147
  },
148
+ ],
149
+ })
150
+
151
+ // Token-2022: no dataSize filter (accounts can have extensions and be larger than 165 bytes)
152
+ conn.send({
153
+ jsonrpc: '2.0',
154
+ id: ++conn.seq,
155
+ method: 'programSubscribe',
156
+ params: [
157
+ token2022ProgramId,
99
158
  {
100
- commitment: 'confirmed',
101
159
  encoding: 'jsonParsed',
102
- transactionDetails: 'full',
103
- showRewards: false,
104
- maxSupportedTransactionVersion: 255,
160
+ commitment: 'confirmed',
161
+ filters: [{ memcmp: { offset: 32, bytes: address } }],
105
162
  },
106
163
  ],
107
164
  })
108
165
  }
109
-
110
- // 3. subscribe to other events, for example use programSubscribe once Helius implements it into the Advanced WebSocket
111
- // to get the new token accounts events and remove the need for the RPC call in the monitor tick
112
166
  }
113
167
 
114
168
  async unwatchAddress({ address }) {
@@ -118,6 +172,49 @@ export class WsApi {
118
172
  }
119
173
  }
120
174
 
175
+ /**
176
+ * Parse programNotification (from programSubscribe to Token / Token-2022).
177
+ * Returns token account info if the account belongs to the given owner address, else null.
178
+ * @param result - data.params.result from programNotification (has context + value with pubkey, account)
179
+ */
180
+ parseProgramNotification({ result, ownerAddress }) {
181
+ const { value } = result || {}
182
+ if (!value) return null
183
+ const { pubkey, account } = value
184
+ if (!account || !pubkey) return null
185
+ const isTokenProgram =
186
+ account.owner === TOKEN_PROGRAM_ID.toBase58() ||
187
+ account.owner === TOKEN_2022_PROGRAM_ID.toBase58()
188
+ if (!isTokenProgram) return null
189
+
190
+ let owner
191
+ let mintAddress
192
+ let amount
193
+
194
+ const parsed = account?.data?.parsed?.info
195
+ if (parsed) {
196
+ owner = parsed.owner
197
+ mintAddress = parsed.mint
198
+ amount = parsed.tokenAmount?.amount ?? '0'
199
+ } else if (Array.isArray(account.data) && account.data[1] === 'base64') {
200
+ const decoded = Token.decode(Buffer.from(account.data[0], 'base64'))
201
+ owner = new PublicKey(decoded.owner).toBase58()
202
+ mintAddress = new PublicKey(decoded.mint).toBase58()
203
+ amount = U64.fromBuffer(decoded.amount).toString()
204
+ } else {
205
+ return null
206
+ }
207
+
208
+ if (owner !== ownerAddress) return null
209
+ return {
210
+ tokenAccountAddress: pubkey,
211
+ mintAddress,
212
+ amount,
213
+ owner,
214
+ tokenProgram: account.owner,
215
+ }
216
+ }
217
+
121
218
  parseAccountNotification({ address, walletAccount, tokenAccountsByOwner, result }) {
122
219
  const isSolAccount = result.value.owner === '11111111111111111111111111111111' // System Program
123
220
  if (isSolAccount) {
@@ -155,6 +252,34 @@ export class WsApi {
155
252
  }
156
253
  }
157
254
 
255
+ /**
256
+ * Derive token account entries from a transaction's meta (postTokenBalances / preTokenBalances)
257
+ * for the given owner address. Use this to augment tokenAccountsByOwner when the wallet
258
+ * receives a new ATA in the same tx (transactionNotification often arrives before programNotification).
259
+ */
260
+ getTokenAccountsFromTxMeta(transaction, ownerAddress) {
261
+ const meta = transaction?.meta
262
+ const accountKeys = transaction?.transaction?.message?.accountKeys ?? []
263
+ const getPubkey = (key) =>
264
+ key && typeof key === 'object' && 'pubkey' in key ? key.pubkey : key
265
+ const balances = [...(meta?.postTokenBalances ?? []), ...(meta?.preTokenBalances ?? [])]
266
+ const byAddress = new Map()
267
+ for (const b of balances) {
268
+ if (b.owner !== ownerAddress) continue
269
+ const key = accountKeys[b.accountIndex]
270
+ const pubkey = key == null ? null : getPubkey(key)
271
+ if (!pubkey) continue
272
+ if (byAddress.has(pubkey)) continue
273
+ byAddress.set(pubkey, {
274
+ tokenAccountAddress: pubkey,
275
+ mintAddress: b.mint,
276
+ owner: b.owner,
277
+ })
278
+ }
279
+
280
+ return [...byAddress.values()]
281
+ }
282
+
158
283
  parseTransactionNotification({
159
284
  address,
160
285
  walletAccount,
@@ -165,7 +290,7 @@ export class WsApi {
165
290
  result,
166
291
  }) {
167
292
  const rawTransaction = result?.value ? result.value.transaction : result.transaction // for Triton Whirligig OR Helius Advanced WS
168
- const parsedTx = parseTransaction(address, rawTransaction, tokenAccountsByOwner)
293
+ const parsedTx = parseTransaction(address, rawTransaction, tokenAccountsByOwner || [])
169
294
  const timestamp = Date.now() // the notification event has no blockTime
170
295
 
171
296
  if (!parsedTx.from && parsedTx.tokenTxs?.length === 0) return { logItemsByAsset: {} } // cannot parse it