@exodus/solana-api 3.30.8 → 3.30.10
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 +20 -0
- package/package.json +4 -4
- package/src/connection.js +26 -15
- package/src/create-unsigned-tx-for-send.js +32 -5
- package/src/fee-payer.js +3 -3
- package/src/index.js +6 -1
- package/src/tx-log/README.md +10 -8
- package/src/tx-log/clarity-monitor.js +1 -7
- package/src/tx-log/ws-monitor.js +97 -45
- package/src/ws-api.js +271 -54
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,26 @@
|
|
|
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.10](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.9...@exodus/solana-api@3.30.10) (2026-04-13)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
* fix: SOL ws single subscription and reconnect backoff (#7727)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## [3.30.9](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.8...@exodus/solana-api@3.30.9) (2026-04-04)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Bug Fixes
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
* fix: sponsored Solana token sends when CU simulation fails (#7707)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
6
26
|
## [3.30.8](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.7...@exodus/solana-api@3.30.8) (2026-04-02)
|
|
7
27
|
|
|
8
28
|
**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.30.
|
|
3
|
+
"version": "3.30.10",
|
|
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",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"@exodus/fetch": "^1.7.3",
|
|
34
34
|
"@exodus/models": "^13.0.0",
|
|
35
35
|
"@exodus/simple-retry": "^0.0.6",
|
|
36
|
-
"@exodus/solana-lib": "^3.22.
|
|
37
|
-
"@exodus/solana-meta": "^2.0
|
|
36
|
+
"@exodus/solana-lib": "^3.22.5",
|
|
37
|
+
"@exodus/solana-meta": "^2.9.0",
|
|
38
38
|
"@exodus/timer": "^1.1.1",
|
|
39
39
|
"debug": "^4.1.1",
|
|
40
40
|
"delay": "^4.0.1",
|
|
@@ -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": "
|
|
52
|
+
"gitHead": "786b5cff109a9a2a3c385e8e3140be4c72120577",
|
|
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
|
@@ -4,7 +4,8 @@ import delay from 'delay'
|
|
|
4
4
|
import assert from 'minimalistic-assert'
|
|
5
5
|
import ms from 'ms'
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const BASE_RECONNECT_DELAY = ms('15s')
|
|
8
|
+
const MAX_RECONNECT_DELAY = ms('5m')
|
|
8
9
|
const PING_INTERVAL = ms('25s')
|
|
9
10
|
|
|
10
11
|
const debug = debugLogger('exodus:solana-api')
|
|
@@ -19,12 +20,10 @@ export class Connection {
|
|
|
19
20
|
|
|
20
21
|
this.shutdown = false
|
|
21
22
|
this.ws = null
|
|
22
|
-
this.messageQueue = []
|
|
23
|
-
this.inProcessMessages = false
|
|
24
23
|
this.pingTimeout = null
|
|
25
24
|
this.reconnectTimeout = null
|
|
26
|
-
this.txCache = {}
|
|
27
25
|
this.seq = 0
|
|
26
|
+
this.reconnectAttempt = 0
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
newSocket(reqUrl) {
|
|
@@ -57,19 +56,12 @@ export class Connection {
|
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
get running() {
|
|
60
|
-
return
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
get connectionState() {
|
|
64
|
-
if (this.isConnecting) return 'CONNECTING'
|
|
65
|
-
if (this.isOpen) return 'OPEN'
|
|
66
|
-
if (this.isClosing) return 'CLOSING'
|
|
67
|
-
if (this.isClosed) return 'CLOSED'
|
|
68
|
-
return 'NONE'
|
|
59
|
+
return !this.isClosed
|
|
69
60
|
}
|
|
70
61
|
|
|
71
62
|
startPing() {
|
|
72
63
|
if (this.ws) {
|
|
64
|
+
clearInterval(this.pingTimeout)
|
|
73
65
|
this.pingTimeout = setInterval(() => {
|
|
74
66
|
if (this.isOpen) {
|
|
75
67
|
if (typeof this.ws.ping === 'function') {
|
|
@@ -90,16 +82,27 @@ export class Connection {
|
|
|
90
82
|
}
|
|
91
83
|
}
|
|
92
84
|
|
|
85
|
+
#nextReconnectDelayMs() {
|
|
86
|
+
const exp = Math.min(MAX_RECONNECT_DELAY, BASE_RECONNECT_DELAY * 2 ** this.reconnectAttempt)
|
|
87
|
+
const jitter = Math.floor(Math.random() * Math.min(exp, BASE_RECONNECT_DELAY))
|
|
88
|
+
return exp + jitter
|
|
89
|
+
}
|
|
90
|
+
|
|
93
91
|
doRestart() {
|
|
94
|
-
|
|
92
|
+
if (this.shutdown || this.reconnectTimeout) return
|
|
93
|
+
|
|
94
|
+
const delayMs = this.#nextReconnectDelayMs()
|
|
95
|
+
this.reconnectAttempt += 1
|
|
96
|
+
console.log(`SOL scheduling ws reconnect in ${delayMs}ms (attempt ${this.reconnectAttempt})`)
|
|
95
97
|
this.reconnectTimeout = setTimeout(async () => {
|
|
98
|
+
this.reconnectTimeout = null
|
|
96
99
|
try {
|
|
97
100
|
console.log('SOL reconnecting ws...')
|
|
98
101
|
this.start()
|
|
99
102
|
} catch (e) {
|
|
100
103
|
console.log(`Error in reconnect callback: ${e.message}`)
|
|
101
104
|
}
|
|
102
|
-
},
|
|
105
|
+
}, delayMs)
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
onMessage(evt) {
|
|
@@ -115,6 +118,9 @@ export class Connection {
|
|
|
115
118
|
|
|
116
119
|
onOpen(evt) {
|
|
117
120
|
debug('Opened WS')
|
|
121
|
+
clearTimeout(this.reconnectTimeout)
|
|
122
|
+
this.reconnectTimeout = null
|
|
123
|
+
this.reconnectAttempt = 0
|
|
118
124
|
this.onConnectionReady(evt)
|
|
119
125
|
this.startPing()
|
|
120
126
|
}
|
|
@@ -132,7 +138,10 @@ export class Connection {
|
|
|
132
138
|
onClose(evt) {
|
|
133
139
|
debug('Closing WS', evt)
|
|
134
140
|
clearInterval(this.pingTimeout)
|
|
141
|
+
this.pingTimeout = null
|
|
135
142
|
clearTimeout(this.reconnectTimeout)
|
|
143
|
+
this.reconnectTimeout = null
|
|
144
|
+
this.ws = null
|
|
136
145
|
this.onConnectionClose(evt)
|
|
137
146
|
if (!this.shutdown) {
|
|
138
147
|
this.doRestart()
|
|
@@ -141,7 +150,9 @@ export class Connection {
|
|
|
141
150
|
|
|
142
151
|
async close() {
|
|
143
152
|
clearTimeout(this.reconnectTimeout)
|
|
153
|
+
this.reconnectTimeout = null
|
|
144
154
|
clearInterval(this.pingTimeout)
|
|
155
|
+
this.pingTimeout = null
|
|
145
156
|
if (this.ws && (this.isConnecting || this.isOpen)) {
|
|
146
157
|
// this.ws.send(JSON.stringify({ method: 'close' }))
|
|
147
158
|
// Not sending the method above so just no need to wait below
|
|
@@ -10,7 +10,9 @@ import assert from 'minimalistic-assert'
|
|
|
10
10
|
|
|
11
11
|
import { maybeAddFeePayerWithAuth } from './fee-payer.js'
|
|
12
12
|
|
|
13
|
-
const
|
|
13
|
+
const COMPUTE_BUDGET_INSTRUCTIONS_CU = 300
|
|
14
|
+
const SOL_TRANSFER_CU = 150 + COMPUTE_BUDGET_INSTRUCTIONS_CU
|
|
15
|
+
const DEFAULT_COMPUTE_UNIT_LIMIT = 200_000
|
|
14
16
|
const TOKEN_ACCOUNT_CREATION_SIZE = 165 // size of the token account
|
|
15
17
|
|
|
16
18
|
export const createTxFactory = ({ assetClientInterface, api, feePayerClient }) => {
|
|
@@ -65,6 +67,7 @@ export const createTxFactory = ({ assetClientInterface, api, feePayerClient }) =
|
|
|
65
67
|
|
|
66
68
|
const feeData =
|
|
67
69
|
providedFeeData ?? (await assetClientInterface.getFeeConfig({ assetName: baseAssetName }))
|
|
70
|
+
const shouldTryFeePayer = feeData.enableFeePayer && useFeePayer
|
|
68
71
|
|
|
69
72
|
const fromAddress =
|
|
70
73
|
providedFromAddress ??
|
|
@@ -211,18 +214,22 @@ export const createTxFactory = ({ assetClientInterface, api, feePayerClient }) =
|
|
|
211
214
|
reference,
|
|
212
215
|
memo,
|
|
213
216
|
// Effective: platform enable AND per-tx intent
|
|
214
|
-
useFeePayer,
|
|
217
|
+
useFeePayer: shouldTryFeePayer,
|
|
215
218
|
...tokenParams,
|
|
216
219
|
...stakingParams,
|
|
217
220
|
...magicEdenParams,
|
|
218
221
|
})
|
|
219
222
|
|
|
220
223
|
unsignedTx.txMeta.stakingParams = stakingParams
|
|
224
|
+
const isFeeSponsoredTokenTransfer = isToken && unsignedTx.txMeta.useFeePayer && !nft && !method
|
|
225
|
+
const shouldUseSafeComputeUnitLimit =
|
|
226
|
+
isFeeSponsoredTokenTransfer &&
|
|
227
|
+
(!unsignedTx.txData.isAssociatedTokenAccountActive || isExchange)
|
|
221
228
|
|
|
222
229
|
const resolveUnitConsumed = async () => {
|
|
223
230
|
// this avoids unnecessary simulations. Also the simulation fails with InsufficientFundsForRent when sending all.
|
|
224
231
|
if (asset.name === asset.baseAsset.name && amount && !nft && !method) {
|
|
225
|
-
return
|
|
232
|
+
return SOL_TRANSFER_CU
|
|
226
233
|
}
|
|
227
234
|
|
|
228
235
|
// Simulate with unsigned transaction. The fee payer service is deterministic -
|
|
@@ -237,10 +244,31 @@ export const createTxFactory = ({ assetClientInterface, api, feePayerClient }) =
|
|
|
237
244
|
// we use this method to compute unitsConsumed
|
|
238
245
|
// we can throw error here and fallback to ~0.025 SOL or estimate fee based on the method
|
|
239
246
|
console.log('error getting units consumed:', err)
|
|
247
|
+
const serializedError = typeof err === 'string' ? err : JSON.stringify(err)
|
|
248
|
+
const isRentOrBalanceError = /insufficientfunds|rent/i.test(serializedError)
|
|
249
|
+
const isAccountNotFoundError = /accountnotfound/i.test(serializedError)
|
|
250
|
+
|
|
251
|
+
// Fee sponsorship is injected after this simulation step. Overestimate CU to
|
|
252
|
+
// avoid on-chain failures; the fee payer absorbs the cost.
|
|
253
|
+
if (!unitsConsumed && isFeeSponsoredTokenTransfer && isRentOrBalanceError) {
|
|
254
|
+
return DEFAULT_COMPUTE_UNIT_LIMIT
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!unitsConsumed && isFeeSponsoredTokenTransfer && isAccountNotFoundError) {
|
|
258
|
+
const senderBaseBalanceLamports = BigInt(await api.getBalance(fromAddress))
|
|
259
|
+
if (senderBaseBalanceLamports === BigInt(0)) return DEFAULT_COMPUTE_UNIT_LIMIT
|
|
260
|
+
}
|
|
261
|
+
|
|
240
262
|
if (!unitsConsumed) throw new Error(err)
|
|
241
263
|
}
|
|
242
264
|
|
|
243
|
-
|
|
265
|
+
const estimatedComputeUnitLimit = unitsConsumed + COMPUTE_BUDGET_INSTRUCTIONS_CU
|
|
266
|
+
|
|
267
|
+
if (shouldUseSafeComputeUnitLimit && estimatedComputeUnitLimit < DEFAULT_COMPUTE_UNIT_LIMIT) {
|
|
268
|
+
return DEFAULT_COMPUTE_UNIT_LIMIT
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return estimatedComputeUnitLimit
|
|
244
272
|
}
|
|
245
273
|
|
|
246
274
|
const priorityFee = feeData.priorityFee
|
|
@@ -272,7 +300,6 @@ export const createTxFactory = ({ assetClientInterface, api, feePayerClient }) =
|
|
|
272
300
|
const tx = await maybeAddFeePayerWithAuth({
|
|
273
301
|
unsignedTx,
|
|
274
302
|
feePayerClient,
|
|
275
|
-
enableFeePayer: feeData.enableFeePayer,
|
|
276
303
|
})
|
|
277
304
|
|
|
278
305
|
const fee = tx.txMeta.usedFeePayer ? asset.feeAsset.currency.ZERO : calculatedFee
|
package/src/fee-payer.js
CHANGED
|
@@ -145,11 +145,11 @@ export const feePayerClientFactory = ({
|
|
|
145
145
|
* @param {Object} params.unsignedTx - The unsigned transaction
|
|
146
146
|
* @param {Object} params.feePayerClient - The fee payer client instance
|
|
147
147
|
*/
|
|
148
|
-
export const maybeAddFeePayerWithAuth = async ({ unsignedTx, feePayerClient
|
|
148
|
+
export const maybeAddFeePayerWithAuth = async ({ unsignedTx, feePayerClient }) => {
|
|
149
149
|
let unsignedTxWithFeePayer = unsignedTx
|
|
150
150
|
|
|
151
|
-
//
|
|
152
|
-
if (!feePayerClient || !
|
|
151
|
+
// `txMeta.useFeePayer` already combines platform-level and per-tx intent.
|
|
152
|
+
if (!feePayerClient || !unsignedTx.txMeta.useFeePayer) {
|
|
153
153
|
unsignedTxWithFeePayer.txMeta.usedFeePayer = false
|
|
154
154
|
return unsignedTxWithFeePayer
|
|
155
155
|
}
|
package/src/index.js
CHANGED
|
@@ -33,5 +33,10 @@ const serverApi = new Api({ assets }) // TODO: remove it, clean every use from p
|
|
|
33
33
|
export default serverApi // TODO: remove it
|
|
34
34
|
|
|
35
35
|
export { Api } from './api.js'
|
|
36
|
-
export {
|
|
36
|
+
export {
|
|
37
|
+
mergeUniqueWatchAddresses,
|
|
38
|
+
normalizeTransactionNotificationResult,
|
|
39
|
+
sameWatchAddressSet,
|
|
40
|
+
WsApi,
|
|
41
|
+
} from './ws-api.js'
|
|
37
42
|
export { ClarityApi } from './clarity-api.js'
|
package/src/tx-log/README.md
CHANGED
|
@@ -22,17 +22,19 @@ This monitor will use a Mix of Clarity RPC calls and WS events (using Helius Enh
|
|
|
22
22
|
|
|
23
23
|
- Both Laserstream gRPC and Geyser enhanced websockets are serviced by Laserstream under the hood.
|
|
24
24
|
|
|
25
|
-
For `
|
|
26
|
-
If you have 10 ATA you will have
|
|
25
|
+
For **Helius Enhanced WSS** (`WsApi` when the endpoint URL matches Helius — see [transactionSubscribe](https://www.helius.dev/docs/enhanced-websockets/transaction-subscribe)):
|
|
27
26
|
|
|
28
|
-
-
|
|
29
|
-
- 1 `transactionSubscribe` - all 11 addresses in account.include
|
|
30
|
-
- 1 `tokenInitSubscribe` - 1 with wallet address
|
|
27
|
+
- Filter uses `accountInclude`: transactions match if **any** listed account appears in the tx (OR). Up to 50,000 addresses per docs.
|
|
31
28
|
|
|
32
|
-
For
|
|
33
|
-
(But this way you will not miss any transaction and will be easy to filter out duplicates on client side by txId)
|
|
29
|
+
For **Triton / standard-style** RPC WebSockets (non-Helius URL), the same logical filter is sent as `accounts: { include: [...] }` (Whirligig / pool-specific docs mirror Solana’s JSON-RPC shape).
|
|
34
30
|
|
|
35
|
-
|
|
31
|
+
If you have 10 ATAs you will have:
|
|
32
|
+
|
|
33
|
+
- 11 `accountSubscribe` — 1 for the SOL owner and 10 for ATAs
|
|
34
|
+
- **1 active** `transactionSubscribe` at a time — the merged list of all 11 addresses in `accountInclude` / `accounts.include`
|
|
35
|
+
- 2 `programSubscribe` — SPL Token + Token-2022 (memcmp on owner), for new ATA discovery
|
|
36
|
+
|
|
37
|
+
When new ATAs appear, `WsApi` sends a **new** `transactionSubscribe` with the **full** merged address list, waits for the subscription id, then `transactionUnsubscribe`s the **previous** tx subscription. You may see duplicate `transactionNotification`s for a short window; dedupe by tx id (same as before).
|
|
36
38
|
|
|
37
39
|
## WS RPC
|
|
38
40
|
|
|
@@ -256,12 +256,7 @@ export class SolanaClarityMonitor extends BaseMonitor {
|
|
|
256
256
|
return { transactions: [], historyCursor: undefined }
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
|
|
260
|
-
...tx,
|
|
261
|
-
data: { ...tx.data, silentSound: true },
|
|
262
|
-
}))
|
|
263
|
-
|
|
264
|
-
return { transactions: transactionsWithSilentSound, historyCursor: before }
|
|
259
|
+
return { transactions, historyCursor: before }
|
|
265
260
|
} catch (error) {
|
|
266
261
|
console.warn('SolanaClarityMonitor fetchOldHistory failed', {
|
|
267
262
|
address,
|
|
@@ -333,7 +328,6 @@ export class SolanaClarityMonitor extends BaseMonitor {
|
|
|
333
328
|
staking: tx.staking || null,
|
|
334
329
|
unparsed: !!tx.unparsed,
|
|
335
330
|
swapTx: !!(tx.data && tx.data.inner),
|
|
336
|
-
silentSound: !!tx.data?.silentSound,
|
|
337
331
|
},
|
|
338
332
|
currencies: { [assetName]: asset.currency, [feeAsset.name]: feeAsset.currency },
|
|
339
333
|
}
|
package/src/tx-log/ws-monitor.js
CHANGED
|
@@ -113,13 +113,65 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
116
|
+
#buildTrackedTokenAccount({
|
|
117
|
+
tokenAccountAddress,
|
|
118
|
+
owner,
|
|
119
|
+
mintAddress,
|
|
120
|
+
balance = '0',
|
|
121
|
+
tokenProgram,
|
|
122
|
+
}) {
|
|
123
|
+
const tokenMeta = this.clarityApi.tokens.get(mintAddress)
|
|
124
|
+
return {
|
|
125
|
+
tokenAccountAddress,
|
|
126
|
+
owner,
|
|
127
|
+
tokenName: tokenMeta?.name ?? 'unknown',
|
|
128
|
+
ticker: tokenMeta?.ticker ?? 'UNKNOWN',
|
|
129
|
+
balance,
|
|
130
|
+
mintAddress,
|
|
131
|
+
tokenProgram,
|
|
132
|
+
decimals: tokenMeta?.decimals ?? 0,
|
|
133
|
+
feeBasisPoints: 0,
|
|
134
|
+
maximumFee: 0,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async #subscribeTokenAccountsForOwner({ address, tokenAccountAddresses }) {
|
|
139
|
+
if (tokenAccountAddresses.length === 0) return
|
|
140
|
+
|
|
141
|
+
for (const tokenAccountAddress of tokenAccountAddresses) {
|
|
142
|
+
await this.wsApi.accountSubscribe({ owner: address, account: tokenAccountAddress })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Do not await `transactionSubscribe`: confirmation can take up to 30s and would stall
|
|
146
|
+
// programNotification / transactionNotification handling after we've already extended
|
|
147
|
+
// `tokenAccountsByOwner` (tx-log parse + postTokenBalances patch must run immediately).
|
|
148
|
+
this.wsApi
|
|
149
|
+
.transactionSubscribe({
|
|
150
|
+
owner: address,
|
|
151
|
+
accounts: tokenAccountAddresses,
|
|
152
|
+
})
|
|
153
|
+
.catch((err) => {
|
|
154
|
+
this.logger.error(
|
|
155
|
+
`SOL WS transactionSubscribe failed for ${this.asset.name} owner ${address}`,
|
|
156
|
+
err
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async #flushBatch(walletAccount) {
|
|
162
|
+
const batch = this.batch[walletAccount]
|
|
163
|
+
this.batch[walletAccount] = null
|
|
164
|
+
if (!batch) return
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
await this.aci.executeOperationsBatch(batch)
|
|
168
|
+
await this.#flushPendingTokenBalancePatches(walletAccount)
|
|
169
|
+
} catch (error) {
|
|
170
|
+
this.logger.error(`SOL ws batch flush failed for ${this.asset.name}:${walletAccount}`, error)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
122
173
|
|
|
174
|
+
async #handleMessage({ address, walletAccount, data }) {
|
|
123
175
|
/*
|
|
124
176
|
1. A new event arrives.
|
|
125
177
|
2. Open a 2-second batch window.
|
|
@@ -152,31 +204,28 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
152
204
|
return
|
|
153
205
|
}
|
|
154
206
|
|
|
155
|
-
const
|
|
156
|
-
const tokenName = tokenMeta?.name
|
|
157
|
-
const newAccount = {
|
|
207
|
+
const newAccount = this.#buildTrackedTokenAccount({
|
|
158
208
|
tokenAccountAddress: parsed.tokenAccountAddress,
|
|
159
209
|
owner: parsed.owner,
|
|
160
|
-
tokenName: tokenName ?? 'unknown',
|
|
161
|
-
ticker: tokenMeta?.ticker ?? 'UNKNOWN',
|
|
162
|
-
balance: parsed.amount,
|
|
163
210
|
mintAddress: parsed.mintAddress,
|
|
211
|
+
balance: parsed.amount,
|
|
164
212
|
tokenProgram: parsed.tokenProgram,
|
|
165
|
-
|
|
166
|
-
feeBasisPoints: 0,
|
|
167
|
-
maximumFee: 0,
|
|
168
|
-
}
|
|
213
|
+
})
|
|
169
214
|
this.tokenAccountsByOwner[walletAccount] = [...tokenAccountsByOwnerList, newAccount]
|
|
170
215
|
|
|
171
|
-
await this
|
|
216
|
+
await this.#subscribeTokenAccountsForOwner({
|
|
217
|
+
address,
|
|
218
|
+
tokenAccountAddresses: [parsed.tokenAccountAddress],
|
|
219
|
+
})
|
|
172
220
|
|
|
173
221
|
const unknownTokensList = await this.emitUnknownTokensEvent({
|
|
174
222
|
tokenAccounts: this.tokenAccountsByOwner[walletAccount],
|
|
175
223
|
})
|
|
176
|
-
|
|
224
|
+
const tokenAsset = newAccount.tokenName && this.assets[newAccount.tokenName]
|
|
225
|
+
if (!unknownTokensList.includes(parsed.mintAddress) && tokenAsset) {
|
|
177
226
|
this.#queueTokenBalancePatchAfterBatch({
|
|
178
227
|
tokenPatch: {
|
|
179
|
-
[tokenName]:
|
|
228
|
+
[newAccount.tokenName]: tokenAsset.currency.baseUnit(parsed.amount),
|
|
180
229
|
},
|
|
181
230
|
extraNewData: {},
|
|
182
231
|
walletAccount,
|
|
@@ -188,26 +237,29 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
188
237
|
|
|
189
238
|
case 'accountNotification':
|
|
190
239
|
// balance changed events for known tokens or SOL address
|
|
191
|
-
|
|
192
|
-
const { amount, tokenMintAddress } = this.wsApi.parseAccountNotification({
|
|
240
|
+
const parsedAccountNotification = this.wsApi.parseAccountNotification({
|
|
193
241
|
address,
|
|
194
242
|
walletAccount,
|
|
195
|
-
tokenAccountsByOwner,
|
|
243
|
+
tokenAccountsByOwner: this.tokenAccountsByOwner[walletAccount],
|
|
196
244
|
result: data.params.result,
|
|
197
245
|
})
|
|
246
|
+
if (!parsedAccountNotification) return
|
|
247
|
+
|
|
248
|
+
const { amount, tokenMintAddress } = parsedAccountNotification
|
|
198
249
|
|
|
199
250
|
// update account state balance for SOL or Token
|
|
200
251
|
if (tokenMintAddress) {
|
|
201
252
|
// token balance changed
|
|
202
253
|
const tokenName = this.clarityApi.tokens.get(tokenMintAddress)?.name
|
|
203
|
-
|
|
254
|
+
const tokenAsset = tokenName && this.assets[tokenName]
|
|
255
|
+
if (!tokenAsset) {
|
|
204
256
|
console.log(`Unknown token mint address: ${tokenMintAddress}`)
|
|
205
257
|
return
|
|
206
258
|
}
|
|
207
259
|
|
|
208
260
|
this.#queueTokenBalancePatchAfterBatch({
|
|
209
261
|
tokenPatch: {
|
|
210
|
-
[tokenName]:
|
|
262
|
+
[tokenName]: tokenAsset.currency.baseUnit(amount),
|
|
211
263
|
},
|
|
212
264
|
extraNewData: {},
|
|
213
265
|
walletAccount,
|
|
@@ -235,10 +287,14 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
235
287
|
}
|
|
236
288
|
}
|
|
237
289
|
|
|
238
|
-
await
|
|
290
|
+
await delay(watchDelay)
|
|
239
291
|
}
|
|
240
292
|
|
|
241
293
|
// otherwise regular SOL update:
|
|
294
|
+
const accountState = await this.aci.getAccountState({
|
|
295
|
+
assetName: this.asset.name,
|
|
296
|
+
walletAccount,
|
|
297
|
+
})
|
|
242
298
|
const stakingInfo = accountState.stakingInfo // NB. we cannot call this.getStakingInfo(...) since it's not in sync with the ws event! we must wait a lot of seconds.
|
|
243
299
|
const balance = this.#computeTotalBalance({ amount, address, stakingInfo, walletAccount })
|
|
244
300
|
|
|
@@ -258,6 +314,7 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
258
314
|
const txDetails = normalizeTransactionNotificationResult(data.params.result)
|
|
259
315
|
const txTokenAccounts = this.wsApi.getTokenAccountsFromTxMeta(txDetails, address)
|
|
260
316
|
let tokenAccountsByOwnerList = this.tokenAccountsByOwner[walletAccount] || []
|
|
317
|
+
const newTokenAccountPubkeys = []
|
|
261
318
|
for (const txAcc of txTokenAccounts) {
|
|
262
319
|
if (
|
|
263
320
|
tokenAccountsByOwnerList.some(
|
|
@@ -267,30 +324,24 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
267
324
|
continue
|
|
268
325
|
}
|
|
269
326
|
|
|
270
|
-
const
|
|
271
|
-
const newAccount = {
|
|
327
|
+
const newAccount = this.#buildTrackedTokenAccount({
|
|
272
328
|
tokenAccountAddress: txAcc.tokenAccountAddress,
|
|
273
329
|
owner: txAcc.owner,
|
|
274
|
-
tokenName: tokenMeta?.name ?? 'unknown',
|
|
275
|
-
ticker: tokenMeta?.ticker ?? 'UNKNOWN',
|
|
276
|
-
balance: '0',
|
|
277
330
|
mintAddress: txAcc.mintAddress,
|
|
331
|
+
balance: '0',
|
|
278
332
|
tokenProgram: null,
|
|
279
|
-
decimals: tokenMeta?.decimals ?? 0,
|
|
280
|
-
feeBasisPoints: 0,
|
|
281
|
-
maximumFee: 0,
|
|
282
|
-
}
|
|
283
|
-
tokenAccountsByOwnerList = [...tokenAccountsByOwnerList, newAccount]
|
|
284
|
-
await this.wsApi.accountSubscribe({ owner: address, account: txAcc.tokenAccountAddress })
|
|
285
|
-
// we need also to perform a transactionSubscribe to the new token account address
|
|
286
|
-
await this.wsApi.transactionSubscribe({
|
|
287
|
-
owner: address,
|
|
288
|
-
accounts: txAcc.tokenAccountAddress,
|
|
289
333
|
})
|
|
334
|
+
tokenAccountsByOwnerList = [...tokenAccountsByOwnerList, newAccount]
|
|
335
|
+
newTokenAccountPubkeys.push(txAcc.tokenAccountAddress)
|
|
290
336
|
}
|
|
291
337
|
|
|
292
338
|
this.tokenAccountsByOwner[walletAccount] = tokenAccountsByOwnerList
|
|
293
339
|
|
|
340
|
+
await this.#subscribeTokenAccountsForOwner({
|
|
341
|
+
address,
|
|
342
|
+
tokenAccountAddresses: newTokenAccountPubkeys,
|
|
343
|
+
})
|
|
344
|
+
|
|
294
345
|
const { logItemsByAsset, cursorState = {} } = this.wsApi.parseTransactionNotification({
|
|
295
346
|
address,
|
|
296
347
|
walletAccount,
|
|
@@ -340,6 +391,10 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
340
391
|
if (stakingTx) {
|
|
341
392
|
// for staking the balance is not updated by the balance handler
|
|
342
393
|
// staking operations won't spend or modify the "total" wallet balance.
|
|
394
|
+
const accountState = await this.aci.getAccountState({
|
|
395
|
+
assetName: this.asset.name,
|
|
396
|
+
walletAccount,
|
|
397
|
+
})
|
|
343
398
|
|
|
344
399
|
// we update stakingInfo (before fetching the new one)
|
|
345
400
|
const temporaryStakingInfo = { ...accountState.stakingInfo }
|
|
@@ -431,11 +486,8 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
431
486
|
// open a new operations batch window if it's not opened already
|
|
432
487
|
if (this.batch[walletAccount]) return this.batch[walletAccount]
|
|
433
488
|
this.batch[walletAccount] = this.aci.createOperationsBatch()
|
|
434
|
-
setTimeout(
|
|
435
|
-
|
|
436
|
-
this.batch[walletAccount] = null
|
|
437
|
-
await this.aci.executeOperationsBatch(batch)
|
|
438
|
-
await this.#flushPendingTokenBalancePatches(walletAccount)
|
|
489
|
+
setTimeout(() => {
|
|
490
|
+
this.#flushBatch(walletAccount)
|
|
439
491
|
}, 2000)
|
|
440
492
|
return this.batch[walletAccount]
|
|
441
493
|
}
|
package/src/ws-api.js
CHANGED
|
@@ -19,6 +19,26 @@ import { isSolAddressPoisoningTx } from './txs-utils.js'
|
|
|
19
19
|
// Helius Enhanced WebSocket (https://www.helius.dev/docs/enhanced-websockets)
|
|
20
20
|
const HELIUS_WS_URL = 'wss://solana-helius-wss.a.exodus.io/ws'
|
|
21
21
|
|
|
22
|
+
/** Merge `extra` addresses onto `base` without duplicates (order preserved). */
|
|
23
|
+
export function mergeUniqueWatchAddresses(base, extra) {
|
|
24
|
+
const seen = new Set(base)
|
|
25
|
+
const out = [...base]
|
|
26
|
+
for (const addr of extra) {
|
|
27
|
+
if (seen.has(addr)) continue
|
|
28
|
+
seen.add(addr)
|
|
29
|
+
out.push(addr)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True if both arrays contain the same set of addresses (order-independent). */
|
|
36
|
+
export function sameWatchAddressSet(a, b) {
|
|
37
|
+
if (a.length !== b.length) return false
|
|
38
|
+
const set = new Set(a)
|
|
39
|
+
return b.every((addr) => set.has(addr))
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
/**
|
|
23
43
|
* Normalize `transactionSubscribe` / JSON-RPC `params.result` into `getTransaction`-shaped txDetails:
|
|
24
44
|
* `{ slot?, transaction: { message, signatures }, meta }`.
|
|
@@ -56,6 +76,10 @@ export function normalizeTransactionNotificationResult(result) {
|
|
|
56
76
|
}
|
|
57
77
|
|
|
58
78
|
export class WsApi {
|
|
79
|
+
#cancelledSubscriptionRequests = Object.create(null)
|
|
80
|
+
#ownerStates = Object.create(null)
|
|
81
|
+
#subscriptionConfirmHandlers = Object.create(null)
|
|
82
|
+
|
|
59
83
|
constructor({ rpcUrl, wsUrl, assets }) {
|
|
60
84
|
this.setWsEndpoint(wsUrl)
|
|
61
85
|
this.connection = null
|
|
@@ -65,20 +89,69 @@ export class WsApi {
|
|
|
65
89
|
|
|
66
90
|
/** Reset subscription maps (used on init and when the WS connection closes). */
|
|
67
91
|
#resetSubscriptionState() {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
92
|
+
for (const entry of Object.values(this.#subscriptionConfirmHandlers)) {
|
|
93
|
+
clearTimeout(entry.timeout)
|
|
94
|
+
entry.reject(new Error('SOL WS connection reset'))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.#cancelledSubscriptionRequests = Object.create(null)
|
|
98
|
+
this.#subscriptionConfirmHandlers = Object.create(null)
|
|
99
|
+
this.#ownerStates = Object.create(null)
|
|
71
100
|
// subscription id (from RPC response) -> { owner, type: 'account'|'transaction'|'program' }
|
|
72
101
|
this.subscriptionIdToMeta = Object.create(null)
|
|
73
102
|
// request id (our conn.seq) -> { owner, type }, until we receive the subscription id
|
|
74
103
|
this.pendingSubscriptionRequests = Object.create(null)
|
|
75
104
|
}
|
|
76
105
|
|
|
106
|
+
get accountSubscriptions() {
|
|
107
|
+
const out = Object.create(null)
|
|
108
|
+
for (const [owner, state] of Object.entries(this.#ownerStates)) {
|
|
109
|
+
if (state.accountSubscriptions.length > 0) out[owner] = [...state.accountSubscriptions]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get transactionSubscriptions() {
|
|
116
|
+
const out = Object.create(null)
|
|
117
|
+
for (const [owner, state] of Object.entries(this.#ownerStates)) {
|
|
118
|
+
if (state.transactionSubscriptions.length > 0) {
|
|
119
|
+
out[owner] = [...state.transactionSubscriptions]
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get programSubscriptions() {
|
|
127
|
+
const out = Object.create(null)
|
|
128
|
+
for (const [owner, state] of Object.entries(this.#ownerStates)) {
|
|
129
|
+
if (state.programSubscribed) out[owner] = true
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get activeTransactionSubscriptionId() {
|
|
136
|
+
const out = Object.create(null)
|
|
137
|
+
for (const [owner, state] of Object.entries(this.#ownerStates)) {
|
|
138
|
+
if (state.activeTransactionSubscriptionId != null) {
|
|
139
|
+
out[owner] = state.activeTransactionSubscriptionId
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
77
146
|
setWsEndpoint(wsUrl) {
|
|
78
147
|
this.wsUrl = wsUrl || HELIUS_WS_URL
|
|
79
148
|
}
|
|
80
149
|
|
|
81
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* True when using Helius Enhanced WebSocket (`accountInclude` filter).
|
|
152
|
+
* Triton / standard Solana-style RPC uses `accounts.include` in the filter object.
|
|
153
|
+
* @see https://www.helius.dev/docs/enhanced-websockets/transaction-subscribe
|
|
154
|
+
*/
|
|
82
155
|
#isHelius() {
|
|
83
156
|
if (!this.wsUrl) return false
|
|
84
157
|
return this.wsUrl.includes('helius') || this.wsUrl === HELIUS_WS_URL
|
|
@@ -96,16 +169,29 @@ export class WsApi {
|
|
|
96
169
|
if (json?.id != null && typeof json?.result === 'number') {
|
|
97
170
|
const pending = this.pendingSubscriptionRequests[json.id]
|
|
98
171
|
if (pending != null) {
|
|
99
|
-
this
|
|
172
|
+
if (this.#cancelledSubscriptionRequests[json.id]) {
|
|
173
|
+
delete this.#cancelledSubscriptionRequests[json.id]
|
|
174
|
+
this.#sendRpcUnsubscribe({ subId: json.result, type: pending.type })
|
|
175
|
+
} else {
|
|
176
|
+
this.subscriptionIdToMeta[json.result] = { owner: pending.owner, type: pending.type }
|
|
177
|
+
}
|
|
178
|
+
|
|
100
179
|
delete this.pendingSubscriptionRequests[json.id]
|
|
101
180
|
}
|
|
102
181
|
|
|
182
|
+
const confirm = this.#subscriptionConfirmHandlers[json.id]
|
|
183
|
+
if (confirm != null) {
|
|
184
|
+
clearTimeout(confirm.timeout)
|
|
185
|
+
delete this.#subscriptionConfirmHandlers[json.id]
|
|
186
|
+
confirm.resolve(json.result)
|
|
187
|
+
}
|
|
188
|
+
|
|
103
189
|
return []
|
|
104
190
|
}
|
|
105
191
|
|
|
106
192
|
if (method === 'accountNotification' || method === 'transactionNotification') {
|
|
107
193
|
const subId = params?.subscription
|
|
108
|
-
if (subId
|
|
194
|
+
if (subId != null) {
|
|
109
195
|
const owner = this.subscriptionIdToMeta[subId]?.owner
|
|
110
196
|
return owner && this.watchedAddresses[owner] ? [owner] : []
|
|
111
197
|
}
|
|
@@ -140,9 +226,24 @@ export class WsApi {
|
|
|
140
226
|
}
|
|
141
227
|
|
|
142
228
|
#dispatchMessage(json) {
|
|
229
|
+
if (json?.id != null && json.error) {
|
|
230
|
+
const confirm = this.#subscriptionConfirmHandlers[json.id]
|
|
231
|
+
if (confirm != null) {
|
|
232
|
+
clearTimeout(confirm.timeout)
|
|
233
|
+
delete this.#subscriptionConfirmHandlers[json.id]
|
|
234
|
+
delete this.pendingSubscriptionRequests[json.id]
|
|
235
|
+
const msg = json.error?.message || 'SOL WS subscription RPC error'
|
|
236
|
+
confirm.reject(new Error(msg))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
delete this.#cancelledSubscriptionRequests[json.id]
|
|
240
|
+
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
143
244
|
const addresses = this.#getAddressesForMessage(json)
|
|
144
245
|
for (const addr of addresses) {
|
|
145
|
-
this.watchedAddresses[addr]
|
|
246
|
+
this.watchedAddresses[addr]?.onMessage?.(json)
|
|
146
247
|
}
|
|
147
248
|
}
|
|
148
249
|
|
|
@@ -153,8 +254,74 @@ export class WsApi {
|
|
|
153
254
|
}
|
|
154
255
|
}
|
|
155
256
|
|
|
257
|
+
#sendRpcUnsubscribe({ subId, type }) {
|
|
258
|
+
const methodByType = {
|
|
259
|
+
account: 'accountUnsubscribe',
|
|
260
|
+
transaction: 'transactionUnsubscribe',
|
|
261
|
+
program: 'programUnsubscribe',
|
|
262
|
+
}
|
|
263
|
+
const method = type && methodByType[type]
|
|
264
|
+
if (!method || !this.connection?.isOpen) return
|
|
265
|
+
|
|
266
|
+
this.connection.send({
|
|
267
|
+
jsonrpc: '2.0',
|
|
268
|
+
id: ++this.connection.seq,
|
|
269
|
+
method,
|
|
270
|
+
params: [Number(subId)],
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#getOwnerState(owner) {
|
|
275
|
+
let state = this.#ownerStates[owner]
|
|
276
|
+
if (state) return state
|
|
277
|
+
|
|
278
|
+
state = {
|
|
279
|
+
accountSubscriptions: [],
|
|
280
|
+
transactionSubscriptions: [],
|
|
281
|
+
programSubscribed: false,
|
|
282
|
+
activeTransactionSubscriptionId: undefined,
|
|
283
|
+
transactionSubscribeChain: Promise.resolve(),
|
|
284
|
+
}
|
|
285
|
+
this.#ownerStates[owner] = state
|
|
286
|
+
return state
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#cancelPendingSubscriptionsForOwner(owner) {
|
|
290
|
+
for (const [requestId, meta] of Object.entries(this.pendingSubscriptionRequests)) {
|
|
291
|
+
if (meta.owner !== owner) continue
|
|
292
|
+
|
|
293
|
+
this.#cancelledSubscriptionRequests[requestId] = true
|
|
294
|
+
const confirm = this.#subscriptionConfirmHandlers[requestId]
|
|
295
|
+
if (confirm != null) {
|
|
296
|
+
clearTimeout(confirm.timeout)
|
|
297
|
+
delete this.#subscriptionConfirmHandlers[requestId]
|
|
298
|
+
confirm.reject(new Error(`SOL WS ${meta.type} subscription cancelled for ${owner}`))
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
156
303
|
async watchAddress({ address, tokensAddresses = [], onMessage }) {
|
|
157
|
-
if (this.watchedAddresses[address])
|
|
304
|
+
if (this.watchedAddresses[address]) {
|
|
305
|
+
const w = this.watchedAddresses[address]
|
|
306
|
+
if (onMessage) w.onMessage = onMessage
|
|
307
|
+
|
|
308
|
+
const prev = new Set(w.tokensAddresses)
|
|
309
|
+
const added = tokensAddresses.filter((t) => !prev.has(t))
|
|
310
|
+
w.tokensAddresses = mergeUniqueWatchAddresses(w.tokensAddresses, tokensAddresses)
|
|
311
|
+
|
|
312
|
+
if (added.length > 0 && this.connection?.isOpen) {
|
|
313
|
+
for (const addr of added) {
|
|
314
|
+
await this.accountSubscribe({ owner: address, account: addr })
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
await this.transactionSubscribe({
|
|
318
|
+
owner: address,
|
|
319
|
+
accounts: mergeUniqueWatchAddresses([address], w.tokensAddresses),
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return
|
|
324
|
+
}
|
|
158
325
|
|
|
159
326
|
this.watchedAddresses[address] = { tokensAddresses, onMessage }
|
|
160
327
|
|
|
@@ -189,8 +356,8 @@ export class WsApi {
|
|
|
189
356
|
return
|
|
190
357
|
}
|
|
191
358
|
|
|
192
|
-
const
|
|
193
|
-
if (
|
|
359
|
+
const ownerState = this.#getOwnerState(owner)
|
|
360
|
+
if (ownerState.accountSubscriptions.includes(account)) return // already subscribed
|
|
194
361
|
|
|
195
362
|
const id = ++conn.seq
|
|
196
363
|
this.pendingSubscriptionRequests[id] = { owner, type: 'account' }
|
|
@@ -207,44 +374,106 @@ export class WsApi {
|
|
|
207
374
|
id,
|
|
208
375
|
})
|
|
209
376
|
|
|
210
|
-
|
|
377
|
+
ownerState.accountSubscriptions = [...ownerState.accountSubscriptions, account]
|
|
211
378
|
}
|
|
212
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Subscribe to transactions touching any of the owner's watched accounts (OR semantics).
|
|
382
|
+
* Merges `accounts` into the existing set, sends **one** `transactionSubscribe` with the full
|
|
383
|
+
* merged `accountInclude` / `accounts.include` list, then unsubscribes the previous tx
|
|
384
|
+
* subscription (Helius + Triton safe; brief duplicate notifications possible — dedupe by tx id).
|
|
385
|
+
*/
|
|
213
386
|
async transactionSubscribe({ owner, accounts }) {
|
|
214
|
-
|
|
215
|
-
|
|
387
|
+
const conn = this.connection
|
|
388
|
+
if (!conn || !conn.isOpen) {
|
|
389
|
+
console.warn('SOL Connection is not open, cannot subscribe to', owner)
|
|
390
|
+
return
|
|
216
391
|
}
|
|
217
392
|
|
|
393
|
+
const ownerState = this.#getOwnerState(owner)
|
|
394
|
+
const normalized = Array.isArray(accounts) ? accounts : [accounts]
|
|
395
|
+
const prevChain = ownerState.transactionSubscribeChain
|
|
396
|
+
const done = prevChain.then(() =>
|
|
397
|
+
this.#transactionSubscribeStep({ owner, accounts: normalized })
|
|
398
|
+
)
|
|
399
|
+
ownerState.transactionSubscribeChain = done.catch((err) => {
|
|
400
|
+
console.error('SOL WS transactionSubscribe failed:', err)
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
return done
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async #transactionSubscribeStep({ owner, accounts }) {
|
|
218
407
|
const conn = this.connection
|
|
219
408
|
if (!conn || !conn.isOpen) {
|
|
220
409
|
console.warn('SOL Connection is not open, cannot subscribe to', owner)
|
|
221
410
|
return
|
|
222
411
|
}
|
|
223
412
|
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
413
|
+
const ownerState = this.#getOwnerState(owner)
|
|
414
|
+
const prevList = ownerState.transactionSubscriptions
|
|
415
|
+
const merged = mergeUniqueWatchAddresses(prevList, accounts)
|
|
416
|
+
const activeId = ownerState.activeTransactionSubscriptionId
|
|
417
|
+
const sameSet = sameWatchAddressSet(merged, prevList)
|
|
228
418
|
|
|
419
|
+
if (sameSet && activeId != null) {
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (sameSet && activeId == null && merged.length === 0) {
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const oldTxSubId = activeId
|
|
229
428
|
const id = ++conn.seq
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
429
|
+
|
|
430
|
+
let subId
|
|
431
|
+
try {
|
|
432
|
+
subId = await new Promise((resolve, reject) => {
|
|
433
|
+
const timeout = setTimeout(() => {
|
|
434
|
+
delete this.#subscriptionConfirmHandlers[id]
|
|
435
|
+
delete this.pendingSubscriptionRequests[id]
|
|
436
|
+
reject(new Error(`transactionSubscribe confirmation timeout for ${owner}`))
|
|
437
|
+
}, 30_000)
|
|
438
|
+
|
|
439
|
+
this.#subscriptionConfirmHandlers[id] = { resolve, reject, timeout }
|
|
440
|
+
this.pendingSubscriptionRequests[id] = { owner, type: 'transaction' }
|
|
441
|
+
|
|
442
|
+
const options = {
|
|
443
|
+
commitment: 'confirmed',
|
|
444
|
+
encoding: 'jsonParsed',
|
|
445
|
+
transactionDetails: 'full',
|
|
446
|
+
showRewards: false,
|
|
447
|
+
// Helius docs use 0 for legacy + v0; 255 keeps prior Exodus behavior on all providers.
|
|
448
|
+
maxSupportedTransactionVersion: 255,
|
|
449
|
+
}
|
|
450
|
+
const filter = this.#isHelius()
|
|
451
|
+
? { vote: false, accountInclude: merged }
|
|
452
|
+
: { vote: false, accounts: { include: merged } }
|
|
453
|
+
conn.send({
|
|
454
|
+
jsonrpc: '2.0',
|
|
455
|
+
id,
|
|
456
|
+
method: 'transactionSubscribe',
|
|
457
|
+
params: [filter, options],
|
|
458
|
+
})
|
|
459
|
+
})
|
|
460
|
+
} catch (e) {
|
|
461
|
+
console.error('SOL WS transactionSubscribe:', e)
|
|
462
|
+
throw e
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (oldTxSubId != null && conn.isOpen) {
|
|
466
|
+
conn.send({
|
|
467
|
+
jsonrpc: '2.0',
|
|
468
|
+
id: ++conn.seq,
|
|
469
|
+
method: 'transactionUnsubscribe',
|
|
470
|
+
params: [Number(oldTxSubId)],
|
|
471
|
+
})
|
|
472
|
+
delete this.subscriptionIdToMeta[String(oldTxSubId)]
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
ownerState.transactionSubscriptions = merged
|
|
476
|
+
ownerState.activeTransactionSubscriptionId = subId
|
|
248
477
|
}
|
|
249
478
|
|
|
250
479
|
async programSubscribe({ owner }) {
|
|
@@ -254,7 +483,8 @@ export class WsApi {
|
|
|
254
483
|
return
|
|
255
484
|
}
|
|
256
485
|
|
|
257
|
-
|
|
486
|
+
const ownerState = this.#getOwnerState(owner)
|
|
487
|
+
if (ownerState.programSubscribed) return // already subscribed (SPL + Token-2022)
|
|
258
488
|
|
|
259
489
|
const splTokenProgramId = TOKEN_PROGRAM_ID.toBase58()
|
|
260
490
|
const token2022ProgramId = TOKEN_2022_PROGRAM_ID.toBase58()
|
|
@@ -294,7 +524,7 @@ export class WsApi {
|
|
|
294
524
|
],
|
|
295
525
|
})
|
|
296
526
|
|
|
297
|
-
|
|
527
|
+
ownerState.programSubscribed = true
|
|
298
528
|
}
|
|
299
529
|
|
|
300
530
|
async sendSubscriptions({ address, tokensAddresses = [] }) {
|
|
@@ -316,30 +546,17 @@ export class WsApi {
|
|
|
316
546
|
if (!this.watchedAddresses[address]) return
|
|
317
547
|
|
|
318
548
|
const conn = this.connection
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
transaction: 'transactionUnsubscribe',
|
|
322
|
-
program: 'programUnsubscribe',
|
|
323
|
-
}
|
|
549
|
+
this.#cancelPendingSubscriptionsForOwner(address)
|
|
550
|
+
|
|
324
551
|
for (const [subId, meta] of Object.entries(this.subscriptionIdToMeta)) {
|
|
325
552
|
if (meta.owner !== address) continue
|
|
326
|
-
|
|
327
|
-
if (method && conn?.isOpen) {
|
|
328
|
-
conn.send({
|
|
329
|
-
jsonrpc: '2.0',
|
|
330
|
-
id: ++conn.seq,
|
|
331
|
-
method,
|
|
332
|
-
params: [Number(subId)],
|
|
333
|
-
})
|
|
334
|
-
}
|
|
553
|
+
this.#sendRpcUnsubscribe({ subId, type: meta.type })
|
|
335
554
|
|
|
336
555
|
delete this.subscriptionIdToMeta[subId]
|
|
337
556
|
}
|
|
338
557
|
|
|
339
558
|
delete this.watchedAddresses[address]
|
|
340
|
-
delete this
|
|
341
|
-
delete this.transactionSubscriptions[address]
|
|
342
|
-
delete this.programSubscriptions[address]
|
|
559
|
+
delete this.#ownerStates[address]
|
|
343
560
|
|
|
344
561
|
if (Object.keys(this.watchedAddresses).length === 0 && conn) {
|
|
345
562
|
await conn.stop()
|