@exodus/solana-api 3.25.3 → 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.
- package/CHANGELOG.md +26 -0
- package/package.json +3 -3
- package/src/api.js +27 -621
- package/src/connection.js +42 -111
- package/src/index.js +2 -0
- package/src/tx-log/README.md +63 -0
- package/src/tx-log/clarity-monitor.js +6 -8
- package/src/tx-log/index.js +3 -2
- package/src/tx-log/solana-monitor.js +10 -10
- package/src/tx-log/ws-monitor.js +390 -0
- package/src/tx-parser.js +533 -0
- package/src/tx-send.js +2 -2
- package/src/ws-api.js +263 -0
|
@@ -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
|
+
}
|