跳转到主要内容
在本教程中,您将学习如何使用 Tenderly SDK 模拟捆绑交易。捆绑交易是一组作为整体进行模拟的多笔交易。 捆绑交易通常用于 DeFi 协议和其他应用中,以模拟执行涉及多个合约交互的复杂操作。
捆绑中的交易在相同的区块上进行模拟。
我们将模拟通过 Uniswap 将一定数量的 DAI 兑换为 WETH,展示已模拟交易的执行状态,并计算总的 gas 使用量。 要达成此目标所需的交易如下:
  1. 模拟铸造 2 个 DAI,使兑换者拥有可兑换的资产。或者,如果您已有 DAI,可以跳过此步骤,直接执行第 2 笔交易。
  2. 批准 UniswapV3Router 使用 DAI。
  3. 执行兑换。调用 UniswapV2Router.exactInputSingle 进行兑换。
要使第 1 笔交易成功运行,发送方需要是 DAI 稳定币的 ward(授权者)。由于我们大多数人都不是,因此我们将了解如何在捆绑模拟的上下文中通过 State Overrides “成为 ward”。

前提条件

第 1 步:设置项目

将以下命令粘贴到您的终端中,以创建一个新的 npm 项目、安装依赖并配置 Typescript:
example
mkdir tenderly-sdk-simulations
cd tenderly-sdk-simulations
npm init -y
yarn add @tenderly/sdk ethers
yarn add --dev typescript ts-node @types/node  dotenv
echo '{
  "compilerOptions": {
  "module": "commonjs",
  "esModuleInterop": true,
  "target": "es6",
  "moduleResolution": "node",
  "sourceMap": true,
  "outDir": "dist"
  "moduleResolution": "node16",
  },
  "lib": ["es2015"]
}' > tsconfig.json

mkdir src
touch src/swap.ts
code .

第 2 步:调用 Simulation API

从下方复制完整可运行示例并粘贴到 swap.ts 文件中。 交易由 mint2DaiTxapproveUniswapV3RouterTxswapSomeDaiForWethTx 函数确定:
example.ts

const fakeWardAddressEOA = '0xe2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2';
const daiOwnerEOA = '0xe58b9ee93700a616b50509c8292977fa7a0f8ce1';
const daiAddressMainnet = '0x6b175474e89094c44da98b954eedeac495271d0f';
const uniswapV3SwapRouterAddressMainnet = '0xe592427a0aece92de3edee1f18e0157c05861564';
const wethAddressMainnet = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2';

(async () => {
  const tenderly = new Tenderly({
    accessKey: 'access key',
    accountName: 'your account name',
    projectName: 'project-name-hyphenated',
    network: Network.MAINNET,
  });

  const simulatedBundle = await tenderly.simulator.simulateBundle({
    blockNumber: 0x103a957,
    transactions: [
      // TX1: Mint 2 DAI for daiOwnerEOA.
      // For minting to happen, we must do a state override so fakeWardAddress EOA is considered a ward for this simulation (see overrides)
      mint2DaiTx(),
      // TX2: daiOwnerEOA approves 1 DAI to uniswapV3SwapRouterAddressMainnet
      approveUniswapV3RouterTx(),
      // TX3: Perform a uniswap swap of 1/3 ETH
      swapSomeDaiForWethTx(),
    ],
    overrides: {
      [daiAddressMainnet]: {
        state: {
          // make DAI think that fakeWardAddress is a ward for minting
          [`wards[${fakeWardAddressEOA}]`]:
            '0x0000000000000000000000000000000000000000000000000000000000000001',
        },
      },
    },
  });
  const totalGasUsed = simulatedBundle
    .map(simulation => simulation.gasUsed)
    .reduce((total, gasUsed) => total + gasUsed);

  console.log('Total gas used:', totalGasUsed);

  simulatedBundle.forEach((simulation, idx) => {
    console.log(
      `Transaction ${idx} at block ${simulation.blockNumber}`,
      simulation.status ? 'success' : 'failed',
    );
  });

  console.log(JSON.stringify(simulatedBundle, null, 2));
})();

function mint2DaiTx(): TransactionParameters {
  return {
    from: fakeWardAddressEOA,
    to: daiAddressMainnet,
    gas: 0,
    gas_price: '0',
    value: 0,
    input: daiEthersInterface().encodeFunctionData('mint', [daiOwnerEOA, parseEther('2')]),
  };
}

