برای پیاده سازی «امضای EIP-712 در سالیدیتی» کافی است ساختار داده قابل امضا را تعریف کنید، دامنه (Domain) را تنظیم کنید، هش ساختار را با EIP-712 بسازید و امضا را با ECDSA.recover بازیابی کنید؛ سپس با کنترل nonce و deadline از تکرار و سوءاستفاده جلوگیری کنید. در ادامه یک الگوی امن، کد نمونه قرارداد و نحوه امضا در فرانت‌اند را می‌بینید.

چرا و چه زمانی از EIP-712 استفاده کنیم؟

EIP-712 راهی استاندارد برای امضای «داده ساخت‌یافته» است تا کاربر دقیقا بداند چه چیزی را امضا می‌کند و قرارداد بتواند همان داده را بدون ابهام تایید کند. این روش برای متاترانزکشن‌ها، مجوزدهی بدون گس (permit-like)، سفارش‌های صرافی یا هر «درخواست»ی که باید خارج از زنجیره امضا و روی زنجیره تایید شود مناسب است.

مسیر سریع پیاده سازی

  1. ساختار درخواست (struct) و TypeHash آن را دقیق تعریف کنید.
  2. دامنه EIP-712 را با name، version، chainId و آدرس قرارداد ثابت نگه دارید.
  3. هش ساختار را با abi.encode و برای فیلدهای پویا (string/bytes) با keccak256 تولید کنید.
  4. digest را با فرمول EIP-712 بسازید و امضا را با ECDSA.recover بررسی کنید.
  5. nonce اختصاصی هر کاربر را مدیریت کنید و deadline را کنترل کنید تا Replay رخ ندهد.

پیش نیازها

  • Solidity 0.8.x یا جدیدتر
  • کتابخانه‌های OpenZeppelin: EIP712 و ECDSA
  • یک کلاینت فرانت‌اند برای امضای Typed Data (مانند ethers)

نمونه پیاده سازی امضای EIP-712 در سالیدیتی

این قرارداد آموزشی، یک «Request» را تایید می‌کند، امضا را می‌سنجد و nonce کاربر را مصرف می‌کند. از OpenZeppelin برای سادگی و درستی دامنه استفاده شده است.

pragma solidity ^0.8.20;

import {EIP712} from "openzeppelin-contracts/utils/cryptography/EIP712.sol";
import {ECDSA} from "openzeppelin-contracts/utils/cryptography/ECDSA.sol";

contract RequestVerifier is EIP712 {
    string private constant NAME = "CodityRequest";
    string private constant VERSION = "1";

    struct Request {
        address user;
        address target;
        bytes callData;
        uint256 nonce;
        uint256 deadline; // timestamp; 0 یعنی بدون محدودیت
    }

    // ساختار نوع باید دقیقا با نام و ترتیب فیلدها یکی باشد.
    bytes32 private constant REQUEST_TYPEHASH =
        keccak256("Request(address user,address target,bytes callData,uint256 nonce,uint256 deadline)");

    // nonce مستقل برای هر کاربر
    mapping(address => uint256) public nonces;

    constructor() EIP712(NAME, VERSION) {}

    // هش EIP-712 قابل دیباگ و تست
    function hashRequest(Request calldata req) public view returns (bytes32) {
        bytes32 structHash = keccak256(abi.encode(
            REQUEST_TYPEHASH,
            req.user,
            req.target,
            keccak256(req.callData), // برای bytes باید keccak256 شود
            req.nonce,
            req.deadline
        ));
        return _hashTypedDataV4(structHash); // \x19\x01 | domain | structHash
    }

    // صرفا تایید امضا و قیود امنیتی
    function verify(Request calldata req, bytes calldata signature) public view returns (address signer) {
        bytes32 digest = hashRequest(req);
        signer = ECDSA.recover(digest, signature);
        require(signer == req.user, "INVALID_SIGNER");
        require(req.deadline == 0 || block.timestamp <= req.deadline, "EXPIRED");
        require(req.nonce == nonces[req.user], "BAD_NONCE");
    }

    // مصرف nonce پس از تایید موفق
    function consume(Request calldata req, bytes calldata signature) external {
        verify(req, signature);
        unchecked { nonces[req.user] += 1; }
    }
}

نکته امنیتی: این قرارداد فقط تایید می‌کند. اگر می‌خواهید پس از تایید، عملی انجام دهید (مثل فراخوانی target)، ابتدا پیامدهای امنیتی call خارجی، reentrancy و مجوزهای دسترسی را کامل بررسی کنید.

امضا در فرانت‌اند با ethers

در فرانت‌اند، همان ساختار، نام و نسخه دامنه را استفاده کنید. مقدار nonce را از قرارداد بخوانید و deadline منطقی بدهید.

// فرض: ethers در دسترس است و contract یک نمونه از RequestVerifier است
const domain = {
  name: "CodityRequest",
  version: "1",
  chainId: await signer.getChainId(),
  verifyingContract: contract.address,
};

const types = {
  Request: [
    { name: "user", type: "address" },
    { name: "target", type: "address" },
    { name: "callData", type: "bytes" },
    { name: "nonce", type: "uint256" },
    { name: "deadline", type: "uint256" },
  ],
};

// callData باید همان چیزی باشد که قرارداد انتظار دارد.
// نمونه: اینجا صرفا دمو است و ممکن است با قرارداد مقصد متفاوت باشد.
const callData = "0x"; // جایگزین با داده واقعی

