Перейти к основному содержанию
В этом туториале вы научитесь использовать Tenderly SDK для симуляции бандлов транзакций. Бандл — это набор из нескольких транзакций, которые симулируются как единое целое. Бандлы транзакций часто используются в DeFi-протоколах и других приложениях для моделирования сложных операций, включающих несколько взаимодействий с контрактами.
Транзакции из бандла симулируются на одних и тех же блоках.
Мы будем симулировать использование Uniswap для обмена определённого количества DAI на WETH, покажем статус выполнения симулированных транзакций и посчитаем суммарный использованный газ. Вот транзакции, необходимые для этого:
  1. Симулировать минт 2 DAI, чтобы у обменивающего были активы для свопа. Как вариант, если у вас уже есть DAI, этот шаг можно пропустить и выполнить только транзакцию 2.
  2. Одобрить UniswapV3Router на использование DAI.
  3. Выполнить своп. Вызвать UniswapV2Router.exactInputSingle, чтобы выполнить обмен.
Чтобы первая транзакция прошла успешно, отправитель должен быть ward стейблкоина DAI. Поскольку большинство из нас таковыми не являются, мы посмотрим, как с помощью 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. Транзакции определяются функциями mint2DaiTx, approveUniswapV3RouterTx и swapSomeDaiForWethTx:
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 ward’ом DAI в рамках симуляции.
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

Мы вызываем функцию mint стейблкоина DAI. Во время симуляции контракт DAI должен «поверить», что вы (отправитель) — один из wards. Иначе он откажется выполнять минт. Для успешного минта, инициированного fakeWardAddressEOA, должно выполняться следующее условие для состояния контракта DAI:
example.ts
daiAddressMainnet.state.wards[fakeWardAddressEOA] == 0x0000...0001
Чтобы это обеспечить, мы передаём в SDK overrides, где переопределяем переменную хранилища wards контракта DAI, так что fakeDaiWardAddress становится ward во время симуляции.
example.ts
overrides: {
  [daiAddressMainnet]: {
    state: {
        // make DAI think that fakeWardAddress is a ward for minting
        [`wards[${fakeWardAddressEOA}]`]:
        '0x0000000000000000000000000000000000000000000000000000000000000001',
    },
  },
},
Обратите внимание, что переопределения применяются ко всему бандлу и видны всем симуляциям внутри бандла.
Теперь вы лучше понимаете, как использовать Tenderly SDK для сложных сценариев симуляции и отображения ключевых результатов.