function approveUniswapV3RouterTx(): TransactionParameters {
  return {
    from: daiOwnerEOA,
    to: daiAddressMainnet,
    gas: 0,
    gas_price: '0',
    value: 0,
    input: daiEthersInterface().encodeFunctionData('approve', [
      uniswapV3SwapRouterAddressMainnet,
      parseEther('1'),
    ]),
  };
}

function swapSomeDaiForWethTx(): TransactionParameters {
  return {
    from: daiOwnerEOA,
    to: uniswapV3SwapRouterAddressMainnet,
    gas: 0,
    gas_price: '0',
    value: 0,
    input: uniswapRouterV2EthersInterface().encodeFunctionData('exactInputSingle', [
      {
        tokenIn: daiAddressMainnet,
        tokenOut: wethAddressMainnet,
        fee: '10000',
        recipient: daiOwnerEOA,
        deadline: (1681109951 + 10 * 365 * 24 * 60 * 60 * 1000).toString(),
        amountIn: '33000000000000000',
        amountOutMinimum: '763124874493',
        sqrtPriceLimitX96: '0',
      },
    ]),
  };
}

function daiEthersInterface() {
  // @formatter:off
  const daiAbi=[
    {constant:false,inputs:[{internalType:'address',name:'src',type:'address',},{internalType:'address',name:'dst',type:'address',},{internalType:'uint256',name:'wad',type:'uint256',},],name:'transferFrom',outputs:[{internalType:'bool',name:'',type:'bool',},],payable:false,stateMutability:'nonpayable',type:'function',},
    {constant:false,inputs:[{internalType:'address',name:'usr',type:'address',},{internalType:'uint256',name:'wad',type:'uint256',},],name:'approve',outputs:[{internalType:'bool',name:'',type:'bool',},],payable:false,stateMutability:'nonpayable',type:'function',},
    {constant:false,inputs:[{internalType:'address',name:'usr',type:'address',},{internalType:'uint256',name:'wad',type:'uint256',},],name:'mint',outputs:[],payable:false,stateMutability:'nonpayable',type:'function',}
  ];

  return new Interface(daiAbi);
  // @formatter:on
}

