@exodus/ethereum-api 9.0.0-alpha.3 → 9.0.1

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 (143) hide show
  1. package/CHANGELOG.md +2139 -0
  2. package/LICENSE +7 -0
  3. package/README.md +124 -0
  4. package/package.json +34 -33
  5. package/src/address-has-history.js +6 -2
  6. package/src/allowance/constants.js +4 -5
  7. package/src/allowance/index.js +159 -40
  8. package/src/check-tx/create-assess-transaction.js +23 -0
  9. package/src/check-tx/create-check-tx.js +104 -0
  10. package/src/check-tx/index.js +1 -0
  11. package/src/create-asset-plugin-factory.js +115 -0
  12. package/src/create-asset-utils.js +520 -0
  13. package/src/create-asset.js +273 -97
  14. package/src/create-token-factory.js +47 -17
  15. package/src/custom-fees.js +57 -0
  16. package/src/eip7702-whitelist.js +62 -0
  17. package/src/ens/index.js +12 -17
  18. package/src/error-wrapper.js +383 -0
  19. package/src/eth-like-util.js +172 -33
  20. package/src/exodus-eth-server/api-coin-nodes.js +27 -196
  21. package/src/exodus-eth-server/api.js +23 -14
  22. package/src/exodus-eth-server/clarity-v2.js +255 -0
  23. package/src/exodus-eth-server/clarity.js +67 -265
  24. package/src/exodus-eth-server/errors.js +40 -0
  25. package/src/exodus-eth-server/eth-like-server.js +385 -0
  26. package/src/exodus-eth-server/fetch-json.js +48 -0
  27. package/src/exodus-eth-server/index.js +77 -0
  28. package/src/exodus-eth-server/utils.js +31 -0
  29. package/src/exodus-eth-server/ws-gateway.js +306 -0
  30. package/src/exodus-eth-server/ws.js +1 -2
  31. package/src/fee-data/EthLikeFeeData.js +81 -0
  32. package/src/fee-data/index.js +54 -0
  33. package/src/fee-utils.js +229 -0
  34. package/src/gas-estimation.js +183 -66
  35. package/src/get-balance-for-address.js +1 -1
  36. package/src/get-balances.js +285 -27
  37. package/src/get-fee.js +92 -56
  38. package/src/get-historical-balance.js +58 -0
  39. package/src/hooks/index.js +1 -1
  40. package/src/hooks/monitor.js +182 -43
  41. package/src/hooks/staking-freshness.js +58 -0
  42. package/src/index.js +118 -12
  43. package/src/move-funds.js +153 -0
  44. package/src/multicall3/index.js +212 -0
  45. package/src/multisig-address.js +146 -0
  46. package/src/nft-utils.js +60 -0
  47. package/src/number-utils.js +34 -2
  48. package/src/optimism-gas/index.js +13 -5
  49. package/src/send-validations.js +58 -0
  50. package/src/server-based-fee-monitor.js +12 -19
  51. package/src/simulate-tx/fetch-tx-preview.js +0 -2
  52. package/src/simulate-tx/index.js +2 -2
  53. package/src/simulate-tx/simulate-eth-tx.js +24 -11
  54. package/src/simulation/common.js +34 -0
  55. package/src/simulation/create-simulate-message.js +49 -0
  56. package/src/simulation/create-simulate-transactions.js +108 -0
  57. package/src/simulation/estimate-fee.js +14 -0
  58. package/src/simulation/estimate-simple-transfer.js +15 -0
  59. package/src/simulation/get-message-type.js +18 -0
  60. package/src/simulation/simulate-message-api.js +68 -0
  61. package/src/simulation/simulate-transactions-api.js +266 -0
  62. package/src/simulation/simulate-transactions.js +16 -0
  63. package/src/simulation/transactions.js +52 -0
  64. package/src/simulation/try-estimating-changes-locally.js +26 -0
  65. package/src/staking/api/index.js +43 -0
  66. package/src/staking/cached-read.js +33 -0
  67. package/src/staking/ethereum/api.js +166 -34
  68. package/src/staking/ethereum/deps.js +37 -0
  69. package/src/staking/ethereum/everstake.js +113 -0
  70. package/src/staking/ethereum/index.js +4 -3
  71. package/src/staking/ethereum/service.js +682 -181
  72. package/src/staking/ethereum/staking-utils.js +275 -4
  73. package/src/staking/fantom-staking.js +9 -9
  74. package/src/staking/index.js +4 -4
  75. package/src/staking/matic/addresses.js +2 -0
  76. package/src/staking/matic/api.js +69 -49
  77. package/src/staking/matic/index.js +3 -3
  78. package/src/staking/matic/matic-staking-utils.js +243 -5
  79. package/src/staking/matic/matic-staking.md +53 -0
  80. package/src/staking/staking-provider-client.js +1 -1
  81. package/src/staking/utils/index.js +39 -0
  82. package/src/tx-create.js +666 -0
  83. package/src/tx-log/clarity-monitor.js +276 -103
  84. package/src/tx-log/clarity-truncated-history-monitor.js +130 -0
  85. package/src/tx-log/clarity-truncated-history-monitor.md +219 -0
  86. package/src/tx-log/clarity-utils/absolute.js +96 -0
  87. package/src/tx-log/clarity-utils/get-log-items-from-server-tx.js +94 -31
  88. package/src/tx-log/clarity-utils/get-names-of-tokens-transferred-by-server-tx.js +3 -2
  89. package/src/tx-log/clarity-utils/index.js +9 -3
  90. package/src/tx-log/clarity-utils/is-spam-pending-tx.js +13 -0
  91. package/src/tx-log/clarity-utils/normalize-transactions-response.js +65 -0
  92. package/src/tx-log/ethereum-monitor.js +93 -47
  93. package/src/tx-log/ethereum-no-history-monitor.js +52 -41
  94. package/src/tx-log/get-optimistic-txlog-effects.js +379 -0
  95. package/src/tx-log/index.js +5 -3
  96. package/src/tx-log/monitor-utils/check-pending-transactions.js +14 -5
  97. package/src/tx-log/monitor-utils/exclude-unchanged-token-balances.js +1 -1
  98. package/src/tx-log/monitor-utils/extract-balance-from-clarity-account-info.js +108 -0
  99. package/src/tx-log/monitor-utils/get-balance-updates.js +44 -0
  100. package/src/tx-log/monitor-utils/get-batched-rpc-balances.js +47 -0
  101. package/src/tx-log/monitor-utils/get-current-blacklist-status.js +38 -0
  102. package/src/tx-log/monitor-utils/get-current-eip7702-delegation.js +79 -0
  103. package/src/tx-log/monitor-utils/get-derive-transactions-to-check.js +10 -5
  104. package/src/tx-log/monitor-utils/get-fee-amount.js +1 -1
  105. package/src/tx-log/monitor-utils/get-history-from-server.js +1 -1
  106. package/src/tx-log/monitor-utils/get-log-items-from-server-tx.js +75 -33
  107. package/src/tx-log/monitor-utils/get-names-of-tokens-transferred-by-server-tx.js +3 -2
  108. package/src/tx-log/monitor-utils/get-raw-transfer-amount.js +29 -0
  109. package/src/tx-log/monitor-utils/get-transfers-by-token-name.js +3 -2
  110. package/src/tx-log/monitor-utils/get-value-of-transfers.js +10 -12
  111. package/src/tx-log/monitor-utils/index.js +12 -7
  112. package/src/tx-log/monitor-utils/is-unverifiable-pending-tx-expired.js +19 -0
  113. package/src/tx-log/monitor-utils/verify-pending-tx-status-rpc.js +90 -0
  114. package/src/tx-log-staking-processor/asset-staking-tx-data.js +82 -24
  115. package/src/tx-log-staking-processor/index.js +28 -14
  116. package/src/tx-log-staking-processor/utils.js +21 -20
  117. package/src/tx-send/broadcast-error-handler.js +63 -0
  118. package/src/tx-send/index.js +1 -2
  119. package/src/tx-send/nonce-utils.js +97 -0
  120. package/src/tx-send/tx-send.js +94 -288
  121. package/src/tx-sign/index.js +17 -0
  122. package/src/tx-type/index.js +280 -0
  123. package/src/watch-tx.js +36 -0
  124. package/src/web3/createSimulateMessage.js +3 -2
  125. package/src/web3/createSimulateTransactions.js +11 -0
  126. package/src/web3/index.js +3 -1
  127. package/src/etherscan/account.js +0 -49
  128. package/src/etherscan/index.js +0 -28
  129. package/src/etherscan/logs.js +0 -16
  130. package/src/etherscan/proxy.js +0 -47
  131. package/src/etherscan/request.js +0 -26
  132. package/src/etherscan/ws.js +0 -88
  133. package/src/exodus-eth-server/server-factory.js +0 -28
  134. package/src/exodus-eth-server/server-map.js +0 -21
  135. package/src/get-fee-async.js +0 -97
  136. package/src/staking/matic/service.js +0 -437
  137. package/src/staking-api.js +0 -5
  138. package/src/tx-log/clarity-utils/get-derive-data-needed-for-tick.js +0 -12
  139. package/src/tx-log-staking-processor/get-asset-tx-amount.js +0 -26
  140. package/src/tx-send/get-fee-info.js +0 -41
  141. package/src/websocket/index.android.js +0 -2
  142. package/src/websocket/index.ios.js +0 -2
  143. package/src/websocket/index.js +0 -21
