פיתוח חוזה תביעה לחלוקת טוקנים

קמפיינים גדולים של איירדרופ מתמודדים לעתים קרובות עם עמלות גז גבוהות בעת חלוקת טוקנים ופרצות המאפשרות תביעות חוזרות של תגמולים. אנו מפתחים חוזי תביעה המבוססים על Merkle tree, אשר מגנים מפני תביעות כפולות ומייעלים את עלויות הפריסה. הצוות שלנו מספק פתרון סוהר—מבניית העץ ועד למימוש ותמיכה מתמשכת, תוך הבטחת אמינות וסקלביליות לפרויקט שלכם.

שירותי פיתוח בלוקצ'יין

שאלות נפוצות

העבודות האחרונות

  • פיתוח אתר חברה B2B ADVANCE
    פיתוח אתר חברה B2B ADVANCE
    1481
  • פיתוח אפליקציית ווב עבור FEEDME
    פיתוח אפליקציית ווב עבור FEEDME
    1335
  • פיתוח אתר עבור BELFINGROUP
    פיתוח אתר עבור BELFINGROUP
    1034
  • פיתוח חנות מקוונת לחברת FURNORO
    פיתוח חנות מקוונת לחברת FURNORO
    1293
  • עיצוב לוגו לחברת B2B Advance
    עיצוב לוגו לחברת B2B Advance
    738
  • פיתוח אפליקציית ווב עבור Enviok
    פיתוח אפליקציית ווב עבור Enviok
    1031

פיתוח חוזה תביעה לחלוקת טוקנים

פרסתם איירדרופ, אבל הגז לפריסת רשימה של 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, ממשקים, דוגמאות קריאה).
  • תמיכה לאחר פריסה: ייעוץ, עזרה בעדכוני שורש מרקל.
  • אופציונלי: ביקורת אבטחה חיצונית (החל משבועיים).

תהליך פיתוח חוזה תביעה

  1. ניתוח דרישות — קביעת רשימת המשתתפים, סכומים, לוח וסטינג, צורך בריבוי סבבים.
  2. עיצוב ארכיטקטורה — בחירת עץ מרקל, ביטמאפ, וסטינג, פונקציות נוספות.
  3. יישום — כתיבת החוזה החכם ב-Solidity 0.8.20 עם כיסוי בדיקות מלא (Foundry, כולל fאזינג).
  4. ביקורת — סקירה פנימית + ביקורת חיצונית אופציונלית (החל משבועיים).
  5. פריסה — פריסה עם פרמטרים מותאמים (ערבות ליעילות גז מוצקה).
  6. תיעוד ואינטגרציה — מתן מדריך אינטגרציה ותמיכה.

ציר זמן: מ-5 ימי עבודה עד 3 שבועות תלוי במורכבות. העלות מחושבת באופן אישי.

יישמנו למעלה מ-30 חוזי תביעה עבור פרויקטים ב-Ethereum, Arbitrum ו-Polygon. צרו קשר לייעוץ — נעריך את הפרויקט שלכם תוך יומיים. הזמינו פיתוח סוהר וקבלו חוזה תביעה אמין המוגן מפני כל הפגיעויות הטיפוסיות.