@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/src/connection.js CHANGED
@@ -1,68 +1,39 @@
1
1
  import { WebSocket } from '@exodus/fetch'
2
2
  import debugLogger from 'debug'
3
3
  import delay from 'delay'
4
- import lodash from 'lodash'
5
- import makeConcurrent from 'make-concurrent'
4
+ import assert from 'minimalistic-assert'
6
5
  import ms from 'ms'
7
6
 
8
- // WS subscriptions: https://docs.solana.com/developing/clients/jsonrpc-api#subscription-websocket
9
-
10
- const SOLANA_DEFAULT_ENDPOINT = 'wss://solana.a.exodus.io/ws'
11
7
  const DEFAULT_RECONNECT_DELAY = ms('15s')
12
- const PING_INTERVAL = ms('60s')
13
- const TIMEOUT = ms('50s')
8
+ const PING_INTERVAL = ms('30s')
14
9
 
15
10
  const debug = debugLogger('exodus:solana-api')
16
11
 
17
12
  export class Connection {
18
13
  constructor({
19
- endpoint = SOLANA_DEFAULT_ENDPOINT,
14
+ endpoint,
20
15
  address,
21
16
  tokensAddresses = [],
22
- callback,
17
+ onConnectionReady,
18
+ onConnectionClose,
23
19
  onMsg,
24
- reconnectCallback = () => {},
25
- reconnectDelay = DEFAULT_RECONNECT_DELAY,
26
20
  }) {
21
+ assert(endpoint, 'endpoint is required')
27
22
  this.address = address
28
23
  this.tokensAddresses = tokensAddresses
29
24
  this.endpoint = endpoint
30
- this.callback = callback
25
+ this.onConnectionReady = onConnectionReady
26
+ this.onConnectionClose = onConnectionClose
31
27
  this.onMsg = onMsg
32
- this.reconnectCallback = reconnectCallback
33
- this.reconnectDelay = reconnectDelay
34
28
 
35
29
  this.shutdown = false
36
30
  this.ws = null
37
- this.rpcQueue = {}
38
31
  this.messageQueue = []
39
32
  this.inProcessMessages = false
40
33
  this.pingTimeout = null
41
34
  this.reconnectTimeout = null
42
35
  this.txCache = {}
43
36
  this.seq = 0
44
-
45
- this.sendMessage = makeConcurrent(
46
- async (method, params = []) => {
47
- return new Promise((resolve, reject) => {
48
- if (this.isClosed || this.shutdown) return reject(new Error('connection not started'))
49
- const id = ++this.seq
50
-
51
- this.rpcQueue[id] = { resolve, reject }
52
- this.rpcQueue[id].timeout = setTimeout(() => {
53
- delete this.rpcQueue[id]
54
- console.log(`solana ws: reply timeout (${method}) - ${JSON.stringify(params)} - ${id}`)
55
- resolve(null)
56
- }, TIMEOUT)
57
- if (typeof this.rpcQueue[id].timeout.unref === 'function') {
58
- this.rpcQueue[id].timeout.unref()
59
- }
60
-
61
- this.ws.send(JSON.stringify({ jsonrpc: '2.0', method, params, id }))
62
- })
63
- },
64
- { concurrency: 15 }
65
- )
66
37
  }
67
38
 
68
39
  newSocket(reqUrl) {
@@ -106,10 +77,25 @@ export class Connection {
106
77
  return 'NONE'
107
78
  }
108
79
 
109
- doPing() {
80
+ startPing() {
110
81
  if (this.ws) {
111
- this.ws.ping()
112
- this.pingTimeout = setTimeout(this.doPing.bind(this), PING_INTERVAL)
82
+ this.pingTimeout = setInterval(() => {
83
+ if (this.isOpen) {
84
+ if (typeof this.ws.ping === 'function') {
85
+ this.ws.ping()
86
+ } else {
87
+ // Some WebSocket implementations (like in browser or Electron renderer) don't have a ping method
88
+ this.ws.send(
89
+ JSON.stringify({
90
+ jsonrpc: '2.0',
91
+ id: Date.now(),
92
+ method: 'getVersion',
93
+ params: [],
94
+ })
95
+ )
96
+ }
97
+ }
98
+ }, PING_INTERVAL)
113
99
  }
114
100
  }
115
101
 
@@ -117,75 +103,35 @@ export class Connection {
117
103
  // debug('Restarting WS:')
118
104
  this.reconnectTimeout = setTimeout(async () => {
119
105
  try {
120
- debug('reconnecting ws...')
106
+ console.log('SOL reconnecting ws...')
121
107
  this.start()
122
- await this.reconnectCallback()
123
108
  } catch (e) {
124
109
  console.log(`Error in reconnect callback: ${e.message}`)
125
110
  }
126
- }, this.reconnectDelay)
111
+ }, DEFAULT_RECONNECT_DELAY)
127
112
  }
128
113
 
129
114
  onMessage(evt) {
130
115
  try {
131
116
  const json = JSON.parse(evt.data)
132
117
  debug('new ws msg:', json)
133
- if (json.error) {
134
- if (lodash.get(this.rpcQueue, json.id)) {
135
- this.rpcQueue[json.id].reject(new Error(json.error.message))
136
- clearTimeout(this.rpcQueue[json.id].timeout)
137
- delete this.rpcQueue[json.id]
138
- } else debug('Unsupported WS message:', json.error.message)
139
- } else {
140
- if (lodash.get(this.rpcQueue, json.id)) {
141
- // json-rpc reply
142
- clearTimeout(this.rpcQueue[json.id].timeout)
143
- this.rpcQueue[json.id].resolve(json.result)
144
- delete this.rpcQueue[json.id]
145
- } else if (json.method) {
146
- const msg = { method: json.method, ...lodash.get(json, 'params.result', json.result) }
147
- debug('pushing msg to queue', msg)
148
- this.messageQueue.push(msg) // sub results
149
- }
150
-
151
- this.processMessages(json)
152
- }
118
+ this.onMsg(json)
153
119
  } catch (e) {
154
120
  debug(e)
155
- debug('Cannot parse msg:', evt.data)
121
+ debug('Cannot process msg:', evt.data)
156
122
  }
157
123
  }
158
124
 
159
125
  onOpen(evt) {
160
126
  debug('Opened WS')
161
- // subscribe to each addresses (SOL and ASA addr)
162
- const addresses = [...this.tokensAddresses, this.address]
163
- addresses.forEach((address) => {
164
- // sub for account state changes
165
- this.ws.send(
166
- JSON.stringify({
167
- jsonrpc: '2.0',
168
- method: 'accountSubscribe',
169
- params: [
170
- address,
171
- {
172
- encoding: 'jsonParsed',
173
- },
174
- ],
175
- id: ++this.seq,
176
- })
177
- )
178
- // sub for incoming/outcoming txs
179
- this.ws.send(
180
- JSON.stringify({
181
- jsonrpc: '2.0',
182
- method: 'logsSubscribe',
183
- params: [{ mentions: [address] }, { commitment: 'finalized' }],
184
- id: ++this.seq,
185
- })
186
- )
187
- })
188
- // this.doPing()
127
+ this.onConnectionReady(evt)
128
+ this.startPing()
129
+ }
130
+
131
+ send(args) {
132
+ if (this.isOpen) {
133
+ this.ws.send(JSON.stringify(args))
134
+ }
189
135
  }
190
136
 
191
137
  onError(evt) {
@@ -194,32 +140,17 @@ export class Connection {
194
140
 
195
141
  onClose(evt) {
196
142
  debug('Closing WS')
197
- clearTimeout(this.pingTimeout)
143
+ clearInterval(this.pingTimeout)
198
144
  clearTimeout(this.reconnectTimeout)
145
+ this.onConnectionClose(evt)
199
146
  if (!this.shutdown) {
200
147
  this.doRestart()
201
148
  }
202
149
  }
203
150
 
204
- async processMessages(json) {
205
- if (this.onMsg) await this.onMsg(json)
206
- if (this.inProcessMessages) return null
207
- this.inProcessMessages = true
208
- try {
209
- while (this.messageQueue.length > 0) {
210
- const items = this.messageQueue.splice(0, this.messageQueue.length)
211
- await this.callback(items)
212
- }
213
- } catch (e) {
214
- console.log(`Solana: error processing streams: ${e.message}`)
215
- } finally {
216
- this.inProcessMessages = false
217
- }
218
- }
219
-
220
151
  async close() {
221
152
  clearTimeout(this.reconnectTimeout)
222
- clearTimeout(this.pingTimeout)
153
+ clearInterval(this.pingTimeout)
223
154
  if (this.ws && (this.isConnecting || this.isOpen)) {
224
155
  // this.ws.send(JSON.stringify({ method: 'close' }))
225
156
  // Not sending the method above so just no need to wait below
@@ -245,4 +176,4 @@ export class Connection {
245
176
  await this.close()
246
177
  while (this.running) await delay(ms('50ms'))
247
178
  }
248
- } // Connection
179
+ }
package/src/index.js CHANGED
@@ -6,6 +6,7 @@ import { Api } from './api.js'
6
6
 
7
7
  export { SolanaMonitor } from './tx-log/index.js'
8
8
  export { SolanaClarityMonitor } from './tx-log/index.js'
9
+ export { SolanaWebsocketMonitor } from './tx-log/index.js'
9
10
  export { createAccountState } from './account-state.js'
10
11
  export { getStakingInfo } from './staking-utils.js'
11
12
  export {
@@ -32,4 +33,5 @@ const serverApi = new Api({ assets }) // TODO: remove it, clean every use from p
32
33
  export default serverApi // TODO: remove it
33
34
 
34
35
  export { Api } from './api.js'
36
+ export { WsApi } from './ws-api.js'
35
37
  export { ClarityApi } from './clarity-api.js'
@@ -0,0 +1,63 @@
1
+ # WS Clarity Monitor
2
+
3
+ This monitor will use a Mix of Clarity RPC calls and WS events (using Helius Enhanced Websockets: https://www.helius.dev/docs/enhanced-websockets)
4
+
5
+ ## Solana Monitor startup logic using WS and Clarity API
6
+
7
+ 1. on wallet first start we call API to get the current account state (balances + txsHistory + staking), for both SOL and tokens.
8
+
9
+ 2. we subscribe to WS for both SOL and tokens addresses (accountSubscribe, transactionSubscribe).
10
+
11
+ 3. after that we will rely only on WS to get new txs and update balances, but we will still ran regular tick to get a list of new "token accounts" (tokenAccountsByOwner), this to detect if the wallet has new ATA with new tokens. We could replace this with programSubscribe once Helius implements that method.
12
+
13
+ 4. when a new tx is received from WS we will parse it and add it to the tx log immediately.
14
+
15
+ 5. when a new balance update is received from WS we will update the balance immediately, (plus we also get and update stakingInfo).
16
+
17
+ 6. when a new tx is received, it is parsed and if it's a staking tx we update the stakingInfo in the state.
18
+
19
+ ### Notes
20
+
21
+ - when there's a balance we always fetch and update stakingInfo.
22
+
23
+ - Both Laserstream gRPC and Geyser enhanced websockets are serviced by Laserstream under the hood.
24
+
25
+ For `Richat`:
26
+ If you have 10 ATA you will have
27
+
28
+ - 11 `accountSubscribe` - 1 for wallet and 10 for ATA
29
+ - 1 `transactionSubscribe` - all 11 addresses in account.include
30
+ - 1 `tokenInitSubscribe` - 1 with wallet address
31
+
32
+ For `transactionSubscribe`, once you have new ATA unfortunately you need to send new `transactionSubscribe`, once you receive messages that subscription is set you need to unsubscribe the old subscription, at that moment you will be able to receive duplicated transactions for a short period of time.
33
+ (But this way you will not miss any transaction and will be easy to filter out duplicates on client side by txId)
34
+
35
+ Note that `tokenInitSubscribe` is a Richat-only method (in development). It doesn't exist in Laserstream/Geyser. It's used to check for new token accounts. For that we still need to rely on the `getTokenAccountsByOwner` RPC (or clarity call).
36
+
37
+ ## WS RPC
38
+
39
+ ##### `accountSubscribe` and `accountNotification`
40
+
41
+ When we are subscribed (`accountSubscribe`) to both SOL and SPL tokens and our account sends out an SPL token.
42
+
43
+ We'll have 2 `accountNotification`. Notifying us about the SPL balance change but also SOL balance change (because of the fees spent to send the SPL token).
44
+
45
+ How can we distinguish that? looking at the owner. For our SOL account the program owner is `11111111111111111111111111111111` while for tokens we have `TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA`
46
+
47
+ ### What to test in platform
48
+
49
+ - [x] check on wallet startup if the wallet tick updating balances and txHistory (for txs received when the wallet was close)
50
+ - [x] send/receive SOL token (got expected balance and txLog)
51
+ - [x] send/receive SPL token (got expected balance and txLog)
52
+ - [x] send/receive SPL2022 token, like PYUSD (got expected balance and txLog)
53
+ - [ ] receive SPL token that was not previously received (no token account yet)
54
+ - [x] staking/unstaking/withdraw SOL
55
+ - [x] force WS disconnect (check if the tokenAccounts are re-subscribed, apparently the onClose event is not called and the socket is still open)
56
+ - [x] disconnect wallet A -> send SOL from a different wallet B (connected) - re-connect A -> the wallet should catch up and see the balance and txLog
57
+ - [x] JUP swaps (and other DEX interactions?)
58
+
59
+ ### Known issues:
60
+
61
+ - [ ] when receiving a new token with no token account yet (the txLog is not written)
62
+
63
+ - if it's a known token (in this.assets) but no tokenAccount yet, the balance will update, txLog won't.
@@ -63,6 +63,8 @@ export class SolanaClarityMonitor extends BaseMonitor {
63
63
  if (unknownTokensList.length > 0) {
64
64
  this.emit('unknown-tokens', unknownTokensList)
65
65
  }
66
+
67
+ return unknownTokensList
66
68
  }
67
69
 
68
70
  async getStakingAddressesFromTxLog({ assetName, walletAccount }) {
@@ -172,12 +174,10 @@ export class SolanaClarityMonitor extends BaseMonitor {
172
174
  const mappedTransactions = []
173
175
  for (const tx of transactions) {
174
176
  // we get the token name using the token.mintAddress
175
- let tokenName = this.api.tokens.get(tx.token?.mintAddress)?.name
176
- if (tx.token && !tokenName) {
177
- tokenName = 'unknown' // unknown token
178
- }
177
+ const assetName = tx.token
178
+ ? this.api.tokens.get(tx.token.mintAddress)?.name ?? 'unknown'
179
+ : baseAsset.name
179
180
 
180
- const assetName = tokenName ?? baseAsset.name
181
181
  const asset = this.assets[assetName]
182
182
  if (assetName === 'unknown' || !asset) continue // skip unknown tokens
183
183
  const feeAsset = asset.feeAsset
@@ -249,12 +249,10 @@ export class SolanaClarityMonitor extends BaseMonitor {
249
249
  }
250
250
 
251
251
  async getAccountsAndBalances({ refresh, address, accountState, walletAccount }) {
252
- const tokens = Object.keys(this.assets).filter((name) => name !== this.asset.name)
253
252
  const [accountInfo, { balances: splBalances, accounts: tokenAccounts }] = await Promise.all([
254
253
  this.api.getAccountInfo(address).catch(() => {}),
255
254
  this.api.getTokensBalancesAndAccounts({
256
255
  address,
257
- filterByTokens: tokens,
258
256
  }),
259
257
  ])
260
258
 
@@ -321,7 +319,7 @@ export class SolanaClarityMonitor extends BaseMonitor {
321
319
  }
322
320
  }
323
321
 
324
- async updateState({ account, cursorState, walletAccount, staking }) {
322
+ async updateState({ account, cursorState = {}, walletAccount, staking }) {
325
323
  const { balance, tokenBalances, rentExemptAmount, accountSize, ownerChanged } = account
326
324
  const newData = {
327
325
  balance,
@@ -1,2 +1,3 @@
1
- export * from './solana-monitor.js'
2
- export * from './clarity-monitor.js'
1
+ export { SolanaMonitor } from './solana-monitor.js'
2
+ export { SolanaClarityMonitor } from './clarity-monitor.js'
3
+ export { SolanaWebsocketMonitor } from './ws-monitor.js'
@@ -7,7 +7,6 @@ import { DEFAULT_POOL_ADDRESS } from '../account-state.js'
7
7
 
8
8
  const DEFAULT_REMOTE_CONFIG = {
9
9
  rpcs: [],
10
- ws: [],
11
10
  staking: { enabled: true, pool: DEFAULT_POOL_ADDRESS },
12
11
  }
13
12
 
@@ -81,9 +80,8 @@ export class SolanaMonitor extends BaseMonitor {
81
80
  }
82
81
 
83
82
  setServer(config = {}) {
84
- const { rpcs, ws, staking = {} } = { ...DEFAULT_REMOTE_CONFIG, ...config }
83
+ const { rpcs, staking = {} } = { ...DEFAULT_REMOTE_CONFIG, ...config }
85
84
  this.api.setServer(rpcs[0])
86
- this.api.setWsEndpoint(ws[0])
87
85
  this.staking = staking
88
86
  }
89
87
 
@@ -100,6 +98,8 @@ export class SolanaMonitor extends BaseMonitor {
100
98
  if (unknownTokensList.length > 0) {
101
99
  this.emit('unknown-tokens', unknownTokensList)
102
100
  }
101
+
102
+ return unknownTokensList
103
103
  }
104
104
 
105
105
  async getStakingAddressesFromTxLog({ assetName, walletAccount }) {
@@ -212,7 +212,11 @@ export class SolanaMonitor extends BaseMonitor {
212
212
 
213
213
  const mappedTransactions = []
214
214
  for (const tx of transactions) {
215
- const assetName = _.get(tx, 'token.tokenName', baseAsset.name)
215
+ // we get the token name using the token.mintAddress
216
+ const assetName = tx.token
217
+ ? this.api.tokens.get(tx.token.mintAddress)?.name ?? 'unknown'
218
+ : baseAsset.name
219
+
216
220
  const asset = this.assets[assetName]
217
221
  if (assetName === 'unknown' || !asset) continue // skip unknown tokens
218
222
  const feeAsset = asset.feeAsset
@@ -262,11 +266,7 @@ export class SolanaMonitor extends BaseMonitor {
262
266
  item.data.meta = tx.data.meta
263
267
  }
264
268
 
265
- if (
266
- asset.assetType === this.api.tokenAssetType &&
267
- item.feeAmount &&
268
- item.feeAmount.isPositive
269
- ) {
269
+ if (asset.name !== asset.baseAsset.name && item.feeAmount && item.feeAmount.isPositive) {
270
270
  const feeItem = {
271
271
  ..._.clone(item),
272
272
  coinName: feeAsset.name,
@@ -352,7 +352,7 @@ export class SolanaMonitor extends BaseMonitor {
352
352
  }
353
353
  }
354
354
 
355
- async updateState({ account, cursorState, walletAccount, staking }) {
355
+ async updateState({ account, cursorState = {}, walletAccount, staking }) {
356
356
  const { balance, tokenBalances, rentExemptAmount, accountSize, ownerChanged } = account
357
357
  const newData = {
358
358
  balance,