@exodus/ethereum-api 8.74.1 → 8.75.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,32 @@
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
+ ## [8.75.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.75.0...@exodus/ethereum-api@8.75.1) (2026-05-19)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: use correct fromAddress for gasLimit multiplication (#8078)
13
+
14
+
15
+
16
+ ## [8.75.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.74.1...@exodus/ethereum-api@8.75.0) (2026-05-13)
17
+
18
+
19
+ ### Features
20
+
21
+
22
+ * feat(ethereum): add no-history receive logs for portfolio transfers (#7967)
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+
28
+ * fix: stale truncated balances after new transaction (#7897)
29
+
30
+
31
+
6
32
  ## [8.74.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.74.0...@exodus/ethereum-api@8.74.1) (2026-05-12)
7
33
 
8
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.74.1",
3
+ "version": "8.75.1",
4
4
  "description": "Transaction monitors, fee monitors, RPC with the blockchain node, and other networking code for Ethereum and EVM-based blockchains",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -23,13 +23,13 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@exodus/asset": "^2.0.4",
26
- "@exodus/asset-lib": "^5.9.0",
26
+ "@exodus/asset-lib": "^5.9.1",
27
27
  "@exodus/assets": "^11.4.0",
28
28
  "@exodus/basic-utils": "^3.0.1",
29
29
  "@exodus/bip44-constants": "^195.0.0",
30
30
  "@exodus/crypto": "^1.0.0-rc.26",
31
31
  "@exodus/currency": "^6.0.1",
32
- "@exodus/ethereum-lib": "^5.24.1",
32
+ "@exodus/ethereum-lib": "^5.24.2",
33
33
  "@exodus/ethereum-meta": "^2.9.1",
34
34
  "@exodus/ethereumholesky-meta": "^2.0.5",
35
35
  "@exodus/ethereumjs": "^1.11.0",
@@ -67,5 +67,5 @@
67
67
  "type": "git",
68
68
  "url": "git+https://github.com/ExodusMovement/assets.git"
69
69
  },
70
- "gitHead": "7f184588091acba3b358e4af13783ee1e2bab4ba"
70
+ "gitHead": "ee5bf2d01055d8822c6421fa0ad4da84d4a33402"
71
71
  }
