@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
package/src/ws-api.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { PublicKey, Token, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, U64 } from '@exodus/solana-lib'
|
|
2
|
+
import lodash from 'lodash'
|
|
3
|
+
|
|
4
|
+
import { Connection } from './connection.js'
|
|
5
|
+
import { parseTransaction } from './tx-parser.js'
|
|
6
|
+
|
|
7
|
+
// Helius Advanced WebSocket
|
|
8
|
+
const WS_ENDPOINT = 'wss://solana-helius-wss.a.exodus.io/ws' // pointing to: wss://atlas-mainnet.helius-rpc.com/?api-key=<API_KEY>
|
|
9
|
+
|
|
10
|
+
export class WsApi {
|
|
11
|
+
constructor({ rpcUrl, wsUrl, assets }) {
|
|
12
|
+
this.setWsEndpoint(wsUrl)
|
|
13
|
+
this.connections = Object.create(null)
|
|
14
|
+
this.accountSubscriptions = Object.create(null)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
setWsEndpoint(wsUrl) {
|
|
18
|
+
this.wsUrl = wsUrl || WS_ENDPOINT
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async watchAddress({ address, tokensAddresses = [], onMessage }) {
|
|
22
|
+
if (this.connections[address]) return // already subscribed
|
|
23
|
+
const conn = new Connection({
|
|
24
|
+
endpoint: this.wsUrl,
|
|
25
|
+
address,
|
|
26
|
+
tokensAddresses,
|
|
27
|
+
onConnectionReady: (evt) => {
|
|
28
|
+
// ws connected, can send subscribe requests (this is called on every re-connect as well)
|
|
29
|
+
console.log('SOL WS connected.')
|
|
30
|
+
this.sendSubscriptions({ address, tokensAddresses })
|
|
31
|
+
},
|
|
32
|
+
onConnectionClose: (evt) => {
|
|
33
|
+
this.accountSubscriptions = Object.create(null) // clear subs
|
|
34
|
+
},
|
|
35
|
+
onMsg: (json) => onMessage(json),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
this.connections[address] = conn
|
|
39
|
+
return this.connections[address].start()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async accountSubscribe({ owner, account }) {
|
|
43
|
+
// could be SOL address or token account address
|
|
44
|
+
const conn = this.connections[owner]
|
|
45
|
+
if (!conn || !conn.isOpen) {
|
|
46
|
+
console.warn('SOL Connection is not open, cannot subscribe to', owner)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const subscriptions = this.accountSubscriptions[owner] || []
|
|
51
|
+
if (subscriptions?.includes(account)) return // already subscribed
|
|
52
|
+
|
|
53
|
+
conn.send({
|
|
54
|
+
jsonrpc: '2.0',
|
|
55
|
+
method: 'accountSubscribe',
|
|
56
|
+
params: [
|
|
57
|
+
account,
|
|
58
|
+
{
|
|
59
|
+
encoding: 'jsonParsed',
|
|
60
|
+
commitment: 'confirmed',
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
id: ++conn.seq,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
this.accountSubscriptions[owner] = [...subscriptions, account]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async sendSubscriptions({ address, tokensAddresses = [] }) {
|
|
70
|
+
const conn = this.connections[address]
|
|
71
|
+
|
|
72
|
+
const addresses = [address, ...tokensAddresses]
|
|
73
|
+
|
|
74
|
+
// 1. subscribe to each addresses (SOL and Token addresses) balance events
|
|
75
|
+
// transform this forEach in a for of to await each subscription:
|
|
76
|
+
for (const addr of addresses) {
|
|
77
|
+
await this.accountSubscribe({ owner: address, account: addr })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. subscribe to transactions involving the addresses
|
|
81
|
+
if (conn) {
|
|
82
|
+
conn.send({
|
|
83
|
+
jsonrpc: '2.0',
|
|
84
|
+
id: ++conn.seq,
|
|
85
|
+
method: 'transactionSubscribe',
|
|
86
|
+
params: [
|
|
87
|
+
{
|
|
88
|
+
vote: false,
|
|
89
|
+
// failed: true,
|
|
90
|
+
accountInclude: addresses,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
commitment: 'confirmed',
|
|
94
|
+
encoding: 'jsonParsed',
|
|
95
|
+
transactionDetails: 'full',
|
|
96
|
+
showRewards: false,
|
|
97
|
+
maxSupportedTransactionVersion: 255,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 3. subscribe to other events, for example use programSubscribe once Helius implements it into the Advanced WebSocket
|
|
104
|
+
// to get the new token accounts events and remove the need for the RPC call in the monitor tick
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async unwatchAddress({ address }) {
|
|
108
|
+
if (this.connections[address]) {
|
|
109
|
+
await this.connections[address].stop()
|
|
110
|
+
delete this.connections[address]
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
parseAccountNotification({ address, walletAccount, tokenAccountsByOwner, result }) {
|
|
115
|
+
const isSolAccount = result.value.owner === '11111111111111111111111111111111' // System Program
|
|
116
|
+
if (isSolAccount) {
|
|
117
|
+
// SOL balance changed
|
|
118
|
+
const amount = result.value.lamports
|
|
119
|
+
return { solAddress: address, amount }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const isSplTokenAccount = result.value.owner === TOKEN_PROGRAM_ID.toBase58()
|
|
123
|
+
const isSpl2022TokenAccount = result.value.owner === TOKEN_2022_PROGRAM_ID.toBase58()
|
|
124
|
+
|
|
125
|
+
// SPL Token balance changed (both spl-token and spl-2022 have the first 165 bytes the same)
|
|
126
|
+
if (isSplTokenAccount || isSpl2022TokenAccount) {
|
|
127
|
+
const decoded = Token.decode(Buffer.from(result.value.data[0], 'base64'))
|
|
128
|
+
const tokenMintAddress = new PublicKey(decoded.mint).toBase58()
|
|
129
|
+
const solAddress = new PublicKey(decoded.owner).toBase58()
|
|
130
|
+
const amount = U64.fromBuffer(decoded.amount).toString()
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
solAddress,
|
|
134
|
+
amount,
|
|
135
|
+
tokenMintAddress,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
parseTransactionNotification({
|
|
141
|
+
address,
|
|
142
|
+
walletAccount,
|
|
143
|
+
baseAsset,
|
|
144
|
+
assets,
|
|
145
|
+
tokens,
|
|
146
|
+
tokenAccountsByOwner,
|
|
147
|
+
result,
|
|
148
|
+
}) {
|
|
149
|
+
const parsedTx = parseTransaction(address, result.transaction, tokenAccountsByOwner)
|
|
150
|
+
const timestamp = Date.now() // the notification event has no blockTime
|
|
151
|
+
|
|
152
|
+
if (!parsedTx.from && parsedTx.tokenTxs?.length === 0) return { logItemsByAsset: {} } // cannot parse it
|
|
153
|
+
|
|
154
|
+
const transactions = [] // because one single tx can have multiple instructions inside
|
|
155
|
+
|
|
156
|
+
if (parsedTx.dexTxs) {
|
|
157
|
+
parsedTx.dexTxs.forEach((tx) => {
|
|
158
|
+
transactions.push({
|
|
159
|
+
timestamp,
|
|
160
|
+
date: new Date(timestamp),
|
|
161
|
+
...tx,
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
delete parsedTx.dexTxs
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (parsedTx.tokenTxs?.length > 0) {
|
|
168
|
+
parsedTx.tokenTxs.forEach((tx) => {
|
|
169
|
+
transactions.push({
|
|
170
|
+
timestamp,
|
|
171
|
+
date: new Date(timestamp),
|
|
172
|
+
...tx,
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
delete parsedTx.tokenTxs
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (parsedTx.from) {
|
|
179
|
+
transactions.push({
|
|
180
|
+
timestamp,
|
|
181
|
+
date: new Date(timestamp),
|
|
182
|
+
...parsedTx,
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const mappedTransactions = []
|
|
187
|
+
for (const tx of transactions) {
|
|
188
|
+
// we get the token name using the token.mintAddress
|
|
189
|
+
let tokenName = tokens.get(tx.token?.mintAddress)?.name
|
|
190
|
+
if (tx.token && !tokenName) {
|
|
191
|
+
tokenName = 'unknown' // unknown token
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const assetName = tokenName ?? baseAsset.name
|
|
195
|
+
const asset = assets[assetName]
|
|
196
|
+
if (assetName === 'unknown' || !asset) return // skip unknown tokens
|
|
197
|
+
const feeAsset = asset.feeAsset
|
|
198
|
+
|
|
199
|
+
const coinAmount = tx.amount ? asset.currency.baseUnit(tx.amount) : asset.currency.ZERO
|
|
200
|
+
|
|
201
|
+
const item = {
|
|
202
|
+
coinName: assetName,
|
|
203
|
+
txId: tx.id,
|
|
204
|
+
from: [tx.from],
|
|
205
|
+
coinAmount,
|
|
206
|
+
confirmations: 1, // tx.confirmations, // avoid multiple notifications
|
|
207
|
+
date: tx.date,
|
|
208
|
+
error: tx.error,
|
|
209
|
+
data: {
|
|
210
|
+
staking: tx.staking || null,
|
|
211
|
+
unparsed: !!tx.unparsed,
|
|
212
|
+
swapTx: !!(tx.data && tx.data.inner),
|
|
213
|
+
},
|
|
214
|
+
currencies: { [assetName]: asset.currency, [feeAsset.name]: feeAsset.currency },
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (tx.owner === address) {
|
|
218
|
+
// send transaction
|
|
219
|
+
item.to = Array.isArray(tx.to) ? undefined : tx.to
|
|
220
|
+
item.feeAmount = baseAsset.currency.baseUnit(tx.fee) // in SOL
|
|
221
|
+
item.feeCoinName = baseAsset.name
|
|
222
|
+
item.coinAmount = item.coinAmount.negate()
|
|
223
|
+
|
|
224
|
+
if (tx.data?.sent) {
|
|
225
|
+
item.data.sent = tx.data.sent.map((s) => ({
|
|
226
|
+
address: s.address,
|
|
227
|
+
amount: asset.currency.baseUnit(s.amount).toDefaultString({ unit: true }),
|
|
228
|
+
}))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (tx.to === tx.owner) {
|
|
232
|
+
item.selfSend = true
|
|
233
|
+
item.coinAmount = asset.currency.ZERO
|
|
234
|
+
}
|
|
235
|
+
} else if (tx.unparsed) {
|
|
236
|
+
if (tx.fee !== 0) {
|
|
237
|
+
item.feeAmount = baseAsset.currency.baseUnit(tx.fee) // in SOL
|
|
238
|
+
item.feeCoinName = baseAsset.name
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
item.data.meta = tx.data.meta
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (asset.name !== asset.baseAsset.name && item.feeAmount && item.feeAmount.isPositive) {
|
|
245
|
+
const feeItem = {
|
|
246
|
+
...lodash.clone(item),
|
|
247
|
+
coinName: feeAsset.name,
|
|
248
|
+
tokens: [asset.name],
|
|
249
|
+
coinAmount: feeAsset.currency.ZERO,
|
|
250
|
+
}
|
|
251
|
+
mappedTransactions.push(feeItem)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
mappedTransactions.push(item)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const logItemsByAsset = lodash.groupBy(mappedTransactions, (item) => item.coinName)
|
|
258
|
+
return {
|
|
259
|
+
logItemsByAsset,
|
|
260
|
+
cursorState: transactions[0]?.id ? { cursor: transactions[0].id } : {},
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|