@exodus/solana-api 3.29.1 → 3.29.3
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 +18 -0
- package/package.json +2 -2
- package/src/index.js +1 -2
- package/src/staking/index.js +167 -0
- package/src/tx-log/delegation-utils.js +1 -3
- package/src/tx-log/ws-monitor.js +123 -63
- package/src/ws-api.js +140 -15
- /package/src/{staking-provider-client.js → staking/staking-provider-client.js} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,24 @@
|
|
|
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.29.3](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.29.2...@exodus/solana-api@3.29.3) (2026-02-12)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @exodus/solana-api
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## [3.29.2](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.29.1...@exodus/solana-api@3.29.2) (2026-02-11)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
* fix: getAccountInfo 3rd param (#7396)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
6
24
|
## [3.29.1](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.29.0...@exodus/solana-api@3.29.1) (2026-02-03)
|
|
7
25
|
|
|
8
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/solana-api",
|
|
3
|
-
"version": "3.29.
|
|
3
|
+
"version": "3.29.3",
|
|
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": "970efdfe3384d11099816eb037bb46a77575dce4",
|
|
53
53
|
"bugs": {
|
|
54
54
|
"url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Asolana-api"
|
|
55
55
|
},
|
package/src/index.js
CHANGED
|
@@ -8,7 +8,6 @@ export { SolanaMonitor } from './tx-log/index.js'
|
|
|
8
8
|
export { SolanaClarityMonitor } from './tx-log/index.js'
|
|
9
9
|
export { SolanaWebsocketMonitor } from './tx-log/index.js'
|
|
10
10
|
export { createAccountState } from './account-state.js'
|
|
11
|
-
export { getStakingInfo } from './staking-utils.js'
|
|
12
11
|
export {
|
|
13
12
|
isSolanaStaking,
|
|
14
13
|
isSolanaUnstaking,
|
|
@@ -18,7 +17,7 @@ export {
|
|
|
18
17
|
export { createAndBroadcastTXFactory } from './tx-send.js'
|
|
19
18
|
export { getBalancesFactory } from './get-balances.js'
|
|
20
19
|
export { getFeeAsyncFactory } from './get-fees.js'
|
|
21
|
-
export {
|
|
20
|
+
export { stakingApiFactory, getStakingInfo } from './staking/index.js'
|
|
22
21
|
export { createTxFactory } from './create-unsigned-tx-for-send.js'
|
|
23
22
|
export { feePayerClientFactory } from './fee-payer.js'
|
|
24
23
|
export { createInitAgentWalletFactory } from './init-agent-wallet.js'
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import chunk from 'lodash/chunk.js'
|
|
2
|
+
|
|
3
|
+
import { getStakingInfo } from '../staking-utils.js'
|
|
4
|
+
import { stakingProviderClientFactory } from './staking-provider-client.js'
|
|
5
|
+
|
|
6
|
+
export { getStakingInfo } from '../staking-utils.js'
|
|
7
|
+
|
|
8
|
+
export const stakingApiFactory = ({ asset, createTx, sendTx, stakingProviderClient }) => {
|
|
9
|
+
const stakingProvider = stakingProviderClient ?? stakingProviderClientFactory()
|
|
10
|
+
|
|
11
|
+
async function sendStake({
|
|
12
|
+
address,
|
|
13
|
+
walletAccount,
|
|
14
|
+
method,
|
|
15
|
+
amount,
|
|
16
|
+
stakeAddresses,
|
|
17
|
+
accounts,
|
|
18
|
+
seed,
|
|
19
|
+
pool,
|
|
20
|
+
}) {
|
|
21
|
+
let error
|
|
22
|
+
let result
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const { unsignedTx } = await createTx({
|
|
26
|
+
asset,
|
|
27
|
+
walletAccount,
|
|
28
|
+
fromAddress: address,
|
|
29
|
+
address,
|
|
30
|
+
amount: amount ?? asset.currency.ZERO,
|
|
31
|
+
method,
|
|
32
|
+
stakeAddresses,
|
|
33
|
+
accounts,
|
|
34
|
+
seed,
|
|
35
|
+
pool,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const sendResult = await sendTx({
|
|
39
|
+
asset,
|
|
40
|
+
walletAccount,
|
|
41
|
+
unsignedTx,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
result = { txId: sendResult.txId }
|
|
45
|
+
} catch (err) {
|
|
46
|
+
error = err
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const notifyMethod = {
|
|
51
|
+
delegate: 'notifyStaking',
|
|
52
|
+
withdraw: 'notifyWithdraw',
|
|
53
|
+
}
|
|
54
|
+
if (notifyMethod[method] && (error || result?.txId)) {
|
|
55
|
+
await stakingProvider[notifyMethod[method]]({
|
|
56
|
+
asset: asset.name,
|
|
57
|
+
txId: result?.txId ?? null,
|
|
58
|
+
delegator: address,
|
|
59
|
+
amount: amount?.toBaseString?.({ unit: false }) ?? '0',
|
|
60
|
+
error,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.error('Failed to notify staking service:', e)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { error, result }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function createAndSendStake({ method, address, amount, stakingInfo, walletAccount }) {
|
|
71
|
+
const txs = []
|
|
72
|
+
switch (method) {
|
|
73
|
+
case 'delegate': {
|
|
74
|
+
const seed = `exodus:${Date.now()}`
|
|
75
|
+
txs.push({
|
|
76
|
+
method: 'delegate',
|
|
77
|
+
address,
|
|
78
|
+
amount,
|
|
79
|
+
seed,
|
|
80
|
+
pool: stakingInfo.staking.pool,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
break
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
case 'undelegate': {
|
|
87
|
+
const addresses = []
|
|
88
|
+
for (const [addr, info] of Object.entries(stakingInfo.accounts)) {
|
|
89
|
+
if (info.state === 'active' || info.state === 'activating') addresses.push(addr)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const N_UNDELEGATE_ADDR = 10
|
|
93
|
+
chunk(addresses, N_UNDELEGATE_ADDR).forEach((stakeAddresses) => {
|
|
94
|
+
txs.push({
|
|
95
|
+
method,
|
|
96
|
+
address,
|
|
97
|
+
amount: asset.currency.ZERO,
|
|
98
|
+
stakeAddresses,
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case 'withdraw': {
|
|
106
|
+
const accounts = {}
|
|
107
|
+
for (const [addr, info] of Object.entries(stakingInfo.accounts)) {
|
|
108
|
+
if (info.state === 'inactive') accounts[addr] = info
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const N_WITHDRAW_ACCOUNTS = 10
|
|
112
|
+
chunk(Object.entries(accounts), N_WITHDRAW_ACCOUNTS).forEach((entries) => {
|
|
113
|
+
txs.push({
|
|
114
|
+
method,
|
|
115
|
+
address,
|
|
116
|
+
amount: asset.currency.ZERO,
|
|
117
|
+
accounts: Object.fromEntries(entries),
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
if (txs.length === 0) throw new Error('no funds to withdraw')
|
|
121
|
+
break
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
default: {
|
|
125
|
+
throw new Error('Unknown method')
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const txIds = []
|
|
130
|
+
for (const tx of txs) {
|
|
131
|
+
const { error, result } = await sendStake({
|
|
132
|
+
address,
|
|
133
|
+
walletAccount,
|
|
134
|
+
...tx,
|
|
135
|
+
})
|
|
136
|
+
if (error) throw error
|
|
137
|
+
|
|
138
|
+
txIds.push(result.txId)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return txIds
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function delegate({ address, amount, stakingInfo, walletAccount }) {
|
|
145
|
+
return createAndSendStake({ method: 'delegate', address, amount, stakingInfo, walletAccount })
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function undelegate({ address, amount, stakingInfo, walletAccount }) {
|
|
149
|
+
return createAndSendStake({ method: 'undelegate', address, amount, stakingInfo, walletAccount })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function withdraw({ address, amount, stakingInfo, walletAccount }) {
|
|
153
|
+
return createAndSendStake({ method: 'withdraw', address, amount, stakingInfo, walletAccount })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
stake: delegate,
|
|
158
|
+
unstake: undelegate,
|
|
159
|
+
claimUnstaked: withdraw,
|
|
160
|
+
getStakingInfo,
|
|
161
|
+
// Legacy names
|
|
162
|
+
createAndSendStake,
|
|
163
|
+
delegate,
|
|
164
|
+
undelegate,
|
|
165
|
+
withdraw,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
async function fetchDelegatedAccountInfo({ rpcCall, delegatedAddress }) {
|
|
2
|
-
return rpcCall('getAccountInfo', [delegatedAddress, { encoding: 'jsonParsed' }]
|
|
3
|
-
address: delegatedAddress,
|
|
4
|
-
})
|
|
2
|
+
return rpcCall('getAccountInfo', [delegatedAddress, { encoding: 'jsonParsed' }])
|
|
5
3
|
}
|
|
6
4
|
|
|
7
5
|
function parseDelegationInfo({ accountInfo, expectedDelegate }) {
|
package/src/tx-log/ws-monitor.js
CHANGED
|
@@ -26,6 +26,7 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
26
26
|
async beforeStart() {
|
|
27
27
|
this.assets = await this.aci.getAssetsForNetwork({ baseAssetName: this.asset.name })
|
|
28
28
|
this.clarityApi.setTokens(this.assets)
|
|
29
|
+
this.rpcApi.setTokens(this.assets)
|
|
29
30
|
|
|
30
31
|
const walletAccounts = await this.aci.getWalletAccounts({ assetName: this.asset.name })
|
|
31
32
|
await Promise.all(
|
|
@@ -49,7 +50,7 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
49
50
|
useCache: true,
|
|
50
51
|
})
|
|
51
52
|
|
|
52
|
-
const { accounts: tokenAccountsByOwner } = await this.
|
|
53
|
+
const { accounts: tokenAccountsByOwner } = await this.rpcApi.getTokensBalancesAndAccounts({
|
|
53
54
|
address,
|
|
54
55
|
})
|
|
55
56
|
this.tokenAccountsByOwner[walletAccount] = tokenAccountsByOwner
|
|
@@ -81,62 +82,11 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
async tick({ walletAccount, refresh }) {
|
|
84
|
-
// we tick using Clarity only on startup
|
|
85
|
+
// we tick using Clarity only on startup or explicit refresh; otherwise we rely on WS events
|
|
86
|
+
// and programSubscribe for new SPL / Token-2022 accounts (no periodic getTokensBalancesAndAccounts).
|
|
85
87
|
if (refresh || this.tickCount[walletAccount] === 0) {
|
|
86
88
|
return super.tick({ walletAccount, refresh }) // Clarity refresh or first tick
|
|
87
89
|
}
|
|
88
|
-
|
|
89
|
-
const assetName = this.asset.name
|
|
90
|
-
this.assets = await this.aci.getAssetsForNetwork({ baseAssetName: assetName })
|
|
91
|
-
this.clarityApi.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.clarityApi.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
|
-
const tokenName = this.clarityApi.tokens.get(mintAddress)?.name
|
|
118
|
-
if (!tokenName) {
|
|
119
|
-
console.log(`Unknown token mint address: ${mintAddress}`)
|
|
120
|
-
continue
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const tokenAccountAddress = tokenAccounts.find(
|
|
124
|
-
(acc) => acc.mintAddress === mintAddress
|
|
125
|
-
)?.tokenAccountAddress
|
|
126
|
-
await this.wsApi.accountSubscribe({ owner: address, account: tokenAccountAddress })
|
|
127
|
-
|
|
128
|
-
// update only token balances for known tokens
|
|
129
|
-
const amount = splBalances[mintAddress]
|
|
130
|
-
const newData = {
|
|
131
|
-
tokenBalances: {
|
|
132
|
-
...accountState.tokenBalances,
|
|
133
|
-
[tokenName]: this.assets[tokenName].currency.baseUnit(amount),
|
|
134
|
-
},
|
|
135
|
-
}
|
|
136
|
-
await this.#updateStateBatch({ newData, walletAccount })
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// await this.updateState({ account, walletAccount, staking }) // we could update tokenBalances but we gotta test for race-conditions
|
|
140
90
|
}
|
|
141
91
|
|
|
142
92
|
async #handleMessage({ address, walletAccount, data }) {
|
|
@@ -153,11 +103,65 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
153
103
|
4. After 2 seconds, close the window and execute the batch.
|
|
154
104
|
*/
|
|
155
105
|
|
|
156
|
-
if (
|
|
106
|
+
if (
|
|
107
|
+
['accountNotification', 'transactionNotification', 'programNotification'].includes(
|
|
108
|
+
data?.method
|
|
109
|
+
)
|
|
110
|
+
) {
|
|
157
111
|
this.#ensureBatch(walletAccount)
|
|
158
112
|
}
|
|
159
113
|
|
|
160
114
|
switch (data?.method) {
|
|
115
|
+
case 'programNotification': {
|
|
116
|
+
// new or updated SPL / Token-2022 account for our wallet (from programSubscribe)
|
|
117
|
+
const parsed = this.wsApi.parseProgramNotification({
|
|
118
|
+
result: data.params?.result,
|
|
119
|
+
ownerAddress: address,
|
|
120
|
+
})
|
|
121
|
+
if (!parsed) return
|
|
122
|
+
const tokenAccountsByOwnerList = this.tokenAccountsByOwner[walletAccount] || []
|
|
123
|
+
const existing = tokenAccountsByOwnerList.find(
|
|
124
|
+
(acc) => acc.tokenAccountAddress === parsed.tokenAccountAddress
|
|
125
|
+
)
|
|
126
|
+
if (existing) {
|
|
127
|
+
// balance update only; accountNotification will also fire for this account
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const tokenMeta = this.clarityApi.tokens.get(parsed.mintAddress)
|
|
132
|
+
const tokenName = tokenMeta?.name
|
|
133
|
+
const newAccount = {
|
|
134
|
+
tokenAccountAddress: parsed.tokenAccountAddress,
|
|
135
|
+
owner: parsed.owner,
|
|
136
|
+
tokenName: tokenName ?? 'unknown',
|
|
137
|
+
ticker: tokenMeta?.ticker ?? 'UNKNOWN',
|
|
138
|
+
balance: parsed.amount,
|
|
139
|
+
mintAddress: parsed.mintAddress,
|
|
140
|
+
tokenProgram: parsed.tokenProgram,
|
|
141
|
+
decimals: tokenMeta?.decimals ?? 0,
|
|
142
|
+
feeBasisPoints: 0,
|
|
143
|
+
maximumFee: 0,
|
|
144
|
+
}
|
|
145
|
+
this.tokenAccountsByOwner[walletAccount] = [...tokenAccountsByOwnerList, newAccount]
|
|
146
|
+
|
|
147
|
+
await this.wsApi.accountSubscribe({ owner: address, account: parsed.tokenAccountAddress })
|
|
148
|
+
|
|
149
|
+
const unknownTokensList = await this.emitUnknownTokensEvent({
|
|
150
|
+
tokenAccounts: this.tokenAccountsByOwner[walletAccount],
|
|
151
|
+
})
|
|
152
|
+
if (!unknownTokensList.includes(parsed.mintAddress) && tokenName) {
|
|
153
|
+
const newData = {
|
|
154
|
+
tokenBalances: {
|
|
155
|
+
...accountState.tokenBalances,
|
|
156
|
+
[tokenName]: this.assets[tokenName].currency.baseUnit(parsed.amount),
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
await this.#updateStateBatch({ newData, walletAccount })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
161
165
|
case 'accountNotification':
|
|
162
166
|
// balance changed events for known tokens or SOL address
|
|
163
167
|
|
|
@@ -224,13 +228,47 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
224
228
|
}
|
|
225
229
|
|
|
226
230
|
return
|
|
227
|
-
case 'transactionNotification':
|
|
231
|
+
case 'transactionNotification': {
|
|
228
232
|
// update tx-log with new txs and state with cursor
|
|
229
233
|
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
+
// Populate tokenAccountsByOwner from this tx first (e.g. new ATA created in same tx)
|
|
235
|
+
// so we can parse and update history; otherwise "cannot parse tx" would trigger for new receives
|
|
236
|
+
const rawTransaction =
|
|
237
|
+
data.params.result?.value?.transaction ?? data.params.result?.transaction
|
|
238
|
+
const txTokenAccounts = this.wsApi.getTokenAccountsFromTxMeta(rawTransaction, address)
|
|
239
|
+
let tokenAccountsByOwnerList = this.tokenAccountsByOwner[walletAccount] || []
|
|
240
|
+
for (const txAcc of txTokenAccounts) {
|
|
241
|
+
if (
|
|
242
|
+
tokenAccountsByOwnerList.some(
|
|
243
|
+
(a) => a.tokenAccountAddress === txAcc.tokenAccountAddress
|
|
244
|
+
)
|
|
245
|
+
) {
|
|
246
|
+
continue
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const tokenMeta = this.clarityApi.tokens.get(txAcc.mintAddress)
|
|
250
|
+
const newAccount = {
|
|
251
|
+
tokenAccountAddress: txAcc.tokenAccountAddress,
|
|
252
|
+
owner: txAcc.owner,
|
|
253
|
+
tokenName: tokenMeta?.name ?? 'unknown',
|
|
254
|
+
ticker: tokenMeta?.ticker ?? 'UNKNOWN',
|
|
255
|
+
balance: '0',
|
|
256
|
+
mintAddress: txAcc.mintAddress,
|
|
257
|
+
tokenProgram: null,
|
|
258
|
+
decimals: tokenMeta?.decimals ?? 0,
|
|
259
|
+
feeBasisPoints: 0,
|
|
260
|
+
maximumFee: 0,
|
|
261
|
+
}
|
|
262
|
+
tokenAccountsByOwnerList = [...tokenAccountsByOwnerList, newAccount]
|
|
263
|
+
await this.wsApi.accountSubscribe({ owner: address, account: txAcc.tokenAccountAddress })
|
|
264
|
+
// we need also to perform a transactionSubscribe to the new token account address
|
|
265
|
+
await this.wsApi.transactionSubscribe({
|
|
266
|
+
owner: address,
|
|
267
|
+
accounts: txAcc.tokenAccountAddress,
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
this.tokenAccountsByOwner[walletAccount] = tokenAccountsByOwnerList
|
|
234
272
|
|
|
235
273
|
const { logItemsByAsset, cursorState = {} } = this.wsApi.parseTransactionNotification({
|
|
236
274
|
address,
|
|
@@ -238,7 +276,7 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
238
276
|
baseAsset: this.asset,
|
|
239
277
|
assets: this.assets,
|
|
240
278
|
tokens: this.clarityApi.tokens,
|
|
241
|
-
tokenAccountsByOwner,
|
|
279
|
+
tokenAccountsByOwner: tokenAccountsByOwnerList,
|
|
242
280
|
result: data.params.result, // raw tx
|
|
243
281
|
})
|
|
244
282
|
|
|
@@ -262,10 +300,30 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
262
300
|
refresh: false,
|
|
263
301
|
})
|
|
264
302
|
|
|
265
|
-
|
|
266
|
-
|
|
303
|
+
// Update token balances from this tx's postTokenBalances so the first receive shows the
|
|
304
|
+
// correct balance even when accountNotification was missed (we subscribe after the tx is
|
|
305
|
+
// processed, so the balance-change notification can already have been sent).
|
|
306
|
+
const postTokenBalances = rawTransaction?.meta?.postTokenBalances ?? []
|
|
307
|
+
const tokenBalancesFromTx = {}
|
|
308
|
+
for (const b of postTokenBalances) {
|
|
309
|
+
if (b.owner !== address) continue
|
|
310
|
+
const tokenName = this.clarityApi.tokens.get(b.mint)?.name
|
|
311
|
+
if (!tokenName || !this.assets[tokenName]) continue
|
|
312
|
+
const amount = b.uiTokenAmount?.amount ?? b.amount ?? '0'
|
|
313
|
+
tokenBalancesFromTx[tokenName] = this.assets[tokenName].currency.baseUnit(amount)
|
|
267
314
|
}
|
|
268
315
|
|
|
316
|
+
const newData =
|
|
317
|
+
Object.keys(tokenBalancesFromTx).length > 0
|
|
318
|
+
? {
|
|
319
|
+
...cursorState,
|
|
320
|
+
tokenBalances: {
|
|
321
|
+
...accountState.tokenBalances,
|
|
322
|
+
...tokenBalancesFromTx,
|
|
323
|
+
},
|
|
324
|
+
}
|
|
325
|
+
: { ...cursorState }
|
|
326
|
+
|
|
269
327
|
if (stakingTx) {
|
|
270
328
|
// for staking the balance is not updated by the balance handler
|
|
271
329
|
// staking operations won't spend or modify the "total" wallet balance.
|
|
@@ -322,6 +380,8 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
|
|
|
322
380
|
await this.#updateStateBatch({ newData, walletAccount })
|
|
323
381
|
|
|
324
382
|
return
|
|
383
|
+
}
|
|
384
|
+
|
|
325
385
|
default:
|
|
326
386
|
if (data?.result && typeof data.result === 'number') {
|
|
327
387
|
// subscription confirmation, skip
|
package/src/ws-api.js
CHANGED
|
@@ -16,6 +16,7 @@ export class WsApi {
|
|
|
16
16
|
this.setWsEndpoint(wsUrl)
|
|
17
17
|
this.connections = Object.create(null)
|
|
18
18
|
this.accountSubscriptions = Object.create(null)
|
|
19
|
+
this.transactionSubscriptions = Object.create(null)
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
setWsEndpoint(wsUrl) {
|
|
@@ -70,6 +71,45 @@ export class WsApi {
|
|
|
70
71
|
this.accountSubscriptions[owner] = [...subscriptions, account]
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
async transactionSubscribe({ owner, accounts }) {
|
|
75
|
+
if (!Array.isArray(accounts)) {
|
|
76
|
+
accounts = [accounts]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const conn = this.connections[owner]
|
|
80
|
+
if (!conn || !conn.isOpen) {
|
|
81
|
+
console.warn('SOL Connection is not open, cannot subscribe to', owner)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const subscriptions = this.transactionSubscriptions[owner] || []
|
|
86
|
+
// compute the difference between subscriptions and accounts
|
|
87
|
+
const difference = accounts.filter((account) => !subscriptions.includes(account))
|
|
88
|
+
if (difference.length === 0) return // already subscribed
|
|
89
|
+
|
|
90
|
+
conn.send({
|
|
91
|
+
jsonrpc: '2.0',
|
|
92
|
+
id: ++conn.seq,
|
|
93
|
+
method: 'transactionSubscribe',
|
|
94
|
+
params: [
|
|
95
|
+
{
|
|
96
|
+
vote: false,
|
|
97
|
+
accounts: {
|
|
98
|
+
include: difference,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
commitment: 'confirmed',
|
|
103
|
+
encoding: 'jsonParsed',
|
|
104
|
+
transactionDetails: 'full',
|
|
105
|
+
showRewards: false,
|
|
106
|
+
maxSupportedTransactionVersion: 255,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
})
|
|
110
|
+
this.transactionSubscriptions[owner] = [...subscriptions, ...difference]
|
|
111
|
+
}
|
|
112
|
+
|
|
73
113
|
async sendSubscriptions({ address, tokensAddresses = [] }) {
|
|
74
114
|
const conn = this.connections[address]
|
|
75
115
|
|
|
@@ -82,33 +122,47 @@ export class WsApi {
|
|
|
82
122
|
}
|
|
83
123
|
|
|
84
124
|
// 2. subscribe to transactions involving the addresses
|
|
125
|
+
await this.transactionSubscribe({ owner: address, accounts: addresses })
|
|
126
|
+
|
|
127
|
+
// 3. subscribe to SPL Token and Token-2022 program account changes to detect new token accounts for this wallet
|
|
128
|
+
const splTokenProgramId = TOKEN_PROGRAM_ID.toBase58()
|
|
129
|
+
const token2022ProgramId = TOKEN_2022_PROGRAM_ID.toBase58()
|
|
130
|
+
const tokenAccountDataSize = 165
|
|
131
|
+
|
|
85
132
|
if (conn) {
|
|
133
|
+
// SPL Token: fixed 165-byte account size
|
|
86
134
|
conn.send({
|
|
87
135
|
jsonrpc: '2.0',
|
|
88
136
|
id: ++conn.seq,
|
|
89
|
-
method: '
|
|
137
|
+
method: 'programSubscribe',
|
|
90
138
|
params: [
|
|
139
|
+
splTokenProgramId,
|
|
91
140
|
{
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
141
|
+
encoding: 'jsonParsed',
|
|
142
|
+
commitment: 'confirmed',
|
|
143
|
+
filters: [
|
|
144
|
+
{ dataSize: tokenAccountDataSize },
|
|
145
|
+
{ memcmp: { offset: 32, bytes: address } },
|
|
146
|
+
],
|
|
98
147
|
},
|
|
148
|
+
],
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// Token-2022: no dataSize filter (accounts can have extensions and be larger than 165 bytes)
|
|
152
|
+
conn.send({
|
|
153
|
+
jsonrpc: '2.0',
|
|
154
|
+
id: ++conn.seq,
|
|
155
|
+
method: 'programSubscribe',
|
|
156
|
+
params: [
|
|
157
|
+
token2022ProgramId,
|
|
99
158
|
{
|
|
100
|
-
commitment: 'confirmed',
|
|
101
159
|
encoding: 'jsonParsed',
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
maxSupportedTransactionVersion: 255,
|
|
160
|
+
commitment: 'confirmed',
|
|
161
|
+
filters: [{ memcmp: { offset: 32, bytes: address } }],
|
|
105
162
|
},
|
|
106
163
|
],
|
|
107
164
|
})
|
|
108
165
|
}
|
|
109
|
-
|
|
110
|
-
// 3. subscribe to other events, for example use programSubscribe once Helius implements it into the Advanced WebSocket
|
|
111
|
-
// to get the new token accounts events and remove the need for the RPC call in the monitor tick
|
|
112
166
|
}
|
|
113
167
|
|
|
114
168
|
async unwatchAddress({ address }) {
|
|
@@ -118,6 +172,49 @@ export class WsApi {
|
|
|
118
172
|
}
|
|
119
173
|
}
|
|
120
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Parse programNotification (from programSubscribe to Token / Token-2022).
|
|
177
|
+
* Returns token account info if the account belongs to the given owner address, else null.
|
|
178
|
+
* @param result - data.params.result from programNotification (has context + value with pubkey, account)
|
|
179
|
+
*/
|
|
180
|
+
parseProgramNotification({ result, ownerAddress }) {
|
|
181
|
+
const { value } = result || {}
|
|
182
|
+
if (!value) return null
|
|
183
|
+
const { pubkey, account } = value
|
|
184
|
+
if (!account || !pubkey) return null
|
|
185
|
+
const isTokenProgram =
|
|
186
|
+
account.owner === TOKEN_PROGRAM_ID.toBase58() ||
|
|
187
|
+
account.owner === TOKEN_2022_PROGRAM_ID.toBase58()
|
|
188
|
+
if (!isTokenProgram) return null
|
|
189
|
+
|
|
190
|
+
let owner
|
|
191
|
+
let mintAddress
|
|
192
|
+
let amount
|
|
193
|
+
|
|
194
|
+
const parsed = account?.data?.parsed?.info
|
|
195
|
+
if (parsed) {
|
|
196
|
+
owner = parsed.owner
|
|
197
|
+
mintAddress = parsed.mint
|
|
198
|
+
amount = parsed.tokenAmount?.amount ?? '0'
|
|
199
|
+
} else if (Array.isArray(account.data) && account.data[1] === 'base64') {
|
|
200
|
+
const decoded = Token.decode(Buffer.from(account.data[0], 'base64'))
|
|
201
|
+
owner = new PublicKey(decoded.owner).toBase58()
|
|
202
|
+
mintAddress = new PublicKey(decoded.mint).toBase58()
|
|
203
|
+
amount = U64.fromBuffer(decoded.amount).toString()
|
|
204
|
+
} else {
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (owner !== ownerAddress) return null
|
|
209
|
+
return {
|
|
210
|
+
tokenAccountAddress: pubkey,
|
|
211
|
+
mintAddress,
|
|
212
|
+
amount,
|
|
213
|
+
owner,
|
|
214
|
+
tokenProgram: account.owner,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
121
218
|
parseAccountNotification({ address, walletAccount, tokenAccountsByOwner, result }) {
|
|
122
219
|
const isSolAccount = result.value.owner === '11111111111111111111111111111111' // System Program
|
|
123
220
|
if (isSolAccount) {
|
|
@@ -155,6 +252,34 @@ export class WsApi {
|
|
|
155
252
|
}
|
|
156
253
|
}
|
|
157
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Derive token account entries from a transaction's meta (postTokenBalances / preTokenBalances)
|
|
257
|
+
* for the given owner address. Use this to augment tokenAccountsByOwner when the wallet
|
|
258
|
+
* receives a new ATA in the same tx (transactionNotification often arrives before programNotification).
|
|
259
|
+
*/
|
|
260
|
+
getTokenAccountsFromTxMeta(transaction, ownerAddress) {
|
|
261
|
+
const meta = transaction?.meta
|
|
262
|
+
const accountKeys = transaction?.transaction?.message?.accountKeys ?? []
|
|
263
|
+
const getPubkey = (key) =>
|
|
264
|
+
key && typeof key === 'object' && 'pubkey' in key ? key.pubkey : key
|
|
265
|
+
const balances = [...(meta?.postTokenBalances ?? []), ...(meta?.preTokenBalances ?? [])]
|
|
266
|
+
const byAddress = new Map()
|
|
267
|
+
for (const b of balances) {
|
|
268
|
+
if (b.owner !== ownerAddress) continue
|
|
269
|
+
const key = accountKeys[b.accountIndex]
|
|
270
|
+
const pubkey = key == null ? null : getPubkey(key)
|
|
271
|
+
if (!pubkey) continue
|
|
272
|
+
if (byAddress.has(pubkey)) continue
|
|
273
|
+
byAddress.set(pubkey, {
|
|
274
|
+
tokenAccountAddress: pubkey,
|
|
275
|
+
mintAddress: b.mint,
|
|
276
|
+
owner: b.owner,
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return [...byAddress.values()]
|
|
281
|
+
}
|
|
282
|
+
|
|
158
283
|
parseTransactionNotification({
|
|
159
284
|
address,
|
|
160
285
|
walletAccount,
|
|
@@ -165,7 +290,7 @@ export class WsApi {
|
|
|
165
290
|
result,
|
|
166
291
|
}) {
|
|
167
292
|
const rawTransaction = result?.value ? result.value.transaction : result.transaction // for Triton Whirligig OR Helius Advanced WS
|
|
168
|
-
const parsedTx = parseTransaction(address, rawTransaction, tokenAccountsByOwner)
|
|
293
|
+
const parsedTx = parseTransaction(address, rawTransaction, tokenAccountsByOwner || [])
|
|
169
294
|
const timestamp = Date.now() // the notification event has no blockTime
|
|
170
295
|
|
|
171
296
|
if (!parsedTx.from && parsedTx.tokenTxs?.length === 0) return { logItemsByAsset: {} } // cannot parse it
|
|
File without changes
|