برای تایید داده بدون نگهداری کل لیست در بلاکچین، ریشه درخت مرکل را روی قرارداد ذخیره کنید و هر کاربر هنگام درخواست، مقدار خود را به همراه Merkle Proof بفرستد. قرارداد با تابع‌های کتابخانه MerkleProof در سالیدیتی، برگ (leaf) را از ورودی کاربر می‌سازد و با ریشه ذخیره‌شده مقایسه می‌کند؛ اگر همخوانی داشت، درخواست معتبر است. این دقیقا کاری است که «Merkle Proof در سالیدیتی» ممکن می‌کند.

Merkle Proof چیست و چرا برای سالیدیتی مناسب است؟

Merkle Proof گواهی‌ای است که نشان می‌دهد یک داده مشخص عضو مجموعه‌ای بزرگ است؛ بدون اینکه کل مجموعه روی زنجیره ذخیره شود. به جای آرایه‌های حجیم، فقط یک مقدار ۳۲ بایتی (ریشه درخت مرکل) روی قرارداد نگه می‌داریم و هر تایید O(log n) هزینه دارد. این الگو برای وایت‌لیست، ایردراپ، حق رای یا هر سناریویی که «عضویت در یک لیست» را باید ارزان و قابل راستی‌آزمایی کنید، ایده‌آل است.

پیش نیازهای فنی

  • Solidity و آشنایی با keccak256 برای هش کردن داده.
  • کتابخانه OpenZeppelin MerkleProof برای پردازش اثبات‌ها (proof).
  • ساخت درخت مرکل و ریشه در محیط خارج از زنجیره (مثلا اسکریپت Node.js).
  • توافق دقیق روی نحوه هش کردن برگ‌ها و ترتیب جفت‌ها (sorted pairs).

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

  1. خارج از زنجیره: داده‌ها را به برگ‌ها تبدیل کنید (مثلا leaf = keccak256(abi.encodePacked(address, amount)) معادل خارج از زنجیره).
  2. خارج از زنجیره: درخت مرکل را با گزینه مرتب سازی جفت‌ها بسازید و ریشه (root) را استخراج کنید.
  3. روی زنجیره: قرارداد را با مقدار merkleRoot مستقر کنید.
  4. کاربر: هنگام درخواست، مقدار خود را به همراه آرایه proof ارسال می‌کند.
  5. قرارداد: با MerkleProof.verify اثبات را بررسی می‌کند. در صورت تایید، عملیات انجام می‌شود.

نمونه کد 1: تایید عضویت یک آدرس در وایت‌لیست

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

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

import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";

contract WhitelistVerifier {
    bytes32 public immutable merkleRoot;

    constructor(bytes32 _root) {
        merkleRoot = _root;
    }

    // برگ بر اساس آدرس ساخته می‌شود: leaf = keccak256(abi.encodePacked(account))
    function isWhitelisted(address account, bytes32[] calldata proof) public view returns (bool) {
        bytes32 leaf = keccak256(abi.encodePacked(account));
        // OpenZeppelin فرض می‌کند جفت‌ها مرتب هستند (sorted pairs)
        return MerkleProof.verifyCalldata(proof, merkleRoot, leaf);
    }
}

نکته: verifyCalldata در برابر verify اندکی بهینه‌تر است چون آرایه proof را در calldata می‌خواند.

نمونه کد 2: ایردراپ با اثبات مرکل (آدرس + مقدار)

در این سناریو، هر کاربر می‌تواند فقط یک بار دقیقا «مقدار از پیش تعریف شده» را دریافت کند. برای سادگی، از یک توکن ERC20 فرضی با رابط حداقلی استفاده شده است. برای کد عملیاتی بهتر است از SafeERC20 استفاده شود.

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

import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
}

