@exodus/balances 12.6.1 → 13.0.0-alpha.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.
@@ -0,0 +1,25 @@
1
+ const balanceFieldsConfig = [
2
+ { name: 'total', legacyName: 'balance', default: 'zero' },
3
+ { name: 'spendable', legacyName: 'spendableBalance', default: 'total' },
4
+ { name: 'unconfirmedSent' },
5
+ { name: 'unconfirmedReceived' },
6
+ { name: 'unspendable' },
7
+ { name: 'walletReserve' },
8
+ { name: 'networkReserve' },
9
+ { name: 'staking' },
10
+ { name: 'staked' },
11
+ { name: 'stakable' },
12
+ { name: 'unstaking' },
13
+ { name: 'unstaked' },
14
+ { name: 'rewards' },
15
+ { name: 'frozen' },
16
+ ]
17
+
18
+ const config = {
19
+ balanceFieldsConfig,
20
+ balanceFields: balanceFieldsConfig
21
+ .flatMap((fieldConfig) => [fieldConfig.name, fieldConfig.legacyName])
22
+ .filter(Boolean), // wallets can reduce the number of exposed fields
23
+ }
24
+
25
+ export default config
package/index.js CHANGED
@@ -8,9 +8,21 @@ import {
8
8
  import balancesReportDefinition from './report'
9
9
  import { balancesLifecyclePluginDefinition, balancesAnalyticsPluginDefinition } from './plugins'
10
10
  import typeforce from '@exodus/typeforce'
11
+ import defaultConfig from './default-config'
11
12
 
12
- const balances = (config) => {
13
- typeforce({ balanceFields: typeforce.arrayOf('String') }, config, true)
13
+ const balances = (walletConfig) => {
14
+ const config = {
15
+ ...defaultConfig,
16
+ balanceFields: walletConfig?.balanceFields || defaultConfig.balanceFields,
17
+ }
18
+ typeforce(
19
+ {
20
+ balanceFields: typeforce.arrayOf('String'),
21
+ balanceFieldsConfig: typeforce.arrayOf('Object'),
22
+ },
23
+ config,
24
+ true
25
+ )
14
26
 
15
27
  return {
16
28
  id: 'balances',
@@ -0,0 +1,86 @@
1
+ import { mapValues } from '@exodus/basic-utils'
2
+
3
+ function resolveFieldBalance({ balances, fieldConfig, zero, processedBalances }) {
4
+ if (balances[fieldConfig.name]) {
5
+ return balances[fieldConfig.name]
6
+ }
7
+
8
+ if (fieldConfig.legacyName && balances[fieldConfig.legacyName]) {
9
+ return balances[fieldConfig.legacyName]
10
+ }
11
+
12
+ if (fieldConfig.default && fieldConfig.default === 'zero') {
13
+ return zero
14
+ }
15
+
16
+ if (fieldConfig.default && processedBalances[fieldConfig.default] !== null) {
17
+ if (processedBalances[fieldConfig.default] === undefined) {
18
+ throw new Error(
19
+ `Field config "${fieldConfig.default}" must be defined before "${fieldConfig.name}"`
20
+ )
21
+ }
22
+
23
+ return processedBalances[fieldConfig.default]
24
+ }
25
+
26
+ return null
27
+ }
28
+
29
+ export const processAssetBalances = ({ balances, zero, balanceFieldsConfig }) => {
30
+ return balanceFieldsConfig.reduce((processedBalances, fieldConfig) => {
31
+ const balance = resolveFieldBalance({
32
+ balances,
33
+ fieldConfig,
34
+ zero,
35
+ processedBalances,
36
+ })
37
+ if (balance !== null) {
38
+ processedBalances[fieldConfig.name] = balance
39
+ if (fieldConfig.legacyName) {
40
+ processedBalances[fieldConfig.legacyName] = balance
41
+ }
42
+ }
43
+
44
+ return processedBalances
45
+ }, {})
46
+ }
47
+
48
+ export const validateBalances = ({ balances, asset, logger }) => {
49
+ const assetName = asset.name
50
+ const zero = asset.currency.ZERO
51
+
52
+ const log = (error) => {
53
+ logger.warn(error.fn, error.error, error.args)
54
+ }
55
+
56
+ return mapValues(balances, (balance, field) => {
57
+ if (!balance) {
58
+ return balance
59
+ }
60
+
61
+ if (!balance.unitType.equals(asset.currency)) {
62
+ log({
63
+ fn: 'getAssetBalances',
64
+ error: `asset.api.getBalances ${assetName} returns different ${field} currency`,
65
+ args: {
66
+ balanceUnit: balance.unitType.defaultUnit.unitName,
67
+ assetUnit: asset.currency.defaultUnit.unitName,
68
+ },
69
+ })
70
+ return zero
71
+ }
72
+
73
+ if (balance.isNegative) {
74
+ log({
75
+ fn: 'getAssetBalances',
76
+ error: `asset.api.getBalances ${assetName} returns negative ${field}`,
77
+ args: {
78
+ amount: balance.toDefaultString({ unit: true }),
79
+ },
80
+ })
81
+ return zero
82
+ }
83
+
84
+ return balance
85
+ })
86
+ }
package/module/index.js CHANGED
@@ -2,9 +2,10 @@ import { isNumberUnit } from '@exodus/currency'
2
2
  import { TxSet } from '@exodus/models'
3
3
  import ExodusModule from '@exodus/module'
4
4
  import { cloneDeepWith, isEmpty, merge, set, zipObject } from 'lodash'
5
- import { difference, flattenToPaths } from '@exodus/basic-utils'
5
+ import { difference, flattenToPaths, pick } from '@exodus/basic-utils'
6
6
  import makeConcurrent from 'make-concurrent'
7
- import getConfirmedBalance from './confirmed-balance'
7
+ import { processAssetBalances, validateBalances } from './balances-utils'
8
+ import defaultConfig from '../default-config'
8
9
 
9
10
  const cloneBalances = (balances) =>
10
11
  cloneDeepWith(balances, (val) => {
@@ -22,6 +23,7 @@ export const MODULE_ID = 'balances'
22
23
  class Balances extends ExodusModule {
23
24
  #assetsModule
24
25
  #assetsAtom
26
+ #balanceFieldsConfig
25
27
  #balanceFields
26
28
  #walletAccounts = [] // string[]
27
29
  #ready = false
@@ -38,7 +40,7 @@ class Balances extends ExodusModule {
38
40
  assetsModule,
39
41
  assetsAtom,
40
42
  enabledWalletAccountsAtom,
41
- config: { balanceFields },
43
+ config,
42
44
  logger,
43
45
  balancesAtom,
44
46
  txLogsAtom,
@@ -48,7 +50,8 @@ class Balances extends ExodusModule {
48
50
 
49
51
  this.#assetsModule = assetsModule
50
52
  this.#assetsAtom = assetsAtom
51
- this.#balanceFields = balanceFields
53
+ this.#balanceFieldsConfig = config?.balanceFieldsConfig || defaultConfig.balanceFieldsConfig
54
+ this.#balanceFields = config?.balanceFields || defaultConfig.balanceFields
52
55
  this.#balancesAtom = balancesAtom
53
56
  this.#accountStatesAtom = accountStatesAtom
54
57
  this.#txLogsAtom = txLogsAtom
@@ -160,59 +163,31 @@ class Balances extends ExodusModule {
160
163
  })
161
164
  }
162
165
 
166
+ // It adds both old and new balances to the response.
167
+ // Ideally, selectors should start using the new ones (total, spendable, etc)
168
+
163
169
  #getBalancesFromBlockchainMetadata = ({ assetNames, accountState, txLogsByAssetName }) => {
164
170
  // in the future accountState may have all the balances and we won't need to rely on txLog
165
171
  // for now, accountState-based balance has both the baseAsset and token balances while txLog is per asset/token
166
172
  const getAssetBalances = (assetName) => {
167
173
  const asset = this.#getAsset(assetName)
168
174
  const txLog = txLogsByAssetName[assetName]
169
- const balances = asset.api.getBalances({ asset, accountState, txLog })
170
- if (!balances || isEmpty(balances) || !balances.balance)
171
- return Object.fromEntries(this.#balanceFields.map((field) => [field, asset.currency.ZERO]))
172
- if (!balances.balance.unitType.equals(asset.currency)) {
173
- this.emit('error', {
174
- fn: 'getAssetBalances',
175
- error: 'asset.api.getBalances returns different currency',
176
- args: {
177
- balanceUnit: balances.balance.unitType.defaultUnit.unitName,
178
- assetUnit: asset.currency.defaultUnit.unitName,
179
- },
180
- })
181
-
182
- this._logger.warn(
183
- 'asset.api.getBalances returns different currency, asset currency:',
184
- asset.currency.defaultUnit.unitName,
185
- 'balance currency',
186
- balances.balance.unitType.defaultUnit.unitName,
187
- 'return zero balance for it'
188
- )
189
175
 
190
- return Object.fromEntries(this.#balanceFields.map((field) => [field, asset.currency.ZERO]))
191
- }
192
-
193
- if (balances.balance && this.#balanceFields.includes('confirmedBalance')) {
194
- balances.confirmedBalance = getConfirmedBalance({
195
- asset,
196
- txLog,
197
- balance: balances.balance,
198
- })
199
- }
176
+ const zero = asset.currency.ZERO
200
177
 
201
- const negativeBalanceFields = Object.keys(balances).filter(
202
- (field) => balances[field]?.isNegative
203
- )
204
-
205
- if (negativeBalanceFields.length > 0) {
206
- this._logger.warn('unexpected negative balance', {
207
- assetName,
208
- accountState,
209
- txLogsByAssetName,
210
- balances,
211
- negativeBalanceFields,
212
- })
213
- }
214
-
215
- return balances
178
+ const apiBalances = asset.api.getBalances({ asset, accountState, txLog })
179
+ const balances = processAssetBalances({
180
+ balanceFieldsConfig: this.#balanceFieldsConfig,
181
+ balances: apiBalances || {},
182
+ zero,
183
+ })
184
+ return validateBalances({
185
+ balances: pick(balances, this.#balanceFields),
186
+ asset,
187
+ zero,
188
+ logger: this._logger,
189
+ emitter: this,
190
+ })
216
191
  }
217
192
 
218
193
  const balances = assetNames.map(getAssetBalances)
package/package.json CHANGED
@@ -1,18 +1,19 @@
1
1
  {
2
2
  "name": "@exodus/balances",
3
- "version": "12.6.1",
3
+ "version": "13.0.0-alpha.0",
4
4
  "description": "Tracks crypto balances across enabled wallet accounts.",
5
5
  "author": "Exodus Movement Inc.",
6
6
  "license": "UNLICENSED",
7
7
  "scripts": {
8
- "test": "jest",
9
- "lint": "eslint . --ignore-path ../../.gitignore",
8
+ "test": "run -T jest",
9
+ "lint": "run -T eslint . --ignore-path ../../.gitignore",
10
10
  "lint:fix": "yarn lint --fix"
11
11
  },
12
12
  "main": "index.js",
13
13
  "files": [
14
14
  "module",
15
15
  "atoms",
16
+ "default-config.js",
16
17
  "report",
17
18
  "plugins",
18
19
  "redux",
@@ -29,12 +30,13 @@
29
30
  "url": "https://github.com/ExodusMovement/exodus-hydra/issues?q=is%3Aissue+is%3Aopen+label%3Abalances"
30
31
  },
31
32
  "dependencies": {
32
- "@exodus/atoms": "^7.0.1",
33
+ "@exodus/atoms": "workspace:^",
33
34
  "@exodus/basic-utils": "^2.1.0",
34
35
  "@exodus/currency": "^2.1.2",
35
36
  "@exodus/models": "^10.1.0",
36
37
  "@exodus/module": "^1.1.0",
37
- "@exodus/multi-account-redux": "^2.0.1",
38
+ "@exodus/multi-account-redux": "workspace:^",
39
+ "@exodus/redux-dependency-injection": "workspace:^",
38
40
  "@exodus/typeforce": "^1.18.0",
39
41
  "lodash": "^4.17.21",
40
42
  "make-concurrent": "^4.0.0",
@@ -46,17 +48,14 @@
46
48
  "devDependencies": {
47
49
  "@exodus/assets": "^8.0.94",
48
50
  "@exodus/assets-base": "^8.1.10",
49
- "@exodus/assets-feature": "^4.1.0",
51
+ "@exodus/assets-feature": "workspace:^",
50
52
  "@exodus/bitcoin-meta": "^1.0.1",
51
53
  "@exodus/blockchain-metadata": "^9.0.1",
52
54
  "@exodus/ethereum-meta": "^1.0.30",
53
- "@exodus/redux-dependency-injection": "^3.0.0",
55
+ "@exodus/redux-dependency-injection": "workspace:^",
54
56
  "@exodus/storage-memory": "*",
55
- "@exodus/wallet-accounts": "^15.1.0",
56
- "eslint": "^8.44.0",
57
+ "@exodus/wallet-accounts": "workspace:^",
57
58
  "events": "^3.3.0",
58
- "jest": "^29.1.2",
59
59
  "redux": "^4.2.1"
60
- },
61
- "gitHead": "e3f89304d9ac03902bd4b6a7b1cd8cbcd1242067"
60
+ }
62
61
  }
@@ -6,7 +6,7 @@ const helper = createReduxModuleHelper({
6
6
  createInitialPerAssetData: ({ asset }) => {
7
7
  return { balance: asset.currency.ZERO }
8
8
  },
9
- assetSourceDataSelectors: [{ name: 'balance', selector: (assetData) => assetData.balance }],
9
+ assetSourceDataSelectors: [{ name: 'balance', selector: (assetData) => assetData.total }],
10
10
  })
11
11
 
12
12
  export default helper
@@ -0,0 +1,43 @@
1
+ import { memoize } from 'lodash'
2
+ import { MY_STATE } from '@exodus/redux-dependency-injection'
3
+ import { createSelector } from 'reselect'
4
+
5
+ const createBalanceSelectorFactory = ({ field, id }) => {
6
+ const selectorFactory = (selfSelector) =>
7
+ memoize(
8
+ ({ assetName, walletAccount }) =>
9
+ createSelector(
10
+ selfSelector,
11
+ (balances) => balances[walletAccount]?.data[assetName]?.[field]
12
+ ),
13
+ ({ assetName, walletAccount, field }) => [assetName, walletAccount, field].join('-')
14
+ )
15
+
16
+ return {
17
+ id,
18
+ selectorFactory,
19
+ dependencies: [{ selector: MY_STATE }],
20
+ }
21
+ }
22
+
23
+ const createBalanceSelectors = [
24
+ // regenerate if there is a new balance field!!
25
+ { id: 'createTotal', field: 'total' },
26
+ { id: 'createBalance', field: 'balance' },
27
+ { id: 'createSpendable', field: 'spendable' },
28
+ { id: 'createSpendableBalance', field: 'spendableBalance' },
29
+ { id: 'createUnconfirmedSent', field: 'unconfirmedSent' },
30
+ { id: 'createUnconfirmedReceived', field: 'unconfirmedReceived' },
31
+ { id: 'createUnspendable', field: 'unspendable' },
32
+ { id: 'createWalletReserve', field: 'walletReserve' },
33
+ { id: 'createNetworkReserve', field: 'networkReserve' },
34
+ { id: 'createStaking', field: 'staking' },
35
+ { id: 'createStaked', field: 'staked' },
36
+ { id: 'createStakable', field: 'stakable' },
37
+ { id: 'createUnstaking', field: 'unstaking' },
38
+ { id: 'createUnstaked', field: 'unstaked' },
39
+ { id: 'createRewards', field: 'rewards' },
40
+ { id: 'createFrozen', field: 'frozen' },
41
+ ].map(createBalanceSelectorFactory)
42
+
43
+ export default createBalanceSelectors
@@ -0,0 +1,26 @@
1
+ import { memoize } from 'lodash'
2
+ import defaultConfig from '../../default-config'
3
+
4
+ const { balanceFields } = defaultConfig
5
+
6
+ const resultFunction = (getBalances) =>
7
+ memoize(
8
+ ({ assetName, walletAccount, field }) => {
9
+ if (!balanceFields.includes(field)) {
10
+ throw new Error(
11
+ `Value '${field}' is not one of the valid fields '${balanceFields.join(', ')}'`
12
+ )
13
+ }
14
+
15
+ return getBalances({ assetName, walletAccount })?.[field]
16
+ },
17
+ ({ assetName, walletAccount, field }) => [assetName, walletAccount, field].join('-')
18
+ )
19
+
20
+ const getBalanceForFieldSelectorDefinition = {
21
+ id: 'getBalanceForField',
22
+ resultFunction,
23
+ dependencies: [{ module: 'balances', selector: 'getBalances' }],
24
+ }
25
+
26
+ export default getBalanceForFieldSelectorDefinition
@@ -0,0 +1,39 @@
1
+ import { memoize } from 'lodash'
2
+
3
+ const createResultFunction = (field) => (getBalanceForField) =>
4
+ memoize(
5
+ ({ assetName, walletAccount }) => {
6
+ return getBalanceForField({ assetName, walletAccount, field })
7
+ },
8
+ ({ assetName, walletAccount }) => [assetName, walletAccount].join('-')
9
+ )
10
+
11
+ const getBalanceSelectorFactory = ({ field, id }) => {
12
+ return {
13
+ id,
14
+ resultFunction: createResultFunction(field),
15
+ dependencies: [{ module: 'balances', selector: 'getBalanceForField' }],
16
+ }
17
+ }
18
+
19
+ const getBalanceSelectors = [
20
+ // regenerate if there is a new balance field!!
21
+ { id: 'getTotal', field: 'total' },
22
+ { id: 'getBalance', field: 'balance' },
23
+ { id: 'getSpendable', field: 'spendable' },
24
+ { id: 'getSpendableBalance', field: 'spendableBalance' },
25
+ { id: 'getUnconfirmedSent', field: 'unconfirmedSent' },
26
+ { id: 'getUnconfirmedReceived', field: 'unconfirmedReceived' },
27
+ { id: 'getUnspendable', field: 'unspendable' },
28
+ { id: 'getWalletReserve', field: 'walletReserve' },
29
+ { id: 'getNetworkReserve', field: 'networkReserve' },
30
+ { id: 'getStaking', field: 'staking' },
31
+ { id: 'getStaked', field: 'staked' },
32
+ { id: 'getStakable', field: 'stakable' },
33
+ { id: 'getUnstaking', field: 'unstaking' },
34
+ { id: 'getUnstaked', field: 'unstaked' },
35
+ { id: 'getRewards', field: 'rewards' },
36
+ { id: 'getFrozen', field: 'frozen' },
37
+ ].map(getBalanceSelectorFactory)
38
+
39
+ export default getBalanceSelectors
@@ -0,0 +1,21 @@
1
+ import { memoize } from 'lodash'
2
+ import { createSelector } from 'reselect'
3
+ import { MY_STATE } from '@exodus/redux-dependency-injection'
4
+
5
+ const selectorFactory = (selfSelector) =>
6
+ createSelector(selfSelector, (balances) =>
7
+ memoize(
8
+ ({ assetName, walletAccount }) => {
9
+ return balances[walletAccount]?.data[assetName]
10
+ },
11
+ ({ assetName, walletAccount }) => [assetName, walletAccount].join('-')
12
+ )
13
+ )
14
+
15
+ const getBalancesSelectorDefinition = {
16
+ id: 'getBalances',
17
+ selectorFactory,
18
+ dependencies: [{ selector: MY_STATE }],
19
+ }
20
+
21
+ export default getBalancesSelectorDefinition
@@ -1,9 +1,17 @@
1
1
  import byAssetSelectorDefinition from './by-asset'
2
2
  import createUnconfirmedBalanceSelectorDefinition from './create-unconfirmed-balance'
3
+ import getBalanceForFieldSelectorDefinition from './get-balance-for-field'
4
+ import getBalanceSelectors from './get-balance-selectors'
5
+ import getBalancesSelectorDefinition from './get-balances'
6
+ import createBalancesSelectors from './create-balance-selectors'
3
7
  import getUnconfirmedBalanceSelectorDefinition from './get-unconfirmed-balance'
4
8
 
5
9
  export default [
6
10
  byAssetSelectorDefinition,
7
11
  createUnconfirmedBalanceSelectorDefinition,
12
+ getBalanceForFieldSelectorDefinition,
13
+ ...getBalanceSelectors,
14
+ ...createBalancesSelectors,
15
+ getBalancesSelectorDefinition,
8
16
  getUnconfirmedBalanceSelectorDefinition,
9
17
  ]
@@ -1,19 +0,0 @@
1
- import { TxSet } from '@exodus/models'
2
-
3
- const isUnconfirmedTx = (tx) => tx.pending && !tx.error
4
-
5
- const getTxSetBalance = (txSet) => txSet.getMutations().slice(-1)[0].balance
6
-
7
- const getUnconfirmedTotal = ({ asset, txLog }) => {
8
- const unconfirmedTxs = [...txLog].filter(isUnconfirmedTx)
9
- return unconfirmedTxs.length === 0
10
- ? asset.currency.ZERO
11
- : getTxSetBalance(TxSet.fromArray(unconfirmedTxs))
12
- }
13
-
14
- const getConfirmedBalance = ({ asset, txLog, balance }) => {
15
- const unconfirmedTotal = getUnconfirmedTotal({ asset, txLog })
16
- return balance.sub(unconfirmedTotal)
17
- }
18
-
19
- export default getConfirmedBalance