@exodus/solana-api 3.25.4 → 3.26.0

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.
@@ -0,0 +1,390 @@
1
+ import delay from 'delay'
2
+ import assert from 'minimalistic-assert'
3
+
4
+ import { DEFAULT_POOL_ADDRESS } from '../account-state.js'
5
+ import { SolanaClarityMonitor } from './clarity-monitor.js'
6
+
7
+ const DEFAULT_REMOTE_CONFIG = {
8
+ clarityUrl: [],
9
+ rpcUrl: [],
10
+ ws: [],
11
+ staking: { enabled: true, pool: DEFAULT_POOL_ADDRESS },
12
+ }
13
+
14
+ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
15
+ constructor({ wsApi, ...args }) {
16
+ assert(wsApi, 'wsApi is required')
17
+ super(args)
18
+ this.wsApi = wsApi
19
+ this.tokenAccountsByOwner = Object.create(null)
20
+ this.batch = Object.create(null)
21
+
22
+ this.addHook('before-start', (...args) => this.beforeStart(...args))
23
+ this.addHook('before-stop', (...args) => this.beforeStop(...args))
24
+ }
25
+
26
+ async beforeStart() {
27
+ this.assets = await this.aci.getAssetsForNetwork({ baseAssetName: this.asset.name })
28
+ this.api.setTokens(this.assets)
29
+
30
+ const walletAccounts = await this.aci.getWalletAccounts({ assetName: this.asset.name })
31
+ await Promise.all(
32
+ walletAccounts.map((walletAccount) => this.#subscribeWalletAddresses({ walletAccount }))
33
+ )
34
+ }
35
+
36
+ async beforeStop() {
37
+ const walletAccounts = await this.aci.getWalletAccounts({ assetName: this.asset.name })
38
+ await Promise.all(
39
+ walletAccounts.map((walletAccount) => this.#unsubscribeWalletAddresses({ walletAccount }))
40
+ )
41
+ const isBatchActive = walletAccounts.some((walletAccount) => !!this.batch[walletAccount])
42
+ if (isBatchActive) await delay(2000) // wait for any batch opened to close
43
+ }
44
+
45
+ async #subscribeWalletAddresses({ walletAccount }) {
46
+ const address = await this.aci.getReceiveAddress({
47
+ assetName: this.asset.name,
48
+ walletAccount,
49
+ useCache: true,
50
+ })
51
+
52
+ const { accounts: tokenAccountsByOwner } = await this.api.getTokensBalancesAndAccounts({
53
+ address,
54
+ })
55
+ this.tokenAccountsByOwner[walletAccount] = tokenAccountsByOwner
56
+ const tokensAddresses = tokenAccountsByOwner.map((acc) => acc.tokenAccountAddress)
57
+
58
+ return this.wsApi.watchAddress({
59
+ address,
60
+ tokensAddresses,
61
+ onMessage: async (json) => {
62
+ // parse event, then update state or tx-log
63
+ await this.#handleMessage({ address, walletAccount, data: json })
64
+ },
65
+ })
66
+ }
67
+
68
+ async #unsubscribeWalletAddresses({ walletAccount }) {
69
+ const address = await this.aci.getReceiveAddress({
70
+ assetName: this.asset.name,
71
+ walletAccount,
72
+ useCache: true,
73
+ })
74
+ return this.wsApi.unwatchAddress({ address })
75
+ }
76
+
77
+ setServer(config = {}) {
78
+ const { ws } = { ...DEFAULT_REMOTE_CONFIG, ...config }
79
+ this.wsApi.setWsEndpoint(ws[0])
80
+ super.setServer(config)
81
+ }
82
+
83
+ 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
+ if (refresh || this.tickCount[walletAccount] === 0) {
86
+ return super.tick({ walletAccount, refresh }) // Clarity refresh or first tick
87
+ }
88
+
89
+ const assetName = this.asset.name
90
+ this.assets = await this.aci.getAssetsForNetwork({ baseAssetName: assetName })
91
+ this.api.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.api.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
+ await this.wsApi.accountSubscribe({ owner: address, account: mintAddress })
118
+ const tokenName = this.api.tokens.get(mintAddress)?.name
119
+ if (!tokenName) {
120
+ console.log(`Unknown token mint address: ${mintAddress}`)
121
+ continue
122
+ }
123
+
124
+ // update only token balances for known tokens
125
+ const amount = splBalances[mintAddress]
126
+ const newData = {
127
+ tokenBalances: {
128
+ ...accountState.tokenBalances,
129
+ [tokenName]: this.assets[tokenName].currency.baseUnit(amount),
130
+ },
131
+ }
132
+ await this.#updateStateBatch({ newData, walletAccount })
133
+ }
134
+
135
+ // await this.updateState({ account, walletAccount, staking }) // we could update tokenBalances but we gotta test for race-conditions
136
+ }
137
+
138
+ async #handleMessage({ address, walletAccount, data }) {
139
+ const accountState = await this.aci.getAccountState({
140
+ assetName: this.asset.name,
141
+ walletAccount,
142
+ })
143
+ const tokenAccountsByOwner = this.tokenAccountsByOwner[walletAccount]
144
+
145
+ /*
146
+ 1. A new event arrives.
147
+ 2. Open a 2-second batch window.
148
+ 3. Add balance updates and transactions to the batch.
149
+ 4. After 2 seconds, close the window and execute the batch.
150
+ */
151
+
152
+ if (['accountNotification', 'transactionNotification'].includes(data?.method)) {
153
+ this.#ensureBatch(walletAccount)
154
+ }
155
+
156
+ switch (data?.method) {
157
+ case 'accountNotification':
158
+ // balance changed events for known tokens or SOL address
159
+
160
+ const { amount, tokenMintAddress } = this.wsApi.parseAccountNotification({
161
+ address,
162
+ walletAccount,
163
+ tokenAccountsByOwner,
164
+ result: data.params.result,
165
+ })
166
+
167
+ // update account state balance for SOL or Token
168
+ if (tokenMintAddress) {
169
+ // token balance changed
170
+ const tokenName = this.api.tokens.get(tokenMintAddress)?.name
171
+ if (!tokenName) {
172
+ console.log(`Unknown token mint address: ${tokenMintAddress}`)
173
+ return
174
+ }
175
+
176
+ const newData = {
177
+ // ...accountState, // we don't wanna update SOL "balance"
178
+ tokenBalances: {
179
+ ...accountState.tokenBalances,
180
+ [tokenName]: this.assets[tokenName].currency.baseUnit(amount),
181
+ },
182
+ }
183
+ await this.#updateStateBatch({ newData, walletAccount })
184
+ } else {
185
+ // SOL balance changed
186
+
187
+ // Wait up to 1.5s looking for an unconfirmed staking tx in the txLog.
188
+ // If we find an unconfirmed staking tx, we don't update balance.
189
+ // we can update staking balances in this handler because we cannot distinguish staking balance update from other updates
190
+ // moreover to compute a right "balance" we need an updated stakingInfo. That we don't have here.
191
+ const watchDelay = 150 // ms
192
+ const watchTimeout = 1500 // ms (1.5s)
193
+ const start = Date.now()
194
+
195
+ while (Date.now() - start < watchTimeout) {
196
+ const baseAssetTxLog = await this.aci.getTxLog({
197
+ assetName: this.asset.name,
198
+ walletAccount,
199
+ })
200
+ for (const tx of baseAssetTxLog) {
201
+ if (tx.pending && tx.data?.staking) {
202
+ console.log('found unconfirmed SOL staking tx. Skipping balance update.')
203
+ return // no balance update, the transactionNotification flow handle staking updates
204
+ }
205
+ }
206
+
207
+ await new Promise((resolve) => setTimeout(resolve, watchDelay))
208
+ }
209
+
210
+ // otherwise regular SOL update:
211
+ 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.
212
+ const balance = this.#computeTotalBalance({ amount, address, stakingInfo, walletAccount })
213
+
214
+ const newData = {
215
+ // ...accountState, // we don't wanna update "tokenBalances" with old values (after sending an SPL token we have 2 events one updating SOL balance and one updating tokenBalances)
216
+ balance,
217
+ }
218
+
219
+ await this.#updateStateBatch({ newData, walletAccount })
220
+ }
221
+
222
+ return
223
+ case 'transactionNotification':
224
+ // update tx-log with new txs and state with cursor
225
+
226
+ // NB. if we receive a tx with a new "token", never received before,
227
+ // this.api.tokens will be populated with new tokens only after we ran a monitor tick
228
+ // since the unknown-tokens event is captured and processed in other places.
229
+ // hence what happens is we skip the txLog update for that new token.
230
+
231
+ const { logItemsByAsset, cursorState = {} } = this.wsApi.parseTransactionNotification({
232
+ address,
233
+ walletAccount,
234
+ baseAsset: this.asset,
235
+ assets: this.assets,
236
+ tokens: this.api.tokens,
237
+ tokenAccountsByOwner,
238
+ result: data.params.result, // raw tx
239
+ })
240
+
241
+ if (Object.keys(logItemsByAsset).length === 0) return // cannot parse tx
242
+
243
+ const clearedLogItems = await this.markStaleTransactions({ walletAccount, logItemsByAsset })
244
+
245
+ // if there are notifications about staking txs, we need to update balance and stakingInfo
246
+ // check if we're dealing with a staking tx
247
+ let stakingTx
248
+ clearedLogItems?.solana?.forEach((tx) => {
249
+ if (tx.data.staking) stakingTx = tx
250
+ })
251
+
252
+ if (stakingTx) stakingTx.confirmations = 0 // HACK: mark it as unconfirmed till we call and update the stakingInfo
253
+
254
+ // update txLog
255
+ await this.#updateHistoryBatch({
256
+ walletAccount,
257
+ logItemsByAsset: clearedLogItems,
258
+ refresh: false,
259
+ })
260
+
261
+ const newData = {
262
+ ...cursorState,
263
+ }
264
+
265
+ if (stakingTx) {
266
+ // for staking the balance is not updated by the balance handler
267
+ // staking operations won't spend or modify the "total" wallet balance.
268
+
269
+ // we update stakingInfo (before fetching the new one)
270
+ const temporaryStakingInfo = { ...accountState.stakingInfo }
271
+ // eslint-disable-next-line sonarjs/no-nested-switch
272
+ switch (stakingTx.data.staking?.method) {
273
+ case 'delegate':
274
+ const stakeAmount = this.asset.currency.baseUnit(stakingTx.data.staking?.stake || '0')
275
+ temporaryStakingInfo.activating = temporaryStakingInfo.activating.add(
276
+ stakeAmount.abs()
277
+ )
278
+ break
279
+ case 'withdraw':
280
+ temporaryStakingInfo.withdrawable = this.asset.currency.ZERO
281
+ break
282
+ case 'undelegate':
283
+ temporaryStakingInfo.pending = temporaryStakingInfo.pending
284
+ .add(temporaryStakingInfo.locked)
285
+ .add(temporaryStakingInfo.activating)
286
+ temporaryStakingInfo.locked = this.asset.currency.ZERO
287
+ temporaryStakingInfo.activating = this.asset.currency.ZERO
288
+ break
289
+ }
290
+
291
+ newData.stakingInfo = temporaryStakingInfo
292
+
293
+ // 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).
294
+ await this.#updateStateBatch({ newData, walletAccount })
295
+
296
+ await delay(12_000) // we introduce a delay to make sure the getStakingInfo RPC returns updated data
297
+ // 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.
298
+ const stakingInfo = await this.getStakingInfo({ address, accountState, walletAccount })
299
+ const balance = this.#computeTotalBalance({
300
+ amount: this.asset.currency.baseUnit(await this.api.getBalance(address)), // we needed the delay otherwise will never be updated right away.
301
+ address,
302
+ stakingInfo,
303
+ walletAccount,
304
+ })
305
+
306
+ stakingTx.confirmations = 1 // mark tx as confirmed
307
+ await this.#updateHistoryBatch({
308
+ walletAccount,
309
+ logItemsByAsset: clearedLogItems,
310
+ refresh: false,
311
+ })
312
+ await this.#updateStateBatch({ newData: { balance, stakingInfo }, walletAccount })
313
+
314
+ return
315
+ }
316
+
317
+ // NB. we don't update any balances here to avoid race conditions with the accountNotification event above
318
+ await this.#updateStateBatch({ newData, walletAccount })
319
+
320
+ return
321
+ default:
322
+ if (data?.result && typeof data.result === 'number') {
323
+ // subscription confirmation, skip
324
+ return
325
+ }
326
+
327
+ if (data?.result && data.result?.['solana-core']) {
328
+ // ping response, skip
329
+ return
330
+ }
331
+
332
+ console.log(`Unknown SOL WS method: ${data?.method}`)
333
+ break
334
+ }
335
+ }
336
+
337
+ #ensureBatch(walletAccount) {
338
+ // open a new operations batch window if it's not opened already
339
+ if (this.batch[walletAccount]) return this.batch[walletAccount]
340
+ this.batch[walletAccount] = this.aci.createOperationsBatch()
341
+ setTimeout(async () => {
342
+ const batch = this.batch[walletAccount]
343
+ this.batch[walletAccount] = null
344
+ await this.aci.executeOperationsBatch(batch)
345
+ }, 2000)
346
+ return this.batch[walletAccount]
347
+ }
348
+
349
+ async #updateStateBatch({ newData, walletAccount }) {
350
+ const assetName = this.asset.name
351
+ const batch = this.#ensureBatch(walletAccount)
352
+ this.updateAccountStateBatch({ assetName, walletAccount, newData, batch })
353
+ }
354
+
355
+ async #updateHistoryBatch({ walletAccount, logItemsByAsset, refresh }) {
356
+ const batch = this.#ensureBatch(walletAccount)
357
+ this.updateTxLogByAssetBatch({ logItemsByAsset, walletAccount, refresh, batch })
358
+ }
359
+
360
+ #computeTotalBalance({ amount, address, stakingInfo, walletAccount }) {
361
+ const solBalance = this.asset.currency.baseUnit(amount)
362
+
363
+ const { stakedBalance, activatingBalance, withdrawableBalance, pendingBalance } =
364
+ this.#getStakingBalances({
365
+ address,
366
+ stakingInfo,
367
+ walletAccount,
368
+ })
369
+
370
+ return solBalance
371
+ .add(stakedBalance)
372
+ .add(activatingBalance)
373
+ .add(withdrawableBalance)
374
+ .add(pendingBalance)
375
+ }
376
+
377
+ #getStakingBalances({ address, stakingInfo, walletAccount }) {
378
+ const stakedBalance = this.asset.currency.baseUnit(stakingInfo.locked)
379
+ const activatingBalance = this.asset.currency.baseUnit(stakingInfo.activating)
380
+ const withdrawableBalance = this.asset.currency.baseUnit(stakingInfo.withdrawable)
381
+ const pendingBalance = this.asset.currency.baseUnit(stakingInfo.pending)
382
+
383
+ return {
384
+ stakedBalance, // staked
385
+ activatingBalance, // staking
386
+ withdrawableBalance, // unstaked
387
+ pendingBalance, // unstaking
388
+ }
389
+ }
390
+ }