contract MerkleAirdrop {
    bytes32 public immutable merkleRoot;
    IERC20  public immutable token;

    mapping(address => bool) public claimed;

    event Claimed(address indexed account, uint256 amount);

    constructor(bytes32 _root, address _token) {
        merkleRoot = _root;
        token = IERC20(_token);
    }

    // برگ: leaf = keccak256(abi.encodePacked(account, amount))
    function claim(uint256 amount, bytes32[] calldata proof) external {
        require(!claimed[msg.sender], "Already claimed");

        bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amount));
        bool ok = MerkleProof.verifyCalldata(proof, merkleRoot, leaf);
        require(ok, "Invalid proof");

        // اثرات قبل از تعامل خارجی
        claimed[msg.sender] = true;

        // انتقال توکن
        require(token.transfer(msg.sender, amount), "Transfer failed");

        emit Claimed(msg.sender, amount);
    }
}

نکات امنیتی کوتاه:

  • الگوی checks-effects-interactions رعایت شده است.
  • برای تولید آماده، از SafeERC20 و در صورت نیاز ReentrancyGuard استفاده کنید.
  • قرارداد باید به اندازه مجموع مقادیر، پیش از شروع claim تامین توکن شده باشد.

ساخت ریشه و Proof خارج از زنجیره (Node.js)

در مثال زیر، برگ‌ها از نوع keccak256(address) هستند و درخت با «مرتب سازی جفت‌ها» ساخته می‌شود تا با MerkleProof همخوان باشد.

// npm i merkletreejs keccak256
const { MerkleTree } = require('merkletreejs');
const keccak256 = require('keccak256');

// فهرست آدرس ها (با 0x و 40 کاراکتر هگز)
const addresses = [
  "0x1111111111111111111111111111111111111111",
  "0x2222222222222222222222222222222222222222",
  "0x3333333333333333333333333333333333333333",
];

// هش برگ ها مطابق abi.encodePacked(address): ورودی باید 20 بایت خام باشد
const leaves = addresses.map(addr => keccak256(Buffer.from(addr.slice(2), 'hex')));

// درخت با مرتب سازی جفت ها (sorted pairs)
const tree = new MerkleTree(leaves, keccak256, { sortPairs: true });

const root = tree.getRoot().toString('hex');
console.log('Merkle Root:', '0x' + root);

// نمونه ساخت proof برای آدرس دوم
const target = addresses[1];
const leaf = keccak256(Buffer.from(target.slice(2), 'hex'));
const proof = tree.getHexProof(leaf);

console.log('Proof for', target, ':', proof);

اگر برگ شما ترکیب آدرس و مقدار است، باید دقیقا همان ترتیب و نوع‌ها را رعایت کنید. در قرارداد از abi.encodePacked(address,uint256) استفاده شده که هر دو نوع ثابت‌طول هستند، پس معادل خارج از زنجیره باید 20 بایت آدرس و 32 بایت عدد را پشت سر هم هش کند. ساده‌ترین راه استفاده از یک کتابخانه ABI encoder است؛ اما اگر دستی می‌سازید:

const { default: keccak256 } = require('keccak256');

// addressHex: مثل "0xabc..."
// amountBN: عدد به صورت BigInt یا BN با تبدیل به 32 بایت Big-Endian
function leafForAddressAmount(addressHex, amountBN) {
  const addrBytes = Buffer.from(addressHex.slice(2), 'hex'); // 20 bytes
  const amtHex = amountBN.toString(16).padStart(64, '0');
  const amtBytes = Buffer.from(amtHex, 'hex');               // 32 bytes
  return keccak256(Buffer.concat([addrBytes, amtBytes]));    // keccak256(abi.encodePacked(addr, amount))
}

