@exodus/solana-api 3.29.3 → 3.29.5

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,22 @@
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.5](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.29.4...@exodus/solana-api@3.29.5) (2026-02-14)
7
+
8
+ **Note:** Version bump only for package @exodus/solana-api
9
+
10
+
11
+
12
+
13
+
14
+ ## [3.29.4](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.29.3...@exodus/solana-api@3.29.4) (2026-02-13)
15
+
16
+ **Note:** Version bump only for package @exodus/solana-api
17
+
18
+
19
+
20
+
21
+
6
22
  ## [3.29.3](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.29.2...@exodus/solana-api@3.29.3) (2026-02-12)
7
23
 
8
24
  **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.3",
3
+ "version": "3.29.5",
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": "970efdfe3384d11099816eb037bb46a77575dce4",
52
+ "gitHead": "01daedc61270d2ef4e7b03f984c130346d06922f",
53
53
  "bugs": {
54
54
  "url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Asolana-api"
55
55
  },
@@ -1,5 +1,5 @@
1
1
  import { memoizeLruCache } from '@exodus/asset-lib'
2
- import { memoize, omitBy } from '@exodus/basic-utils'
2
+ import { isNil, memoize, omitBy } from '@exodus/basic-utils'
3
3
  import wretch from '@exodus/fetch/wretch'
