برای پیاده سازی Meta Transaction در سالیدیتی که کاربر گس پرداخت نکند، یک فورواردر قابل اعتماد (EIP-2771) مستقر کنید، درخواست کاربر را با EIP-712 امضا بگیرید، آن را از طریق یک ریلیر دارای موجودی به فورواردر بفرستید، و در قرارداد مقصد به جای msg.sender از _msgSender() استفاده کنید. این هسته عملی «Meta Transaction در سالیدیتی» است.

Meta Transaction دقیقا چیست و چگونه عمل می‌کند؟

Meta Transaction یعنی کاربر تنها یک پیام امضا می‌کند و هیچ اتر نمی‌پردازد؛ ریلیر آن پیام را روی زنجیره منتشر می‌کند و کارمزد را می‌پردازد. استاندارد EIP-2771 مشخص می‌کند که این پیام باید از طریق یک «Trusted Forwarder» به قرارداد مقصد برسد تا قرارداد بتواند فرستنده واقعی را تشخیص دهد. برای جلوگیری از جعل، امضا بر اساس EIP-712 (Typed Data) انجام می‌شود و نانس از تکرار تراکنش جلوگیری می‌کند.

پیش نیازها و معماری پیشنهادی

  • Solidity 0.8.x یا جدیدتر.
  • کتابخانه‌های OpenZeppelin برای ERC2771Context، ECDSA و EIP712.
  • یک فورواردر: یا از MinimalForwarder اوپن‌زپلین استفاده کنید، یا (برای یادگیری) فورواردر آموزشی زیر را مستقر کنید.
  • ریلیر: یک سرویس/سرور که کلید خصوصی و موجودی اتر دارد و تراکنش اجرا می‌کند.
  • کلاینت (دپ یا اپ): ساخت و امضای درخواست EIP-712 توسط کیف پول کاربر.

مراحل اجرا به صورت گام به گام

  1. یک Trusted Forwarder روی شبکه هدف مستقر کنید.
  2. قرارداد مقصد را طوری بنویسید که از ERC2771Context ارث ببرد و در سازنده، آدرس فورواردر را تنظیم کند.
  3. در کلاینت، ساختار درخواست را بسازید، نانس را از فورواردر بخوانید و کاربر با EIP-712 آن را امضا کند.
  4. ریلیر با آدرس دارای موجودی، متد execute فورواردر را فراخوانی می‌کند و امضا و درخواست را می‌فرستد.
  5. قرارداد مقصد به جای msg.sender از _msgSender() استفاده می‌کند تا آدرس واقعی کاربر به دست آید.

قرارداد مقصد: استفاده امن از ERC2771Context

هدف این قرارداد: یک متد ساده increment که برای هر کاربر شمارنده‌اش را افزایش می‌دهد. تاکید: در توابع، به جای msg.sender از _msgSender() استفاده کنید.

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

import "@openzeppelin/contracts/metatx/ERC2771Context.sol";

contract MyMetaApp is ERC2771Context {
    event Performed(address indexed realSender, string action);

    mapping(address => uint256) public counters;

    constructor(address trustedForwarder) ERC2771Context(trustedForwarder) {}

    function increment() external {
        address sender = _msgSender(); // نه msg.sender
        counters[sender] += 1;
        emit Performed(sender, "increment");
    }

    // الزامی برای حل ابهام ارث بری
    function _msgSender()
        internal
        view
        override(Context, ERC2771Context)
        returns (address)
    {
        return ERC2771Context._msgSender();
    }

    function _msgData()
        internal
        view
        override(Context, ERC2771Context)
        returns (bytes calldata)
    {
        return ERC2771Context._msgData();
    }
}

نکته امنیتی: هرگز در این قرارداد منطق احراز هویت را بر اساس tx.origin یا msg.sender پیاده نکنید. وقتی از EIP-2771 استفاده می‌کنید، تنها مرجع معتبر هویت کاربر _msgSender() است.

یک فورواردر آموزشی (EIP-712) برای درک مسیر داده

اگر از OpenZeppelin MinimalForwarder استفاده می‌کنید، لازم نیست فورواردر زیر را مستقر کنید. کد زیر صرفا آموزشی است تا مفهوم امضا، نانس و الحاق sender در انتهای calldata را شفاف کند. آن را بدون ممیزی امنیتی در محیط تولید به کار نبرید.

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