چطور مطمئن شویم نتیجه درست است؟

  • تطابق ریشه: ریشه‌ای که قرارداد با آن مستقر شده باید دقیقا برابر با ریشه تولیدشده خارج از زنجیره باشد (حروف بزرگ/کوچک در هگز تاثیری ندارد).
  • سازگاری برگ: همان فرمول هش برگ را هر دو سمت استفاده کنید. برای آدرس تنها: keccak256(abi.encodePacked(address)). برای آدرس+مقدار: keccak256(abi.encodePacked(address,uint256)).
  • مرتب سازی جفت‌ها: اگر درخت را با sortPairs: true ساخته‌اید، در قرارداد هم باید از MerkleProof استاندارد (که فرض مرتب بودن دارد) استفاده شود. عدم تطابق، رایج‌ترین علت خطاست.
  • طول proof: اگر proof خیلی کوتاه یا طولانی است، احتمالا برگ یا درخت اشتباه ساخته شده است.
  • مقدار ورودی: هر تغییری در amount یا پارامترهای برگ، proof را نامعتبر می‌کند.

اشتباهات رایج و علت آن‌ها

  • هش کردن رشته آدرس به جای بایت‌های آدرس: keccak256(“0xabc…”) با keccak256(bytes20) فرق دارد. باید 20 بایت خام آدرس را هش کنید.
  • مخلوط کردن abi.encode با abi.encodePacked: اگر از encode در خارج زنجیره استفاده می‌کنید، باید سمت قرارداد هم encode باشد. برای انواع ثابت‌طول، encodePacked معمولا انتخاب بهینه‌تری است.
  • عدم مرتب سازی جفت‌ها: MerkleProof اوپن‌زپلین جفت‌ها را مرتب فرض می‌کند. اگر درخت شما بدون sortPairs ساخته شود، verify شکست می‌خورد.
  • داده‌های متغیرطول در encodePacked: ترکیب رشته‌ها یا بایت‌های متغیرطول با encodePacked می‌تواند ابهام‌زا شود. برای انواع متغیرطول از abi.encode استفاده کنید یا جداکننده/طول اضافه کنید.
  • برگ‌های تکراری: وجود مقدار تکراری می‌تواند تحلیل و اثبات‌ها را مبهم کند. تا حد امکان یکتا نگه دارید.

مزیت‌ها و محدودیت‌ها

  • مزیت‌ها: فقط یک کلمه ۳۲ بایتی روی زنجیره؛ تایید با هزینه O(log n)؛ مناسب برای لیست‌های بزرگ.
  • محدودیت‌ها: لیست ایستا است؛ تغییرات نیازمند ریشه جدید و به‌روزرسانی قرارداد است. مدیریت اعتماد (چه کسی ریشه را می‌سازد/تغییر می‌دهد) باید روشن باشد.

چه زمانی از Merkle Proof استفاده کنیم و چه زمانی نه؟

  • مناسب: وایت‌لیست مینت/ایردراپ، تخصیص توکن، اثبات تعلق داده به یک اسنپ‌شات.
  • نامناسب: داده‌های با نوسان زیاد یا نیازمند به‌روزرسانی مداوم درون تراکنش‌های کاربر. در این حالت امضای خارج از زنجیره (EIP-712) یا نگاشت‌های روی زنجیره ممکن است مناسب‌تر باشند.

نکات اجرایی برای قراردادهای تولید

  • اگر نیاز به به‌روزرسانی ریشه دارید، تابعی برای تغییر merkleRoot با دسترسی محدود در نظر بگیرید و تغییرات را با رویداد لاگ کنید.
  • برای ایردراپ‌های توکنی از SafeERC20 استفاده کنید و ترتیب checks-effects-interactions را رعایت کنید.
  • در بررسی‌های پرتکرار از verifyCalldata استفاده کنید تا کمی گس صرفه‌جویی شود.
  • پروپرتی‌های تراکنش نامعتبر را با پیام خطای شفاف رد کنید تا دیباگ ساده‌تر شود.

گام بعدی چیست؟

یک اسکریپت ساده بسازید، ریشه را تولید کنید و با یکی از قراردادهای بالا روی شبکه آزمایشی مستقر کنید. سپس با proof تولیدشده تابع verify یا claim را صدا بزنید. اگر به دنبال مسیر یادگیری منسجم هستید، شرکت در یک دوره آموزش سالیدیتی کمک می‌کند منطق هش، ABI و الگوهای امنیتی را یک‌پارچه تمرین کنید.