@@ -1,113 +1,230 @@
1
- import BN from 'bn.js'
2
- import * as ethUtil from '@exodus/ethereumjs-util'
3
- import { currency2buffer, isToken } from '@exodus/ethereum-lib'
4
- import { estimateGas, isContractAddressCached } from './eth-like-util'
1
+ import { currency2buffer, isEthereumLikeToken } from '@exodus/ethereum-lib'
2
+ import { bufferToHex, toBuffer } from '@exodus/ethereumjs/util'
5
3
 
6
- const EXTRA_PERCENTAGE = 20
4
+ import { estimateGas, isContractAddressCached, isForwarderContractCached } from './eth-like-util.js'
5
+ import {
6
+ ARBITRARY_ADDRESS,
7
+ resolveCriticalTxAttributes,
8
+ TX_TYPE_TRANSFER,
9
+ } from './tx-type/index.js'
10
+
11
+ export const DEFAULT_GAS_LIMIT_MULTIPLIER = 1.29
7
12
 
8
13
  // 16 gas per non-zero byte (4 gas per zero byte) of "transaction input data"
9
14
  const GAS_PER_NON_ZERO_BYTE = 16
10
15
 
11
16
  export const DEFAULT_CONTRACT_GAS_LIMIT = 1e6
17
+ export const DEFAULT_TOKEN_GAS_LIMIT = 120e3
18
+ export const DEFAULT_GAS_LIMIT = 21_000
19
+
20
+ // HACK: RPCs generally provide imprecise estimates
21
+ // for `gasUsed` (often these are insufficient).
22
+ export const scaleGasLimitEstimate = ({
23
+ estimatedGasLimit,
24
+ gasLimitMultiplier = DEFAULT_GAS_LIMIT_MULTIPLIER,
25
+ }) => {
26
+ const gasLimit = parseInt(estimatedGasLimit)
27
+ if (!Number.isInteger(gasLimit)) {
28
+ throw new TypeError('Invalid scaleGasLimitEstimate ' + estimatedGasLimit)
29
+ }
30
+
31
+ const multiplier = parseFloat(gasLimitMultiplier)
32
+ if (Number.isNaN(multiplier)) {
33
+ throw new TypeError('Invalid gasLimitMultiplier ' + gasLimitMultiplier)
34
+ }
35
+
36
+ return Math.floor(gasLimit * multiplier)
37
+ }
12
38
 
