viem
Version:
555 lines (514 loc) • 19.3 kB
text/typescript
import type { AbiStateMutability, Address, Narrow } from 'abitype'
import * as AbiConstructor from 'ox/AbiConstructor'
import * as AbiEvent from 'ox/AbiEvent'
import * as AbiFunction from 'ox/AbiFunction'
import { parseAccount } from '../../accounts/utils/parseAccount.js'
import type { Client } from '../../clients/createClient.js'
import type { Transport } from '../../clients/transports/createTransport.js'
import { ethAddress, zeroAddress } from '../../constants/address.js'
import { deploylessCallViaBytecodeBytecode } from '../../constants/contracts.js'
import { BaseError } from '../../errors/base.js'
import { CallExecutionError } from '../../errors/contract.js'
import { ExecutionRevertedError } from '../../errors/node.js'
import type { ErrorType } from '../../errors/utils.js'
import type { Account } from '../../types/account.js'
import type { Block, BlockTag } from '../../types/block.js'
import type { Call, Calls } from '../../types/calls.js'
import type { Chain } from '../../types/chain.js'
import type { Log } from '../../types/log.js'
import type { Hex } from '../../types/misc.js'
import type { MulticallResults } from '../../types/multicall.js'
import type { StateOverride } from '../../types/stateOverride.js'
import type { Mutable } from '../../types/utils.js'
import { type PadErrorType, pad } from '../../utils/data/pad.js'
import { hexToBigInt } from '../../utils/encoding/fromHex.js'
import { type CallErrorType, call } from './call.js'
import { type GetBlockErrorType, getBlock } from './getBlock.js'
import {
type GetBlockNumberErrorType,
getBlockNumber,
} from './getBlockNumber.js'
import {
type SimulateBlocksErrorType,
type SimulateBlocksParameters,
simulateBlocks,
} from './simulateBlocks.js'
const getBalanceCode =
'0x6080604052348015600e575f80fd5b5061016d8061001c5f395ff3fe608060405234801561000f575f80fd5b5060043610610029575f3560e01c8063f8b2cb4f1461002d575b5f80fd5b610047600480360381019061004291906100db565b61005d565b604051610054919061011e565b60405180910390f35b5f8173ffffffffffffffffffffffffffffffffffffffff16319050919050565b5f80fd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6100aa82610081565b9050919050565b6100ba816100a0565b81146100c4575f80fd5b50565b5f813590506100d5816100b1565b92915050565b5f602082840312156100f0576100ef61007d565b5b5f6100fd848285016100c7565b91505092915050565b5f819050919050565b61011881610106565b82525050565b5f6020820190506101315f83018461010f565b9291505056fea26469706673582212203b9fe929fe995c7cf9887f0bdba8a36dd78e8b73f149b17d2d9ad7cd09d2dc6264736f6c634300081a0033'
// Runtime bytecode from `contracts/src/deployless/StaticCall.sol`, compiled with
// Solidity 0.8.35.
const staticCallCode =
'0x608060405234801561000f575f5ffd5b5060043610610029575f3560e01c8063fd00430c1461002d575b5f5ffd5b6100476004803603810190610042919061012b565b610049565b005b80825f375f5f825f865afa610060573d5f5f3e3d5ffd5b3d5f5f3e3d5ff35b5f5ffd5b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f61009982610070565b9050919050565b6100a98161008f565b81146100b3575f5ffd5b50565b5f813590506100c4816100a0565b92915050565b5f5ffd5b5f5ffd5b5f5ffd5b5f5f83601f8401126100eb576100ea6100ca565b5b8235905067ffffffffffffffff811115610108576101076100ce565b5b602083019150836001820283011115610124576101236100d2565b5b9250929050565b5f5f5f6040848603121561014257610141610068565b5b5f61014f868287016100b6565b935050602084013567ffffffffffffffff8111156101705761016f61006c565b5b61017c868287016100d6565b9250925050925092509256fea2646970667358221220635ed99185cacf3f2acba6921f23687c969cec2bbaf5f9ad599f507e6e105e6964736f6c63430008230033'
// Bound each probe so a failing candidate cannot exhaust the simulation gas budget.
const assetProbeGas = 1_000_000n
const staticCallAddressBase = 0x00000000000000000000000000000000deadbeefn
// ERC20 & ERC721 share this selector – both are `Transfer(address,address,uint256)`.
const transferEventSelector = AbiEvent.getSelector(
AbiEvent.from(
'event Transfer(address indexed from, address indexed to, uint256 value)',
),
)
const balanceOfFunction = AbiFunction.from(
'function balanceOf(address) returns (uint256)',
)
const decimalsFunction = AbiFunction.from(
'function decimals() returns (uint256)',
)
const tokenUriFunction = AbiFunction.from(
'function tokenURI(uint256) returns (string)',
)
const symbolFunction = AbiFunction.from('function symbol() returns (string)')
const staticCallFunction = AbiFunction.from(
'function query(address target, bytes data)',
)
export type SimulateCallsParameters<
calls extends readonly unknown[] = readonly unknown[],
account extends Account | Address | undefined = Account | Address | undefined,
> = Omit<SimulateBlocksParameters, 'blocks' | 'returnFullTransactions'> & {
/** Account attached to the calls (msg.sender). */
account?: account | undefined
/** Calls to simulate. */
calls: Calls<Narrow<calls>>
/** State overrides. */
stateOverrides?: StateOverride | undefined
/** Whether to trace asset changes. */
traceAssetChanges?: boolean | undefined
}
export type SimulateCallsReturnType<
calls extends readonly unknown[] = readonly unknown[],
> = {
/** Asset changes. */
assetChanges: readonly {
token: {
address: Address
decimals?: number | undefined
symbol?: string | undefined
}
value: { pre: bigint; post: bigint; diff: bigint }
}[]
/** Block results. */
block: Block
/** Call results. */
results: MulticallResults<
Narrow<calls>,
true,
{
extraProperties: {
data: Hex
gasUsed: bigint
logs?: Log[] | undefined
}
error: Error
mutability: AbiStateMutability
}
>
}
export type SimulateCallsErrorType =
| AbiFunction.encodeData.ErrorType
| AbiFunction.from.ErrorType
| CallErrorType
| GetBlockErrorType
| GetBlockNumberErrorType
| PadErrorType
| SimulateBlocksErrorType
| ErrorType
/**
* Simulates execution of a batch of calls.
*
* @param client - Client to use
* @param parameters - {@link SimulateCallsParameters}
* @returns Results. {@link SimulateCallsReturnType}
*
* @example
* ```ts
* import { createPublicClient, http, parseEther } from 'viem'
* import { mainnet } from 'viem/chains'
* import { simulateCalls } from 'viem/actions'
*
* const client = createPublicClient({
* chain: mainnet,
* transport: http(),
* })
*
* const result = await simulateCalls(client, {
* account: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929',
* calls: [{
* {
* data: '0xdeadbeef',
* to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8',
* },
* {
* to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8',
* value: parseEther('1'),
* },
* ]
* })
* ```
*/
export async function simulateCalls<
const calls extends readonly unknown[],
chain extends Chain | undefined,
account extends Account | Address | undefined = undefined,
>(
client: Client<Transport, chain>,
parameters: SimulateCallsParameters<calls, account>,
): Promise<SimulateCallsReturnType<calls>> {
const {
blockNumber,
blockTag,
calls,
stateOverrides,
traceAssetChanges,
traceTransfers,
validation,
} = parameters
const account = parameters.account
? parseAccount(parameters.account)
: undefined
if (traceAssetChanges && !account)
throw new BaseError(
'`account` is required when `traceAssetChanges` is true',
)
// Derive bytecode to extract ETH balance via a contract call.
const getBalanceData = account
? AbiConstructor.encode(AbiConstructor.from('constructor(bytes, bytes)'), {
bytecode: deploylessCallViaBytecodeBytecode,
args: [
getBalanceCode,
AbiFunction.encodeData(
AbiFunction.from('function getBalance(address)'),
[account.address],
),
],
})
: undefined
// Resolve moving canonical tags once so both simulations share a block. Preserve
// `pending` because its prospective number cannot be queried directly.
// Do not infer the base from results because clients disagree on simulated numbering.
const blockTag_ = blockTag ?? client.experimental_blockTag ?? 'latest'
let baseBlockNumber = blockNumber
if (
traceAssetChanges &&
typeof baseBlockNumber !== 'bigint' &&
blockTag_ !== 'earliest' &&
blockTag_ !== 'pending'
) {
if (blockTag_ === 'latest')
baseBlockNumber = await getBlockNumber(client, { cacheTime: 0 })
else {
const block = await getBlock(client, { blockTag: blockTag_ })
if (typeof block.number !== 'bigint')
throw new BaseError(
`Block tag \`${blockTag_}\` did not resolve to a number.`,
)
baseBlockNumber = block.number
}
}
const block_ =
typeof baseBlockNumber === 'bigint'
? { blockNumber: baseBlockNumber }
: { blockTag: blockTag_ }
// Discover ERC20/721 addresses the calls move assets in. Simulating the batch as a
// whole is what makes this correct: the calls run sequentially, with the caller's
// state overrides, on the same base block the results are measured against.
const discovery = traceAssetChanges
? await simulateBlocks(client, {
...block_,
blocks: [
{
calls: calls.map((call) => ({
...(call as Call),
from: account!.address,
})) as any,
stateOverrides,
},
],
traceTransfers,
validation,
})
: undefined
const assetAddresses = discovery
? [
...new Set([
...tokensFromLogs(
discovery[0]!.calls.flatMap((call) => call.logs ?? []),
account!.address,
),
// Included even for calls without data: contracts that mint on receiving
// native value (WETH) emit `Deposit`, not a `Transfer` the logs would catch.
// Candidates without code fall out at `isBalance`.
...parameters.calls.map((call: any) => call.to?.toLowerCase()),
]),
].filter(
(address): address is Address =>
Boolean(address) && address !== ethAddress && address !== zeroAddress,
)
: []
const staticCallAddress = getStaticCallAddress([
...(account ? [account.address] : []),
...assetAddresses,
...(stateOverrides?.map(({ address }) => address) ?? []),
])
const staticCallStateOverrides: StateOverride = [
{ address: staticCallAddress, code: staticCallCode },
]
const [balanceCallsPre, blocks] = await Promise.all([
traceAssetChanges
? Promise.all([
readBalance(client, {
account: account!.address,
...block_,
data: getBalanceData!,
stateOverride: stateOverrides,
}),
...assetAddresses.map((address) =>
readBalance(client, {
account: account!.address,
address,
...block_,
data: AbiFunction.encodeData(balanceOfFunction, [
account!.address,
]),
staticCallAddress,
stateOverride: stateOverrides,
}),
),
])
: [],
simulateBlocks(client, {
...block_,
blocks: [
{
calls: [...calls, { to: zeroAddress }].map((call) => ({
...(call as Call),
from: account?.address,
})) as any,
stateOverrides,
},
...(traceAssetChanges
? [
// ETH post balances
{
calls: [{ data: getBalanceData }],
},
// Asset post balances
{
calls: assetAddresses.map((address) => ({
to: staticCallAddress,
gas: assetProbeGas,
data: encodeStaticCall(
address,
AbiFunction.encodeData(balanceOfFunction, [
account!.address,
]),
),
})),
stateOverrides: staticCallStateOverrides,
},
// Decimals
{
calls: assetAddresses.map((address) => ({
to: staticCallAddress,
gas: assetProbeGas,
data: encodeStaticCall(
address,
AbiFunction.encodeData(decimalsFunction),
),
})),
stateOverrides: staticCallStateOverrides,
},
// Token URI
{
calls: assetAddresses.map((address) => ({
to: staticCallAddress,
gas: assetProbeGas,
data: encodeStaticCall(
address,
AbiFunction.encodeData(tokenUriFunction, [0n]),
),
})),
stateOverrides: staticCallStateOverrides,
},
// Symbols
{
calls: assetAddresses.map((address) => ({
to: staticCallAddress,
gas: assetProbeGas,
data: encodeStaticCall(
address,
AbiFunction.encodeData(symbolFunction),
),
})),
stateOverrides: staticCallStateOverrides,
},
]
: []),
],
traceTransfers,
validation,
}),
])
const block_results = blocks[0]!
const [
block_ethPost,
block_assetsPost,
block_decimals,
block_tokenURI,
block_symbols,
] = traceAssetChanges ? blocks.slice(1) : []
// Extract call results from the simulation.
const { calls: block_calls, ...block } = block_results
const results = block_calls.slice(0, -1)
// Extract pre-execution ETH and asset balances.
const balancesPre = balanceCallsPre.map((call) =>
isBalance(call) ? hexToBigInt(call.data) : null,
)
// Extract post-execution ETH and asset balances.
const ethPost = block_ethPost?.calls ?? []
const assetsPost = block_assetsPost?.calls ?? []
const balanceCallsPost = [...ethPost, ...assetsPost]
const balancesPost = balanceCallsPost.map((call) =>
isBalance(call) ? hexToBigInt(call.data) : null,
)
// Extract asset symbols & decimals.
const decimals = (block_decimals?.calls ?? []).map((call) =>
decodeAssetResult(call, decimalsFunction),
) as (bigint | null)[]
const symbols = (block_symbols?.calls ?? []).map((call) =>
decodeAssetResult(call, symbolFunction),
) as (string | null)[]
const tokenURI = (block_tokenURI?.calls ?? []).map((call) =>
decodeAssetResult(call, tokenUriFunction),
) as (string | null)[]
const changes: Mutable<SimulateCallsReturnType<calls>['assetChanges']> = []
for (const [i, balancePost] of balancesPost.entries()) {
const balancePre_ = balancesPre[i]
const preCall = balanceCallsPre[i]
const balancePre =
typeof balancePre_ === 'bigint'
? balancePre_
: i > 0 && preCall?.status === 'success' && preCall.data === '0x'
? 0n
: null
if (typeof balancePost !== 'bigint') continue
if (typeof balancePre !== 'bigint') continue
const decimals_ = decimals[i - 1]
const symbol_ = symbols[i - 1]
const tokenURI_ = tokenURI[i - 1]
const token = (() => {
if (i === 0)
return {
address: ethAddress,
decimals: 18,
symbol: 'ETH',
}
return {
address: assetAddresses[i - 1]! as Address,
decimals: tokenURI_ || decimals_ ? Number(decimals_ ?? 1) : undefined,
symbol: symbol_ ?? undefined,
}
})()
changes.push({
token,
value: {
pre: balancePre,
post: balancePost,
diff: balancePost - balancePre,
},
})
}
return {
assetChanges: changes,
block,
results,
} as unknown as SimulateCallsReturnType<calls>
}
function encodeStaticCall(address: Address, data: Hex) {
return AbiFunction.encodeData(staticCallFunction, [address, data])
}
// Extract token addresses that the account transferred, from a simulated block's logs.
// Only topics 0-2 are read: ERC721's fourth topic is the token id, which is irrelevant
// as `balanceOf(address)` on a 721 returns the owner's NFT count.
function tokensFromLogs(logs: readonly Log[], account: Address): Address[] {
const account_ = pad(account.toLowerCase() as Hex, { size: 32 })
return logs
.filter((log) => {
if (log.topics[0]?.toLowerCase() !== transferEventSelector) return false
// `traceTransfers` emits synthetic native transfer logs under `ethAddress`, which
// is measured separately.
if (log.address.toLowerCase() === ethAddress) return false
return (
log.topics[1]?.toLowerCase() === account_ ||
log.topics[2]?.toLowerCase() === account_
)
})
.map((log) => log.address.toLowerCase() as Address)
}
// Whether a `balanceOf` probe returned a balance. A candidate without code (an EOA that
// merely received value) succeeds with empty data instead of reverting, so `status`
// alone is not enough.
function isBalance(call: { data: Hex; status: 'success' | 'failure' }) {
return call.status === 'success' && /^0x[\da-f]{64}$/i.test(call.data)
}
function decodeAssetResult(
call: { data: Hex; status: 'success' | 'failure' },
abiFunction: AbiFunction.AbiFunction,
) {
if (call.status === 'failure' || call.data === '0x') return null
try {
return AbiFunction.decodeResult(abiFunction, call.data)
} catch {
return null
}
}
async function readBalance<chain extends Chain | undefined>(
client: Client<Transport, chain>,
parameters: {
account: Address
address?: Address | undefined
blockNumber?: bigint | undefined
blockTag?: BlockTag | undefined
data: Hex
staticCallAddress?: Address | undefined
stateOverride: StateOverride | undefined
},
) {
const {
account,
address,
blockNumber,
blockTag,
data,
staticCallAddress,
stateOverride,
} = parameters
try {
const result = await call({ ...client, ccipRead: false }, {
account: address ? zeroAddress : account,
data: address ? encodeStaticCall(address, data) : data,
stateOverride:
address && staticCallAddress
? [
...(stateOverride ?? []),
{ address: staticCallAddress, code: staticCallCode },
]
: stateOverride,
...(address ? { gas: assetProbeGas, to: staticCallAddress } : {}),
...(typeof blockNumber === 'bigint' ? { blockNumber } : { blockTag }),
} as never)
return { data: result.data ?? '0x', status: 'success' as const }
} catch (error) {
if (
!(error instanceof CallExecutionError) ||
!(error.cause instanceof ExecutionRevertedError)
)
throw error
return { data: '0x' as const, status: 'failure' as const }
}
}
function getStaticCallAddress(addresses: readonly Address[]): Address {
const occupied = new Set(addresses.map((address) => address.toLowerCase()))
let value = staticCallAddressBase
while (occupied.has(`0x${value.toString(16).padStart(40, '0')}`)) value++
return `0x${value.toString(16).padStart(40, '0')}`
}