const nonce = await contract.nonces(await signer.getAddress());
const deadline = Math.floor(Date.now() / 1000) + 600; // 10 دقیقه

const message = {
  user: await signer.getAddress(),
  target: "0xTargetAddressHere",
  callData,
  nonce,
  deadline,
};

// امضای Typed Data
const signature = await signer.signTypedData(domain, types, message);

// ارسال به قرارداد (معمولا توسط رله یا بک‌اند)
const tx = await contract.consume(message, signature);
await tx.wait();

بررسی نتیجه و دیباگ امضا

اگر امضا رد شد، این چک لیست را طی کنید:

  • domain.name و domain.version دقیقا با مقادیر قرارداد یکسان باشد.
  • chainId و verifyingContract صحیح باشند.
  • نام فیلدها، نوع‌ها و ترتیب آنها در types و struct یکی باشد.
  • برای فیلد bytes یا string، در قرارداد از keccak256(field) در abi.encode استفاده شده باشد.
  • nonce در پیام با مقدار on-chain برابر باشد و پس از تایید افزایش یابد.
  • deadline منقضی نشده باشد و ساعت سیستم اختلاف شدید نداشته باشد.

محاسبه دستی digest (برای دیباگ عمیق)

اگر می‌خواهید digest را بدون توابع آماده بسازید، می‌توانید از الگوی زیر استفاده کنید:

bytes32 private constant EIP712DOMAIN_TYPEHASH =
    keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");

function _domainSeparatorManual() internal view returns (bytes32) {
    return keccak256(abi.encode(
        EIP712DOMAIN_TYPEHASH,
        keccak256(bytes(NAME)),
        keccak256(bytes(VERSION)),
        block.chainid,
        address(this)
    ));
}

function _digestManual(bytes32 structHash) internal view returns (bytes32) {
    return keccak256(abi.encodePacked("\x19\x01", _domainSeparatorManual(), structHash));
}

خروجی این دو روش باید با EIP712._domainSeparatorV4 و _hashTypedDataV4 برابر باشد. اگر برابر نیست، اختلاف را در name/version/chainId/آدرس قرارداد یا ترتیب فیلدها جست‌وجو کنید.

خطاها و تله های رایج

  • abi.encodePacked به جای abi.encode: برای ساخت structHash از abi.encode استفاده کنید. ترکیب نادرست ممکن است به برخورد هش یا تفسیر اشتباه منجر شود.
  • فراموشی keccak256 برای فیلدهای پویا: در Typed Data، string/bytes باید داخل abi.encode به keccak256 تبدیل شوند.
  • عدم مدیریت nonce: اگر nonce نگذارید یا مصرف نکنید، همان امضا بارها قابل استفاده است (Replay).
  • نادیده گرفتن deadline: بدون محدودیت زمانی، امضای قدیمی در آینده قابل سوءاستفاده است.
  • دامنه ناپایدار: تغییر نام/نسخه دامنه قرارداد، همه امضاهای قبلی را نامعتبر می‌کند. دامنه را از ابتدا پایدار طراحی کنید.
  • قیاس msg.sender به جای امضاکننده: امضاکننده را با ECDSA.recover به دست آورید، نه msg.sender.
  • ریسک Chain Replay: اگر chainId را در دامنه نگنجانید، امضا ممکن است روی زنجیره‌ای دیگر معتبر باشد. EIP-712 دامنه شامل chainId است.
  • کیف پول‌های قرارداد (Smart Wallets): برای تایید امضا از قراردادها باید EIP-1271 را پشتیبانی کنید؛ ECDSA.recover فقط برای حساب‌های معمولی کاربرد دارد.

گزینه های پیشرفته و نکات امنیتی

  • EIP-1271: اگر انتظار امضا از قراردادهای هوشمند دارید، این استاندارد را برای isValidSignature پیاده و در صورت نیاز فراخوانی کنید.
  • Separator Cache: OpenZeppelin از جداکننده دامنه کش‌شده استفاده می‌کند که با تغییر chainId به‌روز می‌شود؛ اگر دستی پیاده می‌کنید، این نکته را لحاظ کنید.
  • تفکیک Context: اگر چند نوع درخواست دارید، برای هر کدام TypeHash مجزا تعریف کنید تا دامنه معنا حفظ شود.
  • Callهای خارجی: قبل از اجرای هر call مبتنی بر درخواست امضاشده، درباره reentrancy، دسترسی‌ها و سناریوهای شکست طراحی دقیقی انجام دهید.

یک نمونه کاربردی: permit شبیه ERC-20

برای سناریوهای «مجوزدهی بدون گس»، ساختاری مثل {owner، spender، value، nonce، deadline} تعریف و پس از تایید موفق، مقدار مجوز را در حالت ذخیره کنید. این الگو مشابه EIP-2612 است. مزیت آن کاهش تراکنش‌های کاربر و بهبود UX است.

گام بعدی

اگر تازه با قراردادهای هوشمند شروع کرده‌اید و می‌خواهید امضاهای امن، الگوهای permit و متاترانزکشن را عمیق‌تر یاد بگیرید، گذراندن یک دوره آموزش سالیدیتی می‌تواند مسیر شما را کوتاه کند. از همین الگوی ساده شروع کنید، تست واحد بنویسید و قبل از استفاده در محیط واقعی، همه قیود امنیتی (nonce، deadline، دامنه، انواع داده) را با دقت بازبینی کنید.