@exodus/solana-api 3.20.8 → 3.20.10
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 +4 -3
- package/src/clarity-api.js +141 -0
- package/src/create-unsigned-tx-for-send.js +1 -1
- package/src/index.js +4 -2
- package/src/rpc-api.js +455 -0
- package/src/tx-log/clarity-monitor.js +362 -0
- package/src/tx-log/index.js +1 -0
- package/src/tx-send.js +1 -1
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.20.10](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.20.8...@exodus/solana-api@3.20.10) (2025-10-14)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
* fix: point SOL clarity to prod (#6672)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## [3.20.9](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.20.8...@exodus/solana-api@3.20.9) (2025-10-09)
|
|
17
|
+
|
|
18
|
+
**Note:** Version bump only for package @exodus/solana-api
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
6
24
|
## [3.20.8](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.20.7...@exodus/solana-api@3.20.8) (2025-09-18)
|
|
7
25
|
|
|
8
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/solana-api",
|
|
3
|
-
"version": "3.20.
|
|
3
|
+
"version": "3.20.10",
|
|
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",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"author": "Exodus Movement, Inc.",
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"publishConfig": {
|
|
17
|
-
"access": "public"
|
|
17
|
+
"access": "public",
|
|
18
|
+
"provenance": false
|
|
18
19
|
},
|
|
19
20
|
"scripts": {
|
|
20
21
|
"test": "run -T exodus-test --jest",
|
|
@@ -46,7 +47,7 @@
|
|
|
46
47
|
"@exodus/assets-testing": "^1.0.0",
|
|
47
48
|
"@exodus/solana-web3.js": "^1.63.1-exodus.9-rc3"
|
|
48
49
|
},
|
|
49
|
-
"gitHead": "
|
|
50
|
+
"gitHead": "b0f1b6b69b7a6f7e70eee7e03db7bf71ba6be92a",
|
|
50
51
|
"bugs": {
|
|
51
52
|
"url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Asolana-api"
|
|
52
53
|
},
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { memoizeLruCache } from '@exodus/asset-lib'
|
|
2
|
+
import { memoize, omitBy } from '@exodus/basic-utils'
|
|
3
|
+
import wretch from '@exodus/fetch/wretch'
|
|
4
|
+
import { SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@exodus/solana-lib'
|
|
5
|
+
import ms from 'ms'
|
|
6
|
+
import urljoin from 'url-join'
|
|
7
|
+
|
|
8
|
+
import { RpcApi } from './rpc-api.js'
|
|
9
|
+
|
|
10
|
+
const CLARITY_URL = 'https://solana-clarity.a.exodus.io/api/v2/solana'
|
|
11
|
+
|
|
12
|
+
const cleanQuery = (obj) => omitBy(obj, (v) => v === undefined)
|
|
13
|
+
|
|
14
|
+
// Tokens + SOL api support
|
|
15
|
+
export class ClarityApi extends RpcApi {
|
|
16
|
+
getSupply = memoize(async (mintAddress) => {
|
|
17
|
+
// cached getSupply
|
|
18
|
+
return this.request(`/util/get-token-supply/${encodeURIComponent(mintAddress)}`)
|
|
19
|
+
.get()
|
|
20
|
+
.json()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
getMinimumBalanceForRentExemption = memoize(
|
|
24
|
+
(accountSize) =>
|
|
25
|
+
this.request(`/util/min-balance-for-rent-exemption/${encodeURIComponent(accountSize)}`)
|
|
26
|
+
.get()
|
|
27
|
+
.json(),
|
|
28
|
+
(accountSize) => accountSize,
|
|
29
|
+
ms('15m')
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
constructor({ rpcUrl, clarityUrl, assets, txsLimit }) {
|
|
33
|
+
super({ rpcUrl, assets, txsLimit })
|
|
34
|
+
this.setClarityServer(clarityUrl)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
setClarityServer(clarityUrl) {
|
|
38
|
+
this.clarityUrl = clarityUrl || CLARITY_URL
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
request(path, contentType = 'application/json') {
|
|
42
|
+
return wretch(urljoin(this.clarityUrl, path)).headers({
|
|
43
|
+
'Content-Type': contentType,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async getTransactions(address, { cursor, limit, includeUnparsed = false } = Object.create(null)) {
|
|
48
|
+
return this.request(`/addresses/${encodeURIComponent(address)}/transactions`)
|
|
49
|
+
.query(
|
|
50
|
+
cleanQuery({
|
|
51
|
+
cursor,
|
|
52
|
+
limit,
|
|
53
|
+
includeUnparsed,
|
|
54
|
+
})
|
|
55
|
+
)
|
|
56
|
+
.get()
|
|
57
|
+
.json()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async getTransactionById(txId) {
|
|
61
|
+
return this.request(`/transaction/${encodeURIComponent(txId)}`)
|
|
62
|
+
.get()
|
|
63
|
+
.json()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async getTokensBalancesAndAccounts({ address }) {
|
|
67
|
+
return this.request(`/addresses/${encodeURIComponent(address)}/tokens-balance`)
|
|
68
|
+
.get()
|
|
69
|
+
.json()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async getTokenAccountsByOwner(address, tokenTicker) {
|
|
73
|
+
const { accounts } = await this.getTokensBalancesAndAccounts({ address })
|
|
74
|
+
return accounts
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async getAccountInfo(address) {
|
|
78
|
+
return this.request(`/addresses/${encodeURIComponent(address)}/account-info`)
|
|
79
|
+
.get()
|
|
80
|
+
.json()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async getStakeAccountsInfo(address) {
|
|
84
|
+
const { stakingBalances } = await this.request(
|
|
85
|
+
`/addresses/${encodeURIComponent(address)}/base-balance`
|
|
86
|
+
)
|
|
87
|
+
.get()
|
|
88
|
+
.json()
|
|
89
|
+
return stakingBalances
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async getRewards(address) {
|
|
93
|
+
return this.request(`/addresses/${encodeURIComponent(address)}/staking-rewards`)
|
|
94
|
+
.get()
|
|
95
|
+
.json()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async getBalance(address) {
|
|
99
|
+
const { balance } = await this.request(`/addresses/${encodeURIComponent(address)}/base-balance`)
|
|
100
|
+
.get()
|
|
101
|
+
.json()
|
|
102
|
+
return balance
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async getMintAddress(address) {
|
|
106
|
+
const value = await this.getAccountInfo(address)
|
|
107
|
+
// token mint
|
|
108
|
+
return value?.data?.parsed?.info?.mint ?? null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async getAddressMint(address) {
|
|
112
|
+
// alias
|
|
113
|
+
return this.getMintAddress(address)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async ataOwnershipChanged(address, tokenAddress) {
|
|
117
|
+
// associated token address ownership changed
|
|
118
|
+
const value = await this.getAccountInfo(tokenAddress)
|
|
119
|
+
const owner = value?.data?.parsed?.info?.owner
|
|
120
|
+
return owner && owner !== address
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async ownerChanged(address, accountInfo) {
|
|
124
|
+
// method to check if the owner of the account has changed, compared to standard programs.
|
|
125
|
+
// as there could be malicious dapps that reassign the ownership of the account (see https://github.com/coinspect/solana-assign-test)
|
|
126
|
+
const value = accountInfo || (await this.getAccountInfo(address))
|
|
127
|
+
const owner = value?.owner // program owner
|
|
128
|
+
if (!owner) return false // not initialized account (or purged)
|
|
129
|
+
return ![
|
|
130
|
+
SYSTEM_PROGRAM_ID.toBase58(),
|
|
131
|
+
TOKEN_PROGRAM_ID.toBase58(),
|
|
132
|
+
TOKEN_2022_PROGRAM_ID.toBase58(),
|
|
133
|
+
].includes(owner)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
ataOwnershipChangedCached = memoizeLruCache(
|
|
137
|
+
(...args) => this.ataOwnershipChanged(...args),
|
|
138
|
+
(address, tokenAddress) => `${address}:${tokenAddress}`,
|
|
139
|
+
{ max: 1000 }
|
|
140
|
+
)
|
|
141
|
+
}
|
|
@@ -64,7 +64,7 @@ export const createUnsignedTxForSend = async ({
|
|
|
64
64
|
amount = asset.currency.baseUnit(1)
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
const isToken = asset.
|
|
67
|
+
const isToken = asset.name !== asset.baseAsset.name
|
|
68
68
|
|
|
69
69
|
// Check if receiver has address active when sending tokens.
|
|
70
70
|
if (isToken) {
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import assetsList from '@exodus/solana-meta'
|
|
|
5
5
|
import { Api } from './api.js'
|
|
6
6
|
|
|
7
7
|
export { SolanaMonitor } from './tx-log/index.js'
|
|
8
|
+
export { SolanaClarityMonitor } from './tx-log/index.js'
|
|
8
9
|
export { createAccountState } from './account-state.js'
|
|
9
10
|
export { getStakingInfo } from './staking-utils.js'
|
|
10
11
|
export {
|
|
@@ -25,7 +26,8 @@ const assets = connectAssets(keyBy(assetsList, (asset) => asset.name))
|
|
|
25
26
|
|
|
26
27
|
// At some point we would like to exclude this export. Default export should be the whole asset "plugin" ready to be injected.
|
|
27
28
|
// Clients should not call an specific server api directly.
|
|
28
|
-
const serverApi = new Api({ assets })
|
|
29
|
-
export default serverApi
|
|
29
|
+
const serverApi = new Api({ assets }) // TODO: remove it, clean every use from platforms
|
|
30
|
+
export default serverApi // TODO: remove it
|
|
30
31
|
|
|
31
32
|
export { Api } from './api.js'
|
|
33
|
+
export { ClarityApi } from './clarity-api.js'
|
package/src/rpc-api.js
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import createApiCJS from '@exodus/asset-json-rpc'
|
|
2
|
+
import { memoize, pickBy } from '@exodus/basic-utils'
|
|
3
|
+
import { retry } from '@exodus/simple-retry'
|
|
4
|
+
import {
|
|
5
|
+
buildRawTransaction,
|
|
6
|
+
computeBalance,
|
|
7
|
+
deserializeMetaplexMetadata,
|
|
8
|
+
filterAccountsByOwner,
|
|
9
|
+
getMetadataAccount,
|
|
10
|
+
getTransactionSimulationParams,
|
|
11
|
+
SOL_DECIMAL,
|
|
12
|
+
SYSTEM_PROGRAM_ID as SYSTEM_PROGRAM_ID_KEY,
|
|
13
|
+
TOKEN_2022_PROGRAM_ID as TOKEN_2022_PROGRAM_ID_KEY,
|
|
14
|
+
TOKEN_PROGRAM_ID as TOKEN_PROGRAM_ID_KEY,
|
|
15
|
+
} from '@exodus/solana-lib'
|
|
16
|
+
import assert from 'minimalistic-assert'
|
|
17
|
+
import ms from 'ms'
|
|
18
|
+
|
|
19
|
+
import { getStakeActivation } from './get-stake-activation/index.js'
|
|
20
|
+
|
|
21
|
+
const [SYSTEM_PROGRAM_ID, TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID] = [
|
|
22
|
+
SYSTEM_PROGRAM_ID_KEY.toBase58(),
|
|
23
|
+
TOKEN_PROGRAM_ID_KEY.toBase58(),
|
|
24
|
+
TOKEN_2022_PROGRAM_ID_KEY.toBase58(),
|
|
25
|
+
]
|
|
26
|
+
const createApi = createApiCJS.default || createApiCJS
|
|
27
|
+
|
|
28
|
+
const RPC_URL = 'https://solana-clarity.a.exodus.io/api/v2/solana/rpc' // Clarity proxied
|
|
29
|
+
|
|
30
|
+
// Doc: https://docs.solana.com/apps/jsonrpc-api
|
|
31
|
+
|
|
32
|
+
const errorMessagesToRetry = [
|
|
33
|
+
'Blockhash not found',
|
|
34
|
+
'Failed to query long-term storage; please try again',
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
// Tokens + SOL api support
|
|
38
|
+
export class RpcApi {
|
|
39
|
+
getSupply = memoize(async (mintAddress) => {
|
|
40
|
+
// cached getSupply
|
|
41
|
+
const result = await this.rpcCall('getTokenSupply', [mintAddress])
|
|
42
|
+
return result?.value?.amount
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
getAccountSize = memoize(
|
|
46
|
+
(address) => this.getAccountInfo(address),
|
|
47
|
+
(address) => address,
|
|
48
|
+
ms('3m')
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
constructor({ rpcUrl, assets, txsLimit }) {
|
|
52
|
+
this.setRpcServer(rpcUrl)
|
|
53
|
+
this.setTokens(assets)
|
|
54
|
+
this.tokensToSkip = Object.create(null)
|
|
55
|
+
this.txsLimit = txsLimit
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setRpcServer(rpcUrl = RPC_URL) {
|
|
59
|
+
assert(typeof rpcUrl === 'string' && rpcUrl.startsWith('http'), 'Invalid rpcUrl')
|
|
60
|
+
this.rpcUrl = rpcUrl
|
|
61
|
+
this.api = createApi(this.rpcUrl)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setTokens(assets) {
|
|
65
|
+
const solTokens = pickBy(assets, (asset) => asset.name !== asset.baseAsset.name)
|
|
66
|
+
this.tokens = new Map(Object.values(solTokens).map((v) => [v.mintAddress, v]))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async rpcCall(method, params = []) {
|
|
70
|
+
return this.api.post({ method, params })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getTokenByAddress(mint) {
|
|
74
|
+
return this.tokens.get(mint)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
isTokenSupported(mint) {
|
|
78
|
+
return this.tokens.has(mint)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async getRentExemptionMinAmount(address) {
|
|
82
|
+
// minimum amount required for the destination account to be rent-exempt
|
|
83
|
+
const accountInfo = await this.getAccountSize(address).catch(() => {})
|
|
84
|
+
if (accountInfo?.space === 0) {
|
|
85
|
+
// no rent required
|
|
86
|
+
return 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const accountSize = accountInfo?.space || 0
|
|
90
|
+
|
|
91
|
+
// Lamports number
|
|
92
|
+
return this.getMinimumBalanceForRentExemption(accountSize)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async getEpochInfo() {
|
|
96
|
+
const { epoch } = await this.rpcCall('getEpochInfo')
|
|
97
|
+
return Number(epoch)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async getStakeActivation(stakeAddress) {
|
|
101
|
+
const { status } = await getStakeActivation(this, stakeAddress)
|
|
102
|
+
return status
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async getRecentBlockHash(commitment) {
|
|
106
|
+
const result = await this.rpcCall('getLatestBlockhash', [
|
|
107
|
+
{
|
|
108
|
+
commitment: commitment || 'confirmed',
|
|
109
|
+
encoding: 'jsonParsed',
|
|
110
|
+
},
|
|
111
|
+
])
|
|
112
|
+
return result?.value?.blockhash
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async getPriorityFee(transaction) {
|
|
116
|
+
// https://docs.helius.dev/solana-rpc-nodes/alpha-priority-fee-api
|
|
117
|
+
const result = await this.rpcCall('getPriorityFeeEstimate', [
|
|
118
|
+
{ transaction, options: { recommended: true } },
|
|
119
|
+
])
|
|
120
|
+
return result.priorityFeeEstimate
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async getBlockTime(slot) {
|
|
124
|
+
// might result in error if executed on a validator with partial ledger (https://github.com/solana-labs/solana/issues/12413)
|
|
125
|
+
return this.rpcCall('getBlockTime', [slot])
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async waitForTransactionStatus(txIds, status = 'finalized', timeoutMs = ms('1m')) {
|
|
129
|
+
if (!Array.isArray(txIds)) txIds = [txIds]
|
|
130
|
+
const startTime = Date.now()
|
|
131
|
+
|
|
132
|
+
while (true) {
|
|
133
|
+
const response = await this.rpcCall('getSignatureStatuses', [
|
|
134
|
+
txIds,
|
|
135
|
+
{ searchTransactionHistory: true },
|
|
136
|
+
])
|
|
137
|
+
const data = response.value
|
|
138
|
+
const allTxsAreConfirmed = data.every((elem) => elem?.confirmationStatus === status)
|
|
139
|
+
if (allTxsAreConfirmed) {
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check if the timeout has elapsed
|
|
144
|
+
if (Date.now() - startTime >= timeoutMs) {
|
|
145
|
+
// timeout
|
|
146
|
+
throw new Error('waitForTransactionStatus timeout')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Wait for the specified interval before the next request
|
|
150
|
+
await new Promise((resolve) => setTimeout(resolve, ms('10s')))
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async getWalletTokensList({ tokenAccounts }) {
|
|
155
|
+
const tokensMint = []
|
|
156
|
+
for (const account of tokenAccounts) {
|
|
157
|
+
const mint = account.mintAddress
|
|
158
|
+
|
|
159
|
+
// skip cached NFT
|
|
160
|
+
if (this.tokensToSkip[mint]) continue
|
|
161
|
+
// skip 0 balance
|
|
162
|
+
if (account.balance === '0') continue
|
|
163
|
+
// skip NFT
|
|
164
|
+
if (!this.tokens.has(mint)) {
|
|
165
|
+
const supply = await this.getSupply(mint)
|
|
166
|
+
if (supply === '1') {
|
|
167
|
+
this.tokensToSkip[mint] = true
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// OK
|
|
173
|
+
tokensMint.push(mint)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return tokensMint
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async isSpl(address) {
|
|
180
|
+
const { owner } = await this.getAccountInfo(address)
|
|
181
|
+
return [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID].includes(owner)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async getTokenBalance(tokenAddress) {
|
|
185
|
+
// Returns account balance of a SPL Token account.
|
|
186
|
+
const result = await this.rpcCall('getTokenAccountBalance', [tokenAddress])
|
|
187
|
+
return result?.value?.amount
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async isAssociatedTokenAccountActive(tokenAddress) {
|
|
191
|
+
try {
|
|
192
|
+
await this.getTokenBalance(tokenAddress)
|
|
193
|
+
return true
|
|
194
|
+
} catch {
|
|
195
|
+
return false
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async getTokenFeeBasisPoints(address) {
|
|
200
|
+
// only for token-2022
|
|
201
|
+
const value = await this.getAccountInfo(address)
|
|
202
|
+
|
|
203
|
+
const transferFeeBasisPoints =
|
|
204
|
+
value?.data?.parsed?.info?.extensions?.[0]?.state?.newerTransferFee?.transferFeeBasisPoints ??
|
|
205
|
+
0
|
|
206
|
+
const maximumFee =
|
|
207
|
+
value?.data?.parsed?.info?.extensions?.[0]?.state?.newerTransferFee?.maximumFee ?? 0
|
|
208
|
+
|
|
209
|
+
return { feeBasisPoints: transferFeeBasisPoints, maximumFee }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async getMetaplexMetadata(tokenMintAddress) {
|
|
213
|
+
const metaplexPDA = getMetadataAccount(tokenMintAddress)
|
|
214
|
+
const res = await this.getAccountInfo(metaplexPDA, 'base64')
|
|
215
|
+
const data = res?.data?.[0]
|
|
216
|
+
if (!data) return null
|
|
217
|
+
|
|
218
|
+
return deserializeMetaplexMetadata(Buffer.from(data, 'base64'))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async getDecimals(tokenMintAddress) {
|
|
222
|
+
const result = await this.rpcCall('getTokenSupply', [tokenMintAddress])
|
|
223
|
+
return result?.value?.decimals ?? null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async getAddressType(address) {
|
|
227
|
+
// solana, token or null (unknown), meaning address has never been initialized
|
|
228
|
+
const value = await this.getAccountInfo(address)
|
|
229
|
+
if (value === null) return null
|
|
230
|
+
|
|
231
|
+
const account = {
|
|
232
|
+
executable: value.executable,
|
|
233
|
+
owner: value.owner,
|
|
234
|
+
lamports: value.lamports,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (account.owner === SYSTEM_PROGRAM_ID) return 'solana'
|
|
238
|
+
if (account.owner === TOKEN_PROGRAM_ID) return 'token'
|
|
239
|
+
if (account.owner === TOKEN_2022_PROGRAM_ID) return 'token-2022'
|
|
240
|
+
return null
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async getTokenAddressOwner(address) {
|
|
244
|
+
const value = await this.getAccountInfo(address)
|
|
245
|
+
return value?.data?.parsed?.info?.owner ?? null
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async isTokenAddress(address) {
|
|
249
|
+
const type = await this.getAddressType(address)
|
|
250
|
+
return ['token', 'token-2022'].includes(type)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async isSOLaddress(address) {
|
|
254
|
+
const type = await this.getAddressType(address)
|
|
255
|
+
return type === 'solana'
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async getProgramAccounts(programId, config) {
|
|
259
|
+
return this.rpcCall('getProgramAccounts', [programId, config])
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async getMultipleAccounts(pubkeys, config) {
|
|
263
|
+
const response = await this.rpcCall('getMultipleAccounts', [pubkeys, config])
|
|
264
|
+
return response && response.value ? response.value : []
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async getFeeForMessage(message, commitment) {
|
|
268
|
+
const response = await this.rpcCall('getFeeForMessage', [
|
|
269
|
+
Buffer.from(message.serialize()).toString('base64'),
|
|
270
|
+
{ commitment },
|
|
271
|
+
])
|
|
272
|
+
|
|
273
|
+
return response?.value
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Broadcast a signed transaction
|
|
278
|
+
*/
|
|
279
|
+
broadcastTransaction = async (signedTx, options) => {
|
|
280
|
+
console.log('Solana broadcasting TX:', signedTx) // base64
|
|
281
|
+
const defaultOptions = { encoding: 'base64', preflightCommitment: 'confirmed' }
|
|
282
|
+
|
|
283
|
+
const params = [signedTx, { ...defaultOptions, ...options }]
|
|
284
|
+
|
|
285
|
+
const broadcastTxWithRetry = retry(
|
|
286
|
+
async () => {
|
|
287
|
+
try {
|
|
288
|
+
const result = await this.rpcCall('sendTransaction', params)
|
|
289
|
+
console.log(`tx ${JSON.stringify(result)} sent!`)
|
|
290
|
+
|
|
291
|
+
return result || null
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (
|
|
294
|
+
error.message &&
|
|
295
|
+
!errorMessagesToRetry.some((errorMessage) => error.message.includes(errorMessage))
|
|
296
|
+
) {
|
|
297
|
+
error.finalError = true
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
console.warn(`Error broadcasting tx. Retrying...`, error)
|
|
301
|
+
|
|
302
|
+
throw error
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
{ delayTimesMs: ['6s', '6s', '8s', '10s'] }
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return broadcastTxWithRetry()
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
simulateTransaction = async (encodedTransaction, options) => {
|
|
312
|
+
const result = await this.rpcCall('simulateTransaction', [encodedTransaction, options])
|
|
313
|
+
const {
|
|
314
|
+
value: { accounts, unitsConsumed, err },
|
|
315
|
+
} = result
|
|
316
|
+
|
|
317
|
+
return { accounts, unitsConsumed, err }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
resolveSimulationSideEffects = async (solAccounts, tokenAccounts) => {
|
|
321
|
+
const willReceive = []
|
|
322
|
+
const willSend = []
|
|
323
|
+
|
|
324
|
+
const resolveSols = solAccounts.map(async (account) => {
|
|
325
|
+
const currentAmount = await this.getBalance(account.address)
|
|
326
|
+
const balance = computeBalance(account.amount, currentAmount)
|
|
327
|
+
return {
|
|
328
|
+
name: 'SOL',
|
|
329
|
+
symbol: 'SOL',
|
|
330
|
+
balance,
|
|
331
|
+
decimal: SOL_DECIMAL,
|
|
332
|
+
type: 'SOL',
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
const _wrapAndHandleAccountNotFound = (fn, defaultValue) => {
|
|
337
|
+
return async (...params) => {
|
|
338
|
+
try {
|
|
339
|
+
return await fn.apply(this, params)
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (error.message && error.message.includes('could not find account')) {
|
|
342
|
+
return defaultValue
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
throw error
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const _getTokenBalance = _wrapAndHandleAccountNotFound(this.getTokenBalance, '0')
|
|
351
|
+
const _getDecimals = _wrapAndHandleAccountNotFound(this.getDecimals, 0)
|
|
352
|
+
const _getSupply = _wrapAndHandleAccountNotFound(this.getSupply, '0')
|
|
353
|
+
|
|
354
|
+
const resolveTokens = tokenAccounts.map(async (account) => {
|
|
355
|
+
try {
|
|
356
|
+
const [_tokenMetaPlex, currentAmount, decimal] = await Promise.all([
|
|
357
|
+
this.getMetaplexMetadata(account.mint),
|
|
358
|
+
_getTokenBalance(account.address),
|
|
359
|
+
_getDecimals(account.mint),
|
|
360
|
+
])
|
|
361
|
+
|
|
362
|
+
const tokenMetaPlex = _tokenMetaPlex || { name: null, symbol: null }
|
|
363
|
+
let nft = Object.create(null)
|
|
364
|
+
|
|
365
|
+
// Only perform an NFT check (getSupply) if decimal is zero
|
|
366
|
+
if (decimal === 0 && (await _getSupply(account.mint)) === '1') {
|
|
367
|
+
const compositeId = account.mint
|
|
368
|
+
nft = {
|
|
369
|
+
id: `solana:${compositeId}`,
|
|
370
|
+
compositeId,
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const balance = computeBalance(account.amount, currentAmount)
|
|
375
|
+
return {
|
|
376
|
+
balance,
|
|
377
|
+
decimal,
|
|
378
|
+
nft,
|
|
379
|
+
address: account.address,
|
|
380
|
+
mint: account.mint,
|
|
381
|
+
name: tokenMetaPlex.name,
|
|
382
|
+
symbol: tokenMetaPlex.symbol,
|
|
383
|
+
type: 'TOKEN',
|
|
384
|
+
}
|
|
385
|
+
} catch (error) {
|
|
386
|
+
console.warn(error)
|
|
387
|
+
return {
|
|
388
|
+
balance: null,
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
const accounts = await Promise.all([...resolveSols, ...resolveTokens])
|
|
394
|
+
accounts.forEach((account) => {
|
|
395
|
+
if (account.balance === null) {
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (account.balance > 0) {
|
|
400
|
+
willReceive.push(account)
|
|
401
|
+
} else {
|
|
402
|
+
willSend.push(account)
|
|
403
|
+
}
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
willReceive,
|
|
408
|
+
willSend,
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
simulateUnsignedTransaction = async ({ message, transactionMessage }) => {
|
|
413
|
+
const { config, accountAddresses } = getTransactionSimulationParams(
|
|
414
|
+
transactionMessage || message
|
|
415
|
+
)
|
|
416
|
+
// eslint-disable-next-line unicorn/no-new-array
|
|
417
|
+
const signatures = new Array(message.header.numRequiredSignatures || 1).fill(null)
|
|
418
|
+
const encodedTransaction = buildRawTransaction(
|
|
419
|
+
Buffer.from(message.serialize()),
|
|
420
|
+
signatures
|
|
421
|
+
).toString('base64')
|
|
422
|
+
const { accounts, unitsConsumed, err } = await this.simulateTransaction(encodedTransaction, {
|
|
423
|
+
...config,
|
|
424
|
+
replaceRecentBlockhash: false,
|
|
425
|
+
sigVerify: false,
|
|
426
|
+
})
|
|
427
|
+
return {
|
|
428
|
+
accounts,
|
|
429
|
+
accountAddresses,
|
|
430
|
+
unitsConsumed,
|
|
431
|
+
err,
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Simulate transaction and return side effects
|
|
437
|
+
*/
|
|
438
|
+
simulateAndRetrieveSideEffects = async (
|
|
439
|
+
message,
|
|
440
|
+
publicKey,
|
|
441
|
+
transactionMessage // decompiled TransactionMessage
|
|
442
|
+
) => {
|
|
443
|
+
const { accounts, accountAddresses } = await this.simulateUnsignedTransaction({
|
|
444
|
+
message,
|
|
445
|
+
transactionMessage,
|
|
446
|
+
})
|
|
447
|
+
const { solAccounts, tokenAccounts } = filterAccountsByOwner(
|
|
448
|
+
accounts,
|
|
449
|
+
accountAddresses,
|
|
450
|
+
publicKey
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
return this.resolveSimulationSideEffects(solAccounts, tokenAccounts)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { BaseMonitor } from '@exodus/asset-lib'
|
|
2
|
+
import { omitBy } from '@exodus/basic-utils'
|
|
3
|
+
import lodash from 'lodash'
|
|
4
|
+
import assert from 'minimalistic-assert'
|
|
5
|
+
import ms from 'ms'
|
|
6
|
+
|
|
7
|
+
import { DEFAULT_POOL_ADDRESS } from '../account-state.js'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_REMOTE_CONFIG = {
|
|
10
|
+
clarityUrl: [],
|
|
11
|
+
rpcUrl: [],
|
|
12
|
+
staking: { enabled: true, pool: DEFAULT_POOL_ADDRESS },
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const TICKS_BETWEEN_HISTORY_FETCHES = 10
|
|
16
|
+
const TICKS_BETWEEN_STAKE_FETCHES = 5
|
|
17
|
+
const TX_STALE_AFTER = ms('2m') // mark txs as dropped after N minutes
|
|
18
|
+
|
|
19
|
+
export class SolanaClarityMonitor extends BaseMonitor {
|
|
20
|
+
constructor({
|
|
21
|
+
api,
|
|
22
|
+
includeUnparsed = false,
|
|
23
|
+
ticksBetweenHistoryFetches = TICKS_BETWEEN_HISTORY_FETCHES,
|
|
24
|
+
ticksBetweenStakeFetches = TICKS_BETWEEN_STAKE_FETCHES,
|
|
25
|
+
txsLimit,
|
|
26
|
+
shouldUpdateBalanceBeforeHistory = true,
|
|
27
|
+
...args
|
|
28
|
+
}) {
|
|
29
|
+
super(args)
|
|
30
|
+
assert(api, 'api is required')
|
|
31
|
+
this.api = api
|
|
32
|
+
this.cursors = Object.create(null)
|
|
33
|
+
this.assets = Object.create(null)
|
|
34
|
+
this.staking = DEFAULT_REMOTE_CONFIG.staking
|
|
35
|
+
this.ticksBetweenStakeFetches = ticksBetweenStakeFetches
|
|
36
|
+
this.ticksBetweenHistoryFetches = ticksBetweenHistoryFetches
|
|
37
|
+
this.shouldUpdateBalanceBeforeHistory = shouldUpdateBalanceBeforeHistory
|
|
38
|
+
this.includeUnparsed = includeUnparsed
|
|
39
|
+
this.txsLimit = txsLimit
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
setServer(config = Object.create(null)) {
|
|
43
|
+
const {
|
|
44
|
+
rpcUrl,
|
|
45
|
+
clarityUrl,
|
|
46
|
+
staking = Object.create(null),
|
|
47
|
+
} = { ...DEFAULT_REMOTE_CONFIG, ...config }
|
|
48
|
+
this.api.setRpcServer(rpcUrl[0])
|
|
49
|
+
this.api.setClarityServer(clarityUrl[0])
|
|
50
|
+
this.staking = staking
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
hasNewCursor({ walletAccount, cursorState }) {
|
|
54
|
+
const { cursor } = cursorState
|
|
55
|
+
return this.cursors[walletAccount] !== cursor
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async emitUnknownTokensEvent({ tokenAccounts }) {
|
|
59
|
+
const tokensList = await this.api.getWalletTokensList({ tokenAccounts })
|
|
60
|
+
const unknownTokensList = tokensList.filter((mintAddress) => {
|
|
61
|
+
return !this.api.tokens.has(mintAddress)
|
|
62
|
+
})
|
|
63
|
+
if (unknownTokensList.length > 0) {
|
|
64
|
+
this.emit('unknown-tokens', unknownTokensList)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async getStakingAddressesFromTxLog({ assetName, walletAccount }) {
|
|
69
|
+
const txLog = await this.aci.getTxLog({ assetName: this.asset.name, walletAccount })
|
|
70
|
+
const stakingAddresses = [...txLog]
|
|
71
|
+
.filter((tx) => tx?.data?.staking?.stakeAddresses)
|
|
72
|
+
.map((tx) => tx.data.staking.stakeAddresses)
|
|
73
|
+
return lodash.uniq(stakingAddresses.flat())
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#balanceChanged({ account, newAccount }) {
|
|
77
|
+
const solBalanceChanged = !account.balance || !account.balance.equals(newAccount.balance)
|
|
78
|
+
if (solBalanceChanged) return true
|
|
79
|
+
|
|
80
|
+
// token balance changed
|
|
81
|
+
return (
|
|
82
|
+
!account.tokenBalances ||
|
|
83
|
+
Object.entries(newAccount.tokenBalances).some(
|
|
84
|
+
([token, balance]) =>
|
|
85
|
+
!account.tokenBalances[token] || !account.tokenBalances[token].equals(balance)
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async markStaleTransactions({ walletAccount, logItemsByAsset = Object.create(null) }) {
|
|
91
|
+
// mark stale txs as dropped in logItemsByAsset
|
|
92
|
+
const clearedLogItems = logItemsByAsset
|
|
93
|
+
const assetNames = Object.keys(this.assets)
|
|
94
|
+
|
|
95
|
+
for (const assetName of assetNames) {
|
|
96
|
+
const txSet = await this.aci.getTxLog({ assetName, walletAccount })
|
|
97
|
+
const { stale } = this.getUnconfirmed({ txSet, staleTxAge: TX_STALE_AFTER })
|
|
98
|
+
if (stale.length > 0) {
|
|
99
|
+
clearedLogItems[assetName] = lodash.unionBy(logItemsByAsset[assetName], stale, 'txId')
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return clearedLogItems
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
isStakingEnabled() {
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async tick({ walletAccount, refresh }) {
|
|
111
|
+
const assetName = this.asset.name
|
|
112
|
+
this.assets = await this.aci.getAssetsForNetwork({ baseAssetName: assetName })
|
|
113
|
+
this.api.setTokens(this.assets)
|
|
114
|
+
|
|
115
|
+
const accountState = await this.aci.getAccountState({ assetName, walletAccount })
|
|
116
|
+
const address = await this.aci.getReceiveAddress({ assetName, walletAccount, useCache: true })
|
|
117
|
+
|
|
118
|
+
const { account, tokenAccounts, staking } = await this.getAccountsAndBalances({
|
|
119
|
+
refresh,
|
|
120
|
+
address,
|
|
121
|
+
accountState,
|
|
122
|
+
walletAccount,
|
|
123
|
+
})
|
|
124
|
+
const balanceChanged = this.#balanceChanged({ account: accountState, newAccount: account })
|
|
125
|
+
|
|
126
|
+
const isHistoryUpdateTick =
|
|
127
|
+
this.tickCount[walletAccount] % this.ticksBetweenHistoryFetches === 0
|
|
128
|
+
|
|
129
|
+
const shouldUpdateHistory = refresh || isHistoryUpdateTick || balanceChanged
|
|
130
|
+
const shouldUpdateOnlyBalance = balanceChanged && !shouldUpdateHistory
|
|
131
|
+
|
|
132
|
+
// getHistory is more likely to fail/be rate limited, so we want to update users balance only on a lot of ticks
|
|
133
|
+
if (this.shouldUpdateBalanceBeforeHistory || shouldUpdateOnlyBalance) {
|
|
134
|
+
// update all state at once
|
|
135
|
+
await this.updateState({ account, walletAccount, staking })
|
|
136
|
+
await this.emitUnknownTokensEvent({ tokenAccounts })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (shouldUpdateHistory) {
|
|
140
|
+
const { logItemsByAsset, cursorState } = await this.getHistory({
|
|
141
|
+
address,
|
|
142
|
+
accountState,
|
|
143
|
+
walletAccount,
|
|
144
|
+
refresh,
|
|
145
|
+
tokenAccounts,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const cursorChanged = this.hasNewCursor({ walletAccount, cursorState })
|
|
149
|
+
|
|
150
|
+
// update all state at once
|
|
151
|
+
const clearedLogItems = await this.markStaleTransactions({ walletAccount, logItemsByAsset })
|
|
152
|
+
await this.updateTxLogByAsset({ walletAccount, logItemsByAsset: clearedLogItems, refresh })
|
|
153
|
+
await this.updateState({ account, cursorState, walletAccount, staking })
|
|
154
|
+
await this.emitUnknownTokensEvent({ tokenAccounts })
|
|
155
|
+
if (refresh || cursorChanged) {
|
|
156
|
+
this.cursors[walletAccount] = cursorState.cursor
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async getHistory({ address, accountState, refresh, tokenAccounts } = Object.create(null)) {
|
|
162
|
+
const cursor = refresh ? '' : accountState.cursor
|
|
163
|
+
const baseAsset = this.asset
|
|
164
|
+
|
|
165
|
+
const { transactions, newCursor } = await this.api.getTransactions(address, {
|
|
166
|
+
cursor,
|
|
167
|
+
includeUnparsed: this.includeUnparsed,
|
|
168
|
+
limit: this.txsLimit,
|
|
169
|
+
tokenAccounts,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const mappedTransactions = []
|
|
173
|
+
for (const tx of transactions) {
|
|
174
|
+
// 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
|
+
}
|
|
179
|
+
|
|
180
|
+
const assetName = tokenName ?? baseAsset.name
|
|
181
|
+
const asset = this.assets[assetName]
|
|
182
|
+
if (assetName === 'unknown' || !asset) continue // skip unknown tokens
|
|
183
|
+
const feeAsset = asset.feeAsset
|
|
184
|
+
|
|
185
|
+
const coinAmount = tx.amount ? asset.currency.baseUnit(tx.amount) : asset.currency.ZERO
|
|
186
|
+
|
|
187
|
+
const item = {
|
|
188
|
+
coinName: assetName,
|
|
189
|
+
txId: tx.id,
|
|
190
|
+
from: [tx.from],
|
|
191
|
+
coinAmount,
|
|
192
|
+
confirmations: 1, // tx.confirmations, // avoid multiple notifications
|
|
193
|
+
date: tx.date,
|
|
194
|
+
error: tx.error,
|
|
195
|
+
data: {
|
|
196
|
+
staking: tx.staking || null,
|
|
197
|
+
unparsed: !!tx.unparsed,
|
|
198
|
+
swapTx: !!(tx.data && tx.data.inner),
|
|
199
|
+
},
|
|
200
|
+
currencies: { [assetName]: asset.currency, [feeAsset.name]: feeAsset.currency },
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (tx.owner === address) {
|
|
204
|
+
// send transaction
|
|
205
|
+
item.to = Array.isArray(tx.to) ? undefined : tx.to
|
|
206
|
+
item.feeAmount = baseAsset.currency.baseUnit(tx.fee) // in SOL
|
|
207
|
+
item.feeCoinName = baseAsset.name
|
|
208
|
+
item.coinAmount = item.coinAmount.negate()
|
|
209
|
+
|
|
210
|
+
if (tx.data?.sent) {
|
|
211
|
+
item.data.sent = tx.data.sent.map((s) => ({
|
|
212
|
+
address: s.address,
|
|
213
|
+
amount: asset.currency.baseUnit(s.amount).toDefaultString({ unit: true }),
|
|
214
|
+
}))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (tx.to === tx.owner) {
|
|
218
|
+
item.selfSend = true
|
|
219
|
+
item.coinAmount = asset.currency.ZERO
|
|
220
|
+
}
|
|
221
|
+
} else if (tx.unparsed) {
|
|
222
|
+
if (tx.fee !== 0) {
|
|
223
|
+
item.feeAmount = baseAsset.currency.baseUnit(tx.fee) // in SOL
|
|
224
|
+
item.feeCoinName = baseAsset.name
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
item.data.meta = tx.data.meta
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (asset.name !== asset.baseAsset.name && item.feeAmount && item.feeAmount.isPositive) {
|
|
231
|
+
const feeItem = {
|
|
232
|
+
...lodash.clone(item),
|
|
233
|
+
coinName: feeAsset.name,
|
|
234
|
+
tokens: [asset.name],
|
|
235
|
+
coinAmount: feeAsset.currency.ZERO,
|
|
236
|
+
}
|
|
237
|
+
mappedTransactions.push(feeItem)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
mappedTransactions.push(item)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const logItemsByAsset = lodash.groupBy(mappedTransactions, (item) => item.coinName)
|
|
244
|
+
return {
|
|
245
|
+
logItemsByAsset,
|
|
246
|
+
hasNewTxs: transactions.length > 0,
|
|
247
|
+
cursorState: { cursor: newCursor },
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async getAccountsAndBalances({ refresh, address, accountState, walletAccount }) {
|
|
252
|
+
const tokens = Object.keys(this.assets).filter((name) => name !== this.asset.name)
|
|
253
|
+
const [accountInfo, { balances: splBalances, accounts: tokenAccounts }] = await Promise.all([
|
|
254
|
+
this.api.getAccountInfo(address).catch(() => {}),
|
|
255
|
+
this.api.getTokensBalancesAndAccounts({
|
|
256
|
+
address,
|
|
257
|
+
filterByTokens: tokens,
|
|
258
|
+
}),
|
|
259
|
+
])
|
|
260
|
+
|
|
261
|
+
const solBalance = accountInfo?.lamports || 0
|
|
262
|
+
|
|
263
|
+
const accountSize = accountInfo?.space || 0
|
|
264
|
+
|
|
265
|
+
const rentExemptAmount = this.asset.currency.baseUnit(
|
|
266
|
+
await this.api.getMinimumBalanceForRentExemption(accountSize)
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
const ownerChanged = await this.api.ownerChanged(address, accountInfo)
|
|
270
|
+
|
|
271
|
+
// we can have splBalances for tokens that are not in our asset list
|
|
272
|
+
const clientKnownTokens = omitBy(splBalances, (v, mintAddress) => {
|
|
273
|
+
const tokenName = this.api.tokens.get(mintAddress)?.name
|
|
274
|
+
return !this.assets[tokenName]
|
|
275
|
+
})
|
|
276
|
+
const tokenBalances = Object.fromEntries(
|
|
277
|
+
Object.entries(clientKnownTokens).map(([mintAddress, balance]) => {
|
|
278
|
+
const tokenName = this.api.tokens.get(mintAddress)?.name
|
|
279
|
+
return [tokenName, this.assets[tokenName].currency.baseUnit(balance)]
|
|
280
|
+
})
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
const solBalanceChanged = this.#balanceChanged({
|
|
284
|
+
account: accountState,
|
|
285
|
+
newAccount: {
|
|
286
|
+
balance: this.asset.currency.baseUnit(solBalance), // balance without staking
|
|
287
|
+
tokenBalances,
|
|
288
|
+
},
|
|
289
|
+
})
|
|
290
|
+
const fetchStakingInfo =
|
|
291
|
+
refresh ||
|
|
292
|
+
solBalanceChanged ||
|
|
293
|
+
this.tickCount[walletAccount] % this.ticksBetweenStakeFetches === 0
|
|
294
|
+
|
|
295
|
+
const staking =
|
|
296
|
+
this.isStakingEnabled() && fetchStakingInfo
|
|
297
|
+
? await this.getStakingInfo({ address, accountState, walletAccount })
|
|
298
|
+
: { ...accountState.stakingInfo, staking: this.staking }
|
|
299
|
+
|
|
300
|
+
const stakedBalance = this.asset.currency.baseUnit(staking.locked)
|
|
301
|
+
const activatingBalance = this.asset.currency.baseUnit(staking.activating)
|
|
302
|
+
const withdrawableBalance = this.asset.currency.baseUnit(staking.withdrawable)
|
|
303
|
+
const pendingBalance = this.asset.currency.baseUnit(staking.pending)
|
|
304
|
+
const balance = this.asset.currency
|
|
305
|
+
.baseUnit(solBalance)
|
|
306
|
+
.add(stakedBalance)
|
|
307
|
+
.add(activatingBalance)
|
|
308
|
+
.add(withdrawableBalance)
|
|
309
|
+
.add(pendingBalance)
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
account: {
|
|
313
|
+
balance,
|
|
314
|
+
tokenBalances,
|
|
315
|
+
rentExemptAmount,
|
|
316
|
+
accountSize,
|
|
317
|
+
ownerChanged,
|
|
318
|
+
},
|
|
319
|
+
staking,
|
|
320
|
+
tokenAccounts,
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async updateState({ account, cursorState, walletAccount, staking }) {
|
|
325
|
+
const { balance, tokenBalances, rentExemptAmount, accountSize, ownerChanged } = account
|
|
326
|
+
const newData = {
|
|
327
|
+
balance,
|
|
328
|
+
rentExemptAmount,
|
|
329
|
+
accountSize,
|
|
330
|
+
ownerChanged,
|
|
331
|
+
tokenBalances,
|
|
332
|
+
stakingInfo: staking,
|
|
333
|
+
...cursorState,
|
|
334
|
+
}
|
|
335
|
+
return this.updateAccountState({ newData, walletAccount })
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async getStakingInfo({ address, accountState, walletAccount }) {
|
|
339
|
+
const stakingInfo = await this.api.getStakeAccountsInfo(address)
|
|
340
|
+
let earned = accountState.stakingInfo.earned.toBaseString()
|
|
341
|
+
try {
|
|
342
|
+
const rewards = await this.api.getRewards(address)
|
|
343
|
+
earned = rewards
|
|
344
|
+
} catch (error) {
|
|
345
|
+
console.warn(error)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
loaded: true,
|
|
350
|
+
staking: this.staking,
|
|
351
|
+
isDelegating: Object.values(stakingInfo.accounts).some(({ state }) =>
|
|
352
|
+
['active', 'activating', 'inactive'].includes(state)
|
|
353
|
+
), // true if at least 1 account is delegating
|
|
354
|
+
locked: this.asset.currency.baseUnit(stakingInfo.locked),
|
|
355
|
+
activating: this.asset.currency.baseUnit(stakingInfo.activating),
|
|
356
|
+
withdrawable: this.asset.currency.baseUnit(stakingInfo.withdrawable),
|
|
357
|
+
pending: this.asset.currency.baseUnit(stakingInfo.pending), // still undelegating (not yet available for withdraw)
|
|
358
|
+
earned: this.asset.currency.baseUnit(earned),
|
|
359
|
+
accounts: stakingInfo.accounts, // Obj
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
package/src/tx-log/index.js
CHANGED
package/src/tx-send.js
CHANGED