Перейти к основному контенту

Позвольте вашим пользователям без газа хранить токены и вызывать контракты

без газа
ERC-20
абстракция учетной записи
Средний уровень
Ори Померанц
1 апреля 2026 г.
16 минут на чтение

Введение

В предыдущей статье обсуждалось использование доступа без газа к вашему собственному приложению с помощью подписей EIP-712, но это ограничено вашими собственными смарт-контрактами. Используя абстракцию учетной записи, мы можем создавать кошельки на базе смарт-контрактов, которые принимают два типа транзакций и ретранслируют их в запрошенное место назначения:

  • Транзакции, отправленные определенным внешне принадлежащим аккаунтом (EOA) (что требует наличия ETH на этом EOA)
  • Транзакции, отправленные откуда угодно, но подписанные тем же EOA.

Таким образом, мы можем предоставить аккаунту способ без газа хранить активы (токены и т. д.) и выполнять все функции, которые может выполнять EOA с газом.

Почему мы не можем просто ретранслировать запрос?

В ERC-20 и связанных стандартах владельцем аккаунта является msg.sender (открывается в новой вкладке) — адрес, который вызвал контракт токена, что не обязательно совпадает с инициатором транзакции, tx.origin (открывается в новой вкладке). Это необходимо по соображениям безопасности (открывается в новой вкладке). Это означает, что если мы будем ретранслировать запросы на перевод токенов, они попытаются перевести токены с адреса ретранслятора, а не с адреса, контролируемого пользователем.

Существует решение, позволяющее использовать адрес EOA через EIP-7702 (открывается в новой вкладке), но оно требует подписания потенциально опасного делегирования, поэтому вы можете использовать его только для делегирования смарт-контракту, который одобряет провайдер кошелька. Для этого руководства я предпочитаю гораздо более простой метод создания смарт-контракта в качестве прокси для пользователя.

Как это работает на практике

  1. Убедитесь, что у вас установлены Node (открывается в новой вкладке) и Foundry (открывается в новой вкладке).

  2. Клонируйте приложение и установите необходимое программное обеспечение.

    git clone https://github.com/qbzzt/260315-gasless-tokens.git
    cd 260315-gasless-tokens
    forge build
    cd server
    npm install
    
  3. Отредактируйте .env, чтобы установить для SEPOLIA_PRIVATE_KEY кошелек, на котором есть ETH в сети Sepolia. Если вам нужны Sepolia ETH, используйте кран, чтобы получить их. В идеале этот приватный ключ должен отличаться от того, который вы используете в своем браузерном кошельке.

  4. Запустите сервер.

    npm run dev
    
  5. Перейдите к приложению по URL-адресу http://localhost:5173 (открывается в новой вкладке).

  6. Нажмите Connect with Injected, чтобы подключиться к кошельку. Одобрите действие в кошельке и при необходимости одобрите переключение на сеть Sepolia.

  7. Прокрутите вниз и нажмите Deploy UserProxy (slow process).

  8. Вы можете увидеть, когда пользовательский прокси развернут, так как рядом с UserProxy access появится адрес. Если вы подождали 24 секунды (2 блока), и этого все еще не произошло, возможно, возникла проблема с обнаружением изменений.

    В таком случае перейдите в обозреватель блоков Sepolia (открывается в новой вкладке) и введите хеш транзакции развертывания, который вы видите в выводе сервера в npm run dev. Нажмите на созданный контракт, чтобы просмотреть его адрес, затем скопируйте его. Вставьте адрес в поле Or enter existing proxy address, затем нажмите Set proxy address.

  9. Нажмите Request more tokens for proxy, чтобы отправить вызов функции faucet (открывается в новой вкладке) контракта ERC-20 для получения токенов. Подтвердите подпись в кошельке. Конечно, токены поступают на адрес прокси, а не пользователя.

  10. Прокрутите вниз и нажмите на ссылку под Last transaction:. Откроется браузер, в котором будет показана транзакция faucet.

  11. В поле amount to transfer введите число от одного до тысячи. Нажмите Transfer, чтобы перевести токены на свой собственный адрес. Прежде чем нажать Confirm для запроса, обратите внимание, что подписываемые данные непрозрачны. Пользователям было бы трудно понять, что они подписывают. Помните, что мы обсудим это ниже.

  12. После подтверждения транзакции подождите, пока не увидите изменения как в your balance, так и в proxy balance. Обратите внимание, что это также займет некоторое время, поскольку время блока в Sepolia составляет 12 секунд.