function uniswapRouterV2EthersInterface() {
  // @formatter:off
  const swapRouterAbi = [
    {inputs: [ {internalType: 'address', name: '_factory', type: 'address',}, {internalType: 'address', name: '_WETH9', type: 'address',}, ], stateMutability: 'nonpayable', type: 'constructor',},
    {inputs: [], name: 'WETH9', outputs: [ {internalType: 'address', name: '', type: 'address',}, ], stateMutability: 'view', type: 'function',},
    {inputs: [ {components: [ {internalType: 'bytes', name: 'path', type: 'bytes',}, {internalType: 'address', name: 'recipient', type: 'address',}, {internalType: 'uint256', name: 'deadline', type: 'uint256',}, {internalType: 'uint256', name: 'amountIn', type: 'uint256',}, {internalType: 'uint256', name: 'amountOutMinimum', type: 'uint256',}, ], internalType: 'struct ISwapRouter.ExactInputParams', name: 'params', type: 'tuple',}, ], name: 'exactInput', outputs: [ {internalType: 'uint256', name: 'amountOut', type: 'uint256',}, ], stateMutability: 'payable', type: 'function',},
    {inputs: [ {components: [ {internalType: 'address', name: 'tokenIn', type: 'address',}, {internalType: 'address', name: 'tokenOut', type: 'address',}, {internalType: 'uint24', name: 'fee', type: 'uint24',}, {internalType: 'address', name: 'recipient', type: 'address',}, {internalType: 'uint256', name: 'deadline', type: 'uint256',}, {internalType: 'uint256', name: 'amountIn', type: 'uint256',}, {internalType: 'uint256', name: 'amountOutMinimum', type: 'uint256',}, {internalType: 'uint160', name: 'sqrtPriceLimitX96', type: 'uint160',}, ], internalType: 'struct ISwapRouter.ExactInputSingleParams', name: 'params', type: 'tuple',}, ], name: 'exactInputSingle', outputs: [ {internalType: 'uint256', name: 'amountOut', type: 'uint256',}, ], stateMutability: 'payable', type: 'function',},
    {inputs: [ {components: [ {internalType: 'bytes', name: 'path', type: 'bytes',}, {internalType: 'address', name: 'recipient', type: 'address',}, {internalType: 'uint256', name: 'deadline', type: 'uint256',}, {internalType: 'uint256', name: 'amountOut', type: 'uint256',}, {internalType: 'uint256', name: 'amountInMaximum', type: 'uint256',}, ], internalType: 'struct ISwapRouter.ExactOutputParams', name: 'params', type: 'tuple',}, ], name: 'exactOutput', outputs: [ {internalType: 'uint256', name: 'amountIn', type: 'uint256',}, ], stateMutability: 'payable', type: 'function',},
    {inputs: [ {components: [ {internalType: 'address', name: 'tokenIn', type: 'address',}, {internalType: 'address', name: 'tokenOut', type: 'address',}, {internalType: 'uint24', name: 'fee', type: 'uint24',}, {internalType: 'address', name: 'recipient', type: 'address',}, {internalType: 'uint256', name: 'deadline', type: 'uint256',}, {internalType: 'uint256', name: 'amountOut', type: 'uint256',}, {internalType: 'uint256', name: 'amountInMaximum', type: 'uint256',}, {internalType: 'uint160', name: 'sqrtPriceLimitX96', type: 'uint160',}, ], internalType: 'struct ISwapRouter.ExactOutputSingleParams', name: 'params', type: 'tuple',}, ], name: 'exactOutputSingle', outputs: [ {internalType: 'uint256', name: 'amountIn', type: 'uint256',}, ], stateMutability: 'payable', type: 'function',},
    {inputs: [], name: 'factory', outputs: [ {internalType: 'address', name: '', type: 'address',}, ], stateMutability: 'view', type: 'function',},
    {inputs: [ {internalType: 'bytes[]', name: 'data', type: 'bytes[]',}, ], name: 'multicall', outputs: [ {internalType: 'bytes[]', name: 'results', type: 'bytes[]',}, ], stateMutability: 'payable', type: 'function',},
    {inputs: [], name: 'refundETH', outputs: [], stateMutability: 'payable', type: 'function',},
    {inputs: [ {internalType: 'address', name: 'token', type: 'address',}, {internalType: 'uint256', name: 'value', type: 'uint256',}, {internalType: 'uint256', name: 'deadline', type: 'uint256',}, {internalType: 'uint8', name: 'v', type: 'uint8',}, {internalType: 'bytes32', name: 'r', type: 'bytes32',}, {internalType: 'bytes32', name: 's', type: 'bytes32',}, ], name: 'selfPermit', outputs: [], stateMutability: 'payable', type: 'function',},
    {inputs: [ {internalType: 'address', name: 'token', type: 'address',}, {internalType: 'uint256', name: 'nonce', type: 'uint256',}, {internalType: 'uint256', name: 'expiry', type: 'uint256',}, {internalType: 'uint8', name: 'v', type: 'uint8',}, {internalType: 'bytes32', name: 'r', type: 'bytes32',}, {internalType: 'bytes32', name: 's', type: 'bytes32',}, ], name: 'selfPermitAllowed', outputs: [], stateMutability: 'payable', type: 'function',},
    {inputs: [ {internalType: 'address', name: 'token', type: 'address',}, {internalType: 'uint256', name: 'nonce', type: 'uint256',}, {internalType: 'uint256', name: 'expiry', type: 'uint256',}, {internalType: 'uint8', name: 'v', type: 'uint8',}, {internalType: 'bytes32', name: 'r', type: 'bytes32',}, {internalType: 'bytes32', name: 's', type: 'bytes32',}, ], name: 'selfPermitAllowedIfNecessary', outputs: [], stateMutability: 'payable', type: 'function',},
    {inputs: [ {internalType: 'address', name: 'token', type: 'address',}, {internalType: 'uint256', name: 'value', type: 'uint256',}, {internalType: 'uint256', name: 'deadline', type: 'uint256',}, {internalType: 'uint8', name: 'v', type: 'uint8',}, {internalType: 'bytes32', name: 'r', type: 'bytes32',}, {internalType: 'bytes32', name: 's', type: 'bytes32',}, ], name: 'selfPermitIfNecessary', outputs: [], stateMutability: 'payable', type: 'function',},
    {inputs: [ {internalType: 'address', name: 'token', type: 'address',}, {internalType: 'uint256', name: 'amountMinimum', type: 'uint256',}, {internalType: 'address', name: 'recipient', type: 'address',}, ], name: 'sweepToken', outputs: [], stateMutability: 'payable', type: 'function',},
    {inputs: [ {internalType: 'address', name: 'token', type: 'address',}, {internalType: 'uint256', name: 'amountMinimum', type: 'uint256',}, {internalType: 'address', name: 'recipient', type: 'address',}, {internalType: 'uint256', name: 'feeBips', type: 'uint256',}, {internalType: 'address', name: 'feeRecipient', type: 'address',}, ], name: 'sweepTokenWithFee', outputs: [], stateMutability: 'payable', type: 'function',},
    {inputs: [ {internalType: 'int256', name: 'amount0Delta', type: 'int256',}, {internalType: 'int256', name: 'amount1Delta', type: 'int256',}, {internalType: 'bytes', name: '_data', type: 'bytes',}, ], name: 'uniswapV3SwapCallback', outputs: [], stateMutability: 'nonpayable', type: 'function',}, {inputs: [ {internalType: 'uint256', name: 'amountMinimum', type: 'uint256',}, {internalType: 'address', name: 'recipient', type: 'address',}, ], name: 'unwrapWETH9', outputs: [], stateMutability: 'payable', type: 'function',}, {inputs: [ {internalType: 'uint256', name: 'amountMinimum', type: 'uint256',}, {internalType: 'address', name: 'recipient', type: 'address',}, {internalType: 'uint256', name: 'feeBips', type: 'uint256',}, {internalType: 'address', name: 'feeRecipient', type: 'address',}, ], name: 'unwrapWETH9WithFee', outputs: [], stateMutability: 'payable', type: 'function',}, {stateMutability: 'payable', type: 'receive',}
  ];

  return new Interface(swapRouterAbi);
  // @formatter:on
}
在本教程的后续部分,您将找到关于代码在做什么的详细解释。

