@exodus/solana-api 3.30.7 → 3.30.8
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 +8 -0
- package/package.json +2 -2
- package/src/connection.js +10 -10
- package/src/tx-log/token-balance-patch.js +49 -0
- package/src/tx-log/ws-monitor.js +70 -24
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
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.8](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.7...@exodus/solana-api@3.30.8) (2026-04-02)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @exodus/solana-api
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
6
14
|
## [3.30.7](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.6...@exodus/solana-api@3.30.7) (2026-04-01)
|
|
7
15
|
|
|
8
16
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/solana-api",
|
|
3
|
-
"version": "3.30.
|
|
3
|
+
"version": "3.30.8",
|
|
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": "
|
|
52
|
+
"gitHead": "c4e253a7ee39a0ee2524ff3c0e528fe3614d15de",
|
|
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
|
@@ -74,17 +74,17 @@ export class Connection {
|
|
|
74
74
|
if (this.isOpen) {
|
|
75
75
|
if (typeof this.ws.ping === 'function') {
|
|
76
76
|
this.ws.ping()
|
|
77
|
-
} else {
|
|
78
|
-
// Some WebSocket implementations (like in browser or Electron renderer) don't have a ping method
|
|
79
|
-
this.ws.send(
|
|
80
|
-
JSON.stringify({
|
|
81
|
-
jsonrpc: '2.0',
|
|
82
|
-
id: Date.now(),
|
|
83
|
-
method: 'getVersion',
|
|
84
|
-
params: [],
|
|
85
|
-
})
|
|
86
|
-
)
|
|
87
77
|
}
|
|
78
|
+
|
|
79
|
+
// Application-level keepalive (always); native ping is optional per WebSocket impl
|
|
80
|
+
this.ws.send(
|
|
81
|
+
JSON.stringify({
|
|
82
|
+
jsonrpc: '2.0',
|
|
83
|
+
id: Date.now(),
|
|
84
|
+
method: 'getVersion',
|
|
85
|
+
params: [],
|
|
86
|
+
})
|
|
87
|
+
)
|
|
88
88
|
}
|
|
89
89
|
}, PING_INTERVAL)
|
|
90
90
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds `newData` for a token-balance patch applied after `executeOperationsBatch`.
|
|
3
|
+
* `AccountState.merge` is shallow at the top level: keys omitted from `newData` are left unchanged
|
|
4
|
+
* on the existing state, so an empty `extraNewData` (e.g. account/program WS) does not clear
|
|
5
|
+
* `cursor`, `historyCursor`, etc. set by an earlier patch in the same flush queue.
|
|
6
|
+
*
|
|
7
|
+
* @param {{ tokenBalances?: Record<string, unknown> } & Record<string, unknown>} current - Latest account state from `getAccountState`
|
|
8
|
+
* @param {{ tokenPatch: Record<string, unknown>, extraNewData: Record<string, unknown> }} patch
|
|
9
|
+
*/
|
|
10
|
+
export function buildTokenBalancePatchNewData(current, { tokenPatch, extraNewData }) {
|
|
11
|
+
return {
|
|
12
|
+
...extraNewData,
|
|
13
|
+
tokenBalances: { ...(current.tokenBalances ?? Object.create(null)), ...tokenPatch },
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Applies a queue of token balance patches sequentially. After each `updateAccountState`, the next
|
|
19
|
+
* patch reads fresh state via `getAccountState` so multiple SPL mints in one tx (and mixed WS events)
|
|
20
|
+
* merge correctly.
|
|
21
|
+
*
|
|
22
|
+
* On failure, throws an `Error` with `cause` set to the underlying error, `remainingPatches` set to
|
|
23
|
+
* patches not yet applied (including the one that failed), and `appliedPatchCount` set to how many
|
|
24
|
+
* completed successfully. Callers can re-queue `remainingPatches` for retry.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} aci - Asset client interface with `getAccountState` and `updateAccountState`
|
|
27
|
+
* @param {{ assetName: string, walletAccount: string, patches: Array<{ tokenPatch: Record<string, unknown>, extraNewData: Record<string, unknown> }> }} params
|
|
28
|
+
*/
|
|
29
|
+
export async function flushTokenBalancePatchesQueue(aci, { assetName, walletAccount, patches }) {
|
|
30
|
+
if (!patches?.length) return
|
|
31
|
+
|
|
32
|
+
let i = 0
|
|
33
|
+
try {
|
|
34
|
+
for (; i < patches.length; i++) {
|
|
35
|
+
const { tokenPatch, extraNewData } = patches[i]
|
|
36
|
+
const current = await aci.getAccountState({ assetName, walletAccount })
|
|
37
|
+
await aci.updateAccountState({
|
|
38
|
+
assetName,
|
|
39
|
+
walletAccount,
|
|
40
|
+
newData: buildTokenBalancePatchNewData(current, { tokenPatch, extraNewData }),
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
const err = new Error('Token balance patch flush failed', { cause })
|
|
45
|
+
err.remainingPatches = patches.slice(i)
|
|
46
|
+
err.appliedPatchCount = i
|
|
47
|
+
throw err
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/tx-log/ws-monitor.js
CHANGED
|
@@ -4,6 +4,7 @@ import assert from 'minimalistic-assert'
|
|
|
4
4
|
import { DEFAULT_POOL_ADDRESS } from '../account-state.js'
|
|
5
5
|
import { normalizeTransactionNotificationResult } from '../ws-api.js'
|
|
6
6
|
import { SolanaClarityMonitor } from './clarity-monitor.js'
|
|
7
|
+
import { flushTokenBalancePatchesQueue } from './token-balance-patch.js'
|
|
7
8
|
|
|
8
9
|
const DEFAULT_REMOTE_CONFIG = {
|
|
9
10
|
clarityUrl: [],
|
|
@@ -14,6 +15,8 @@ const DEFAULT_REMOTE_CONFIG = {
|
|
|
14
15
|
|
|
15
16
|
export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
16
17
|
#clarityInitialSyncDone = Object.create(null)
|
|
18
|
+
/** Queued after each `executeOperationsBatch` so token deltas merge onto latest state (fluent batch has no `push`). */
|
|
19
|
+
#pendingTokenBalancePatches = Object.create(null)
|
|
17
20
|
|
|
18
21
|
async #hasLoadedStakingInfo(walletAccount) {
|
|
19
22
|
const accountState = await this.aci.getAccountState({
|
|
@@ -171,13 +174,13 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
171
174
|
tokenAccounts: this.tokenAccountsByOwner[walletAccount],
|
|
172
175
|
})
|
|
173
176
|
if (!unknownTokensList.includes(parsed.mintAddress) && tokenName) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
...accountState.tokenBalances,
|
|
177
|
+
this.#queueTokenBalancePatchAfterBatch({
|
|
178
|
+
tokenPatch: {
|
|
177
179
|
[tokenName]: this.assets[tokenName].currency.baseUnit(parsed.amount),
|
|
178
180
|
},
|
|
179
|
-
|
|
180
|
-
|
|
181
|
+
extraNewData: {},
|
|
182
|
+
walletAccount,
|
|
183
|
+
})
|
|
181
184
|
}
|
|
182
185
|
|
|
183
186
|
return
|
|
@@ -202,13 +205,13 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
202
205
|
return
|
|
203
206
|
}
|
|
204
207
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
...accountState.tokenBalances,
|
|
208
|
+
this.#queueTokenBalancePatchAfterBatch({
|
|
209
|
+
tokenPatch: {
|
|
208
210
|
[tokenName]: this.assets[tokenName].currency.baseUnit(amount),
|
|
209
211
|
},
|
|
210
|
-
|
|
211
|
-
|
|
212
|
+
extraNewData: {},
|
|
213
|
+
walletAccount,
|
|
214
|
+
})
|
|
212
215
|
} else {
|
|
213
216
|
// SOL balance changed
|
|
214
217
|
|
|
@@ -332,16 +335,7 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
332
335
|
tokenBalancesFromTx[tokenName] = this.assets[tokenName].currency.baseUnit(amount)
|
|
333
336
|
}
|
|
334
337
|
|
|
335
|
-
const
|
|
336
|
-
Object.keys(tokenBalancesFromTx).length > 0
|
|
337
|
-
? {
|
|
338
|
-
...cursorState,
|
|
339
|
-
tokenBalances: {
|
|
340
|
-
...accountState.tokenBalances,
|
|
341
|
-
...tokenBalancesFromTx,
|
|
342
|
-
},
|
|
343
|
-
}
|
|
344
|
-
: { ...cursorState }
|
|
338
|
+
const hasTokenBalancePatch = Object.keys(tokenBalancesFromTx).length > 0
|
|
345
339
|
|
|
346
340
|
if (stakingTx) {
|
|
347
341
|
// for staking the balance is not updated by the balance handler
|
|
@@ -369,10 +363,18 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
369
363
|
break
|
|
370
364
|
}
|
|
371
365
|
|
|
372
|
-
|
|
366
|
+
const stakingStateUpdate = { ...cursorState, stakingInfo: temporaryStakingInfo }
|
|
373
367
|
|
|
374
368
|
// we update only stakingInfo, so that in this 12sec window, the spendable balance is valid. In the meanwhile we can fetch the complete one from RPC and update it in background (this one has the "staking accounts" references).
|
|
375
|
-
|
|
369
|
+
if (hasTokenBalancePatch) {
|
|
370
|
+
this.#queueTokenBalancePatchAfterBatch({
|
|
371
|
+
tokenPatch: tokenBalancesFromTx,
|
|
372
|
+
extraNewData: stakingStateUpdate,
|
|
373
|
+
walletAccount,
|
|
374
|
+
})
|
|
375
|
+
} else {
|
|
376
|
+
await this.#updateStateBatch({ newData: stakingStateUpdate, walletAccount })
|
|
377
|
+
}
|
|
376
378
|
|
|
377
379
|
await delay(12_000) // we introduce a delay to make sure the getStakingInfo RPC returns updated data
|
|
378
380
|
// because we NEED at a certain point to have an updated stakingInfo in the state to know what's the new solana stake account address.
|
|
@@ -395,8 +397,16 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
395
397
|
return
|
|
396
398
|
}
|
|
397
399
|
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
+
// Token deltas are applied after `executeOperationsBatch` so we merge onto latest state (swap race).
|
|
401
|
+
if (hasTokenBalancePatch) {
|
|
402
|
+
this.#queueTokenBalancePatchAfterBatch({
|
|
403
|
+
tokenPatch: tokenBalancesFromTx,
|
|
404
|
+
extraNewData: cursorState,
|
|
405
|
+
walletAccount,
|
|
406
|
+
})
|
|
407
|
+
} else if (Object.keys(cursorState).length > 0) {
|
|
408
|
+
await this.#updateStateBatch({ newData: cursorState, walletAccount })
|
|
409
|
+
}
|
|
400
410
|
|
|
401
411
|
return
|
|
402
412
|
}
|
|
@@ -425,10 +435,46 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
425
435
|
const batch = this.batch[walletAccount]
|
|
426
436
|
this.batch[walletAccount] = null
|
|
427
437
|
await this.aci.executeOperationsBatch(batch)
|
|
438
|
+
await this.#flushPendingTokenBalancePatches(walletAccount)
|
|
428
439
|
}, 2000)
|
|
429
440
|
return this.batch[walletAccount]
|
|
430
441
|
}
|
|
431
442
|
|
|
443
|
+
/**
|
|
444
|
+
* Production `createOperationsBatch()` returns a fluent batch (`updateAccountState` / `commit`), not
|
|
445
|
+
* `{ push, exec }` from tests. Token balance deltas are queued here and applied after `executeOperationsBatch`
|
|
446
|
+
* so each patch merges `{ ...current.tokenBalances, ...tokenPatch }` against up-to-date state.
|
|
447
|
+
*/
|
|
448
|
+
#queueTokenBalancePatchAfterBatch({ tokenPatch, extraNewData, walletAccount }) {
|
|
449
|
+
if (!this.#pendingTokenBalancePatches[walletAccount]) {
|
|
450
|
+
this.#pendingTokenBalancePatches[walletAccount] = []
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
this.#pendingTokenBalancePatches[walletAccount].push({ tokenPatch, extraNewData })
|
|
454
|
+
this.#ensureBatch(walletAccount)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async #flushPendingTokenBalancePatches(walletAccount) {
|
|
458
|
+
const queue = this.#pendingTokenBalancePatches[walletAccount]
|
|
459
|
+
if (!queue?.length) return
|
|
460
|
+
|
|
461
|
+
const snapshot = [...queue]
|
|
462
|
+
try {
|
|
463
|
+
await flushTokenBalancePatchesQueue(this.aci, {
|
|
464
|
+
assetName: this.asset.name,
|
|
465
|
+
walletAccount,
|
|
466
|
+
patches: snapshot,
|
|
467
|
+
})
|
|
468
|
+
this.#pendingTokenBalancePatches[walletAccount] = []
|
|
469
|
+
} catch (e) {
|
|
470
|
+
const remaining = e.remainingPatches
|
|
471
|
+
this.#pendingTokenBalancePatches[walletAccount] = Array.isArray(remaining)
|
|
472
|
+
? remaining
|
|
473
|
+
: snapshot
|
|
474
|
+
throw e
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
432
478
|
async #updateStateBatch({ newData, walletAccount }) {
|
|
433
479
|
const assetName = this.asset.name
|
|
434
480
|
const batch = this.#ensureBatch(walletAccount)
|