Как это работает

Для работы без газа нам нужен пользовательский интерфейс для пользователя, сервер для маршрутизации сообщений от пользовательского интерфейса в цепь и смарт-контракт для их получения и проверки.

Смарт-контракт кошелька

Это смарт-контракт (открывается в новой вкладке). Его цель — выполнять все, что запрашивает реальный владелец, независимо от канала, используемого для запроса, и игнорировать все остальное. Для этого его функции получают целевой адрес для вызова и данные для его вызова.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

contract UserProxy {
    address immutable OWNER;
    uint public nonce = 0;

Личность владельца и нонс (открывается в новой вкладке) для предотвращения повторения сообщений. Поскольку нонс является переменной public, компилятор Solidity также создает функцию просмотра nonce() (открывается в новой вкладке), которая позволяет офчейн-коду считывать ее значение.

    bytes32 private constant SIGNED_ACCESS_TYPEHASH =
        keccak256("SignedAccess(address target,bytes data,uint256 nonce)");

    bytes32 private constant SIGNED_ACCESS_PAYABLE_TYPEHASH =
        keccak256("SignedAccessPayable(address target,bytes data,uint256 nonce,uint256 value)");

    bytes32 immutable DOMAIN_SEPARATOR;

Информация, необходимая для проверки подписей EIP-712 (открывается в новой вкладке).

    constructor(address owner_) {
        OWNER = owner_;

UserProxy привязан к одному адресу владельца. Это необходимо, поскольку он может владеть активами (токенами ERC-20, NFT и т. д.). Мы не хотим смешивать активы, принадлежащие разным владельцам.

Разделитель домена (открывается в новой вкладке). Его нельзя вычислить во время компиляции, поскольку он зависит от идентификатора цепи и адреса контракта. Это делает невозможным обман UserProxy сообщением, подготовленным для другого.

    event CallResult(address target, bytes returnData);

Записать в лог результаты вызова.

    function directAccess(address target, bytes calldata data)
            external returns (bytes memory) {

Эта функция может быть вызвана непосредственно владельцем. Если ретрансляторы недоступны, владелец все равно может получить доступ к активами напрямую в блокчейне (если у пользователя есть ETH).

        require(msg.sender == OWNER, "Only owner can call");
        (bool success, bytes memory returnData) = target.call(data);
        require(success, "Call failed");

        emit CallResult(target, returnData);

        return returnData;
    }

Если нас вызывает напрямую владелец, вызываем цель с предоставленными данными вызова.

    function signedAccess(
        address target,
        bytes calldata data,
        uint8 v,
        bytes32 r,
        bytes32 s)

Это основная функция UserProxy. Она получает target и data, а также подпись.

Дайджест также включает нонс, но нам не нужно получать его из транзакции; мы уже знаем правильное значение. Подпись с неправильным нонсом будет отклонена.


    // Восстановить подписанта
    address signer = ecrecover(digest, v, r, s);
    require(signer == OWNER, "Signature invalid or not by owner");

Если подпись недействительна, ecrecover обычно возвращает другой адрес, и она не будет принята.

    (bool success, bytes memory returnData) = target.call(data);
    require(success, "Call failed");

Вызываем контракт, который пользователь указал нам вызвать, и выполняем откат в случае неудачи.

    emit CallResult(target, returnData);

    nonce++; // Увеличить нонс для предотвращения повторного воспроизведения

    return returnData;
}

В случае успеха генерируем событие в лог и увеличиваем нонс.

Это почти идентичные варианты, которые также позволяют вам переводить ETH из контракта.

Ретранслятор

Ретранслятор — это серверный компонент. Он написан на JavaScript; вы можете посмотреть исходный код здесь (открывается в новой вкладке).

import express from "express";
import { createServer as createViteServer } from "vite";
import { createWalletClient, createPublicClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { sepolia } from 'viem/chains'
import dotenv from 'dotenv'

Необходимые нам библиотеки. Это сервер Express (открывается в новой вкладке), который использует Vite (открывается в новой вкладке) для обслуживания кода пользовательского интерфейса. Мы используем Viem (открывается в новой вкладке) для связи с блокчейном и dotenv (открывается в новой вкладке) для чтения приватного ключа адреса, который отправляет транзакцию.

import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const UserProxy = require('../contracts/out/UserProxy.sol/UserProxy.json')

Это простой способ прочитать скомпилированный UserProxy. Нам нужен ABI, чтобы иметь возможность вызывать UserProxy, и скомпилированный код, чтобы иметь возможность развернуть его для пользователя.

dotenv.config()
const sepoliaAccount = privateKeyToAccount(process.env.SEPOLIA_PRIVATE_KEY)
console.log("Using account:", sepoliaAccount.address)

Читаем файл .env, извлекаем адрес и выводим его в консоль.

Клиенты Viem, которые взаимодействуют с блокчейном.

const start = async () => {
  const app = express()

Запускаем сервер Express.

  app.use(express.json())

Указываем Express прочитать тело запроса и, если это JSON, проанализировать его.

  app.post("/server/deploy", async (req, res) => {

Это код, который обрабатывает запросы на развертывание прокси. Обратите внимание, что здесь мы уязвимы для атак отказа в обслуживании (DoS) (открывается в новой вкладке), поскольку злоумышленник может заспамить нас запросами на развертывание прокси, пока наши ETH не будут исчерпаны. В производственной системе мы, вероятно, потребовали бы, чтобы запрос на развертывание прокси был подписан, а подписавший был существующим клиентом.

    try {
      const ownerAddress = req.body.ownerAddress

Получаем адрес владельца из запроса.

Развертываем контракт (открывается в новой вкладке) и ждем, пока он не будет развернут (открывается в новой вкладке).

      res.json({ contractAddress: receipt.contractAddress })

Если все в порядке, возвращаем адрес прокси в пользовательский интерфейс.

    } catch (err) {
      console.error(err)
      res.status(500).json({ error: err.message })
    }
  })

Если возникла проблема, сообщаем о ней.

  app.post("/server/message", async (req, res) => {

Это код, который обрабатывает сообщения пользователя для контракта UserProxy. Это еще одна точка, уязвимая для атаки отказа в обслуживании.

Получаем данные запроса и используем их для вызова signedAccess на прокси.

      console.log("Message transaction hash:", txHash)

      res.json({ txHash })

Сообщаем хеш транзакции. Это позволяет пользовательскому интерфейсу отображать URL-адрес, чтобы пользователь мог проверить транзакцию.

    } catch (err) {
      console.error(err)
      res.status(500).json({ error: err.message })
    }
  })

Опять же, если есть проблема, сообщаем о ней.

Для всего остального используем Vite, который занимается обслуживанием пользовательского интерфейса для нас.

Пользовательский интерфейс

Это код пользовательского интерфейса (открывается в новой вкладке). Большая часть кода почти идентична той, что задокументирована в этой статье, за исключением Token.jsx (открывается в новой вкладке).

Части Token.jsx (открывается в новой вкладке) похожи на Greeter.jsx (открывается в новой вкладке) в этой статье. Вот новые части.

import {
   encodeFunctionData
       } from 'viem'

Эта функция (открывается в новой вкладке) создает данные вызова для вызова функции EVM. Это необходимо, чтобы пользователь мог подписать данные вызова.

import UserProxy from '../../contracts/out/UserProxy.sol/UserProxy.json'

UserProxy, объясненный выше.

import Erc20 from '../../contracts/out/Faucet.sol/FaucetToken.json'

Этот контракт (открывается в новой вкладке) в основном является обычным контрактом ERC-20, с добавлением одной важной функции — faucet(). Эта функция предоставляет токены любому, кто их запрашивает, в целях тестирования.

const erc20Addrs = {
  // Sepolia
    11155111: '0x4cBedDEDA88fDd9e116618a5cD71BB0E440C2A78'
}

Адрес для FaucetToken.

const Address = ({ address }) => {
   if (!address) return null
   return (
      <a href={`https://eth-sepolia.blockscout.com/address/${address}?tab=read_write_contract`} target="_blank">{address}</a>
   )
}

Этот компонент выводит адрес со ссылкой на контракт в обозревателе блоков.

const Token = () => {
    ...

Это основной компонент, который выполняет большую часть работы.

  const [ balanceAmount, setBalanceAmount ] = useState("Loading...")

Баланс токенов на адресе пользователя.

  const [ proxyAddr, setProxyAddr ] = useState(null)

Адрес прокси, принадлежащего пользователю.

  const [ proxyBalanceAmount, setProxyBalanceAmount ] = useState("Loading...")

Баланс токенов прокси.

  const [ newProxyAddr, setNewProxyAddr ] = useState("")

Это поле используется, когда пользователь вручную устанавливает адрес прокси. Возможность установить адрес прокси вручную позволяет пользователю использовать существующий прокси вместо того, чтобы каждый раз развертывать новый (и терять все токены, принадлежащие старому прокси).

  const [ txHash, setTxHash ] = useState(null)

Хеш последней транзакции, используемый для отображения ссылки на обозреватель, чтобы пользователь мог проверить эту транзакцию.

  const [ transferToken, setTransferToken ] = useState("")
  const [ transferAmount, setTransferAmount ] = useState("")
  const [ transferTo, setTransferTo ] = useState("")

Все эти поля используются для отправки команд перевода токенов в контракт ERC-20. Это может быть FaucetToken, но не обязательно. Функция transfer является частью стандарта ERC-20.

  const balance = useReadContract({
    ...
  })


  const proxyBalance = useReadContract({
    ...
  })

Считываем два интересующих нас баланса токенов: сколько принадлежит пользователю и сколько принадлежит прокси.

  const nonce = useReadContract({
      address: proxyAddr,
      abi: UserProxy.abi,
      functionName: 'nonce',
      args: [],
  })

Для предотвращения атак повторного воспроизведения (например, когда продавец повторяет транзакцию, которая приносит ему деньги), мы используем нонс (открывается в новой вкладке). Нам нужно знать текущее значение, чтобы добавить его к подписываемым данным.

Используйте useEffect (открывается в новой вкладке) для обновления баланса, отображаемого пользователю, когда информация, считанная из блокчейна, изменяется.

  useEffect(() => {
    setTransferToken(faucetAddr)
  }, [faucetAddr])

  useEffect(() => {
    setTransferTo(account.address)
  }, [account.address])

По умолчанию токены FaucetToken переводятся на собственный аккаунт пользователя. Здесь мы устанавливаем эти значения, когда получаем их от Viem.

  const proxyAddressChange = (evt) => setNewProxyAddr(evt.target.value)
  const transferTokenChange = (evt) => setTransferToken(evt.target.value)
  const transferToChange = (evt) => setTransferTo(evt.target.value)
  const transferAmountChange = (evt) => setTransferAmount(evt.target.value)

Обработчики событий для изменения текстовых полей.

Просим сервер развернуть прокси для этого пользователя.

  const signMessage = async(proxyAddr, target, calldata) => {

Подписываем сообщение перед отправкой его на сервер для отправки в UserProxy ончейн. Это объясняется здесь. Нам нужно подписать сообщение как с целевым адресом (адресом токена, который мы вызываем), так и с данными вызова для отправки.

    const domain = {
      .
      .
      .
    return {v, r, s}
  }

  const messageUserProxy = async (proxy, target, data, v, r, s) => {

Отправляем подписанное сообщение в UserProxy, который проверит подпись, а затем отправит его на target.

Отправляем запрос на сервер и, когда получаем ответ, извлекаем хеш транзакции.

  const faucetSimulation = useSimulateContract({
    address: faucetAddr,
    abi: Erc20.abi,
    functionName: 'faucet',
    account: account.address
  })

Симулируем вызов функции faucet. Мы включаем кнопку крана только в случае успеха.

Чтобы вызвать функцию через сервер и UserProxy, мы выполняем три шага:

  1. Создаем данные вызова для подписания и отправки с помощью encodeFunctionData (открывается в новой вкладке).

  2. Подписываем сообщение (целевой адрес, данные вызова и нонс).

  3. Отправляем сообщение на сервер.

Эта часть компонента позволяет вам использовать FaucetToken прямо из браузера. Ее основная цель — облегчить отладку.

         <h4>UserProxy access <Address address={proxyAddr} /></h4>
         <button onClick={deployUserProxy}>
         Deploy UserProxy (slow process)
         </button>

Позволяем пользователю развернуть новый UserProxy.

Позволяем пользователям нажимать Set proxy address только тогда, когда они вводят правильный адрес. Обратите внимание, что это не гарантирует, что рассматриваемый адрес действительно является контрактом UserProxy. Можно добавить такую проверку, но это будет намного медленнее (худший пользовательский опыт) и не улучшит безопасность (злоумышленники всегда могут использовать свой собственный код для пользовательского интерфейса).

         <br /><br />
         { proxyAddr && (

Показываем остальное только в том случае, если есть правильный адрес прокси.

            <>
               Proxy balance: {proxyBalanceAmount}
               <br />
               Proxy nonce: {nonce?.data?.toString() ?? "Loading..."}

Пользователю не нужно знать нонс; это только для целей отладки.

               <br />
               <button disabled={!proxyAddr || proxyAddr === "Loading..." || nonce?.status !== 'success'}
                  onClick={proxyFaucet}
               >
                  Request more tokens for proxy
               </button>

Мы не можем симулировать вызов faucet() через прокси. Однако мы можем по крайней мере убедиться, что у нас есть прокси и что прокси сообщил нам нонс.

Позволяем пользователю инициировать транзакции перевода ERC-20.

Если есть хеш последней транзакции, показываем ссылку, чтобы пользователь мог просмотреть ее в обозревателе блоков.

 
</div>
    </>
  )
}

export {Token}

Это просто шаблонный код React.

Уязвимости

Наш сервер уязвим для атак отказа в обслуживании. Эта атака объясняется в предыдущей статье серии.

Кроме того, мы поощряем плохое поведение пользователей. Вот что мы просим пользователя подписать:

Screen capture with opaque calldata

Мы знаем, что это законный перевод ERC-20 для токена, суммы и адреса назначения, которые пользователь хочет перевести. Но большинство пользователей не знают, как интерпретировать данные вызова, и понятия не имеют, что они подписывают. Это плохой дизайн по двум причинам:

  • Некоторые пользователи не будут использовать нас, потому что они не доверяют данным, которые мы просим их подписать.
  • Другие пользователи будут доверять нам и усвоят, что им следует просто подписывать данные вызова, не понимая, что это такое. Это означает, что если злоумышленнику Адаму удастся перенаправить их на свой веб-сайт, он сможет заставить их подписать транзакцию, которая предоставит ему все USDC (или DAI, или любой другой ERC-20), которыми владеет пользователь.

Решение состоит в том, чтобы иметь отдельные функции в UserProxy для часто используемых функций, таких как перевод. Тогда пользователи смогут подписывать то, что они понимают.

Screen capture with transfer details

Примечание: Хотя пользователи могут использовать любой кошелек, который они хотят, настоятельно рекомендуется, чтобы приложения, использующие EIP-712, поощряли их использовать кошелек, который показывает все данные подписи (открывается в новой вкладке). Некоторые кошельки усекают адрес, что небезопасно. Злоумышленник может создать адрес, который имеет те же начальные и конечные символы, но отличается в середине.

Screen capture with truncated addresses

Заключение

В дополнение к вышеупомянутым уязвимостям, решение в этом руководстве имеет несколько недостатков, которые Эфириум может помочь нам устранить.

  • Устойчивость к цензуре. В настоящее время пользователи могут использовать ваш сервер, конкурирующий сервер, настроенный кем-то другим, или подключаться к Эфириуму напрямую, что влечет за собой затраты на газ. Использование ERC-4337 (открывается в новой вкладке) позволяет пользователям предлагать свою транзакцию большому пулу серверов, снижая вероятность того, что их транзакции будут подвергнуты цензуре.
  • Активы, принадлежащие EOA. Как отмечалось выше, EIP-7702 (открывается в новой вкладке) можно использовать для управления активами, уже принадлежащими адресу EOA. В этом есть свои трудности, но иногда это необходимо.

Я надеюсь опубликовать руководства по добавлению этих функций в ближайшем будущем.

Смотрите здесь больше моих работ (открывается в новой вкладке).