第 3 步:运行脚本

example
npx ts-node src/swap.ts

理解代码

首先,我们创建 Tenderly SDK 的新实例:
example.ts
const tenderly = new Tenderly({
  accessKey: 'access key',
  accountName: 'your account name',
  projectName: 'project-name-hyphenated',
  network: Network.MAINNET,
});
要调用 Simulation API 并执行捆绑模拟,我们使用 Tenderly.simulator.simulateBundle() 并传入包含以下字段的对象:
  • blockNumber - 您希望在其上执行模拟的区块号。
  • transactions - 包含三笔交易的数组:铸造 2 个 DAI、批准 Uniswap Router 以及将 DAI 兑换为 WETH。
  • overrides - 一个对象,使 fakeWardAddress 在本次模拟中成为 DAI 的 ward。
example.ts
const simulatedBundle = await tenderly.simulator.simulateBundle({
  blockNumber: 0x103a957,
  transactions: [
    // TX1: Mint 2 DAI for daiOwnerEOA.
    // For minting to happen, we must do a state override so fakeWardAddressEOA is considered a ward for this simulation (see overrides below)
    mint2DaiTx(),
    // TX2: daiOwnerEOA approves 1 DAI to uniswapV3SwapRouterAddressMainnet
    approveUniswapV3RouterTx(),
    // TX3: Perform a uniswap swap of 1/3 ETH
    swapSomeDaiForWethTx(),
  ],
  overrides: {
    [daiAddressMainnet]: {
      state: {
        // make DAI think that fakeWardAddress is a ward for minting
        [`wards[${fakeWardAddressEOA}]`]:
          '0x0000000000000000000000000000000000000000000000000000000000000001',
      },
    },
  },
});

理解 State Overrides

我们正在调用 DAI 稳定币的 mint 函数。在模拟过程中,DAI 合约必须相信您(发送方)是 wards 之一,否则它将拒绝铸造。 为使 fakeWardAddressEOA 发起的铸造成功,DAI 合约状态必须满足以下条件:
example.ts
daiAddressMainnet.state.wards[fakeWardAddressEOA] == 0x0000...0001
为实现这一点,我们将 overrides 传递给 SDK,从而覆盖 DAI 的 wards 存储变量,使 fakeDaiWardAddress 在模拟期间成为 ward。
example.ts
overrides: {
  [daiAddressMainnet]: {
    state: {
        // make DAI think that fakeWardAddress is a ward for minting
        [`wards[${fakeWardAddressEOA}]`]:
        '0x0000000000000000000000000000000000000000000000000000000000000001',
    },
  },
},
请注意,overrides 适用于整个捆绑,并对捆绑中的所有模拟可见。
现在,您对如何使用 Tenderly SDK 执行复杂的模拟场景并展示关键结果有了更深入的了解。