@exodus/solana-api 3.29.2 → 3.29.4

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.4](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.29.3...@exodus/solana-api@3.29.4) (2026-02-13)
7
+
8
+ **Note:** Version bump only for package @exodus/solana-api
9
+
10
+
11
+
12
+
13
+
14
+ ## [3.29.3](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.29.2...@exodus/solana-api@3.29.3) (2026-02-12)
15
+
16
+ **Note:** Version bump only for package @exodus/solana-api
17
+
18
+
19
+
20
+
21
+
6
22
  ## [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
23
 
8
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-api",
3
- "version": "3.29.2",
3
+ "version": "3.29.4",
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": "167b72157b276c512c14870fe16792cba83ea5e2",
52
+ "gitHead": "7f0ba4d0c14841809acd9d9365fbe1d38db3835e",
53
53
  "bugs": {
54
54
  "url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Asolana-api"
55
55
  },
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
package/src/index.js CHANGED
@@ -8,7 +8,6 @@ export { SolanaMonitor } from './tx-log/index.js'
8
8
  export { SolanaClarityMonitor } from './tx-log/index.js'
9
9
  export { SolanaWebsocketMonitor } from './tx-log/index.js'
10
10
  export { createAccountState } from './account-state.js'
11
- export { getStakingInfo } from './staking-utils.js'
12
11
  export {
13
12
  isSolanaStaking,
14
13
  isSolanaUnstaking,
@@ -18,7 +17,7 @@ export {
18
17
  export { createAndBroadcastTXFactory } from './tx-send.js'
19
18
  export { getBalancesFactory } from './get-balances.js'
20
19
  export { getFeeAsyncFactory } from './get-fees.js'
21
- export { stakingProviderClientFactory } from './staking-provider-client.js'
20
+ export { stakingApiFactory, getStakingInfo } from './staking/index.js'
22
21
  export { createTxFactory } from './create-unsigned-tx-for-send.js'
23
22
  export { feePayerClientFactory } from './fee-payer.js'
24
23
  export { createInitAgentWalletFactory } from './init-agent-wallet.js'
@@ -0,0 +1,173 @@
1
+ import chunk from 'lodash/chunk.js'
2
+
3
+ import { getStakingInfo } from '../staking-utils.js'
4
+ import { stakingProviderClientFactory } from './staking-provider-client.js'
5
+
6
+ export { getStakingInfo } from '../staking-utils.js'
7
+
8
+ export const stakingApiFactory = ({ assetName, assetClientInterface, stakingProviderClient }) => {
9
+ const stakingProvider = stakingProviderClient ?? stakingProviderClientFactory()
10
+
11
+ async function sendStake({
12
+ address,
13
+ walletAccount,
14
+ method,
15
+ amount,
16
+ stakeAddresses,
17
+ accounts,
18
+ seed,
19
+ pool,
20
+ }) {
21
+ let error
22
+ let result
23
+ const { [assetName]: asset } = await assetClientInterface.getAssetsForNetwork({
24
+ baseAssetName: assetName,
25
+ })
26
+
27
+ try {
28
+ const { unsignedTx } = await asset.api.createTx({
29
+ asset,
30
+ walletAccount,
31
+ fromAddress: address,
32
+ address,
33
+ amount: amount ?? asset.currency.ZERO,
34
+ method,
35
+ stakeAddresses,
36
+ accounts,
37
+ seed,
38
+ pool,
39
+ })
40
+
41
+ const sendResult = await asset.api.sendTx({
42
+ asset,
43
+ walletAccount,
44
+ unsignedTx,
45
+ })
46
+
47
+ result = { txId: sendResult.txId }
48
+ } catch (err) {
49
+ error = err
50
+ }
51
+
52
+ try {
53
+ const notifyMethod = {
54
+ delegate: 'notifyStaking',
55
+ withdraw: 'notifyWithdraw',
56
+ }
57
+ if (notifyMethod[method] && (error || result?.txId)) {
58
+ await stakingProvider[notifyMethod[method]]({
59
+ asset: asset.name,
60
+ txId: result?.txId ?? null,
61
+ delegator: address,
62
+ amount: amount?.toBaseString?.({ unit: false }) ?? '0',
63
+ error,
64
+ })
65
+ }
66
+ } catch (e) {
67
+ console.error('Failed to notify staking service:', e)
68
+ }
69
+
70
+ return { error, result }
71
+ }
72
+
73
+ async function createAndSendStake({ method, address, amount, stakingInfo, walletAccount }) {
74
+ const { [assetName]: asset } = await assetClientInterface.getAssetsForNetwork({
75
+ baseAssetName: assetName,
76
+ })
77
+ const txs = []
78
+ switch (method) {
79
+ case 'delegate': {
80
+ const seed = `exodus:${Date.now()}`
81
+ txs.push({
82
+ method: 'delegate',
83
+ address,
84
+ amount,
85
+ seed,
86
+ pool: stakingInfo.staking.pool,
87
+ })
88
+
89
+ break
90
+ }
91
+
92
+ case 'undelegate': {
93
+ const addresses = []
94
+ for (const [addr, info] of Object.entries(stakingInfo.accounts)) {
95
+ if (info.state === 'active' || info.state === 'activating') addresses.push(addr)
96
+ }
97
+
98
+ const N_UNDELEGATE_ADDR = 10
99
+ chunk(addresses, N_UNDELEGATE_ADDR).forEach((stakeAddresses) => {
100
+ txs.push({
101
+ method,
102
+ address,
103
+ amount: asset.currency.ZERO,
104
+ stakeAddresses,
105
+ })
106
+ })
107
+
108
+ break
109
+ }
110
+
111
+ case 'withdraw': {
112
+ const accounts = {}
113
+ for (const [addr, info] of Object.entries(stakingInfo.accounts)) {
114
+ if (info.state === 'inactive') accounts[addr] = info
115
+ }
116
+
117
+ const N_WITHDRAW_ACCOUNTS = 10
118
+ chunk(Object.entries(accounts), N_WITHDRAW_ACCOUNTS).forEach((entries) => {
119
+ txs.push({
120
+ method,
121
+ address,
122
+ amount: asset.currency.ZERO,
123
+ accounts: Object.fromEntries(entries),
124
+ })
125
+ })
126
+ if (txs.length === 0) throw new Error('no funds to withdraw')
127
+ break
128
+ }
129
+
130
+ default: {
131
+ throw new Error('Unknown method')
132
+ }
133
+ }
134
+
135
+ const txIds = []
136
+ for (const tx of txs) {
137
+ const { error, result } = await sendStake({
138
+ address,
139
+ walletAccount,
140
+ ...tx,
141
+ })
142
+ if (error) throw error
143
+
144
+ txIds.push(result.txId)
145
+ }
146
+
147
+ return txIds
148
+ }
149
+
150
+ async function delegate({ address, amount, stakingInfo, walletAccount }) {
151
+ return createAndSendStake({ method: 'delegate', address, amount, stakingInfo, walletAccount })
152
+ }
153
+
154
+ async function undelegate({ address, amount, stakingInfo, walletAccount }) {
155
+ return createAndSendStake({ method: 'undelegate', address, amount, stakingInfo, walletAccount })
156
+ }
157
+
158
+ async function withdraw({ address, amount, stakingInfo, walletAccount }) {
159
+ return createAndSendStake({ method: 'withdraw', address, amount, stakingInfo, walletAccount })
160
+ }
161
+
162
+ return {
163
+ stake: delegate,
164
+ unstake: undelegate,
165
+ claimUnstaked: withdraw,
166
+ getStakingInfo,
167
+ // Legacy names
168
+ createAndSendStake,
169
+ delegate,
170
+ undelegate,
171
+ withdraw,
172
+ }
173
+ }
@@ -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