4
4
  import { SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@exodus/solana-lib'
5
5
  import ms from 'ms'
@@ -9,7 +9,8 @@ import { RpcApi } from './rpc-api.js'
9
9
 
10
10
  const CLARITY_URL = 'https://solana-clarity.a.exodus.io/api/v2/solana'
11
11
 
12
- const cleanQuery = (obj) => omitBy(obj, (v) => v === undefined)
12
+ // Filter out nil and empty string values to prevent API validation errors (e.g., empty cursor)
13
+ const cleanQuery = (obj) => omitBy(obj, (v) => isNil(v) || v === '')
13
14
 
14
15
  // Tokens + SOL api support
15
16
  export class ClarityApi extends RpcApi {
package/src/connection.js CHANGED
@@ -10,17 +10,8 @@ const PING_INTERVAL = ms('25s')
10
10
  const debug = debugLogger('exodus:solana-api')
11
11
 
12
12
  export class Connection {
13
- constructor({
14
- endpoint,
15
- address,
16
- tokensAddresses = [],
17
- onConnectionReady,
18
- onConnectionClose,
19
- onMsg,
20
- }) {
13
+ constructor({ endpoint, onConnectionReady, onConnectionClose, onMsg }) {
21
14
  assert(endpoint, 'endpoint is required')
22
- this.address = address
23
- this.tokensAddresses = tokensAddresses
24
15
  this.endpoint = endpoint
25
16
  this.onConnectionReady = onConnectionReady
26
17
  this.onConnectionClose = onConnectionClose
@@ -5,7 +5,7 @@ import { stakingProviderClientFactory } from './staking-provider-client.js'
5
5
 
6
6
  export { getStakingInfo } from '../staking-utils.js'
7
7
 
8
- export const stakingApiFactory = ({ asset, createTx, sendTx, stakingProviderClient }) => {
8
+ export const stakingApiFactory = ({ assetName, assetClientInterface, stakingProviderClient }) => {
9
9
  const stakingProvider = stakingProviderClient ?? stakingProviderClientFactory()
10
10
 
11
11
  async function sendStake({
@@ -20,9 +20,12 @@ export const stakingApiFactory = ({ asset, createTx, sendTx, stakingProviderClie
20
20
  }) {
21
21
  let error
22
22
  let result
23
+ const { [assetName]: asset } = await assetClientInterface.getAssetsForNetwork({
24
+ baseAssetName: assetName,
25
+ })
23
26
 
24
27
  try {
25
- const { unsignedTx } = await createTx({
28
+ const { unsignedTx } = await asset.api.createTx({
26
29
  asset,
27
30
  walletAccount,
28
31
  fromAddress: address,
@@ -35,7 +38,7 @@ export const stakingApiFactory = ({ asset, createTx, sendTx, stakingProviderClie
35
38
  pool,
36
39
  })
37
40
 
38
- const sendResult = await sendTx({
41
+ const sendResult = await asset.api.sendTx({
39
42
  asset,
40
43
  walletAccount,
41
44
  unsignedTx,
@@ -68,6 +71,9 @@ export const stakingApiFactory = ({ asset, createTx, sendTx, stakingProviderClie
68
71
  }
69
72
 
70
73
  async function createAndSendStake({ method, address, amount, stakingInfo, walletAccount }) {
74
+ const { [assetName]: asset } = await assetClientInterface.getAssetsForNetwork({
75
+ baseAssetName: assetName,
76
+ })
71
77
  const txs = []
72
78
  switch (method) {
73
79
  case 'delegate': {
@@ -1,27 +1,6 @@
1
- import {
2
- createApproveDelegationTx,
3
- createRevokeDelegationTx,
4
- TOKEN_2022_PROGRAM_ID,
5
- TOKEN_PROGRAM_ID,
6
- } from '@exodus/solana-lib'
1
+ import { createApproveDelegationTx, createRevokeDelegationTx } from '@exodus/solana-lib'
7
2
  import assert from 'minimalistic-assert'
8
3
 
9
- // SPL Token account layout offsets
10
- const MINT_OFFSET = 0
11
- const DELEGATE_OFFSET = 76 // mint (32) + owner (32) + amount (8) + delegateOption (4)
12
-
13
- async function fetchDelegatedAccountsForMint({ api, programId, mintAddress, delegateAddress }) {
14
- const config = {
15
- filters: [
16
- { memcmp: { offset: MINT_OFFSET, bytes: mintAddress } },
17
- { memcmp: { offset: DELEGATE_OFFSET, bytes: delegateAddress } },
18
- ],
19
- encoding: 'jsonParsed',
20
- }
21
-
22
- return api.getProgramAccounts(programId, config)
23
- }
24
-
25
4
  export const createTokenDelegationFactory = ({ api, assetClientInterface }) => {
26
5
  assert(api, 'api is required')
27
6
  assert(assetClientInterface, 'assetClientInterface is required')
@@ -97,33 +76,5 @@ export const createTokenDelegationFactory = ({ api, assetClientInterface }) => {
97
76
  }
98
77
  }
99
78
 
100
- const getDelegatedAddresses = async ({ address, mintAddress }) => {
101
- assert(address, 'address is required')
102
- assert(mintAddress, 'mintAddress is required')
103
-
104
- const token = api.tokens.get(mintAddress)
105
- if (!token) return []
106
-
107
- const [tokenAccounts, token2022Accounts] = await Promise.all([
108
- fetchDelegatedAccountsForMint({
109
- api,
110
- programId: TOKEN_PROGRAM_ID.toBase58(),
111
- mintAddress,
112
- delegateAddress: address,
113
- }),
114
- fetchDelegatedAccountsForMint({
115
- api,
116
- programId: TOKEN_2022_PROGRAM_ID.toBase58(),
117
- mintAddress,
118
- delegateAddress: address,
119
- }),
120
- ])
121
-
122
- return [...tokenAccounts, ...token2022Accounts].map((account) => ({
123
- delegatedAddress: account.pubkey,
124
- assetName: token.name,
125
- }))
126
- }
127
-
128
- return { approveDelegation, revokeDelegation, getDelegatedAddresses }
79
+ return { approveDelegation, revokeDelegation }
129
80
  }
package/src/ws-api.js CHANGED
@@ -14,39 +14,122 @@ const WS_ENDPOINT = 'wss://solana-triton.a.exodus.io/whirligig' // pointing to:
14
14
  export class WsApi {
15
15
  constructor({ rpcUrl, wsUrl, assets }) {
16
16
  this.setWsEndpoint(wsUrl)
17
- this.connections = Object.create(null)
17
+ this.connection = null
18
+ this.watchedAddresses = Object.create(null) // address -> { tokensAddresses, onMessage }
19
+ this.#resetSubscriptionState()
20
+ }
21
+
22
+ /** Reset subscription maps (used on init and when the WS connection closes). */
23
+ #resetSubscriptionState() {
18
24
  this.accountSubscriptions = Object.create(null)
19
25
  this.transactionSubscriptions = Object.create(null)
26
+ // subscription id (from RPC response) -> { owner, type: 'account'|'transaction'|'program' }
27
+ this.subscriptionIdToMeta = Object.create(null)
28
+ // request id (our conn.seq) -> { owner, type }, until we receive the subscription id
29
+ this.pendingSubscriptionRequests = Object.create(null)
20
30
  }
21
31
 
22
32
  setWsEndpoint(wsUrl) {
23
33
  this.wsUrl = wsUrl || WS_ENDPOINT
24
34
  }
25
35
 
26
- async watchAddress({ address, tokensAddresses = [], onMessage }) {
27
- if (this.connections[address]) return // already subscribed
28
- const conn = new Connection({
29
- endpoint: this.wsUrl,
30
- address,
31
- tokensAddresses,
32
- onConnectionReady: (evt) => {
33
- // ws connected, can send subscribe requests (this is called on every re-connect as well)
34
- console.log('SOL WS connected.')
35
- this.sendSubscriptions({ address, tokensAddresses })
36
- },
37
- onConnectionClose: (evt) => {
38
- this.accountSubscriptions = Object.create(null) // clear subs
39
- },
40
- onMsg: (json) => onMessage(json),
41
- })
36
+ /**
37
+ * Resolve which watched address(es) a message is for; only dispatch to those.
38
+ */
39
+ #getAddressesForMessage(json) {
40
+ const method = json?.method
41
+ const params = json?.params
42
+ const result = params?.result
43
+
44
+ // Subscription confirmation: store subscription id -> owner and type, do not dispatch
45
+ if (json?.id != null && typeof json?.result === 'number') {
46
+ const pending = this.pendingSubscriptionRequests[json.id]
47
+ if (pending != null) {
48
+ this.subscriptionIdToMeta[json.result] = { owner: pending.owner, type: pending.type }
49
+ delete this.pendingSubscriptionRequests[json.id]
50
+ }
51
+
52
+ return []
53
+ }
54
+
55
+ if (method === 'accountNotification' || method === 'transactionNotification') {
56
+ const subId = params?.subscription
57
+ if (subId !== null) {
58
+ const owner = this.subscriptionIdToMeta[subId]?.owner
59
+ return owner && this.watchedAddresses[owner] ? [owner] : []
60
+ }
61
+ }
62
+
63
+ if (method === 'programNotification' && result) {
64
+ const owner = this.#getOwnerFromProgramNotificationResult(result)
65
+ return owner && this.watchedAddresses[owner] ? [owner] : []
66
+ }
67
+
68
+ // Ping response, unknown method, etc. – no address to dispatch to
69
+ return []
70
+ }
71
+
72
+ /** Extract owner address from programNotification result (Token/Token-2022 account). */
73
+ #getOwnerFromProgramNotificationResult(result) {
74
+ const { value } = result
75
+ if (!value?.account) return null
76
+ const account = value.account
77
+ const parsed = account?.data?.parsed?.info
78
+ if (parsed?.owner) return parsed.owner
79
+ if (Array.isArray(account.data) && account.data[1] === 'base64') {
80
+ try {
81
+ const decoded = Token.decode(Buffer.from(account.data[0], 'base64'))
82
+ return new PublicKey(decoded.owner).toBase58()
83
+ } catch {
84
+ return null
85
+ }
86
+ }
87
+
88
+ return null
89
+ }
90
+
91
+ #dispatchMessage(json) {
92
+ const addresses = this.#getAddressesForMessage(json)
93
+ for (const addr of addresses) {
94
+ this.watchedAddresses[addr].onMessage(json)
95
+ }
96
+ }
42
97
 
43
- this.connections[address] = conn
44
- return this.connections[address].start()
98
+ async #sendAllSubscriptions() {
99
+ for (const address of Object.keys(this.watchedAddresses)) {
100
+ const { tokensAddresses } = this.watchedAddresses[address]
101
+ await this.sendSubscriptions({ address, tokensAddresses })
102
+ }
103
+ }
104
+
105
+ async watchAddress({ address, tokensAddresses = [], onMessage }) {
106
+ if (this.watchedAddresses[address]) return // already subscribed
107
+
108
+ this.watchedAddresses[address] = { tokensAddresses, onMessage }
109
+
110
+ if (!this.connection) {
111
+ this.connection = new Connection({
112
+ endpoint: this.wsUrl,
113
+ onConnectionReady: () => {
114
+ console.log('SOL WS connected.')
115
+ this.#sendAllSubscriptions().catch((err) => {
116
+ console.error('SOL WS: failed to re-subscribe after connect:', err)
117
+ })
118
+ },
119
+ onConnectionClose: () => {
120
+ this.#resetSubscriptionState()
121
+ },
122
+ onMsg: (json) => this.#dispatchMessage(json),
123
+ })
124
+ await this.connection.start()
125
+ } else if (this.connection.isOpen) {
126
+ await this.sendSubscriptions({ address, tokensAddresses })
127
+ }
45
128
  }
46
129
 
47
130
  async accountSubscribe({ owner, account }) {
48
131
  // could be SOL address or token account address
49
- const conn = this.connections[owner]
132
+ const conn = this.connection
50
133
  if (!conn || !conn.isOpen) {
51
134
  console.warn('SOL Connection is not open, cannot subscribe to', owner)
52
135
  return
@@ -55,6 +138,8 @@ export class WsApi {
55
138
  const subscriptions = this.accountSubscriptions[owner] || []
56
139
  if (subscriptions?.includes(account)) return // already subscribed
57
140
 
141
+ const id = ++conn.seq
142
+ this.pendingSubscriptionRequests[id] = { owner, type: 'account' }
58
143
  conn.send({
59
144
  jsonrpc: '2.0',
60
145
  method: 'accountSubscribe',
@@ -65,7 +150,7 @@ export class WsApi {
65
150
  commitment: 'confirmed',
66
151
  },
67
152
  ],
68
- id: ++conn.seq,
153
+ id,
69
154
  })
70
155
 
71
156
  this.accountSubscriptions[owner] = [...subscriptions, account]
@@ -76,7 +161,7 @@ export class WsApi {
76
161
  accounts = [accounts]
77
162
  }
78
163
 
79
- const conn = this.connections[owner]
164
+ const conn = this.connection
80
165
  if (!conn || !conn.isOpen) {
81
166
  console.warn('SOL Connection is not open, cannot subscribe to', owner)
82
167
  return
@@ -87,9 +172,11 @@ export class WsApi {
87
172
  const difference = accounts.filter((account) => !subscriptions.includes(account))
88
173
  if (difference.length === 0) return // already subscribed
89
174
 
175
+ const id = ++conn.seq
176
+ this.pendingSubscriptionRequests[id] = { owner, type: 'transaction' }
90
177
  conn.send({
91
178
  jsonrpc: '2.0',
92
- id: ++conn.seq,
179
+ id,
93
180
  method: 'transactionSubscribe',
94
181
  params: [
95
182
  {
@@ -111,7 +198,7 @@ export class WsApi {
111
198
  }
112
199
 
113
200
  async sendSubscriptions({ address, tokensAddresses = [] }) {
114
- const conn = this.connections[address]
201
+ const conn = this.connection
115
202
 
116
203
  const addresses = [address, ...tokensAddresses]
117
204
 
@@ -129,11 +216,13 @@ export class WsApi {
129
216
  const token2022ProgramId = TOKEN_2022_PROGRAM_ID.toBase58()
130
217
  const tokenAccountDataSize = 165
131
218
 
132
- if (conn) {
219
+ if (conn?.isOpen) {
133
220
  // SPL Token: fixed 165-byte account size
221
+ const id1 = ++conn.seq
222
+ this.pendingSubscriptionRequests[id1] = { owner: address, type: 'program' }
134
223
  conn.send({
135
224
  jsonrpc: '2.0',
136
- id: ++conn.seq,
225
+ id: id1,
137
226
  method: 'programSubscribe',
138
227
  params: [
139
228
  splTokenProgramId,
@@ -149,9 +238,11 @@ export class WsApi {
149
238
  })
150
239
 
151
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' }
152
243
  conn.send({
153
244
  jsonrpc: '2.0',
154
- id: ++conn.seq,
245
+ id: id2,
155
246
  method: 'programSubscribe',
156
247
  params: [
157
248
  token2022ProgramId,
@@ -166,9 +257,36 @@ export class WsApi {
166
257
  }
167
258
 
168
259
  async unwatchAddress({ address }) {
169
- if (this.connections[address]) {
170
- await this.connections[address].stop()
171
- delete this.connections[address]
260
+ if (!this.watchedAddresses[address]) return
261
+
262
+ const conn = this.connection
263
+ const unsubMethods = {
264
+ account: 'accountUnsubscribe',
265
+ transaction: 'transactionUnsubscribe',
266
+ program: 'programUnsubscribe',
267
+ }
268
+ for (const [subId, meta] of Object.entries(this.subscriptionIdToMeta)) {
269
+ if (meta.owner !== address) continue
270
+ const method = meta.type && unsubMethods[meta.type]
271
+ if (method && conn?.isOpen) {
272
+ conn.send({
273
+ jsonrpc: '2.0',
274
+ id: ++conn.seq,
275
+ method,
276
+ params: [Number(subId)],
277
+ })
278
+ }
279
+
280
+ delete this.subscriptionIdToMeta[subId]
281
+ }
282
+
283
+ delete this.watchedAddresses[address]
284
+ delete this.accountSubscriptions[address]
285
+ delete this.transactionSubscriptions[address]
286
+
287
+ if (Object.keys(this.watchedAddresses).length === 0 && conn) {
288
+ await conn.stop()
289
+ this.connection = null
172
290
  }
173
291
  }
174
292