@@ -120,11 +120,9 @@ export const buildLegacyApproveTx = async ({
120
120
  const baseAsset = asset.baseAsset
121
121
 
122
122
  return {
123
- asset: baseAsset.name,
124
- receiver: {
125
- address: unsignedTx.txData.to,
126
- amount: buffer2currency({ asset: baseAsset, value: unsignedTx.txData.value }),
127
- },
123
+ asset: baseAsset,
124
+ address: unsignedTx.txData.to,
125
+ amount: buffer2currency({ asset: baseAsset, value: unsignedTx.txData.value }),
128
126
  txInput: unsignedTx.txData.data,
129
127
  gasPrice: buffer2currency({ asset: baseAsset, value: unsignedTx.txData.gasPrice }),
130
128
  tipGasPrice: unsignedTx.txData.tipGasPrice
@@ -132,13 +130,12 @@ export const buildLegacyApproveTx = async ({
132
130
  : undefined,
133
131
  gasLimit: bufferToInt(unsignedTx.txData.gasLimit),
134
132
  fromAddress: unsignedTx.txMeta.fromAddress,
135
- silent: true,
136
133
  }
137
134
  }
138
135
 
139
136
  // @deprecated use buildApproveTx instead
140
137
  export const createApprove =
141
- ({ assetClientInterface, sendTx }) =>
138
+ ({ assetClientInterface } = Object.create(null)) =>
142
139
  async ({
143
140
  spenderAddress,
144
141
  asset,
@@ -164,7 +161,16 @@ export const createApprove =
164
161
  walletAccount,
165
162
  })
166
163
 
167
- const txData = await sendTx({ walletAccount, ...approveTx, ...extras })
164
+ const { unsignedTx } = await asset.baseAsset.api.createTx({
165
+ walletAccount,
166
+ ...approveTx,
167
+ ...extras,
168
+ })
169
+ const txData = await asset.baseAsset.api.sendTx({
170
+ asset: asset.baseAsset,
171
+ walletAccount,
172
+ unsignedTx,
173
+ })
168
174
 
169
175
  if (!txData || !txData.txId) {
170
176
  throw new Error(`Failed to approve ${asset.displayTicker} - ${spenderAddress}`)
@@ -185,25 +191,20 @@ export const createApprove =
185
191
  walletAccount,
186
192
  })
187
193
 
188
- const { txId, rawTx } = await assetClientInterface.signTransaction({
189
- assetName: asset.baseAsset.name,
190
- unsignedTx,
191
- walletAccount,
192
- })
194
+ const { txId } = await asset.baseAsset.api.sendTx({ asset, walletAccount, unsignedTx })
193
195
 
194
- await asset.baseAsset.api.broadcastTx(rawTx.toString('hex'))
195
196
  return { txId }
196
197
  }
197
198
 
198
199
  const createSendApprovalAndWatchConfirmation =
199
- ({ sendTx, watchTxConfirmation }) =>
200
+ ({ watchTxConfirmation }) =>
200
201
  async (data) => {
201
202
  // To change the approve amount you first have to reduce the addresses`
202
203
  // allowance to zero by calling `approve(_spender, 0)` if it is not
203
204
  // already 0 to mitigate the race condition described here:
204
205
  // see: https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
205
206
  // see: https://etherscan.io/address/0xdac17f958d2ee523a2206206994597c13d831ec7#code
206
- const approve = createApprove({ sendTx })
207
+ const approve = createApprove()
207
208
  const { txId } = await approve(data)
208
209
  await watchTxConfirmation(
209
210
  { asset: data.asset.baseAsset.name, walletAccount: data.walletAccount },
@@ -215,11 +216,10 @@ const createSendApprovalAndWatchConfirmation =
215
216
  }
216
217
 
217
218
  export const createApproveSpendingTokens =
218
- ({ sendTx, watchTxConfirmation }) =>
219
+ ({ watchTxConfirmation }) =>
219
220
  async (data) => {
220
221
  const txIds = []
221
222
  const sendApprovalAndWatchConfirmation = createSendApprovalAndWatchConfirmation({
222
- sendTx,
223
223
  watchTxConfirmation,
224
224
  })
225
225
  if (ZERO_ALLOWANCE_ASSETS.includes(data.asset.name) && !data.approveAmount.isZero) {
@@ -235,7 +235,7 @@ export const createApproveSpendingTokens =
235
235
  data.gasLimit = Math.max(data.gasLimit || 0, APPROVAL_GAS_LIMIT)
236
236
  }
237
237
 
238
- const tokenApprovalTxId = await sendApprovalAndWatchConfirmation({ ...data, sendTx })
238
+ const tokenApprovalTxId = await sendApprovalAndWatchConfirmation(data)
239
239
  txIds.push(tokenApprovalTxId)
240
240
 
241
241
  return txIds
@@ -12,7 +12,9 @@ import { ClarityMonitorV2 } from './tx-log/clarity-monitor-v2.js'
12
12
  import { ClarityTruncatedHistoryMonitor } from './tx-log/clarity-truncated-history-monitor.js'
13
13
  import { EthereumMonitor } from './tx-log/ethereum-monitor.js'
14
14
  import { EthereumNoHistoryMonitor } from './tx-log/ethereum-no-history-monitor.js'
15
+ import { getOptimisticTxLogEffects } from './tx-log/get-optimistic-txlog-effects.js'
15
16
  import { BLOCK_TAG_LATEST, BLOCK_TAG_PENDING, resolveNonce } from './tx-send/nonce-utils.js'
17
+ import { signTx } from './tx-sign/index.js'
16
18
 
17
19
  // Determines the appropriate `monitorType`, `serverUrl` and `monitorInterval`
18
20
  // to use for a given config.
@@ -121,7 +123,126 @@ const broadcastPrivateBundleFactory =
121
123
  return null
122
124
  }
123
125
 
124
- export const createTransactionPrivacyFactory = ({ assetName, privacyRpcUrl }) => {
126
+ const assertSendPrivateTxProps = ({ asset, unsignedTx, walletAccount }) => {
127
+ assert(asset, 'expected asset')
128
+ assert(unsignedTx, 'expected unsignedTx')
129
+ assert(walletAccount, 'expected walletAccount')
130
+ }
131
+
132
+ export const createTransactionPrivacyResult = ({
133
+ assetClientInterface,
134
+ broadcastPrivateBundle,
135
+ broadcastPrivateTx,
136
+ privacyServer,
137
+ }) => {
138
+ assert(assetClientInterface, 'expected assetClientInterface')
139
+ assert(typeof broadcastPrivateBundle === 'function')
140
+ assert(typeof broadcastPrivateTx === 'function')
141
+ assert(privacyServer, 'expected privacyServer')
142
+
143
+ const sendPrivateTx = async ({ asset, unsignedTx, walletAccount }) => {
144
+ assertSendPrivateTxProps({ asset, unsignedTx, walletAccount })
145
+
146
+ const { rawTx, txId } = await signTx({
147
+ asset,
148
+ assetClientInterface,
149
+ unsignedTx,
150
+ walletAccount,
151
+ })
152
+
153
+ await broadcastPrivateTx(rawTx.toString('hex'))
154
+
155
+ const fromAddress = unsignedTx.txMeta?.fromAddress
156
+ assert(typeof fromAddress === 'string', 'expected fromAddress')
157
+
158
+ const { nonce, optimisticTxLogEffects } = await getOptimisticTxLogEffects({
159
+ asset,
160
+ assetClientInterface,
161
+ fromAddress,
162
+ txId,
163
+ unsignedTx,
164
+ walletAccount,
165
+ })
166
+
167
+ for (const optimisticTxLogEffect of optimisticTxLogEffects) {
168
+ await assetClientInterface.updateTxLogAndNotify(optimisticTxLogEffect)
169
+ }
170
+
171
+ return { nonce, txId }
172
+ }
173
+
174
+ const sendPrivateBundle = async ({ unsignedTxBundle }) => {
175
+ assert(
176
+ Array.isArray(unsignedTxBundle) && unsignedTxBundle.length > 0,
177
+ 'expected non-empty unsignedTxBundle'
178
+ )
179
+
180
+ unsignedTxBundle.forEach(assertSendPrivateTxProps)
181
+
182
+ const signTxResults = await Promise.all(
183
+ unsignedTxBundle.map(({ asset, unsignedTx, walletAccount }) =>
184
+ signTx({ assetClientInterface, asset, unsignedTx, walletAccount })
185
+ )
186
+ )
187
+
188
+ const maybeBundleResult = await broadcastPrivateBundle({
189
+ txs: signTxResults.map(({ rawTx }) => rawTx.toString('hex')),
190
+ })
191
+
192
+ const bundleHash = maybeBundleResult?.bundleHash || undefined
193
+
194
+ const getOptimisticTxLogEffectsResults = []
195
+
196
+ for (const [i, { txId }] of signTxResults.entries()) {
197
+ const { asset, unsignedTx, walletAccount } = unsignedTxBundle[i]
198
+
199
+ const fromAddress = unsignedTx.txMeta?.fromAddress
200
+ assert(typeof fromAddress === 'string', 'expected fromAddress')
201
+
202
+ void getOptimisticTxLogEffectsResults.push(
203
+ await getOptimisticTxLogEffects({
204
+ asset,
205
+ assetClientInterface,
206
+ fromAddress,
207
+ txId,
208
+ unsignedTx,
209
+ walletAccount,
210
+ // TODO: use consistent naming
211
+ bundleId: bundleHash,
212
+ })
213
+ )
214
+ }
215
+
216
+ const optimisticTxLogEffects = getOptimisticTxLogEffectsResults.flatMap(
217
+ ({ optimisticTxLogEffects }) => optimisticTxLogEffects
218
+ )
219
+
220
+ for (const optimisticTxLogEffect of optimisticTxLogEffects) {
221
+ await assetClientInterface.updateTxLogAndNotify(optimisticTxLogEffect)
222
+ }
223
+
224
+ return {
225
+ bundleHash,
226
+ txIds: signTxResults.map(({ txId }) => txId),
227
+ }
228
+ }
229
+
230
+ return {
231
+ broadcastPrivateBundle,
232
+ broadcastPrivateTx,
233
+ privacyServer,
234
+ sendPrivateTx,
235
+ sendPrivateBundle,
236
+ }
237
+ }
238
+
239
+ export const createTransactionPrivacyFactory = ({
240
+ assetClientInterface,
241
+ assetName,
242
+ privacyRpcUrl,
243
+ }) => {
244
+ assert(assetClientInterface, 'expected assetClientInterface')
245
+
125
246
  if (!privacyRpcUrl) return Object.create(null)
126
247
 
127
248
  const privacyServer = createEvmServer({
@@ -130,11 +251,16 @@ export const createTransactionPrivacyFactory = ({ assetName, privacyRpcUrl }) =>
130
251
  monitorType: 'no-history',
131
252
  })
132
253
 
133
- return {
134
- broadcastPrivateBundle: broadcastPrivateBundleFactory({ privacyServer }),
135
- broadcastPrivateTx: (...args) => privacyServer.sendRawTransaction(...args),
254
+ const broadcastPrivateBundle = broadcastPrivateBundleFactory({ privacyServer })
255
+
256
+ const broadcastPrivateTx = (...args) => privacyServer.sendRawTransaction(...args)
257
+
258
+ return createTransactionPrivacyResult({
259
+ assetClientInterface,
260
+ broadcastPrivateBundle,
261
+ broadcastPrivateTx,
136
262
  privacyServer,
137
- }
263
+ })
138
264
  }
139
265
 
140
266
  export const createHistoryMonitorFactory = ({
@@ -195,11 +195,17 @@ export const createAssetFactory = ({
195
195
  server,
196
196
  })
197
197
 
198
- const { broadcastPrivateBundle, broadcastPrivateTx, privacyServer } =
199
- createTransactionPrivacyFactory({
200
- assetName: asset.name,
201
- privacyRpcUrl,
202
- })
198
+ const {
199
+ broadcastPrivateBundle,
200
+ broadcastPrivateTx,
201
+ privacyServer,
202
+ sendPrivateTx,
203
+ sendPrivateBundle,
204
+ } = createTransactionPrivacyFactory({
205
+ assetClientInterface,
206
+ assetName: asset.name,
207
+ privacyRpcUrl,
208
+ })
203
209
 
204
210
  const features = {
205
211
  accountState: true,
@@ -354,16 +360,18 @@ export const createAssetFactory = ({
354
360
  chainId,
355
361
  monitorType,
356
362
  estimateL1DataFee,
357
- broadcastPrivateBundle,
358
- broadcastPrivateTx,
359
363
  forceGasLimitEstimation,
360
364
  eip7702Supported,
361
365
  getEIP7702Delegation: (addr) => getEIP7702Delegation({ address: addr, server }),
362
366
  getNonce,
363
- privacyServer,
364
367
  server,
365
368
  ...(erc20FuelBuffer && { erc20FuelBuffer }),
366
369
  ...(fuelThreshold && { fuelThreshold: asset.currency.defaultUnit(fuelThreshold) }),
370
+ broadcastPrivateBundle,
371
+ broadcastPrivateTx,
372
+ privacyServer,
373
+ sendPrivateTx,
374
+ sendPrivateBundle,
367
375
  }
368
376
  return overrideCallback({
369
377
  asset: fullAsset,
@@ -157,7 +157,7 @@ export async function fetchGasLimit({
157
157
  const gasLimitMultiplier = await resolveGasLimitMultiplier({
158
158
  asset,
159
159
  feeData,
160
- fromAddress: providedFromAddress,
160
+ fromAddress,
161
161
  toAddress: txToAddress,
162
162
  })
163
163
 
@@ -9,6 +9,63 @@ import assert from 'minimalistic-assert'
9
9
 
10
10
  import { getLatestCanonicalAbsoluteBalanceTx } from './tx-log/clarity-utils/index.js'
11
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
+
12
69
  /**
13
70
  * TODO: Balance model gaps for EVM staking assets
14
71
  *
@@ -39,15 +96,12 @@ export const getAbsoluteBalance = ({ asset, txLog, accountState }) => {
39
96
  assert(asset, 'asset is required')
40
97
  assert(txLog, 'txLog is required')
41
98
 
42
- const accountStateBalance = getBalanceFromAccountStateIfAny({ asset, accountState })
43
-
44
- if (txLog.size === 0) {
45
- return accountStateBalance || asset.currency.ZERO
46
- }
47
-
48
- // Prefer an explicit `accountState` snapshot (e.g. populated from Clarity `accountInfo`
49
- // in truncated-history mode) over walking a potentially truncated `txLog`.
99
+ const isBase = asset.name === asset.baseAsset.name
100
+ const accountStateBalance = isBase
101
+ ? getBaseBalanceFromAccountState({ asset, txLog, accountState })
102
+ : getTokenBalanceFromAccountState({ asset, txLog, accountState })
50
103
  if (accountStateBalance) {
104
+ // Authoritative `accountState` snapshot (or empty-txLog terminal value).
51
105
  return accountStateBalance
52
106
  }
53
107
 
@@ -98,6 +152,25 @@ export const getAbsoluteBalance = ({ asset, txLog, accountState }) => {
98
152
  }
99
153
 
100
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
+
101
174
  console.warn('No absolute balance found', { assetName: asset.name, txLogSize: txLog.size })
102
175
  return
103
176
  }
@@ -66,7 +66,6 @@ import NumberUnit from '@exodus/currency'
66
66
  import assert from 'minimalistic-assert'
67
67
 
68
68
  import { estimateGasLimit, scaleGasLimitEstimate } from '../../gas-estimation.js'
69
- import { getOptimisticTxLogEffects } from '../../tx-log/get-optimistic-txlog-effects.js'
70
69
  import { createWatchTx as defaultCreateWatch } from '../../watch-tx.js'
71
70
  import { stakingProviderClientFactory } from '../staking-provider-client.js'
72
71
  import { amountToCurrency, DISABLE_BALANCE_CHECKS, resolveFeeData } from '../utils/index.js'
@@ -281,8 +280,6 @@ export function createEthereumStakingService({
281
280
  asset,
282
281
  fromAddress: delegatorAddress,
283
282
  walletAccount,
284
- tag: 'latest',
285
- forceFromNode: true,
286
283
  })
287
284
 
288
285
  return {
@@ -369,14 +366,12 @@ export function createEthereumStakingService({
369
366
  plan: null,
370
367
  gasLimit: null,
371
368
  unsignedTx: null,
372
- signedTx: null,
373
369
  txId: null,
374
370
  },
375
371
  undelegate: {
376
372
  plan: null,
377
373
  gasLimit: null,
378
374
  unsignedTx: null,
379
- signedTx: null,
380
375
  txId: null,
381
376
  },
382
377
  }
@@ -465,34 +460,16 @@ export function createEthereumStakingService({
465
460
  revertOnSimulationError,
466
461
  })
467
462
 
468
- // 4. Sign transactions
469
- const signedTxEntries = await Promise.all(
470
- Object.entries(txSteps)
471
- .filter(([, txStep]) => txStep.unsignedTx)
472
- .map(async ([key, txStep]) => {
473
- const signedTx = await assetClientInterface.signTransaction({
474
- assetName: asset.baseAsset.name,
475
- unsignedTx: txStep.unsignedTx,
476
- walletAccount,
477
- })
478
- const txId = `0x${signedTx.txId.toString('hex')}`
479
- return [key, { signedTx, txId }]
480
- })
481
- )
482
-
483
- for (const [key, { signedTx, txId }] of signedTxEntries) {
484
- txSteps[key].signedTx = signedTx
485
- txSteps[key].txId = txId
486
- }
463
+ const unsignedTxBundle = createdTxEntries.map(([_, unsignedTx]) => ({
464
+ asset,
465
+ unsignedTx,
466
+ walletAccount,
467
+ }))
487
468
 
469
+ // 4. Sign transactions
470
+ //
488
471
  // 5. Broadcast transactions via bundle and get bundle hash
489
- const bundleResponse = await asset.broadcastPrivateBundle({
490
- txs: Object.values(txSteps)
491
- .filter((txStep) => txStep.signedTx)
492
- .map(({ signedTx }) => signedTx.rawTx),
493
- })
494
- const bundleHash = bundleResponse?.bundleHash
495
-
472
+ //
496
473
  // 6. Optimistic tx-log effects: reflect tx presence and fee consumption only.
497
474
  // The ETH returned to the user via the contract's internal transfer is not
498
475
  // captured here — it will be picked up once the tx is confirmed on-chain.
@@ -518,26 +495,14 @@ export function createEthereumStakingService({
518
495
  // That is incompatible with the current tx-log side-effect hook, which only
519
496
  // receives tx-local data (method id, calldata, nonce, gas, etc.) and not
520
497
  // staking snapshots or precomputed ETH-return amounts.
521
- const optimisticEffects = await Promise.all(
522
- Object.values(txSteps)
523
- .filter((txStep) => txStep.txId)
524
- .map((txStep) =>
525
- getOptimisticTxLogEffects({
526
- asset: asset.baseAsset,
527
- assetClientInterface,
528
- fromAddress: delegatorAddress,
529
- txId: txStep.txId,
530
- unsignedTx: txStep.unsignedTx,
531
- walletAccount,
532
- bundleId: bundleHash ?? undefined,
533
- })
534
- )
535
- )
498
+ const {
499
+ bundleHash,
500
+ txIds: [...txIds],
501
+ } = await asset.baseAsset.sendPrivateBundle({ unsignedTxBundle })
536
502
 
537
- for (const { optimisticTxLogEffects } of optimisticEffects) {
538
- for (const effect of optimisticTxLogEffects) {
539
- await assetClientInterface.updateTxLogAndNotify(effect)
540
- }
503
+ for (const txStep of Object.values(txSteps)) {
504
+ if (!txStep.unsignedTx) continue
505
+ assert((txStep.txId = txIds.shift()), 'expected txId')
541
506
  }
542
507
 
543
508
  // Testnet assets do not support delegations tracking
@@ -770,7 +735,7 @@ export function createEthereumStakingService({
770
735
  waitForConfirmation = false,
771
736
  feeData,
772
737
  }) {
773
- const sendTxArgs = {
738
+ const { unsignedTx } = await asset.baseAsset.api.createTx({
774
739
  asset,
775
740
  walletAccount,
776
741
  address: to,
@@ -781,9 +746,9 @@ export function createEthereumStakingService({
781
746
  // HACK: Override the `tipGasPrice` to use a custom `maxPriorityFeePerGas`.
782
747
  tipGasPrice,
783
748
  feeData,
784
- }
749
+ })
785
750
 
786
- const { txId } = await asset.baseAsset.api.sendTx(sendTxArgs)
751
+ const { txId } = await asset.baseAsset.api.sendTx({ asset, walletAccount, unsignedTx })
787
752
 
788
753
  const baseAsset = asset.baseAsset
789
754
  if (waitForConfirmation) {
@@ -143,8 +143,9 @@ export class ClarityTruncatedHistoryMonitor extends ClarityMonitor {
143
143
  currentTokenBalances,
144
144
  })
145
145
 
146
- // Pre-cursor snapshot from Clarity `accountInfo`, then RPC `accountState` (empty here) overlays.
146
+ // Clarity `accountInfo`, then RPC `accountState` (empty here) overlays.
147
147
  const prevTokenBalances = derivedData.currentAccountState?.tokenBalances
148
+ const prevTruncatedAccountState = derivedData.currentAccountState?.truncatedAccountState
148
149
  const newData = {
149
150
  ...balanceFromAccountInfo,
150
151
  ...accountState,
@@ -161,6 +162,23 @@ export class ClarityTruncatedHistoryMonitor extends ClarityMonitor {
161
162
  }
162
163
  }
163
164
 
165
+ if (
166
+ prevTruncatedAccountState ||
167
+ balanceFromAccountInfo.truncatedAccountState ||
168
+ accountState.truncatedAccountState
169
+ ) {
170
+ newData.truncatedAccountState = {
171
+ ...prevTruncatedAccountState,
172
+ ...balanceFromAccountInfo.truncatedAccountState,
173
+ ...accountState.truncatedAccountState,
174
+ tokenBalances: {
175
+ ...prevTruncatedAccountState?.tokenBalances,
176
+ ...balanceFromAccountInfo.truncatedAccountState?.tokenBalances,
177
+ ...accountState.truncatedAccountState?.tokenBalances,
178
+ },
179
+ }
180
+ }
181
+
164
182
  try {
165
183
  this.aci.updateAccountStateBatch({
166
184
  assetName,
@@ -3,7 +3,7 @@
3
3
  Extends `ClarityMonitor`. Used when Clarity only serves a **recent window** of txs and attaches pre-cursor snapshots in `accountInfo`.
4
4
 
5
5
  - Sends `truncated=true` on `getAllTransactions`.
6
- - Folds `accountInfo` into `accountState`.
6
+ - Folds `accountInfo` into `accountState`, including truncated-history baselines.
7
7
  - Forces `rpcBalanceAssetNames = []` (no per-asset RPC `eth_getBalance` batch).
8
8
 
9
9
  Plugins opting in: `bsc-plugin`, `basemainnet-plugin`, `ethereum-plugin`.
@@ -16,18 +16,18 @@ Plugins opting in: `bsc-plugin`, `basemainnet-plugin`, `ethereum-plugin`.
16
16
  flowchart TD
17
17
  A[Clarity HTTP<br/>GET /addresses/:addr/transactions<br/>truncated=true & cursor] --> B[response]
18
18
  B --> B1[transactions.confirmed + .pending]
19
- B --> B2[accountInfo rows<br/>rebase/special tokens only]
19
+ B --> B2[accountInfo rows<br/>account_balance or truncated baseline]
20
20
  B --> B3[cursor]
21
21
 
22
22
  B1 --> C[normalizeTransactionsResponse<br/>filter spam + stale pending]
23
23
  C --> D[getAllLogItemsByAsset<br/>bucket per assetName]
24
24
  D --> E[aci.updateTxLogAndNotifyBatch<br/>per asset]
25
25
 
26
- B2 --> F[extractBalanceFromClarityAccountInfo<br/>balance? nonce? tokenBalances?]
26
+ B2 --> F[extractBalanceFromClarityAccountInfo<br/>balance? tokenBalances?<br/>truncatedAccountState?]
27
27
 
28
28
  E --> G[accountState]
29
29
  F --> G
30
- B3 --> G[accountState<br/>balance, nonce, tokenBalances,<br/>clarityCursor]
30
+ B3 --> G[accountState<br/>balance, tokenBalances,<br/>truncatedAccountState,<br/>clarityCursor]
31
31
 
32
32
  G --> H1[getBalances - asset.api.getBalances]
33
33
  G --> H2[resolveNonce - tx send path]
@@ -35,13 +35,13 @@ flowchart TD
35
35
  H1 --> I1{txLog empty?}
36
36
  I1 -- yes --> I2[accountState.balance]
37
37
  I1 -- no --> I3{accountState<br/>balance<br/>non-zero?}
38
- I3 -- yes --> I2
39
- I3 -- no --> I4[walk txLog for latest<br/>balanceChange]
38
+ I3 -- yes + token account balance --> I2
39
+ I3 -- no / native --> I4[walk txLog<br/>use truncated baseline only when no balanceChange exists]
40
40
 
41
41
  H2 --> J1[max - nonceFromTxLog,<br/>accountState.nonce]
42
42
  ```
43
43
 
44
- Left-to-right: `accountInfo` only writes `accountState` for what Clarity can't derive from `walletChanges` (rebase tokens). Regular ERC-20s and ETH flow through the tx log. `getBalances` and `resolveNonce` then read `accountState` + `txLog` together.
44
+ Left-to-right: `accountInfo` can write native balance baselines, authoritative token balances, and truncated baselines to `accountState`. Native ETH still prioritizes tx history when history exists. Rows with `source: 'account_balance'` go into `balance`/`tokenBalances`; rows with `source: 'truncated'` go into `truncatedAccountState`.
45
45
 
46
46
  ---
47
47
 
@@ -106,7 +106,7 @@ Wallet `0x3F80…9164` on Ethereum. Two txs + the one real `accountInfo` row:
106
106
  "accountInfo": [
107
107
  {
108
108
  "type": "token",
109
- "balanceType": "account_balance",
109
+ "source": "account_balance",
110
110
  "value": "20191469428776270391810",
111
111
  "blockNumber": 24933420,
112
112
  "assetId": "0x2a8e…5e86",
@@ -129,9 +129,13 @@ Wallet `0x3F80…9164` on Ethereum. Two txs + the one real `accountInfo` row:
129
129
  {
130
130
  clarityCursor: <Buffer LE 24211168>,
131
131
  tokenBalances: {
132
- ousd_ethereum_48fcf72d: NumberUnit('20191.469… OUSD'), // from accountInfo
132
+ ousd_ethereum_48fcf72d: NumberUnit('20191.469… OUSD'), // account_balance row
133
133
  },
134
- balance: ZERO, // unchanged by monitor; derived live by getBalances
134
+ truncatedAccountState: {
135
+ balance: ZERO,
136
+ tokenBalances: {},
137
+ },
138
+ balance: ZERO, // no native balance row in this response
135
139
  nonce: 0, // unchanged by monitor; derived live by resolveNonce
136
140
  }
137
141
  ```
@@ -151,25 +155,30 @@ ethereum (ETH) walk txLog → latest 43_326_434_
151
155
  usdc walk txLog → latest 95_948_474_200 base
152
156
  walletChange.to
153
157
  ousd_ethereum_48fcf72d accountState short-circuit 20_191_469_428_… base
154
- (non-zero tokenBalances) (from accountInfo)
158
+ (account_balance row) (from accountInfo)
155
159
  ```
156
160
 
157
161
  Source: `getAbsoluteBalance` in `src/get-balances.js`, ordered branches:
158
162
 
159
- 1. `txLog.size === 0` `getBalanceFromAccountState`
160
- 2. `accountState` non-zero → return it (`getBalanceFromAccountStateIfAny` skips default `ZERO` via `NumberUnit.isZero` getter)
161
- 3. Walk reversed `txLog` for canonical `balanceChange` tx
163
+ 1. Non-zero `accountState` balancereturn it.
164
+ 2. `txLog.size === 0` → return `truncatedAccountState` balance or `ZERO`.
165
+ 3. Token value exists in `accountState.tokenBalances` return accountState balance, including explicit zero.
166
+ 4. Walk reversed `txLog` for canonical `balanceChange` tx.
167
+ 5. If no absolute balance exists, fall back to the txLog-derived balance path.
162
168
 
163
169
  ### `resolveNonce` (`src/tx-send/nonce-utils.js`)
164
170
 
165
171
  ```js
166
172
  nonce = Math.max(
167
173
  nonceFromTxLog, // latest canonical `type: 'nonce'` walletChange
168
- accountState.nonce || 0 // populated from accountInfo row if sent
174
+ accountState.nonce || 0 // any `accountInfo` `type: 'nonce'` row, regardless of source
169
175
  )
170
176
  ```
171
177
 
172
- `accountInfo` `nonce` row patches the gap when truncation hides the latest `nonceChange`.
178
+ Both `account_balance` and `truncated` source nonce rows collapse into `accountState.nonce`
179
+ (the max wins) — they represent the same chain-known nonce, just at different observation
180
+ points. `accountInfo` `nonce` row patches the gap when truncation hides the latest
181
+ `nonceChange`.
173
182
 
174
183
  ---
175
184
 
@@ -192,9 +201,11 @@ case 'clarity-truncated-history': // normalized to clarity-v2
192
201
 
193
202
  1. Accept `?truncated=true` on `GET /addresses/:addr/transactions`.
194
203
  2. Attach `accountInfo: []` (or omit) when nothing to snapshot.
195
- 3. Emit `accountInfo` **only** for assets that can't be derived from `walletChanges` (rebase / `balanceType: 'account_balance'`).
196
- 4. Row shapes: `{ type: 'balance' | 'nonce' | 'token', value, blockNumber, assetId?, assetName?, decimals? }`. Sorted client-side by `blockNumber` desc; first value per type/assetId wins.
197
- 5. Unknown `assetId` client drops the row.
204
+ 3. Emit `source: 'account_balance'` rows for current account/token balances. These may appear on every fetch.
205
+ 4. Emit `source: 'truncated'` rows on the initial `cursor=0` fetch for state that existed before the returned history window.
206
+ 5. Row shapes: `{ type: 'balance' | 'nonce' | 'token', source: 'account_balance' | 'truncated', value, blockNumber, assetId?, assetName?, decimals? }`. Sorted client-side by `blockNumber` desc; first value per type/assetId wins for `balance`/`token`. `nonce` rows are collapsed via `Math.max` regardless of source.
207
+ 6. Unknown `assetId` → client drops the row.
208
+ 7. `value` arrives as a decimal string. `null`, `undefined`, and empty-string `""` mean "no value" and the row is skipped.
198
209
 
199
210
  If the backend can't emit `accountInfo`, the monitor degrades gracefully to `clarity-v2` behavior (tx-log walk only).
200
211
 
@@ -202,7 +213,7 @@ If the backend can't emit `accountInfo`, the monitor degrades gracefully to `cla
202
213
 
203
214
  ## Tests
204
215
 
205
- - `src/tx-log/__tests__/clarity-truncated-history-monitor.test.js` — `truncated: true`, no RPC balance batch, accountInfo → `tokenBalances`.
216
+ - `src/tx-log/__tests__/clarity-truncated-history-monitor.test.js` — `truncated: true`, no RPC balance batch, accountInfo → `balance`, token `tokenBalances`, and `truncatedAccountState`.
206
217
  - `src/__tests__/create-asset-utils.test.js` — factory → `ClarityTruncatedHistoryMonitor`.
207
- - `src/__tests__/get-balances.test.js` — accountState short-circuit (truncated history cases).
218
+ - `src/__tests__/get-balances.test.js` — accountState token short-circuit and truncated baseline cases.
208
219
  - `src/__tests__/balances-model.test.js` — default-ZERO accountState still falls through (regression for `NumberUnit.isZero` getter).
@@ -123,6 +123,34 @@ export const getOptimisticTxLogEffects = async ({
123
123
  },
124
124
  }
125
125
 
126
+ const receiveWalletAccount = await findOtherPortfolioReceiveWalletAccount({
127
+ amount,
128
+ asset,
129
+ assetClientInterface,
130
+ methodId,
131
+ selfSend,
132
+ to,
133
+ txToAddress,
134
+ walletAccount,
135
+ })
136
+
137
+ const receiveSideEffects = receiveWalletAccount
138
+ ? getOtherPortfolioReceiveEffects({
139
+ amount,
140
+ asset,
141
+ confirmations,
142
+ date,
143
+ fromAddress,
144
+ gasLimit,
145
+ methodId,
146
+ maybeTipGasPrice,
147
+ nonce,
148
+ receiveWalletAccount,
149
+ txId,
150
+ data,
151
+ })
152
+ : []
153
+
126
154
  const optimisticTxLogEffects = [
127
155
  {
128
156
  assetName: asset.name,
@@ -156,11 +184,116 @@ export const getOptimisticTxLogEffects = async ({
156
184
  ],
157
185
  },
158
186
  ...methodOptimisticSideEffects,
187
+ ...receiveSideEffects,
159
188
  ].filter(Boolean)
160
189
 
161
190
  return { optimisticTxLogEffects, nonce }
162
191
  }
163
192
 
193
+ const findOtherPortfolioReceiveWalletAccount = async ({
194
+ amount,
195
+ asset,
196
+ assetClientInterface,
197
+ methodId,
198
+ selfSend,
199
+ to,
200
+ txToAddress,
201
+ walletAccount,
202
+ }) => {
203
+ // Exclude cases where another-portfolio receive logs would be misleading:
204
+ // history-backed assets, self-sends, incomplete/zero-value transfers, and
205
+ // token approvals/swaps/router calls that are not plain ERC-20 transfers.
206
+ if (!asset.baseAsset.api.features.noHistory || selfSend || !to || amount.isZero) return
207
+
208
+ if (isEthereumLikeToken(asset)) {
209
+ const isPlainTransfer =
210
+ txToAddress?.toLowerCase() === asset.contract.address.toLowerCase() &&
211
+ methodId === asset.contract.transfer.methodId
212
+ if (!isPlainTransfer) return
213
+ }
214
+
215
+ try {
216
+ return await getWalletAccountForReceiveAddress({
217
+ assetClientInterface,
218
+ baseAsset: asset.baseAsset,
219
+ sendingWalletAccount: walletAccount,
220
+ to,
221
+ })
222
+ } catch {}
223
+ }
224
+
225
+ const getOtherPortfolioReceiveEffects = ({
226
+ amount,
227
+ asset,
228
+ confirmations,
229
+ date,
230
+ fromAddress,
231
+ gasLimit,
232
+ methodId,
233
+ maybeTipGasPrice,
234
+ nonce,
235
+ receiveWalletAccount,
236
+ txId,
237
+ data,
238
+ }) => {
239
+ const receiveProps = {
240
+ confirmations,
241
+ date,
242
+ selfSend: false,
243
+ from: [fromAddress],
244
+ txId,
245
+ data: {
246
+ gasLimit,
247
+ nonce,
248
+ ...(maybeTipGasPrice ? { tipGasPrice: maybeTipGasPrice.toBaseString() } : null),
249
+ ...(methodId ? { methodId } : null),
250
+ ...(data ? { data: bufferToHex(data) } : null),
251
+ },
252
+ }
253
+
254
+ return [
255
+ {
256
+ assetName: asset.name,
257
+ walletAccount: receiveWalletAccount,
258
+ txs: [
259
+ {
260
+ ...receiveProps,
261
+ coinAmount: amount.abs(),
262
+ coinName: asset.name,
263
+ currencies: {
264
+ [asset.name]: asset.currency,
265
+ [asset.feeAsset.name]: asset.feeAsset.currency,
266
+ },
267
+ },
268
+ ],
269
+ },
270
+ ]
271
+ }
272
+
273
+ const getWalletAccountForReceiveAddress = async ({
274
+ assetClientInterface,
275
+ baseAsset,
276
+ sendingWalletAccount,
277
+ to,
278
+ }) => {
279
+ const normalizedTo = to.toLowerCase()
280
+ const walletAccounts = await assetClientInterface.getWalletAccounts({ assetName: baseAsset.name })
281
+
282
+ for (const walletAccount of walletAccounts) {
283
+ if (walletAccount === sendingWalletAccount) continue
284
+
285
+ const receiveAddresses = await assetClientInterface.getReceiveAddresses({
286
+ assetName: baseAsset.name,
287
+ walletAccount,
288
+ useCache: true,
289
+ })
290
+
291
+ if (receiveAddresses.some((address) => String(address).toLowerCase() === normalizedTo)) {
292
+ return walletAccount
293
+ }
294
+ }
295
+ }
296
+
164
297
  const operationTxLogSideEffects = async ({
165
298
  txToAddress,
166
299
  methodId,
@@ -2,38 +2,60 @@
2
2
  * Maps Clarity `accountInfo` rows (GET .../transactions?truncated=true) into fields aligned
3
3
  * with Ethereum-like account state: `balance`, `tokenBalances`, `nonce` (plus base units
4
4
  * for amounts via `currency.baseUnit`).
5
- * Rows are applied newest-first by `blockNumber` so duplicate types resolve like
6
- * walletChanges-derived snapshots.
5
+ * `account_balance` source rows are stored in `balance`/`tokenBalances`; truncated source rows
6
+ * are stored in `truncatedAccountState`.
7
+ *
8
+ * NOTE: All `row.value` payloads arrive over the wire as decimal strings (e.g. `"0"`,
9
+ * `"20191469428776270391810"`). They're parsed with `currency.baseUnit(...)` for amounts
10
+ * and `Number(...)` for nonce. `null`, `undefined`, and empty-string `""` all mean "row
11
+ * carries no value" and the row is skipped — `currency.baseUnit("")` throws and `Number("")`
12
+ * returns 0, both of which would silently corrupt accountState.
7
13
  */
14
+ const ACCOUNT_BALANCE = 'account_balance'
15
+ const TRUNCATED = 'truncated'
16
+
17
+ const hasValue = (value) => value !== null && value !== undefined && value !== ''
18
+
19
+ const warnUnknownSource = (row) => {
20
+ console.warn('Unknown accountInfo source', row)
21
+ }
22
+
8
23
  export const extractBalanceFromClarityAccountInfo = ({ accountInfo, asset, tokensByAddress }) => {
9
24
  if (!accountInfo || accountInfo.length === 0) return {}
10
25
 
11
26
  const sorted = [...accountInfo].sort((a, b) => (b.blockNumber || 0) - (a.blockNumber || 0))
12
27
 
13
28
  const result = {}
14
- let haveBalance = false
15
- let haveNonce = false
16
29
  const seenAssetIds = new Set()
17
30
  const tokenBalances = Object.create(null)
31
+ const truncatedTokenBalances = Object.create(null)
32
+ let truncatedBalance
18
33
 
19
34
  for (const row of sorted) {
20
35
  switch (row.type) {
21
36
  case 'balance':
22
- if (!haveBalance && row.value != null) {
23
- result.balance = asset.currency.baseUnit(row.value)
24
- haveBalance = true
37
+ if (hasValue(row.value)) {
38
+ const balance = asset.currency.baseUnit(row.value)
39
+ if (row.source === ACCOUNT_BALANCE) {
40
+ result.balance = balance
41
+ } else if (row.source === TRUNCATED) {
42
+ truncatedBalance = balance
43
+ } else {
44
+ warnUnknownSource(row)
45
+ }
25
46
  }
26
47
 
27
48
  break
28
49
 
29
50
  case 'nonce': {
30
- if (haveNonce) break
51
+ // Nonce collapses regardless of source — both `account_balance` and
52
+ // `truncated` rows represent the latest chain-known nonce; max wins
53
+ // for safety against out-of-order rows.
54
+ if (!hasValue(row.value)) break
31
55
  const nonce = Number(row.value)
56
+ if (!Number.isFinite(nonce) || nonce < 0) break
32
57
 
33
- if (Number.isFinite(nonce) && nonce >= 0) {
34
- result.nonce = nonce
35
- haveNonce = true
36
- }
58
+ result.nonce = Math.max(result.nonce ?? 0, nonce)
37
59
 
38
60
  break
39
61
  }
@@ -41,10 +63,19 @@ export const extractBalanceFromClarityAccountInfo = ({ accountInfo, asset, token
41
63
  case 'token': {
42
64
  const assetId = row.assetId?.toLowerCase()
43
65
 
44
- if (!assetId || seenAssetIds.has(assetId) || row.value == null) break
66
+ if (!assetId || seenAssetIds.has(assetId) || !hasValue(row.value)) break
45
67
  const token = tokensByAddress.get(assetId)
46
68
  if (!token) break
47
- tokenBalances[token.name] = token.currency.baseUnit(row.value)
69
+
70
+ const balance = token.currency.baseUnit(row.value)
71
+ if (row.source === ACCOUNT_BALANCE) {
72
+ tokenBalances[token.name] = balance
73
+ } else if (row.source === TRUNCATED) {
74
+ truncatedTokenBalances[token.name] = balance
75
+ } else {
76
+ warnUnknownSource(row)
77
+ }
78
+
48
79
  seenAssetIds.add(assetId)
49
80
  break
50
81
  }
@@ -58,5 +89,20 @@ export const extractBalanceFromClarityAccountInfo = ({ accountInfo, asset, token
58
89
  result.tokenBalances = tokenBalances
59
90
  }
60
91
 
92
+ const hasTruncatedTokenBalances = Object.keys(truncatedTokenBalances).length > 0
93
+ if (truncatedBalance || hasTruncatedTokenBalances) {
94
+ const truncatedAccountState = Object.create(null)
95
+
96
+ if (truncatedBalance) {
97
+ truncatedAccountState.balance = truncatedBalance
98
+ }
99
+
100
+ if (hasTruncatedTokenBalances) {
101
+ truncatedAccountState.tokenBalances = truncatedTokenBalances
102
+ }
103
+
104
+ result.truncatedAccountState = truncatedAccountState
105
+ }
106
+
61
107
  return result
62
108
  }
@@ -28,8 +28,8 @@ export const resolveNonce = async ({
28
28
 
29
29
  const nonceFromTxLog = getNonceFromTxLog({ txLog, useAbsoluteNonce, tag })
30
30
 
31
- // Fallback to accountState nonce when txLog has no nonce info (truncated history).
32
- // accountState.nonce is populated from Clarity accountInfo during monitor ticks.
31
+ // `accountState.nonce` is the single source of truth — clarity-truncated-history
32
+ // collapses both `account_balance` and `truncated` source nonces into this field.
33
33
  const nonceFromAccountState = accountState?.nonce || 0
34
34
 
35
35
  return Math.max(nonceFromTxLog, nonceFromAccountState)
@@ -1,8 +1,9 @@
1
- import { normalizeTxId, parseUnsignedTx, updateNonce } from '@exodus/ethereum-lib'
1
+ import { parseUnsignedTx, updateNonce } from '@exodus/ethereum-lib'
2
2
  import { safeString } from '@exodus/safe-string'
3
3
  import assert from 'minimalistic-assert'
4
4
 
5
5
  import { getOptimisticTxLogEffects } from '../tx-log/index.js'
6
+ import { signTx } from '../tx-sign/index.js'
6
7
  import { ARBITRARY_ADDRESS } from '../tx-type/index.js'
7
8
  import { handleBroadcastError } from './broadcast-error-handler.js'
8
9
 
@@ -10,16 +11,6 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
10
11
  assert(assetClientInterface, 'assetClientInterface is required')
11
12
  assert(createTx, 'createTx is required')
12
13
 
13
- async function signTx({ asset, unsignedTx, walletAccount }) {
14
- const { rawTx, txId } = await assetClientInterface.signTransaction({
15
- assetName: asset.baseAsset.name,
16
- unsignedTx,
17
- walletAccount,
18
- })
19
-
20
- return { rawTx, txId: normalizeTxId(txId) }
21
- }
22
-
23
14
  return async ({ asset, walletAccount, unsignedTx: providedUnsignedTx, ...legacyParams }) => {
24
15
  const baseAsset = asset.baseAsset
25
16
 
@@ -41,6 +32,7 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
41
32
  // Are there any signin-level errors that should be caught here?
42
33
  let { txId, rawTx } = await signTx({
43
34
  asset,
35
+ assetClientInterface,
44
36
  unsignedTx,
45
37
  walletAccount,
46
38
  })
@@ -92,7 +84,12 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
92
84
  unsignedTx.txData.nonce = newNonce
93
85
  }
94
86
 
95
- ;({ txId, rawTx } = await signTx({ asset, unsignedTx, walletAccount }))
87
+ ;({ txId, rawTx } = await signTx({
88
+ asset,
89
+ assetClientInterface,
90
+ unsignedTx,
91
+ walletAccount,
92
+ }))
96
93
 
97
94
  try {
98
95
  await baseAsset.api.broadcastTx(rawTx.toString('hex'))
@@ -0,0 +1,17 @@
1
+ import { normalizeTxId } from '@exodus/ethereum-lib'
2
+ import assert from 'minimalistic-assert'
3
+
4
+ export const signTx = async ({ asset, assetClientInterface, unsignedTx, walletAccount }) => {
5
+ assert(asset, 'expected asset')
6
+ assert(assetClientInterface, 'expected assetClientInterface')
7
+ assert(unsignedTx, 'expected unsignedTx')
8
+ assert(walletAccount, 'expected walletAccount')
9
+
10
+ const { rawTx, txId } = await assetClientInterface.signTransaction({
11
+ assetName: asset.baseAsset.name,
12
+ unsignedTx,
13
+ walletAccount,
14
+ })
15
+
16
+ return { rawTx, txId: normalizeTxId(txId) }
17
+ }