import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract CodityForwarder is EIP712 {
    using ECDSA for bytes32;

    struct ForwardRequest {
        address from;
        address to;
        uint256 value;
        uint256 gas;
        uint256 nonce;
        bytes data;
    }

    bytes32 private constant TYPEHASH =
        keccak256(
            "ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data)"
        );

    mapping(address => uint256) public nonces;

    constructor() EIP712("CodityForwarder", "1") {}

    function getNonce(address from) external view returns (uint256) {
        return nonces[from];
    }

    function verify(ForwardRequest calldata req, bytes calldata signature)
        public
        view
        returns (bool)
    {
        bytes32 structHash = keccak256(
            abi.encode(
                TYPEHASH,
                req.from,
                req.to,
                req.value,
                req.gas,
                req.nonce,
                keccak256(req.data)
            )
        );
        bytes32 digest = _hashTypedDataV4(structHash);
        address signer = ECDSA.recover(digest, signature);
        return signer == req.from && nonces[req.from] == req.nonce;
    }

    function execute(ForwardRequest calldata req, bytes calldata signature)
        external
        payable
        returns (bool, bytes memory)
    {
        require(verify(req, signature), "Invalid signature or nonce");
        nonces[req.from] = req.nonce + 1;

        // فراخوانی قرارداد مقصد؛ الحاق from برای سازگاری با ERC-2771
        (bool success, bytes memory returndata) = req.to.call{gas: req.gas, value: req.value}(
            abi.encodePacked(req.data, req.from)
        );

        return (success, returndata);
    }
}

در این الگو، قرارداد مقصد باید همان MyMetaApp باشد که از ERC2771Context استفاده می‌کند و آدرس این فورواردر را در سازنده به عنوان trusted forwarder دریافت کرده است.

امضای کاربر (EIP-712) و ارسال توسط ریلیر

سمت کاربر: ساخت Typed Data و امضاء با کیف پول

کاربر لازم نیست اتر داشته باشد. او فقط داده زیر را امضا می‌کند. این مثال از RPC استاندارد eth_signTypedData_v4 استفاده می‌کند.

// داده های لازم برای امضا
const forwarderAddress = "0xFORWARDER";
const chainId = 1; // شبکه هدف
const request = {
  from: "0xUSER",
  to: "0xMY_META_APP",
  value: "0",
  gas: "200000",
  nonce: "0", // از getNonce(forwarder, user) بخوانید
  data: "0x" // calldata تابع مقصد؛ مثلا iface.encodeFunctionData("increment", [])
};

// دامنه EIP-712 باید با فورواردر منطبق باشد
const domain = {
  name: "CodityForwarder",
  version: "1",
  chainId,
  verifyingContract: forwarderAddress
};

const types = {
  ForwardRequest: [
    { name: "from", type: "address" },
    { name: "to", type: "address" },
    { name: "value", type: "uint256" },
    { name: "gas", type: "uint256" },
    { name: "nonce", type: "uint256" },
    { name: "data", type: "bytes" }
  ]
};

// با متامسک یا هر کیف پول سازگار:
const signature = await window.ethereum.request({
  method: "eth_signTypedData_v4",
  params: [
    request.from,
    JSON.stringify({
      domain,
      primaryType: "ForwardRequest",
      types: { EIP712Domain: [
          { name: "name", type: "string" },
          { name: "version", type: "string" },
          { name: "chainId", type: "uint256" },
          { name: "verifyingContract", type: "address" }
        ],
        ForwardRequest: types.ForwardRequest
      },
      message: request
    })
  ]
});

// signature را به ریلیر بفرستید

سمت ریلیر: اجرای درخواست روی زنجیره

ریلیر باید دارای اتر باشد و تابع execute فورواردر را صدا بزند. نمونه با ethers (ویرایش توابع ممکن است در نسخه‌های مختلف کتابخانه متفاوت باشد؛ ایده کلی یکسان است).

import { ethers } from "ethers";
import ForwarderAbi from "./CodityForwarder.abi.json"; // ABI فورواردر

