@exodus/ethereum-api 8.76.7 → 8.77.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,31 @@
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.77.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.77.0...@exodus/ethereum-api@8.77.1) (2026-07-09)
7
+
8
+
9
+ ### Features
10
+
11
+ * add contract `staticcall` helper for `rpcRequestAccumulator` ([#8336](https://github.com/ExodusMovement/assets/issues/8336)) ([674f1a3](https://github.com/ExodusMovement/assets/commit/674f1a39c0a8fd5353b6ae7a19b79166f5aabfc9))
12
+ * include recipient address state changes in simulation result (EVM + Solana) ([#8321](https://github.com/ExodusMovement/assets/issues/8321)) ([556bfde](https://github.com/ExodusMovement/assets/commit/556bfded420ca37692e53865cafd5688557ed283))
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **ethereum-api:** guard batch RPC against non-array responses ([#8350](https://github.com/ExodusMovement/assets/issues/8350)) ([683dbab](https://github.com/ExodusMovement/assets/commit/683dbabf32ffd7c03a649c6b037cfed4665f108d))
18
+ * lower ETH min staking amount ([#8319](https://github.com/ExodusMovement/assets/issues/8319)) ([461e4c4](https://github.com/ExodusMovement/assets/commit/461e4c4f46e58e3750363edb9d0b6cddbfae5478))
19
+
20
+
21
+
22
+ ## [8.77.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.76.7...@exodus/ethereum-api@8.77.0) (2026-06-29)
23
+
24
+
25
+ ### Features
26
+
27
+ * **ethereum:** implement getHistoricalBalance via clarity absolute balance ([#8281](https://github.com/ExodusMovement/assets/issues/8281)) ([be202a9](https://github.com/ExodusMovement/assets/commit/be202a94d9734d7af8ed118d11d4cc38c3b5ef10))
28
+
29
+
30
+
6
31
  ## [8.76.7](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.76.6...@exodus/ethereum-api@8.76.7) (2026-06-26)
7
32
 
8
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.76.7",
3
+ "version": "8.77.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",
@@ -69,5 +69,5 @@
69
69
  "type": "git",
70
70
  "url": "git+https://github.com/ExodusMovement/assets.git"
71
71
  },
72
- "gitHead": "c704777d72888bf3a94f3a7ddc1b0e57cda3e6dc"
72
+ "gitHead": "96337ab20f90e6fa96043f7d569a4f6527c04ebd"
73
73
  }
@@ -41,7 +41,9 @@ import { createFeeData } from './fee-data/index.js'
41
41
  import { createGetBalanceForAddress } from './get-balance-for-address.js'
42
42
  import { getBalancesFactory } from './get-balances.js'
43
43
  import { getFeeFactory } from './get-fee.js'
44
+ import { getHistoricalBalanceFactory } from './get-historical-balance.js'
44
45
  import { moveFundsFactory } from './move-funds.js'
46
+ import { createEncodeMultisigContract } from './multisig-address.js'
45
47
  import { estimateL1DataFeeFactory, getL1GetFeeFactory } from './optimism-gas/index.js'
46
48
  import { createSendValidations } from './send-validations.js'
47
49
  import { serverBasedFeeMonitorFactoryFactory } from './server-based-fee-monitor.js'
@@ -171,18 +173,32 @@ export const createAssetFactory = ({
171
173
  encodePublic: encodePublicFactory({ chainId, useEip1191ChainIdChecksum }),
172
174
  }
173
175
 
176
+ // The Safe 4337 contract constants baked into the derivation are chainId 1 specific
177
+ const encodeMultisigContract =
178
+ chainId === 1
179
+ ? createEncodeMultisigContract({ chainId, encodePublic: keys.encodePublic })
180
+ : undefined
181
+
174
182
  const getBalances = getBalancesFactory({
175
183
  monitorType,
176
184
  useAbsoluteBalance: useAbsoluteBalanceAndNonce,
177
185
  rpcBalanceAssetNames,
178
186
  })
179
187
 
188
+ const historicalBalanceSupported = monitorType !== 'no-history'
189
+ const getHistoricalBalance = getHistoricalBalanceFactory({
190
+ assetClientInterface,
191
+ getBalances,
192
+ })
193
+
180
194
  const { createToken, getTokens } = createTokenFactory(
181
195
  {
182
196
  address,
183
197
  bip44,
184
198
  keys,
185
199
  getBalances,
200
+ getHistoricalBalance,
201
+ historicalBalance: historicalBalanceSupported,
186
202
  assetClientInterface,
187
203
  server,
188
204
  stakingConfiguration,
@@ -225,9 +241,11 @@ export const createAssetFactory = ({
225
241
  family: ASSET_FAMILY.EVM,
226
242
  feeMonitor: true,
227
243
  feesApi: true,
244
+ historicalBalance: historicalBalanceSupported,
228
245
  isMaxFeeAsset,
229
246
  isTestnet,
230
247
  moveFunds: true,
248
+ multisig: Boolean(encodeMultisigContract),
231
249
  nfts,
232
250
  noHistory: monitorType === 'no-history',
233
251
  signWithSigner: true,
@@ -336,6 +354,7 @@ export const createAssetFactory = ({
336
354
  features,
337
355
  getActivityTxs,
338
356
  getBalances,
357
+ ...(features.historicalBalance && { getHistoricalBalance }),
339
358
  getBalanceForAddress: createGetBalanceForAddress({ asset, server }),
340
359
  ...(getBlackListStatus && { getBlackListStatus }),
341
360
  getConfirmationsNumber: () => confirmationsNumber,
@@ -388,6 +407,7 @@ export const createAssetFactory = ({
388
407
  address,
389
408
  api,
390
409
  chainId,
410
+ ...(encodeMultisigContract && { encodeMultisigContract }),
391
411
  monitorType,
392
412
  estimateL1DataFee,
393
413
  forceGasLimitEstimation,
@@ -37,6 +37,8 @@ const getActivityTxs = ({ txs }) => txs.filter((tx) => !smallbalanceTx(tx))
37
37
  const getCreateBaseToken =
38
38
  ({
39
39
  getBalances,
40
+ getHistoricalBalance,
41
+ historicalBalance,
40
42
  assetClientInterface,
41
43
  server,
42
44
  stakingConfiguration = Object.create(null),
@@ -53,6 +55,7 @@ const getCreateBaseToken =
53
55
  features: tokenFeatures,
54
56
  hasFeature: (feature) => !!tokenFeatures[feature], // @deprecated use api.features instead
55
57
  getBalances,
58
+ ...(historicalBalance && { getHistoricalBalance }),
56
59
  getTxLogFilter: name === 'polygon' ? getPolygonTxLogFilter : getTxLogFilter,
57
60
  }
58
61
 
@@ -34,17 +34,7 @@ export default class ApiCoinNodesServer extends EthLikeServerBase {
34
34
  async sendBatchRequest(batch) {
35
35
  if (isEmpty(batch)) return batch
36
36
  const responses = await this.request(batch)
37
- const isValid = responses.every((response) => {
38
- return !isNaN(response?.id) && response?.result !== undefined
39
- })
40
- if (responses.length !== batch.length || !isValid) {
41
- throw new Error('Bad rpc batch response')
42
- }
43
-
44
- const keyed = responses.reduce((acc, response) => {
45
- return { ...acc, [`${response.id}`]: response.result }
46
- }, Object.create(null))
47
- return batch.map((request) => keyed[`${request.id}`])
37
+ return this.parseBatchResponse({ batch, responses, errorMessage: 'Bad rpc batch response' })
48
38
  }
49
39
 
50
40
  async sendRequest(request) {
@@ -216,18 +216,7 @@ export default class ClarityServer extends EthLikeServerBase {
216
216
  // Transport: WS only in ClarityServer, WS first → HTTP fallback in ClarityServerV2
217
217
  async sendBatchRequest(batch) {
218
218
  const responses = await this.sendRpcRequest(batch)
219
- // FIXME: this falls apart if responses is not an array
220
- const isValid = responses.every((response) => {
221
- return !isNaN(response?.id) && response?.result !== undefined
222
- })
223
- if (responses.length !== batch.length || !isValid) {
224
- throw new Error('Bad Response')
225
- }
226
-
227
- const keyed = responses.reduce((acc, response) => {
228
- return { ...acc, [`${response.id}`]: response.result }
229
- }, Object.create(null))
230
- return batch.map((request) => keyed[`${request.id}`])
219
+ return this.parseBatchResponse({ batch, responses, errorMessage: 'Bad Response' })
231
220
  }
232
221
 
233
222
  // Transport: Uses sendRpcRequest - WS only in ClarityServer, WS first → HTTP fallback in ClarityServerV2
@@ -120,4 +120,39 @@ export default class EthLikeServerBase extends EventEmitter {
120
120
  assert(Array.isArray(txs), 'expected array txs')
121
121
  return this.buildRequest({ method: 'eth_sendBundle', params: [{ txs }] })
122
122
  }
123
+
124
+ /**
125
+ * Validates a JSON-RPC batch response, returning each `result` ordered to
126
+ * match `batch` (correlated by `id`, since responses can arrive out of order).
127
+ *
128
+ * @param {Array<{ id: number|string }>} args.batch - Requests sent, in the order results are expected.
129
+ * @param {unknown} args.responses - Raw transport payload; expected `[{ id, result }]` but treated as untrusted (gateways may return `{ message }` on error).
130
+ * @param {string} args.errorMessage - Thrown-error prefix per transport (e.g. `'Bad rpc batch response'`, `'Bad Response'`).
131
+ * @returns {unknown[]} Each request's `result`, ordered to match `batch`.
132
+ * @throws {Error} When `responses` isn't an array, length mismatches `batch`, or any entry lacks a numeric `id` / defined `result`.
133
+ */
134
+ parseBatchResponse({ batch, responses, errorMessage }) {
135
+ // Gateways can respond with a JSON object (e.g. `{ message }`) instead of a batch array.
136
+ if (!Array.isArray(responses)) {
137
+ // Untrusted server text: keep only a short string preview, developer-facing (no `hint`).
138
+ const message = [responses?.message, responses?.error?.message].find(
139
+ (value) => typeof value === 'string'
140
+ )
141
+ const preview = message ? `: ${message.slice(0, 100)}` : ''
142
+ throw new Error(`${errorMessage}${preview}`)
143
+ }
144
+
145
+ const isValid = responses.every(
146
+ (response) => !isNaN(response?.id) && response?.result !== undefined
147
+ )
148
+ if (responses.length !== batch.length || !isValid) {
149
+ throw new Error(errorMessage)
150
+ }
151
+
152
+ // Safe against prototype keys: every `id` is validated numeric by the `isValid` check above.
153
+ const keyed = Object.fromEntries(
154
+ responses.map((response) => [`${response.id}`, response.result])
155
+ )
156
+ return batch.map((request) => keyed[`${request.id}`])
157
+ }
123
158
  }
@@ -0,0 +1,58 @@
1
+ import { TxSet } from '@exodus/models'
2
+ import assert from 'minimalistic-assert'
3
+
4
+ import { getLatestCanonicalAbsoluteBalanceTx } from './tx-log/clarity-utils/index.js'
5
+
6
+ const walkBackFromBalance = ({ txLog, deadline, balance }) => {
7
+ let result = balance
8
+
9
+ for (const tx of txLog) {
10
+ if (new Date(tx.date).getTime() <= deadline) continue
11
+ if (tx.failed || tx.dropped || tx.pending || tx.data?.replacedBy) continue
12
+
13
+ result = result.sub(tx.coinAmount)
14
+ if (tx.feeAmount?.unitType.equals(tx.coinAmount.unitType)) {
15
+ result = result.add(tx.feeAmount)
16
+ }
17
+ }
18
+
19
+ return result.clampLowerZero()
20
+ }
21
+
22
+ /**
23
+ * Builds the Ethereum-like balance as of `date`. Anchors on the latest Clarity
24
+ * absolute-balance snapshot at or before the date, falling back to a walk-back
25
+ * from the current balance when no snapshot is available.
26
+ */
27
+ export const getHistoricalBalanceFactory = ({ assetClientInterface, getBalances }) => {
28
+ assert(assetClientInterface, 'assetClientInterface is required')
29
+ assert(getBalances, 'getBalances is required')
30
+
31
+ return async ({ asset, walletAccount, date }) => {
32
+ assert(asset, 'asset is required')
33
+ assert(walletAccount, 'walletAccount is required')
34
+ assert(date instanceof Date, 'date is required')
35
+
36
+ const txLog = await assetClientInterface.getTxLog({ assetName: asset.name, walletAccount })
37
+ const deadline = date.getTime()
38
+ const anchor = getLatestCanonicalAbsoluteBalanceTx({ reversedTxLog: txLog.reverse(), deadline })
39
+
40
+ if (anchor) {
41
+ const total = asset.currency.baseUnit(anchor.data.balanceChange.to)
42
+ return { total, entries: [{ amount: total, date: deadline }] }
43
+ }
44
+
45
+ const accountState = await assetClientInterface.getAccountState({
46
+ assetName: asset.baseAsset.name,
47
+ walletAccount,
48
+ })
49
+ const confirmedTxLog = TxSet.fromArray([...txLog].filter((tx) => !tx.pending))
50
+ const balance = getBalances({ asset, accountState, txLog: confirmedTxLog }).spendable
51
+ const total = walkBackFromBalance({ txLog: confirmedTxLog, deadline, balance })
52
+
53
+ return {
54
+ total,
55
+ entries: [{ amount: total, date: deadline }],
56
+ }
57
+ }
58
+ }
package/src/index.js CHANGED
@@ -38,6 +38,8 @@ export {
38
38
  } from './tx-log/index.js'
39
39
 
40
40
  export { getStakingHistoryBalance, getBalancesFactory } from './get-balances.js'
41
+ export { getHistoricalBalanceFactory } from './get-historical-balance.js'
42
+ export { createEncodeMultisigContract } from './multisig-address.js'
41
43
 
42
44
  export {
43
45
  FantomStaking,
@@ -22,20 +22,41 @@ const assertSupportedMethod = (method) => {
22
22
  const getMulticall3Contract = memoize(() => new SolidityContract(ABI.multicall3))
23
23
 
24
24
  export class EthLikeRpcRequestAccumulator extends EthLikeServerBase {
25
- #requestContext = null
25
+ static REQUEST_PARSER_NOOP = ({ result }) => result
26
+
27
+ static REQUEST_PARSER_UINT256 = ({ result }) => `0x${BigInt(result).toString(16)}`
28
+
29
+ static REQUEST_PARSER_BALANCE_OF = ({ originalRequest, result }) => {
30
+ const {
31
+ params: [{ to: tokenAddress }],
32
+ } = originalRequest
33
+
34
+ return {
35
+ confirmed: {
36
+ [tokenAddress]: BigInt(result).toString(),
37
+ },
38
+ }
39
+ }
40
+
41
+ #requestParser = null
26
42
  #requests = []
27
43
 
28
- #withRequestContext(requestContext, ...args) {
29
- assert(this.#requestContext === null)
30
- this.#requestContext = requestContext
44
+ #withRequestParser(functionName, requestParser) {
45
+ assert(typeof functionName === 'string', 'expected functionName')
46
+ assert(typeof requestParser === 'function', 'expected requestParser')
31
47
 
32
- const result = super[requestContext](...args)
48
+ return (...args) => {
49
+ assert(!this.#requestParser) // invariant
33
50
 
34
- this.#requestContext = null
35
- return result
51
+ this.#requestParser = requestParser
52
+ const result = super[functionName](...args)
53
+ this.#requestParser = null
54
+
55
+ return result
56
+ }
36
57
  }
37
58
 
38
- _serializeRequest({ context: _, originalRequest }) {
59
+ _serializeRequest({ originalRequest }) {
39
60
  return super.buildRequest(originalRequest)
40
61
  }
41
62
 
@@ -52,19 +73,49 @@ export class EthLikeRpcRequestAccumulator extends EthLikeServerBase {
52
73
  assertValidMulticall3BlockTag(tag)
53
74
  }
54
75
 
55
- const context = this.#requestContext ?? method
56
-
57
76
  void this.#requests.push({
58
- context,
59
77
  originalRequest,
60
- request: this._serializeRequest({ context, originalRequest }),
78
+ request: this._serializeRequest({ originalRequest }),
79
+ requestParser: this.#requestParser ?? null,
61
80
  })
62
81
 
63
82
  return this
64
83
  }
65
84
 
66
85
  balanceOfRequest(...args) {
67
- return this.#withRequestContext('balanceOfRequest', ...args)
86
+ return this.#withRequestParser(
87
+ 'balanceOfRequest',
88
+ EthLikeRpcRequestAccumulator.REQUEST_PARSER_BALANCE_OF
89
+ )(...args)
90
+ }
91
+
92
+ blockNumberRequest(...args) {
93
+ return this.#withRequestParser(
94
+ 'blockNumberRequest',
95
+ EthLikeRpcRequestAccumulator.REQUEST_PARSER_UINT256
96
+ )(...args)
97
+ }
98
+
99
+ getBalanceRequest(...args) {
100
+ return this.#withRequestParser(
101
+ 'getBalanceRequest',
102
+ EthLikeRpcRequestAccumulator.REQUEST_PARSER_UINT256
103
+ )(...args)
104
+ }
105
+
106
+ contractStaticCallRequest(contract, functionName, ...args) {
107
+ assert(contract instanceof SolidityContract, 'expected SolidityContract contract')
108
+ assert(typeof functionName === 'string', 'expected string functionName')
109
+
110
+ const requestParser = ({ result }) => contract[functionName].parse(result)
111
+
112
+ return this.#withRequestParser(
113
+ 'ethCallRequest',
114
+ requestParser
115
+ )({
116
+ to: contract.address,
117
+ data: bufferToHex(contract[functionName].build(...args)),
118
+ })
68
119
  }
69
120
 
70
121
  length() {
@@ -89,22 +140,11 @@ export class EthLikeRpcRequestAccumulator extends EthLikeServerBase {
89
140
  const request = requests[i]
90
141
  assert(request)
91
142
 
92
- const { context, originalRequest } = request
143
+ const { requestParser: maybeRequestParser, originalRequest } = request
93
144
 
94
- if (context === 'eth_getBalance') return `0x${BigInt(result).toString(16)}`
95
- if (context === 'eth_blockNumber') return `0x${BigInt(result).toString(16)}`
96
- if (context === 'balanceOfRequest') {
97
- const {
98
- params: [{ to: tokenAddress }],
99
- } = originalRequest
100
- return {
101
- confirmed: {
102
- [tokenAddress]: BigInt(result).toString(),
103
- },
104
- }
105
- }
145
+ const requestParser = maybeRequestParser ?? EthLikeRpcRequestAccumulator.REQUEST_PARSER_NOOP
106
146
 
107
- return result
147
+ return requestParser({ originalRequest, result })
108
148
  })
109
149
  }
110
150
  }
@@ -121,7 +161,7 @@ export class EthLikeMulticall3RpcRequestAccumulator extends EthLikeRpcRequestAcc
121
161
  this.#multicall3Contract = getMulticall3Contract()
122
162
  }
123
163
 
124
- _serializeRequest({ context: _, originalRequest: { method, params } }) {
164
+ _serializeRequest({ originalRequest: { method, params } }) {
125
165
  switch (method) {
126
166
  case 'eth_getBalance':
127
167
  return {
@@ -0,0 +1,146 @@
1
+ import { memoize } from '@exodus/basic-utils'
2
+ import { hash } from '@exodus/crypto/hash'
3
+ import { publicKeyIsValid } from '@exodus/crypto/secp256k1'
4
+ import { toChecksumAddress } from '@exodus/ethereumjs/util'
5
+ import SolidityContract from '@exodus/solidity-contract'
6
+
7
+ const keccak256 = async (data) => hash('keccak256', data)
8
+
9
+ // Limit multisig keys to 16 for now
10
+ const MAX_PUBKEYS = 16
11
+
12
+ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
13
+
14
+ // This derives the counterfactual CREATE2 address of OUR canonical Safe 4337
15
+ // deployment (fixed singleton, modules and salt nonce, so owners + threshold
16
+ // fully determine the address). It intentionally does NOT resolve addresses of
17
+ // arbitrary pre-existing Safes, whose setup we cannot reconstruct from pubkeys.
18
+ // https://docs.safe.global/advanced/erc-4337/guides/permissionless-detailed
19
+
20
+ // Safe{Core} Protocol deployments for the Safe 4337 setup, chainId 1.
21
+ // https://github.com/safe-global/safe-modules-deployments
22
+ const ADD_MODULES_LIB_ADDRESS = '0x8EcD4ec46D4D2a6B64fE960B3D64e8B94B2234eb'
23
+ const SAFE_4337_MODULE_ADDRESS = '0xa581c4A4DB7175302464fF3C06380BC3270b4037'
24
+ const PROXY_FACTORY_ADDRESS = '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67'
25
+
26
+ // enableModules([SAFE_4337_MODULE_ADDRESS]) calldata passed to setup()
27
+ const SETUP_DATA = `0x8d0dc49f00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000${SAFE_4337_MODULE_ADDRESS.slice(2)}`
28
+
29
+ // SALT_NONCE is protocol-kit's getChainSpecificDefaultSaltNonce(1n). Careful
30
+ // reproducing it: the derivation is stringly, NOT abi.encodePacked of numbers.
31
+ // PREDETERMINED_SALT_NONCE is a hex *string*, `+ chainId` is JS *string
32
+ // concatenation*, and toHex() *utf8-encodes* the resulting text before hashing:
33
+ //
34
+ // PREDETERMINED_SALT_NONCE = '0x' + keccak256(utf8('Safe Account Abstraction'))
35
+ // = '0xb1073742015cbcf5a3a4d9d1ae33ecf619439710b89475f92e2abd2117e90f90'
36
+ // SALT_NONCE = keccak256(utf8(PREDETERMINED_SALT_NONCE + '1')) // chainId 1
37
+ //
38
+ // i.e. keccak256 of the 67 ASCII characters '0xb107...f901'
39
+ const SALT_NONCE = '0x69b348339eea4ed93f9d11931c3b894c8f9d8c7663a053024b11cb7eb4e5a1f6'
40
+
41
+ // SafeProxy creation code with the SafeL2 singleton (0x41675C09...) constructor argument appended
42
+ const INIT_CODE =
43
+ '0x608060405234801561001057600080fd5b506040516101e63803806101e68339818101604052602081101561003357600080fd5b8101908080519060200190929190505050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614156100ca576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260228152602001806101c46022913960400191505060405180910390fd5b806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505060ab806101196000396000f3fe608060405273ffffffffffffffffffffffffffffffffffffffff600054167fa619486e0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000f35b3660008037600080366000845af43d6000803e60008114156070573d6000fd5b3d6000f3fea264697066735822122003d1488ee65e08fa41e58e888a9865554c535f2c77126a82cb4c0f917f31441364736f6c63430007060033496e76616c69642073696e676c65746f6e20616464726573732070726f766964656400000000000000000000000041675c099f32341bf84bfc5382af534df5c7461a'
44
+
45
+ const SAFE_SETUP_ABI = [
46
+ {
47
+ inputs: [
48
+ { internalType: 'address[]', name: '_owners', type: 'address[]' },
49
+ { internalType: 'uint256', name: '_threshold', type: 'uint256' },
50
+ { internalType: 'address', name: 'to', type: 'address' },
51
+ { internalType: 'bytes', name: 'data', type: 'bytes' },
52
+ { internalType: 'address', name: 'fallbackHandler', type: 'address' },
53
+ { internalType: 'address', name: 'paymentToken', type: 'address' },
54
+ { internalType: 'uint256', name: 'payment', type: 'uint256' },
55
+ { internalType: 'address payable', name: 'paymentReceiver', type: 'address' },
56
+ ],
57
+ name: 'setup',
58
+ outputs: [],
59
+ stateMutability: 'nonpayable',
60
+ type: 'function',
61
+ },
62
+ ]
63
+
64
+ const getSafeContract = memoize(() => new SolidityContract(SAFE_SETUP_ABI, '', true))
65
+
66
+ const SALT_NONCE_BUFFER = Buffer.from(SALT_NONCE.slice(2), 'hex')
67
+
68
+ export const createEncodeMultisigContract = ({ chainId, encodePublic }) => {
69
+ // The Safe deployment constants above (addresses and SALT_NONCE) only derive
70
+ // valid addresses for chainId 1, so refuse other chains instead of silently
71
+ // deriving an incorrect address
72
+ if (chainId !== 1) {
73
+ throw new Error(`asset.encodeMultisigContract is not supported for chainId ${chainId}`)
74
+ }
75
+
76
+ return async (publicKeys, meta = Object.create(null)) => {
77
+ if (
78
+ !Array.isArray(publicKeys) ||
79
+ publicKeys.some((k) => !Buffer.isBuffer(k) || !publicKeyIsValid({ publicKey: k }))
80
+ ) {
81
+ throw new Error('publicKeys must be an Array of Buffers representing valid public keys')
82
+ }
83
+
84
+ const { threshold = publicKeys.length, version = 0 } = meta
85
+
86
+ if (publicKeys.length <= 0 || publicKeys.length > MAX_PUBKEYS) {
87
+ throw new Error(`asset.encodeMultisigContract supports from 1 to ${MAX_PUBKEYS} pubKeys`)
88
+ }
89
+
90
+ if (!Number.isSafeInteger(version)) {
91
+ throw new TypeError('asset.encodeMultisigContract requires meta.version to be an integer')
92
+ }
93
+
94
+ // Only support version 0 for now
95
+ if (version !== 0) {
96
+ throw new Error(`asset.encodeMultisigContract does not support version ${version}`)
97
+ }
98
+
99
+ if (!Number.isSafeInteger(threshold) || threshold <= 0 || threshold > MAX_PUBKEYS) {
100
+ throw new Error(
101
+ `asset.encodeMultisigContract requires meta.threshold to be an integer between 1 and ${MAX_PUBKEYS}`
102
+ )
103
+ }
104
+
105
+ if (threshold > publicKeys.length) {
106
+ throw new Error('threshold must be <= publicKeys.length')
107
+ }
108
+
109
+ // Owners are intentionally NOT sorted: the Safe address depends on the owner
110
+ // order in setup(), so publicKeys must be passed in a stable, agreed-upon order
111
+ const owners = publicKeys.map((publicKey) => encodePublic(publicKey))
112
+
113
+ if (new Set(owners.map((o) => o.toLowerCase())).size !== owners.length) {
114
+ throw new Error('publicKeys must not contain any duplicates')
115
+ }
116
+
117
+ const setupData = getSafeContract().setup.build(
118
+ owners,
119
+ threshold,
120
+ ADD_MODULES_LIB_ADDRESS,
121
+ SETUP_DATA,
122
+ SAFE_4337_MODULE_ADDRESS,
123
+ ZERO_ADDRESS,
124
+ 0,
125
+ ZERO_ADDRESS
126
+ )
127
+
128
+ // CREATE2: keccak256(0xff ++ proxyFactory ++ keccak256(keccak256(setupData) ++ saltNonce) ++ keccak256(initCode))[12:]
129
+ const [setupDataHash, initCodeHash] = await Promise.all([
130
+ keccak256(setupData),
131
+ keccak256(Buffer.from(INIT_CODE.slice(2), 'hex')),
132
+ ])
133
+
134
+ const salt = await keccak256([setupDataHash, SALT_NONCE_BUFFER])
135
+
136
+ const create2Hash = await keccak256([
137
+ Buffer.from('ff', 'hex'),
138
+ Buffer.from(PROXY_FACTORY_ADDRESS.slice(2), 'hex'),
139
+ salt,
140
+ initCodeHash,
141
+ ])
142
+ const address = create2Hash.slice(12)
143
+
144
+ return { address: toChecksumAddress('0x' + address.toString('hex')) }
145
+ }
146
+ }
@@ -12,6 +12,7 @@ export const createSimulateTransactions =
12
12
  asset,
13
13
  origin,
14
14
  blockNumber,
15
+ recipientAddress,
15
16
  headers: headersFromParams,
16
17
  overrideApiEndpoint,
17
18
  }) => {
@@ -56,6 +57,7 @@ export const createSimulateTransactions =
56
57
  apiEndpoint: overrideApiEndpoint || apiEndpoint,
57
58
  origin,
58
59
  blockNumber,
60
+ recipientAddress,
59
61
  simulationResult,
60
62
  headers,
61
63
  })
@@ -3,7 +3,7 @@ import { createCurrency, makeSimulationAPICall } from '@exodus/web3-utils'
3
3
 
4
4
  import { BLOWFISH_EVM_CHAINS, MAX_INT256_SOLIDITY } from './common.js'
5
5
 
6
- const convertTransactionToApiPayload = (transactions, origin, blockNumber) => {
6
+ const convertTransactionToApiPayload = (transactions, origin, blockNumber, recipientAddress) => {
7
7
  const payload = {
8
8
  txObjects: transactions.map((transaction) => ({
9
9
  from: transaction.from,
@@ -13,9 +13,7 @@ const convertTransactionToApiPayload = (transactions, origin, blockNumber) => {
13
13
  gas: transaction.gas,
14
14
  gas_price: transaction.gasPrice,
15
15
  })),
16
- metadata: {
17
- origin,
18
- },
16
+ metadata: recipientAddress ? { origin, recipientAddress } : { origin },
19
17
  userAccount: transactions[0].from,
20
18
  }
21
19
 
@@ -156,6 +154,7 @@ export const simulateTransactionsApi = async ({
156
154
  apiEndpoint,
157
155
  origin,
158
156
  blockNumber,
157
+ recipientAddress,
159
158
  simulationResult,
160
159
  headers,
161
160
  asset,
@@ -174,7 +173,12 @@ export const simulateTransactionsApi = async ({
174
173
 
175
174
  const userAddress = transactions[0].from
176
175
 
177
- const payload = convertTransactionToApiPayload(transactions, origin, blockNumber)
176
+ const payload = convertTransactionToApiPayload(
177
+ transactions,
178
+ origin,
179
+ blockNumber,
180
+ recipientAddress
181
+ )
178
182
 
179
183
  if (!Object.hasOwnProperty.call(BLOWFISH_EVM_CHAINS, asset.name)) {
180
184
  return successDefaultResult
@@ -240,24 +244,21 @@ export const simulateTransactionsApi = async ({
240
244
  return successDefaultResult
241
245
  }
242
246
 
243
- const userExpectedChanges = Object.keys(expectedStateChanges).reduce((prev, address) => {
244
- if (userAddress.toLowerCase() !== address.toLowerCase()) {
245
- return prev
246
- }
247
-
248
- const result =
249
- (Object.prototype.hasOwnProperty.call(expectedStateChanges, address) &&
250
- expectedStateChanges[address]) ||
251
- []
247
+ const addressesToProcess = [userAddress.toLowerCase()]
248
+ if (recipientAddress && recipientAddress.toLowerCase() !== userAddress.toLowerCase()) {
249
+ addressesToProcess.push(recipientAddress.toLowerCase())
250
+ }
252
251
 
253
- return [...prev, ...result]
254
- }, [])
252
+ const relevantExpectedChanges = Object.entries(expectedStateChanges).flatMap(
253
+ ([address, changes]) =>
254
+ addressesToProcess.includes(address.toLowerCase()) ? changes ?? [] : []
255
+ )
255
256
 
256
- if (!simulationResultHasAssets(userExpectedChanges)) {
257
+ if (!simulationResultHasAssets(relevantExpectedChanges)) {
257
258
  return successDefaultResult
258
259
  }
259
260
 
260
- userExpectedChanges.forEach((expectedChange) => {
261
+ relevantExpectedChanges.forEach((expectedChange) => {
261
262
  handleExpectedBalanceChange(expectedChange, simulationResult, { network })
262
263
  })
263
264
 
@@ -50,12 +50,15 @@ const getLatestCanonicalAbsoluteTx = ({
50
50
  searchDepthMs = DEFAULT_CANONICAL_ABSOLUTE_TX_SEARCH_DEPTH,
51
51
  reversedTxLog,
52
52
  fieldName,
53
+ deadline = Infinity,
53
54
  }) => {
54
55
  assert(reversedTxLog, 'expected reversedTxLog')
55
56
 
56
57
  let latest = null
57
58
 
58
59
  for (const tx of reversedTxLog) {
60
+ if (+tx.date > deadline) continue
61
+
59
62
  if (latest) {
60
63
  const diff = +latest.date - +tx.date
61
64
 
@@ -77,10 +80,11 @@ const getLatestCanonicalAbsoluteTx = ({
77
80
  return latest
78
81
  }
79
82
 
80
- export const getLatestCanonicalAbsoluteBalanceTx = ({ searchDepthMs, reversedTxLog }) =>
83
+ export const getLatestCanonicalAbsoluteBalanceTx = ({ searchDepthMs, reversedTxLog, deadline }) =>
81
84
  getLatestCanonicalAbsoluteTx({
82
85
  searchDepthMs,
83
86
  reversedTxLog,
87
+ deadline,
84
88
  fieldName: ABSOLUTE_FIELD_NAME_BALANCE_CHANGE,
85
89
  })
86
90