@exodus/balances 1.0.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.
Files changed (2) hide show
  1. package/index.js +232 -0
  2. package/package.json +34 -0
package/index.js ADDED
@@ -0,0 +1,232 @@
1
+ import { isNumberUnit } from '@exodus/currency'
2
+ import ExodusModule from '@exodus/module'
3
+ import { cloneDeepWith, difference, isEmpty, merge, set, zipObject } from 'lodash'
4
+ import makeConcurrent from 'make-concurrent'
5
+
6
+ const cloneBalances = (balances) =>
7
+ cloneDeepWith(balances, (val) => {
8
+ if (isNumberUnit(val)) return val
9
+ })
10
+
11
+ const zipNonNull = (keys, values) =>
12
+ zipObject(
13
+ keys.filter((key, i) => values[i] != null),
14
+ values.filter((value) => value != null)
15
+ )
16
+
17
+ class Balances extends ExodusModule {
18
+ #assetsModule
19
+ #blockchainMetadata
20
+ #balanceFields
21
+ #walletAccounts = null // string[]
22
+ #ready = false
23
+
24
+ #oldBalances = {}
25
+ #balances = {}
26
+
27
+ constructor({ assetsModule, blockchainMetadata, walletAccountsAtom, balanceFields, logger }) {
28
+ super({ name: 'Balances', logger })
29
+
30
+ this.#assetsModule = assetsModule
31
+ this.#blockchainMetadata = blockchainMetadata
32
+ this.#balanceFields = balanceFields
33
+
34
+ const shareQueue =
35
+ (fn) =>
36
+ (...args) => {
37
+ if (!this.#ready) return
38
+
39
+ this.#pushIntoSharedQueue(() => fn.apply(this, args))
40
+ }
41
+
42
+ // these share a queue
43
+ this.#updateAssetSource = shareQueue(this.#updateAssetSource)
44
+ this.#recompute = shareQueue(this.#recompute)
45
+
46
+ blockchainMetadata.on('account-state', async (opts) => {
47
+ return this.#updateAssetSource(opts)
48
+ })
49
+
50
+ blockchainMetadata.on('tx-log', async (opts) => {
51
+ if (this.#isHistoryBasedBalanceAsset(opts.assetName)) return this.#updateAssetSource(opts)
52
+ })
53
+
54
+ walletAccountsAtom.observe((payload) =>
55
+ this.#setWalletAccounts(
56
+ Object.keys(payload).filter((walletAccount) => payload[walletAccount].enabled)
57
+ )
58
+ )
59
+
60
+ assetsModule.on('assets-add', this.#onAssetsChanged)
61
+ }
62
+
63
+ #getAsset = (assetName) => this.#assetsModule.getAsset(assetName)
64
+
65
+ #pushIntoSharedQueue = makeConcurrent((fn) => fn(), { concurrency: 1 })
66
+
67
+ #reset = ({ walletAccounts = this.#walletAccounts } = {}) => {
68
+ walletAccounts.forEach((walletAccount) => {
69
+ this.#balances[walletAccount] = {}
70
+ })
71
+ }
72
+
73
+ #getBalancesForAssetSource = async ({ walletAccount, assetName }) => {
74
+ const metadata = await this.#loadBlockchainMetadataForBalance({ walletAccount, assetName })
75
+ return this.#getBalancesFromBlockchainMetadata({ assetName, ...metadata })
76
+ }
77
+
78
+ #getBalancesFromBlockchainMetadata = ({ assetName, accountState, txLog }) => {
79
+ // in the future accountState may have all the balances and we won't need to rely on txLog
80
+ // for now, accountState-based balance has both the baseAsset and token balances while txLog is per asset/token
81
+ const getAssetBalances = (assetName) => {
82
+ const asset = this.#getAsset(assetName)
83
+ return asset.api.getBalances({ asset, accountState, txLog })
84
+ }
85
+
86
+ if (this.#isHistoryBasedBalanceAsset(assetName)) {
87
+ const balances = getAssetBalances(assetName)
88
+ return balances
89
+ ? {
90
+ [assetName]: balances,
91
+ }
92
+ : {}
93
+ }
94
+
95
+ const assetNames = [assetName, ...this.#assetsModule.getTokenNames(assetName)].filter(
96
+ this.#isSupportedAsset
97
+ )
98
+
99
+ const balances = assetNames.map(getAssetBalances)
100
+ return zipNonNull(assetNames, balances)
101
+ }
102
+
103
+ #getBalancesForWalletAccount = async ({ walletAccount }) => {
104
+ const assetNames = this.#assetsModule.getBaseAssetNames()
105
+ const balances = await Promise.all(
106
+ assetNames.map((assetName) => this.#getBalancesForAssetSource({ assetName, walletAccount }))
107
+ )
108
+
109
+ return merge({}, ...balances)
110
+ }
111
+
112
+ #load = async ({ walletAccount }) => {
113
+ this._logger.log(`loading walletAccount ${walletAccount}`)
114
+ this.#balances[walletAccount] = await this.#getBalancesForWalletAccount({ walletAccount })
115
+ }
116
+
117
+ #setWalletAccounts = (walletAccounts) => {
118
+ const added = difference(walletAccounts, this.#walletAccounts)
119
+ const removed = difference(this.#walletAccounts, walletAccounts)
120
+ if (added.length === 0 && removed.length === 0) return
121
+
122
+ this.#walletAccounts = walletAccounts
123
+ this.#ready = true
124
+ if (removed.length > 0) {
125
+ this._logger.log('recomputing after removing walletAccounts', removed)
126
+ removed.forEach((walletAccount) => {
127
+ delete this.#balances[walletAccount]
128
+ })
129
+ }
130
+
131
+ if (added.length > 0) {
132
+ this._logger.log('recomputing after adding walletAccounts', added)
133
+ this.#recompute({ walletAccounts: added })
134
+ } else {
135
+ this.sync()
136
+ }
137
+ }
138
+
139
+ #onAssetsChanged = async () => {
140
+ // TODO: make this more efficient
141
+ this._logger.log('recomputing based on new assets list')
142
+ this.#recompute()
143
+ }
144
+
145
+ #isSupportedAsset = (assetName) => !this.#getAsset(assetName).isCombined
146
+
147
+ #isHistoryBasedBalanceAsset = (assetName) => {
148
+ const asset = this.#getAsset(assetName)
149
+ return asset.api?.hasFeature('historyBasedBalance')
150
+ }
151
+
152
+ #loadBlockchainMetadataForBalance = async ({ assetName, walletAccount, accountState, txLog }) => {
153
+ return {
154
+ accountState:
155
+ accountState ||
156
+ (await this.#blockchainMetadata.getAccountState({
157
+ assetName: this.#getAsset(assetName).baseAsset.name,
158
+ walletAccount,
159
+ })),
160
+ txLog: txLog || (await this.#blockchainMetadata.getTxLog({ assetName, walletAccount })),
161
+ }
162
+ }
163
+
164
+ #updateAssetSource = async ({
165
+ assetName,
166
+ walletAccount,
167
+ accountState: providedAccountState,
168
+ txLog: providedTxLog,
169
+ }) => {
170
+ if (!this.#isSupportedAsset(assetName)) return
171
+
172
+ if (!this.#walletAccounts.includes(walletAccount)) {
173
+ this._logger.warn(`unexpected update for disabled walletAccount ${walletAccount}`)
174
+ return
175
+ }
176
+
177
+ const { accountState, txLog } = await this.#loadBlockchainMetadataForBalance({
178
+ assetName,
179
+ walletAccount,
180
+ accountState: providedAccountState,
181
+ txLog: providedTxLog,
182
+ })
183
+
184
+ const accountBalances = this.#balances[walletAccount]
185
+ const newBalances = this.#getBalancesFromBlockchainMetadata({ assetName, accountState, txLog })
186
+ const changes = {}
187
+ for (let subAssetName in newBalances) {
188
+ const { currency } = this.#getAsset(subAssetName)
189
+ const newAssetBalances = newBalances?.[subAssetName]
190
+ // e.g. totalBalance or spendableBalance
191
+ for (const field of this.#balanceFields) {
192
+ const oldBalance = accountBalances?.[subAssetName]?.[field]
193
+ const newBalance = newAssetBalances?.[field]
194
+ if (!newBalance) continue
195
+ if ((oldBalance || currency.ZERO).equals(newBalance)) continue
196
+
197
+ this._logger.log(`updating assetSource ${walletAccount}:${assetName}`)
198
+ set(changes, [walletAccount, subAssetName, field], {
199
+ from: oldBalance ?? currency.ZERO,
200
+ to: newBalance,
201
+ })
202
+
203
+ set(accountBalances, [subAssetName, field], newBalance)
204
+ }
205
+ }
206
+
207
+ if (!isEmpty(changes)) this.sync({ changes })
208
+ }
209
+
210
+ #recompute = async ({ walletAccounts = this.#walletAccounts } = {}) => {
211
+ this.#reset({ walletAccounts })
212
+ await Promise.all(walletAccounts.map((walletAccount) => this.#load({ walletAccount })))
213
+ this.sync()
214
+ }
215
+
216
+ sync = ({ changes } = {}) => {
217
+ this._logger.log('balances updated')
218
+
219
+ const oldBalances = this.#oldBalances
220
+ this.emit('balances', {
221
+ balances: this.#balances,
222
+ oldBalances,
223
+ changes,
224
+ })
225
+
226
+ this.#oldBalances = cloneBalances(this.#balances)
227
+ }
228
+ }
229
+
230
+ const createBalances = (opts) => new Balances(opts)
231
+
232
+ export default createBalances
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@exodus/balances",
3
+ "version": "1.0.0",
4
+ "description": "balances",
5
+ "author": "Exodus Movement Inc",
6
+ "license": "UNLICENSED",
7
+ "scripts": {
8
+ "test": "jest",
9
+ "lint": "eslint .",
10
+ "lint:fix": "yarn lint --fix"
11
+ },
12
+ "files": [
13
+ "index.js"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ExodusMovement/exodus-hydra.git"
18
+ },
19
+ "homepage": "https://github.com/ExodusMovement/exodus-hydra/tree/master/modules/balances",
20
+ "bugs": {
21
+ "url": "https://github.com/ExodusMovement/exodus-hydra/issues?q=is%3Aissue+is%3Aopen+label%3Abalances"
22
+ },
23
+ "dependencies": {
24
+ "@exodus/currency": "^2.1.2",
25
+ "@exodus/module": "^1.0.0",
26
+ "lodash": "^4.17.21",
27
+ "make-concurrent": "^4.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "@exodus/atoms": "^1.0.0",
31
+ "@exodus/basic-utils": "^0.7.0",
32
+ "@exodus/models": "^8.9.0"
33
+ }
34
+ }