פיתוח חוזה תביעה לחלוקת טוקנים
פרסתם איירדרופ, אבל הגז לפריסת רשימה של 10,000 כתובות אכל חצי מהתקציב שלכם. או גרוע מכך — מישהו מצא דרך לתבוע טוקנים פעמיים עם אותה הוכחה. הבעיות האלה מופיעות בייצור כל הזמן. חוזה תביעה הוא כלי סטנדרטי, אבל היישום שלו דורש דיוק. אנחנו, צוות עם ניסיון של למעלה מ-5 שנים בבלוקצ'יין, מספקים יצירת חוזה תביעה סוהר עם ערבויות אבטחה ואופטימיזציית גז. במאמר זה, נפרק למה עץ מרקל הוא התקן התעשייתי ואיך להימנע מטעויות יקרות.
למה עץ מרקל על פני רשימה על-השרשרת?
| פרמטר | רשימה לבנה על-השרשרת | עץ מרקל |
|---|---|---|
| גז בפריסה (10k כתובות) | ~0.5 ETH | ~0.01 ETH |
| גז בתביעה | ~50k (SLOAD) | ~30k (SLOAD + 2x SHA3) |
| אחסון | 10k משבצות אחסון | 1 bytes32 |
| יכולת עדכון | דורש הגירת חוזה | שינוי שורש |
חיסכון בגז בפריסה עד 90% — עבור פרויקטים גדולים זה מתורגם לאלפי דולרים.
איך ליישם תביעה מבוססת מרקל
בניית העץ (מחוץ לשרשרת)
import { StandardMerkleTree } from "@openzeppelin/merkle-tree";
// Листья: [address, amount]
const values = [
["0xAddress1...", ethers.parseEther("100")],
["0xAddress2...", ethers.parseEther("250")],
// ...
];
const tree = StandardMerkleTree.of(values, ["address", "uint256"]);
console.log("Merkle Root:", tree.root);
// Сохраняем дерево для генерации proofs
fs.writeFileSync("tree.json", JSON.stringify(tree.dump()));
// Для конкретного адреса генерируем proof
for (const [i, v] of tree.entries()) {
if (v[0] === "0xAddress1...") {
const proof = tree.getProof(i);
console.log("Proof:", proof);
}
} חוזה
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MerkleClaim is Ownable {
IERC20 public immutable token;
bytes32 public immutable merkleRoot;
uint256 public immutable claimDeadline;
// Packed bitmap для газовой эффективности вместо mapping(address => bool)
mapping(uint256 => uint256) private claimedBitMap;
event Claimed(address indexed account, uint256 amount, uint256 index);
constructor(
address _token,
bytes32 _merkleRoot,
uint256 _claimWindowDays
) Ownable(msg.sender) {
token = IERC20(_token);
merkleRoot = _merkleRoot;
claimDeadline = block.timestamp + (_claimWindowDays * 1 days);
}
function isClaimed(uint256 index) public view returns (bool) {
uint256 claimedWordIndex = index / 256;
uint256 claimedBitIndex = index % 256;
uint256 claimedWord = claimedBitMap[claimedWordIndex];
uint256 mask = (1 << claimedBitIndex);
return claimedWord & mask == mask;
}
function _setClaimed(uint256 index) private {
uint256 claimedWordIndex = index / 256;
uint256 claimedBitIndex = index % 256;
claimedBitMap[claimedWordIndex] = claimedBitMap[claimedWordIndex] | (1 << claimedBitIndex);
}
function claim(
uint256 index,
address account,
uint256 amount,
bytes32[] calldata merkleProof
) external {
require(block.timestamp <= claimDeadline, "Claim period ended");
require(!isClaimed(index), "Already claimed");
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(index, account, amount))));
require(MerkleProof.verify(merkleProof, merkleRoot, leaf), "Invalid proof");
_setClaimed(index);
token.transfer(account, amount);
emit Claimed(account, amount, index);
}
// Возврат неклеймленных токенов после дедлайна
function recoverUnclaimed() external onlyOwner {
require(block.timestamp > claimDeadline, "Claim period active");
uint256 balance = token.balanceOf(address(this));
token.transfer(owner(), balance);
}
}שימוש בביטמאפ במקום import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; // Листья: [address, amount] const values = [ ["0xAddress1...", ethers.parseEther("100")], ["0xAddress2...", ethers.parseEther("250")], // ... ]; const tree = StandardMerkleTree.of(values, ["address", "uint256"]); console.log("Merkle Root:", tree.root); // Сохраняем дерево для генерации proofs fs.writeFileSync("tree.json", JSON.stringify(tree.dump())); // Для конкретного адреса генерируем proof for (const [i, v] of tree.entries()) { if (v[0] === "0xAddress1...") { const proof = tree.getProof(i); console.log("Proof:", proof); } } הוא אופטימיזציה חשובה. משבצת אחסון אחת (32 בתים) מאחסנת 256 דגלים. עבור 10,000 משתתפים, יש צורך בכ-40 משבצות במקום 10,000. התביעה הראשונה במשבצת עולה 20,000 גז (SSTORE קר), הבאות אחריה 5,000 (SSTORE חם). החיסכון משמעותי.
איך עובדת תביעת וסטינג
עבור צוות ומשקיעים, תביעה בדרך כלל עובדת יחד עם וסטינג. Cliff + שחרור ליניארי הוא הסכימה הסטנדרטית:
struct VestingSchedule {
uint256 totalAmount;
uint256 cliffEnd; // timestamp конца клиффа
uint256 vestingEnd; // timestamp полной разблокировки
uint256 claimed; // уже клеймлено
}
mapping(address => VestingSchedule) public schedules;
function claimVested() external {
VestingSchedule storage schedule = schedules[msg.sender];
require(block.timestamp >= schedule.cliffEnd, "Cliff not reached");
uint256 vested = _calculateVested(schedule);
uint256 claimable = vested - schedule.claimed;
require(claimable > 0, "Nothing to claim");
schedule.claimed += claimable;
token.transfer(msg.sender, claimable);
}
function _calculateVested(VestingSchedule memory s) private view returns (uint256) {
if (block.timestamp >= s.vestingEnd) return s.totalAmount;
if (block.timestamp < s.cliffEnd) return 0;
uint256 vestingDuration = s.vestingEnd - s.cliffEnd;
uint256 elapsed = block.timestamp - s.cliffEnd;
return (s.totalAmount * elapsed) / vestingDuration;
} פגיעויות נפוצות
| פגיעות | השלכות | פתרון |
|---|---|---|
| תביעה כפולה ללא ביטמאפ | אובדן טוקנים בתביעה חוזרת עם סכום שונה | ביטמאפ עם אינדקס ייחודי |
| הטרדה דרך תביעה בשם אחר | העברת טוקנים כפויה לכתובת לא רצויה | דרוש // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; contract MerkleClaim is Ownable { IERC20 public immutable token; bytes32 public immutable merkleRoot; uint256 public immutable claimDeadline; // Packed bitmap для газовой эффективности вместо mapping(address => bool) mapping(uint256 => uint256) private claimedBitMap; event Claimed(address indexed account, uint256 amount, uint256 index); constructor( address _token, bytes32 _merkleRoot, uint256 _claimWindowDays ) Ownable(msg.sender) { token = IERC20(_token); merkleRoot = _merkleRoot; claimDeadline = block.timestamp + (_claimWindowDays * 1 days); } function isClaimed(uint256 index) public view returns (bool) { uint256 claimedWordIndex = index / 256; uint256 claimedBitIndex = index % 256; uint256 claimedWord = claimedBitMap[claimedWordIndex]; uint256 mask = (1 << claimedBitIndex); return claimedWord & mask == mask; } function _setClaimed(uint256 index) private { uint256 claimedWordIndex = index / 256; uint256 claimedBitIndex = index % 256; claimedBitMap[claimedWordIndex] = claimedBitMap[claimedWordIndex] | (1 << claimedBitIndex); } function claim( uint256 index, address account, uint256 amount, bytes32[] calldata merkleProof ) external { require(block.timestamp <= claimDeadline, "Claim period ended"); require(!isClaimed(index), "Already claimed"); bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(index, account, amount)))); require(MerkleProof.verify(merkleProof, merkleRoot, leaf), "Invalid proof"); _setClaimed(index); token.transfer(account, amount); emit Claimed(account, amount, index); } // Возврат неклеймленных токенов после дедлайна function recoverUnclaimed() external onlyOwner { require(block.timestamp > claimDeadline, "Claim period active"); uint256 balance = token.balanceOf(address(this)); token.transfer(owner(), balance); } } |
| חוסר ב-recoverUnclaimed | טוקנים נעולים לנצח | הוספת פונקציית שחזור עם timelock |
| Frontrunning של הוכחה | גניבת טוקנים על ידי החלפת חשבון | כלול חשבון ב-leaf |
תביעה כפולה ללא ביטמאפ
אם אתם משתמשים בmapping(address => bool) במקום ביטמאפ, ואותה כתובת מופיעה עם סכומים שונים — ההוכחה תקפה לכל וריאציה, הדגל struct VestingSchedule { uint256 totalAmount; uint256 cliffEnd; // timestamp конца клиффа uint256 vestingEnd; // timestamp полной разблокировки uint256 claimed; // уже клеймлено } mapping(address => VestingSchedule) public schedules; function claimVested() external { VestingSchedule storage schedule = schedules[msg.sender]; require(block.timestamp >= schedule.cliffEnd, "Cliff not reached"); uint256 vested = _calculateVested(schedule); uint256 claimable = vested - schedule.claimed; require(claimable > 0, "Nothing to claim"); schedule.claimed += claimable; token.transfer(msg.sender, claimable); } function _calculateVested(VestingSchedule memory s) private view returns (uint256) { if (block.timestamp >= s.vestingEnd) return s.totalAmount; if (block.timestamp < s.cliffEnd) return 0; uint256 vestingDuration = s.vestingEnd - s.cliffEnd; uint256 elapsed = block.timestamp - s.cliffEnd; return (s.totalAmount * elapsed) / vestingDuration; } מוגדר פעם אחת, אבל התביעה השנייה עם סכום שונה עדיין עוברת. ביטמאפ עם msg.sender == account כמפתח מונע זאת: כל אינדקס הוא ייחודי.
הטרדה דרך תביעה בשם אחר
אם mapping(address => bool) נקרא על ידי מישהו אחר מלבד claimed[address] = true — טוקנים יכולים להישלח בכוח לכתובת שלא עברה KYC או לחוזה ללא index. עבור פרוטוקולים עם דרישות ציות, הגבילו: claim(account, ...).
אין RecoverUnclaimed
טוקנים נעולים לצמיתות על החוזה אם המועד האחרון לא מטופל. תמיד הוסיפו פונקציית שחזור.
Frontrunning של הוכחה
ההוכחה היא ציבורית; כל אחד יכול לראות אותה ב-mempool ולהגיש אותה עם הכתובת שלו. הגנה: כלול account ב-leaf (כבר יושם למעלה) — ההוכחה עובדת רק עבור אותה כתובת ספציפית.
תביעות מרובות סבבים
עבור איירדרופים עם מספר סבבים (לדוגמה, רטרואקטיבי + תגמולים שוטפים), השתמשו במספר שורשי מרקל — אחד לכל סבב, או שורש ניתן לשינוי עם timelock על עדכונים:
bytes32[] public merkleRoots; // индекс = номер раунда
mapping(uint256 => mapping(uint256 => uint256)) private claimedBitMaps; // round => bitmap
function addRound(bytes32 root) external onlyOwner {
merkleRoots.push(root);
} מה כלול בעבודה?
- קוד מקור של חוזה חכם (Solidity 0.8.20) עם כיסוי בדיקות מלא (Foundry, כולל fאזינג).
- סקריפטים לפריסה ואימות עבור Etherscan.
- תיעוד אינטגרציה (ABI, ממשקים, דוגמאות קריאה).
- תמיכה לאחר פריסה: ייעוץ, עזרה בעדכוני שורש מרקל.
- אופציונלי: ביקורת אבטחה חיצונית (החל משבועיים).
תהליך פיתוח חוזה תביעה
- ניתוח דרישות — קביעת רשימת המשתתפים, סכומים, לוח וסטינג, צורך בריבוי סבבים.
- עיצוב ארכיטקטורה — בחירת עץ מרקל, ביטמאפ, וסטינג, פונקציות נוספות.
- יישום — כתיבת החוזה החכם ב-Solidity 0.8.20 עם כיסוי בדיקות מלא (Foundry, כולל fאזינג).
- ביקורת — סקירה פנימית + ביקורת חיצונית אופציונלית (החל משבועיים).
- פריסה — פריסה עם פרמטרים מותאמים (ערבות ליעילות גז מוצקה).
- תיעוד ואינטגרציה — מתן מדריך אינטגרציה ותמיכה.
ציר זמן: מ-5 ימי עבודה עד 3 שבועות תלוי במורכבות. העלות מחושבת באופן אישי.
יישמנו למעלה מ-30 חוזי תביעה עבור פרויקטים ב-Ethereum, Arbitrum ו-Polygon. צרו קשר לייעוץ — נעריך את הפרויקט שלכם תוך יומיים. הזמינו פיתוח סוהר וקבלו חוזה תביעה אמין המוגן מפני כל הפגיעויות הטיפוסיות.