const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const relayer = new ethers.Wallet(process.env.PRIVATE_KEY, provider);

const forwarder = new ethers.Contract(forwarderAddress, ForwarderAbi, relayer);

// اختیاری: اعتبارسنجی امضا در زنجیره یا خارج از زنجیره
// const ok = await forwarder.verify(request, signature);

// اجرای متا تراکنش
const tx = await forwarder.execute(request, signature, {
  gasLimit: 300000
});
const receipt = await tx.wait();

console.log("Meta-tx mined in", receipt.transactionHash);

بررسی نتیجه: رویداد Performed را با فیلتر realSender برابر با آدرس کاربر بررسی کنید و مقدار counters[user] را بخوانید. همچنین تایید کنید که در لاگ‌ها، msg.sender قرارداد مقصد معادل آدرس فورواردر است اما _msgSender() همان کاربر واقعی است.

خطاهای رایج و روش عیب یابی

  • Invalid signature or nonce: نانس اشتباه است. قبل از امضا، مقدار نانس را از فورواردر بخوانید و در درخواست قرار دهید.
  • Out of gas: پارامتر gas در درخواست کافی نیست یا gasLimit تراکنش ریلیر کم است. هر دو را افزایش دهید.
  • تابع قرارداد از msg.sender استفاده می‌کند: باید به _msgSender() مهاجرت کنید، وگرنه کاربر اشتباه شناسایی می‌شود.
  • دامنه EIP-712 نادرست: name/version/chainId/verifyingContract باید کاملا با فورواردر همخوان باشد، وگرنه امضا باطل می‌شود.
  • Revert در مقصد: کد مقصد را با همان calldata محلی صدا بزنید تا پیام خطا را پیدا کنید. ممکن است شرط‌های require نقض شده باشد.

نکات امنیتی و محدودیت‌ها

  • Trusted Forwarder: فقط به یک فورواردر مشخص اعتماد کنید. این آدرس را در سازنده قرارداد مقصد تثبیت کنید.
  • محافظت در برابر Replay: از نانس یکتا برای هر فرستنده استفاده کنید. دامنه EIP-712 شامل chainId است تا بین شبکه‌ها ایزوله شود.
  • استراتژی پرداخت گس: ریلیر ممکن است هزینه گس را از کاربر بعدا با توکن بگیرد یا آن را سوبسید کند. این منطق خارج از قرارداد مقصد است.
  • حملات Gas Griefing: مقدار gas ارسالی در درخواست را معقول انتخاب کنید و در ریلیر gasLimit کافی بگذارید تا تراکنش ناتمام نماند.
  • Reentrancy: مثل هر فراخوانی خارجی، اگر قرارداد مقصد وجوه مدیریت می‌کند از الگوهای امن (Checks-Effects-Interactions) و در صورت نیاز قفل‌های nonReentrant استفاده کنید.

چه زمانی Meta Transaction انتخاب بهتری است؟

وقتی می‌خواهید ورود کاربر بدون کیف پول دارای موجودی یا بدون خرید اولیه اتر انجام شود (ثبت نام، مینت NFT رایگان، بازی‌ها، رأی گیری). اگر می‌خواهید مدیریت ریلیر و فورواردر را برون‌سپاری کنید یا امکانات پیشرفته‌تری مثل سیاست‌های پرداخت داشته باشید، سرویس‌ها و فریمورک‌های گس‌لس ثالث یا مسیر Account Abstraction (ERC-4337) گزینه‌های قابل بررسی هستند. با این حال برای بسیاری از سناریوهای DApp ساده، الگوی EIP-2771 مستقیم، شفاف و کم‌هزینه راه‌اندازی است.

از کجا شروع کنیم؟

ابتدا یک فورواردر (MinimalForwarder یا نمونه آموزشی بالا) را مستقر کنید، قرارداد مقصد را با ERC2771Context بنویسید و تنها در یک تابع ساده مثل increment مسیر را تست کنید. سپس امضای EIP-712 را در کلاینت خود پیاده سازی کنید و یک ریلیر کوچک بنویسید. اگر به دنبال یادگیری منسجم‌تری هستید، مطالعه مبانی قرارداد هوشمند در «دوره آموزش سالیدیتی» مسیر طبیعی بعدی است.