آنچه در این مقاله میخوانید [پنهانسازی]
برای پیاده سازی «امضای EIP-712 در سالیدیتی» کافی است ساختار داده قابل امضا را تعریف کنید، دامنه (Domain) را تنظیم کنید، هش ساختار را با EIP-712 بسازید و امضا را با ECDSA.recover بازیابی کنید؛ سپس با کنترل nonce و deadline از تکرار و سوءاستفاده جلوگیری کنید. در ادامه یک الگوی امن، کد نمونه قرارداد و نحوه امضا در فرانتاند را میبینید.
چرا و چه زمانی از EIP-712 استفاده کنیم؟
EIP-712 راهی استاندارد برای امضای «داده ساختیافته» است تا کاربر دقیقا بداند چه چیزی را امضا میکند و قرارداد بتواند همان داده را بدون ابهام تایید کند. این روش برای متاترانزکشنها، مجوزدهی بدون گس (permit-like)، سفارشهای صرافی یا هر «درخواست»ی که باید خارج از زنجیره امضا و روی زنجیره تایید شود مناسب است.
مسیر سریع پیاده سازی
- ساختار درخواست (struct) و TypeHash آن را دقیق تعریف کنید.
- دامنه EIP-712 را با name، version، chainId و آدرس قرارداد ثابت نگه دارید.
- هش ساختار را با abi.encode و برای فیلدهای پویا (string/bytes) با keccak256 تولید کنید.
- digest را با فرمول EIP-712 بسازید و امضا را با ECDSA.recover بررسی کنید.
- 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، دامنه، انواع داده) را با دقت بازبینی کنید.