13
39
  // Starting with geth v1.9.14, if gasPrice is set for eth_estimateGas call, the call allowance will
14
40
  // be calculated with account's balance divided by gasPrice. If user's balance is too low,
15
41
  // the gasEstimation will fail. If gasPrice is set to '0x0', the account's balance is not
16
42
  // used to estimate gas.
17
- export async function estimateGasLimit(
43
+ export async function estimateGasLimit({
18
44
  asset,
19
45
  fromAddress,
20
46
  toAddress,
21
- amount,
47
+ // TODO: rename to value, as this is `msg.value`.
48
+ amount = asset.currency.ZERO,
22
49
  data,
23
50
  gasPrice = '0x',
24
- extraPercentage = EXTRA_PERCENTAGE
25
- ) {
51
+ }) {
26
52
  const opts = {
27
53
  from: fromAddress,
28
54
  to: toAddress,
29
55
  value: normalizeAmount(amount),
30
- data: Buffer.isBuffer(data) ? ethUtil.bufferToHex(data) : data,
56
+ data: Buffer.isBuffer(data) ? bufferToHex(data) : data,
31
57
  gasPrice: normalizeGasPrice(gasPrice),
32
58
  }
33
59
 
34
60
  const estimatedGas = await estimateGas({ asset, ...opts })
35
- return new BN(estimatedGas.slice(2), 16)
36
- .imuln(100 + extraPercentage)
37
- .idivn(100)
38
- .toNumber()
61
+ return Number(BigInt(estimatedGas))
39
62
  }
40
63
 
41
- export async function fetchGasLimit({
42
- asset,
43
- fromAddress,
44
- toAddress,
45
- txInput = '0x',
46
- amount,
47
- bip70,
48
- throwOnError = true,
49
- extraPercentage,
50
- }) {
51
- if (bip70?.bitpay?.data && bip70?.bitpay?.gasPrice)
52
- return asset.name === 'ethereum' ? 65_000 : 130_000 // from on chain stats https://dune.xyz/queries/189123
64
+ export async function resolveGasLimitMultiplier({ asset, feeData, toAddress, fromAddress }) {
65
+ const gasLimitMultiplierWhenUnknownAddress =
66
+ feeData?.gasLimits?.[asset.name]?.gasLimitMultiplierWhenUnknownAddress
53
67
 
54
- if (!amount) amount = asset.currency.ZERO
68
+ // NOTE: If either the `fromAddress` or `toAddress` has fell
69
+ // back to the `ARBITRARY_ADDRESS`, these qualify as
70
+ // "unknown" addresses.
71
+ const fromAddressIsUnknown = fromAddress === ARBITRARY_ADDRESS
72
+ const toAddressIsUnknown = toAddress === ARBITRARY_ADDRESS
55
73
 
56
- const defaultGasLimit = () =>
57
- asset.gasLimit + GAS_PER_NON_ZERO_BYTE * ethUtil.toBuffer(txInput).length
74
+ if (gasLimitMultiplierWhenUnknownAddress && (fromAddressIsUnknown || toAddressIsUnknown)) {
75
+ return gasLimitMultiplierWhenUnknownAddress
76
+ }
58
77
 
59
- const _isToken = isToken(asset)
78
+ const gasLimitMultiplier = feeData?.gasLimits?.[asset.name]?.gasLimitMultiplier
60
79
 
61
- const isContract = await isContractAddressCached({ asset, address: toAddress })
80
+ if (gasLimitMultiplier) {
81
+ return gasLimitMultiplier
82
+ }
62
83
 
63
- if (_isToken) {
64
- // only create tx-input only if not pass tx-input to a token asset
65
- if (txInput === '0x') {
66
- txInput = ethUtil.bufferToHex(
67
- asset.contract.transfer.build(toAddress.toLowerCase(), amount.toBaseString())
68
- )
69
- }
84
+ if (asset.baseAsset.estimateL1DataFee) {
85
+ return 2
86
+ }
87
+
88
+ const isToken = asset.name !== asset.baseAsset.name
89
+
90
+ // calling forwarder contracts with a bumped gas limit causes 'Out Of Gas' error on chain
91
+ const hasForwarderContract =
92
+ !isToken && toAddress ? await isForwarderContractCached({ asset, address: toAddress }) : false
93
+
94
+ if (hasForwarderContract) {
95
+ return 1
96
+ }
70
97
 
71
- amount = asset.baseAsset.currency.ZERO
72
- toAddress = asset.contract.address
73
- } else if (
74
- !isContract &&
75
- // hacky, convert this to a fetchGasLimit forceGasLimitEstimation plugin flag!
76
- ![
77
- 'ethereumarbone',
78
- 'ethereumarbonesepolia',
79
- 'ethereumarbnova',
80
- 'mantle',
81
- 'mantlesepolia',
82
- ].includes(asset.name)
98
+ return DEFAULT_GAS_LIMIT_MULTIPLIER
99
+ }
100
+
101
+ // https://eips.ethereum.org/EIPS/eip-7623
102
+ export const getEip7623CalldataCost = (calldata) => {
103
+ calldata = toBuffer(calldata)
104
+
105
+ const TOTAL_COST_FLOOR_PER_TOKEN = 10
106
+
107
+ const nonZeroBytesInCalldata = calldata.filter(Boolean).length
108
+ const zeroBytesInCalldata = calldata.length - nonZeroBytesInCalldata
109
+ const tokensInCalldata = zeroBytesInCalldata + nonZeroBytesInCalldata * 4
110
+
111
+ return TOTAL_COST_FLOOR_PER_TOKEN * tokensInCalldata
112
+ }
113
+
114
+ export const defaultGasLimit = ({ asset, txInput }) => {
115
+ const isToken = isEthereumLikeToken(asset)
116
+
117
+ const calldataCost = asset.baseAsset.eip7623Supported
118
+ ? getEip7623CalldataCost(txInput)
119
+ : GAS_PER_NON_ZERO_BYTE * toBuffer(txInput).length
120
+
121
+ return (isToken ? DEFAULT_TOKEN_GAS_LIMIT : DEFAULT_GAS_LIMIT) + calldataCost
122
+ }
123
+
124
+ // TODO: `gasLimit` needs to be a responsibility of `resolveTxAttributesByTxType`.
125
+ export async function fetchGasLimit({
126
+ asset,
127
+ feeData,
128
+ fromAddress: providedFromAddress,
129
+ toAddress: providedToAddress,
130
+ txInput: providedTxInput,
131
+ txValue: providedTxValue,
132
+ amount: providedAmount,
133
+ contractAddress: providedTxToAddress,
134
+ bip70,
135
+ txType = TX_TYPE_TRANSFER,
136
+ throwOnError = true,
137
+ isSendAll = false,
138
+ }) {
139
+ if (bip70?.bitpay?.data && bip70?.bitpay?.gasPrice) {
140
+ // from on chain stats https://dune.xyz/queries/189123
141
+ return asset.name === 'ethereum' ? 65_000 : 130_000
142
+ }
143
+
144
+ // TODO: If we can provide the `walletAccount` via `fetchGasLimit` then
145
+ // we can use `resolveTxAttributesByTxType` instead (and remove
146
+ // the concept of critical attributes).
147
+ const { txToAddress, txInput, txValue } = resolveCriticalTxAttributes({
148
+ asset,
149
+ amount: providedAmount,
150
+ toAddress: providedToAddress,
151
+ txToAddress: providedTxToAddress,
152
+ txInput: providedTxInput,
153
+ txValue: providedTxValue,
154
+ txType,
155
+ })
156
+
157
+ const isContractTxToAddress =
158
+ Boolean(txToAddress) && (await isContractAddressCached({ asset, address: txToAddress }))
159
+
160
+ const fromAddress = providedFromAddress ?? ARBITRARY_ADDRESS
161
+
162
+ const isToken = isEthereumLikeToken(asset)
163
+
164
+ if (
165
+ txType === TX_TYPE_TRANSFER &&
166
+ !isToken &&
167
+ !isContractTxToAddress &&
168
+ !asset.forceGasLimitEstimation
83
169
  ) {
84
- return defaultGasLimit()
170
+ return defaultGasLimit({ asset, txInput })
85
171
  }
86
172
 
87
- // calling forwarder contracts with a bumped gas limit causes 'Out Of Gas' error on chain
88
- // Since geth v1.9.14 estimateGas will throw if user does not have enough ETH.
89
- // If gasPrice is set to zero, estimateGas will make the expected estimation.
90
- const gasPrice = ethUtil.bufferToHex(currency2buffer(asset.baseAsset.currency.ZERO))
173
+ const gasLimitMultiplier = await resolveGasLimitMultiplier({
174
+ asset,
175
+ feeData,
176
+ fromAddress,
177
+ toAddress: txToAddress,
178
+ })
91
179
 
92
180
  try {
93
- return await estimateGasLimit(
181
+ // NOTE: Although we'll return the `fixedGasLimit` for known
182
+ // tokens, we'll still want to execute `estimateGasLimit`
183
+ // to verify their transaction actually succeeds (i.e.
184
+ // they aren't trying to transfer more than their balance).
185
+ //
186
+ // This prevents users from submitting `fixedGasLimit`
187
+ // token transactions which result in a `revert`.
188
+ const estimatedGasLimit = await estimateGasLimit({
94
189
  asset,
95
190
  fromAddress,
96
- toAddress,
97
- amount,
98
- txInput,
99
- gasPrice,
100
- extraPercentage
101
- )
191
+ toAddress: txToAddress,
192
+ amount: txValue,
193
+ data: txInput,
194
+ })
195
+
196
+ const scaledGasLimitEstimate = scaleGasLimitEstimate({ estimatedGasLimit, gasLimitMultiplier })
197
+ if (!isToken) {
198
+ // For native send-all on EOA targets, return the raw `eth_estimateGas`
199
+ // result (no 2x safety pad). Combined with the EIP-1559 tip override
200
+ // applied in tx-create.js (also gated on `isSendAll && !isContract`),
201
+ // this is the no-dust path: gasLimit ≈ gasUsed, so nothing comes back
202
+ // as refund. For contracts we keep the safety pad because contract
203
+ // gas can be nondeterministic against estimates.
204
+ return isSendAll && !isContractTxToAddress ? estimatedGasLimit : scaledGasLimitEstimate
205
+ }
206
+
207
+ // NOTE: If we've enabled `fixGasLimit`s for a token,
208
+ // we need to make sure that transaction we're
209
+ // making actually targets that token.
210
+ //
211
+ // Otherwise, we risk applying the fixed gas limit
212
+ // to transactions with _other_ smart contracts
213
+ // simply because the caller specified the token
214
+ // asset.
215
+ const isTokenTransaction = asset.contract.address.toLowerCase() === txToAddress.toLowerCase()
216
+ if (!isTokenTransaction) return scaledGasLimitEstimate
217
+
218
+ return feeData?.gasLimits?.[asset.name]?.fixedGasLimit ?? scaledGasLimitEstimate
102
219
  } catch (err) {
103
220
  if (throwOnError) throw err
221
+
104
222
  console.error('fetchGasLimit error', err)
105
223
 
106
224
  // fallback value for contract case
107
- if (isContract) return asset.contractGasLimit || DEFAULT_CONTRACT_GAS_LIMIT
225
+ if (isContractTxToAddress) return DEFAULT_CONTRACT_GAS_LIMIT
108
226
 
109
- // fallback value for rest cases: token.
110
- return defaultGasLimit()
227
+ return defaultGasLimit({ asset, txInput })
111
228
  }
112
229
  }
113
230
 
@@ -116,7 +233,7 @@ function normalizeAmount(amount) {
116
233
  amount = currency2buffer(amount)
117
234
  }
118
235
 
119
- amount = ethUtil.bufferToHex(amount)
236
+ amount = bufferToHex(amount)
120
237
  while (amount[2] === '0') amount = '0x' + amount.slice(3)
121
238
  if (amount === '0x') amount = '0x0'
122
239
 
@@ -4,7 +4,7 @@ export const createGetBalanceForAddress = ({ asset, server }) => {
4
4
  assert(asset, 'asset is required')
5
5
  assert(server, 'server is required')
6
6
  return async (address) => {
7
- const balance = await server.getBalance(address)
7
+ const balance = await server.getBalanceProxied(address)
8
8
  if (typeof balance === 'string') {
9
9
  // hex-response from eth_getBalance node
10
10
  return asset.currency.baseUnit(balance)
@@ -1,29 +1,241 @@
1
- import { isRpcBalanceAsset } from '@exodus/ethereum-lib'
2
-
3
- import { get } from 'lodash'
4
- import assert from 'minimalistic-assert'
5
-
6
1
  import {
7
2
  getBalanceFromAccountState,
3
+ getBalanceFromAccountStateIfAny,
8
4
  getBalanceFromTxLog,
9
5
  getUnconfirmedReceivedBalance,
10
6
  getUnconfirmedSentBalance,
11
7
  } from '@exodus/asset-lib'
8
+ import assert from 'minimalistic-assert'
12
9
 
13
- const getStaked = ({ accountState, asset }) => {
14
- return get(accountState, ['staking', asset.name, 'delegatedBalance']) || asset.currency.ZERO
10
+ import { getLatestCanonicalAbsoluteBalanceTx } from './tx-log/clarity-utils/index.js'
11
+
12
+ /**
13
+ * Native balance helper for {@link getAbsoluteBalance}. Only consults
14
+ * `accountState` when the txLog is empty; otherwise the caller walks the
15
+ * txLog for a `balanceChange`. Inside the empty-txLog branch, picks a
16
+ * signal in decreasing order of trust:
17
+ *
18
+ * 1. Non-zero `accountState.balance` (authoritative — RPC `eth_getBalance`
19
+ * result or `accountInfo` `account_balance` row from truncated history).
20
+ * 2. `truncatedAccountState.balance` (state at the cursor), default ZERO.
21
+ *
22
+ * ZERO is treated as "no signal" because `EthereumLikeAccountState` defaults
23
+ * `balance` to ZERO; we cannot distinguish "model default" from "chain says
24
+ * you hold 0". The truncated baseline acts as the explicit fallback.
25
+ */
26
+ const getBaseBalanceFromAccountState = ({ asset, txLog, accountState }) => {
27
+ if (txLog.size === 0) {
28
+ const balance = getBalanceFromAccountStateIfAny({ asset, accountState })
29
+ if (balance && !balance.isZero) return balance
30
+
31
+ return getTruncatedBaseBalance({ accountState }) ?? asset.currency.ZERO
32
+ }
33
+
34
+ return null
35
+ }
36
+
37
+ /**
38
+ * Token counterpart of {@link getBaseBalance}. Tokens are sparse — there's no
39
+ * model default for `tokenBalances[name]`, so any value present (including an
40
+ * explicit ZERO from `accountInfo` after the user drained the token) is
41
+ * authoritative and returned as-is. Only when the key is missing entirely do
42
+ * we fall through to the truncated baseline / txLog walk.
43
+ */
44
+ const getTokenBalanceFromAccountState = ({ asset, txLog, accountState }) => {
45
+ const balance = getBalanceFromAccountStateIfAny({ asset, accountState })
46
+ if (balance) return balance
47
+
48
+ if (txLog.size === 0) {
49
+ return getTruncatedTokenBalance({ asset, accountState }) ?? asset.currency.ZERO
50
+ }
51
+
52
+ return null
53
+ }
54
+
55
+ /**
56
+ * Cursor-baseline native balance from {@link accountState.truncatedAccountState},
57
+ * sourced from Clarity `truncated` `accountInfo` rows on initial `cursor=0`
58
+ * fetch. Returns `undefined` when the snapshot isn't populated (e.g. the
59
+ * server didn't ship a baseline for this account).
60
+ */
61
+ const getTruncatedBaseBalance = ({ accountState }) => accountState?.truncatedAccountState?.balance
62
+
63
+ /**
64
+ * Token counterpart of {@link getTruncatedBaseBalance}.
65
+ */
66
+ const getTruncatedTokenBalance = ({ asset, accountState }) =>
67
+ accountState?.truncatedAccountState?.tokenBalances?.[asset.name]
68
+
69
+ /**
70
+ * TODO: Balance model gaps for EVM staking assets
71
+ *
72
+ * Missing fields that should be added to getBalancesFactory return value:
73
+ *
74
+ * 1. `rewards` / `liquidRewards` - Currently, accountState.staking[asset.name] contains
75
+ * rewardsBalance and liquidRewards (from asset.api.staking.getStakingInfo → accountState.staking),
76
+ * but getBalancesFactory doesn't read or expose them. Need a getRewards() helper similar
77
+ * to getStaked() and getStaking().
78
+ *
79
+ * 2. `rewardsBalance` (total lifetime rewards) - Available in accountState but not surfaced.
80
+ *
81
+ * 3. `withdrawable` - For Polygon, this represents claimable rewards, distinct from
82
+ * unclaimedUndelegatedBalance (unstaked principal). Currently conflated.
83
+ *
84
+ * 4. `walletReserve` - Documented in balances-model.md but not implemented.
85
+ *
86
+ * 5. `staking` field reads pendingBalance + pendingDepositedBalance, which Polygon staking
87
+ * doesn't return (only Ethereum native staking uses these). Needs asset-specific handling.
88
+ *
89
+ * The data flow already exists: monitor hooks call getStakingInfo() → accountState.staking[asset.name].
90
+ * Implementation would add helper functions (getLiquidRewards, getRewardsBalance, etc.) and
91
+ * include these in the returned balance object, standardized across Ethereum/Polygon/etc.
92
+ *
93
+ * See: docs/balances-model.md for the intended balance model spec.
94
+ */
95
+ export const getAbsoluteBalance = ({ asset, txLog, accountState }) => {
96
+ assert(asset, 'asset is required')
97
+ assert(txLog, 'txLog is required')
98
+
99
+ const isBase = asset.name === asset.baseAsset.name
100
+ const accountStateBalance = isBase
101
+ ? getBaseBalanceFromAccountState({ asset, txLog, accountState })
102
+ : getTokenBalanceFromAccountState({ asset, txLog, accountState })
103
+ if (accountStateBalance) {
104
+ // Authoritative `accountState` snapshot (or empty-txLog terminal value).
105
+ return accountStateBalance
106
+ }
107
+
108
+ // NOTE: We reverse the `txLog` to prioritize the handling
109
+ // of the most recent transactions first. The reason
110
+ // we do this is to terminate early if we find an
111
+ // absolute balance node.
112
+ const reversedTxLog = txLog.reverse()
113
+
114
+ const maybeLatestAbsoluteBalanceTx = getLatestCanonicalAbsoluteBalanceTx({ reversedTxLog })
115
+
116
+ let balance = asset.currency.ZERO
117
+ let hasAbsoluteBalance = false
118
+
119
+ for (const tx of reversedTxLog) {
120
+ if (tx.data.balanceChange) {
121
+ // If we are aware of the existence of the most
122
+ // recent absolute balance, then we can ignore
123
+ // any competing instances.
124
+ if (maybeLatestAbsoluteBalanceTx && tx.txId !== maybeLatestAbsoluteBalanceTx.txId) {
125
+ continue
126
+ }
127
+
128
+ hasAbsoluteBalance = true
129
+ balance = balance.add(asset.currency.baseUnit(tx.data.balanceChange.to))
130
+
131
+ break
132
+ }
133
+
134
+ // NOTE: Avoids processing any unconfirmed balances, since
135
+ // `getAbsoluteBalance` only processes a subset of
136
+ // transactions up until the first `balanceChange` event
137
+ // is encountered, but `getBalancesFactory` takes all
138
+ // all pending amounts into consideration (i.e. those
139
+ // at a greater depth than the most recent `balanceChange`
140
+ // event).
141
+ if (!tx.failed && tx.pending) continue
142
+
143
+ if (!(tx.dropped || tx.data.replacedBy)) {
144
+ if (!tx.error) {
145
+ balance = balance.add(tx.coinAmount)
146
+ }
147
+
148
+ if (tx.feeAmount?.unitType.equals(tx.coinAmount.unitType)) {
149
+ balance = balance.sub(tx.feeAmount)
150
+ }
151
+ }
152
+ }
153
+
154
+ if (!hasAbsoluteBalance) {
155
+ // Last line of defense: under truncated history the post-cursor txLog can
156
+ // omit the most recent `balanceChange` (e.g. only a pending send remains).
157
+ // Anchor on the `accountInfo` snapshot, mirroring the same precedence as
158
+ // {@link getBaseBalanceFromAccountState} / {@link getTokenBalanceFromAccountState}:
159
+ // prefer the current `accountState.balance` (`account_balance` row), and
160
+ // fall back to `truncatedAccountState.balance` (cursor baseline) when the
161
+ // former is the model default ZERO.
162
+ const accountBalance = isBase
163
+ ? accountState?.balance
164
+ : accountState?.tokenBalances?.[asset.name]
165
+ if (accountBalance && (!isBase || !accountBalance.isZero)) {
166
+ return balance.add(accountBalance)
167
+ }
168
+
169
+ const truncatedBalance = isBase
170
+ ? getTruncatedBaseBalance({ accountState })
171
+ : getTruncatedTokenBalance({ asset, accountState })
172
+ if (truncatedBalance) return balance.add(truncatedBalance)
173
+
174
+ console.warn('No absolute balance found', { assetName: asset.name, txLogSize: txLog.size })
175
+ return
176
+ }
177
+
178
+ return balance
15
179
  }
16
180
 
17
- const getUnstaking = ({ accountState, asset }) => {
18
- return get(accountState, ['staking', asset.name, 'undelegatedBalance']) || asset.currency.ZERO
181
+ const getStaked = ({ accountState, asset }) => {
182
+ return accountState?.staking?.[asset.name]?.activeStakedBalance || asset.currency.ZERO
19
183
  }
20
184
 
21
- const getUnstaked = ({ accountState, asset }) => {
22
- return (
23
- get(accountState, ['staking', asset.name, 'unclaimedUndelegatedBalance']) || asset.currency.ZERO
185
+ const getStaking = ({ accountState, asset }) => {
186
+ return (accountState?.staking?.[asset.name]?.pendingBalance || asset.currency.ZERO).add(
187
+ accountState?.staking?.[asset.name]?.pendingDepositedBalance || asset.currency.ZERO
24
188
  )
25
189
  }
26
190
 
191
+ const getUnclaimedUndelegatedBalance = ({ accountState, asset }) => {
192
+ return accountState?.staking?.[asset.name]?.unclaimedUndelegatedBalance || asset.currency.ZERO
193
+ }
194
+
195
+ const getCanClaimUndelegatedBalance = ({ accountState, asset }) =>
196
+ accountState?.staking?.[asset.name]?.canClaimUndelegatedBalance || false
197
+
198
+ /**
199
+ * Calculates amount received in staking txs from txLog
200
+ * Staking txs are counted based in the amount received in the tx (tx.coinAmount)
201
+ * It can be different from staked / unstaked / claimed requested amount)
202
+ *
203
+ * Unstaked tx.coinAmount - Staked tx.coinAmount + Claimed tx.coinAmount
204
+ *
205
+ * Depending on the asset, is what tx.coinAmount is:
206
+ *
207
+ * Ethereum: for unstake txs, it's unknown how much of it are rewards and unstaked received (full or partial)
208
+ * Polygon: rewards are transferred in stake and unstake txs
209
+ */
210
+ export const getStakingHistoryBalance = ({ asset, txLog }) => {
211
+ let stakingHistoryBalance = asset.currency.ZERO
212
+ for (const tx of txLog) {
213
+ const successfulTx = tx.confirmations && !tx.failed
214
+ const txData = tx.data
215
+
216
+ // staking tx must has data value
217
+ if (!txData) continue
218
+
219
+ let { txAmount } = txData
220
+ if (successfulTx && (txAmount || txData.delegate)) {
221
+ // only staking txs have tx.data.txAmount set
222
+
223
+ if (txData.delegate && !txAmount) {
224
+ txAmount = txData.delegate // fix missing txAmount in stakeTx
225
+ }
226
+
227
+ stakingHistoryBalance = stakingHistoryBalance.add(asset.currency.defaultUnit(txAmount))
228
+ }
229
+ }
230
+
231
+ return stakingHistoryBalance
232
+ }
233
+
234
+ const getSpendable = ({ asset, balance, txLog, unconfirmedReceived }) => {
235
+ const stakingHistoryBalance = getStakingHistoryBalance({ asset, txLog })
236
+ return balance.add(stakingHistoryBalance).sub(unconfirmedReceived)
237
+ }
238
+
27
239
  /**
28
240
  * Api method to return the balance based on either account state balances or tx history.
29
241
  *
@@ -32,33 +244,79 @@ const getUnstaked = ({ accountState, asset }) => {
32
244
  * @param accountState the account state when the balance is loaded from RPC
33
245
  * @returns {{balance}|null} an object with the balance or null if the balance is unknown
34
246
  */
35
- export const getBalancesFactory = ({ monitorType }) => {
247
+ export const getBalancesFactory = ({ monitorType, useAbsoluteBalance, rpcBalanceAssetNames }) => {
36
248
  assert(monitorType, 'monitorType is required')
37
249
  return ({ asset, txLog, accountState }) => {
38
250
  const unconfirmedReceived = getUnconfirmedReceivedBalance({ asset, txLog })
39
251
  const unconfirmedSent = getUnconfirmedSentBalance({ asset, txLog })
40
252
 
41
- // balance from txLog / rpc is considered to be total
42
- const balanceWithoutUnconfirmedSent =
43
- monitorType === 'no-history' || isRpcBalanceAsset(asset)
44
- ? getBalanceFromAccountState({ asset, accountState }).sub(unconfirmedSent)
45
- : getBalanceFromTxLog({ txLog, asset })
46
-
47
253
  const staked = getStaked({ asset, accountState })
48
- const unstaking = getUnstaking({ asset, accountState })
49
- const unstaked = getUnstaked({ asset, accountState })
50
- const total = balanceWithoutUnconfirmedSent
51
- const spendable = balanceWithoutUnconfirmedSent
52
- .sub(unconfirmedReceived)
53
- .sub(staked)
54
- .sub(unstaking)
55
- .sub(unstaked)
254
+ const staking = getStaking({ asset, accountState })
255
+
256
+ const canClaimUndelegatedBalance = getCanClaimUndelegatedBalance({ accountState, asset })
257
+
258
+ const unclaimedUndelegatedBalance = getUnclaimedUndelegatedBalance({ asset, accountState })
259
+
260
+ const unstaking = canClaimUndelegatedBalance ? asset.currency.ZERO : unclaimedUndelegatedBalance
261
+ const unstaked = canClaimUndelegatedBalance ? unclaimedUndelegatedBalance : asset.currency.ZERO
262
+
263
+ let spendable
264
+
265
+ // Balance from accountState is considered total b/c is fetched from rpc
266
+ if (rpcBalanceAssetNames.includes(asset.name) || monitorType === 'no-history') {
267
+ // NOTE: Balances from a `no-history` monitor work like calls
268
+ // to `balanceOf`/`eth_getBalance` at the latest block tag,
269
+ // therefore there's no need to subtract `unconfirmedReceived`.
270
+ //
271
+ // Likewise, they do not compensate for pending sends, so we
272
+ // must manually subtract the `unconfirmedSent` to produce a
273
+ // pessimistic reading that's safe for consumers.
274
+ spendable = getBalanceFromAccountState({ asset, accountState }).sub(unconfirmedSent)
275
+ } else {
276
+ const absoluteBalance =
277
+ useAbsoluteBalance && getAbsoluteBalance({ asset, txLog, accountState })
278
+
279
+ if (absoluteBalance) {
280
+ // NOTE: The returned `absoluteBalance` returns only confirmed
281
+ // values similar to the `'no-history'` case, so we need
282
+ // to reduce this amount by the `unconfirmedSent`.
283
+ spendable = absoluteBalance.sub(unconfirmedSent)
284
+ } else {
285
+ // Balance from txLog does not include staking rewards
286
+ // spendable and total are calculated differently based on staking txs
287
+ spendable = getSpendable({
288
+ asset,
289
+ balance: getBalanceFromTxLog({ txLog, asset }),
290
+ txLog,
291
+ unconfirmedReceived,
292
+ })
293
+ }
294
+ }
295
+
296
+ // A pending send already reflected in the account-state reading is double-counted
297
+ // via `unconfirmedSent`, making spendable transiently negative (bounded by
298
+ // `unconfirmedSent`). A negative beyond that bound is a real accounting bug.
299
+ if (spendable.isNegative && spendable.abs().gt(unconfirmedSent)) {
300
+ console.warn('unexpected negative spendable beyond pending sends', {
301
+ assetName: asset.name,
302
+ spendable: spendable.toBaseString(),
303
+ unconfirmedSent: unconfirmedSent.toBaseString(),
304
+ })
305
+ }
306
+
307
+ // Clamp before deriving `total` so the artifact doesn't drag it below staked balances.
308
+ spendable = spendable.clampLowerZero()
309
+
310
+ const total = spendable.add(staked).add(staking).add(unstaking).add(unstaked)
311
+ const stakeable = spendable
56
312
 
57
313
  return {
58
314
  // new
59
315
  spendable,
60
316
  total,
317
+ stakeable,
61
318
  staked,
319
+ staking,
62
320
  unstaking,
63
321
  unstaked,
64
322
  unconfirmedReceived,