Solidity / Эцсийн төсөл

Эцсийн төсөл — Crowdfunding Smart Contract

Энэ хичээлд курсын туршид сурсан бүх мэдлэгийг нэгтгэж, бүрэн ажиллах crowdfunding (олон нийтийн санхүүжилт) smart contract бүтээнэ. Тест бичиж, Sepolia testnet-д deploy хийнэ.

Шаардлага

| Функц | Тайлбар | | ----------------------- | -------------------------------------------- | | Зорилтот дүн тохируулах | Эзэмшигч зорилтот дүн, хугацаа тогтооно | | Хувь нэмэр оруулах | Хэн ч ETH хийж болно | | Зорилго биелсэн бол | Хугацаа дуусмагц эзэмшигч бүгдийг татаж авна | | Зорилго биелээгүй бол | Хувь нэмэрчид өөрийн мөнгийг буцааж авна |

Contract бичих

contracts/Crowdfund.sol файл үүсгэнэ:

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

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

/**
 * @title Crowdfund
 * @notice Олон нийтийн санхүүжилтийн smart contract
 * @dev ReentrancyGuard ашиглан reentrancy attack-с хамгаална
 */
contract Crowdfund is ReentrancyGuard {
    // ── Төлөв ────────────────────────────────────────

    address public immutable owner;       // Кампанит ажлын эзэмшигч
    uint256 public immutable goal;        // Зорилтот дүн (wei)
    uint256 public immutable deadline;    // Дуусах хугацаа (timestamp)

    mapping(address => uint256) public contributions; // Хувь нэмэрчдийн дүн
    uint256 public totalRaised;           // Нийт цугларсан дүн
    bool public claimed;                  // Эзэмшигч татаж авсан эсэх

    // ── Үйл явдлууд ──────────────────────────────────

    event Contributed(address indexed contributor, uint256 amount);
    event GoalReached(uint256 totalAmount);
    event FundsClaimed(address indexed owner, uint256 amount);
    event Refunded(address indexed contributor, uint256 amount);

    // ── Алдаанууд ────────────────────────────────────

    error CampaignEnded();
    error CampaignNotEnded();
    error GoalNotReached();
    error GoalAlreadyReached();
    error AlreadyClaimed();
    error NothingToRefund();
    error NotOwner();
    error ZeroContribution();

    // ── Modifier ─────────────────────────────────────

    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();
        _;
    }

    modifier campaignActive() {
        if (block.timestamp >= deadline) revert CampaignEnded();
        _;
    }

    modifier campaignEnded() {
        if (block.timestamp < deadline) revert CampaignNotEnded();
        _;
    }

    // ── Constructor ───────────────────────────────────

    /**
     * @param _goalInEther Зорилтот дүн ETH-р
     * @param _durationDays Кампанит ажлын үргэлжлэх хоног
     */
    constructor(uint256 _goalInEther, uint256 _durationDays) {
        require(_goalInEther > 0, "Зорилтот дүн 0-ээс их байх ёстой");
        require(_durationDays > 0, "Хугацаа 0-ээс их байх ёстой");

        owner = msg.sender;
        goal = _goalInEther * 1 ether;
        deadline = block.timestamp + (_durationDays * 1 days);
    }

    // ── Үндсэн функцүүд ───────────────────────────────

    /**
     * @notice ETH хандивлах
     */
    function contribute() public payable campaignActive nonReentrant {
        if (msg.value == 0) revert ZeroContribution();

        contributions[msg.sender] += msg.value;
        totalRaised += msg.value;

        emit Contributed(msg.sender, msg.value);

        if (totalRaised >= goal) {
            emit GoalReached(totalRaised);
        }
    }

    /**
     * @notice Зорилго биелсэн бол эзэмшигч бүх мөнгийг татаж авна
     */
    function claimFunds() public onlyOwner campaignEnded nonReentrant {
        if (totalRaised < goal) revert GoalNotReached();
        if (claimed) revert AlreadyClaimed();

        claimed = true;
        uint256 amount = address(this).balance;

        emit FundsClaimed(owner, amount);

        (bool success,) = payable(owner).call{value: amount}("");
        require(success, "Мөнгө илгээхэд алдаа гарлаа");
    }

    /**
     * @notice Зорилго биелээгүй бол хувь нэмэрчид буцааж авна
     */
    function refund() public campaignEnded nonReentrant {
        if (totalRaised >= goal) revert GoalAlreadyReached();

        uint256 amount = contributions[msg.sender];
        if (amount == 0) revert NothingToRefund();

        // Checks-Effects-Interactions
        contributions[msg.sender] = 0;

        emit Refunded(msg.sender, amount);

        (bool success,) = payable(msg.sender).call{value: amount}("");
        require(success, "Буцаалт илгээхэд алдаа гарлаа");
    }

    // ── Унших функцүүд ────────────────────────────────

    /**
     * @notice Кампанит ажил амжилттай дуусч, зорилго биелсэн эсэх
     */
    function isSuccessful() public view returns (bool) {
        return block.timestamp >= deadline && totalRaised >= goal;
    }

    /**
     * @notice Кампанит ажил идэвхтэй байгаа эсэх
     */
    function isActive() public view returns (bool) {
        return block.timestamp < deadline;
    }

    /**
     * @notice Үлдсэн хугацаа (секундээр)
     */
    function timeLeft() public view returns (uint256) {
        if (block.timestamp >= deadline) return 0;
        return deadline - block.timestamp;
    }

    /**
     * @notice Зорилтот дүнд хэр ойртсон (хувиар)
     */
    function progressPercent() public view returns (uint256) {
        if (goal == 0) return 0;
        uint256 percent = (totalRaised * 100) / goal;
        return percent > 100 ? 100 : percent;
    }

    // ETH шууд илгээхэд contribute дуудна
    receive() external payable {
        contribute();
    }
}

Тест бичих

test/Crowdfund.js файл үүсгэнэ:

javascript
const { expect } = require("chai");
const { ethers } = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-network-helpers");

describe("Crowdfund", function () {
  let crowdfund;
  let owner;
  let contributor1;
  let contributor2;

  const GOAL_ETH = 10n; // 10 ETH зорилго
  const DURATION_DAYS = 7n; // 7 хоног

  beforeEach(async function () {
    [owner, contributor1, contributor2] = await ethers.getSigners();

    const Crowdfund = await ethers.getContractFactory("Crowdfund");
    crowdfund = await Crowdfund.deploy(GOAL_ETH, DURATION_DAYS);
    await crowdfund.waitForDeployment();
  });

  // ── Байрлуулалтын тест ───────────────────────────────

  describe("Байрлуулалт", function () {
    it("Эзэмшигчийг зөв тохируулах ёстой", async function () {
      expect(await crowdfund.owner()).to.equal(owner.address);
    });

    it("Зорилтот дүнг зөв тохируулах ёстой", async function () {
      expect(await crowdfund.goal()).to.equal(
        ethers.parseEther(GOAL_ETH.toString()),
      );
    });

    it("Нийт цугларсан дүн 0 байх ёстой", async function () {
      expect(await crowdfund.totalRaised()).to.equal(0);
    });

    it("Кампанит ажил идэвхтэй байх ёстой", async function () {
      expect(await crowdfund.isActive()).to.be.true;
    });
  });

  // ── Хувь нэмэр оруулах тест ──────────────────────────

  describe("contribute", function () {
    it("ETH хийж, баланс шинэчлэгдэх ёстой", async function () {
      const amount = ethers.parseEther("1.0");

      await crowdfund.connect(contributor1).contribute({ value: amount });

      expect(await crowdfund.contributions(contributor1.address)).to.equal(
        amount,
      );
      expect(await crowdfund.totalRaised()).to.equal(amount);
    });

    it("Contributed үйл явдал гарах ёстой", async function () {
      const amount = ethers.parseEther("2.0");

      await expect(
        crowdfund.connect(contributor1).contribute({ value: amount }),
      )
        .to.emit(crowdfund, "Contributed")
        .withArgs(contributor1.address, amount);
    });

    it("Олон хувь нэмэрчид хандивлаж болно", async function () {
      await crowdfund.connect(contributor1).contribute({
        value: ethers.parseEther("3.0"),
      });
      await crowdfund.connect(contributor2).contribute({
        value: ethers.parseEther("2.0"),
      });

      expect(await crowdfund.totalRaised()).to.equal(ethers.parseEther("5.0"));
    });

    it("0 ETH хийхэд revert хийх ёстой", async function () {
      await expect(
        crowdfund.connect(contributor1).contribute({ value: 0 }),
      ).to.be.revertedWithCustomError(crowdfund, "ZeroContribution");
    });

    it("Хугацаа дуусмагц хийхэд revert хийх ёстой", async function () {
      await time.increase(7 * 24 * 60 * 60 + 1); // 7 хоног + 1 секунд

      await expect(
        crowdfund.connect(contributor1).contribute({
          value: ethers.parseEther("1.0"),
        }),
      ).to.be.revertedWithCustomError(crowdfund, "CampaignEnded");
    });
  });

  // ── Мөнгө татан авах тест ─────────────────────────────

  describe("claimFunds", function () {
    beforeEach(async function () {
      // Зорилгод хүрэх хэмжээний ETH хийнэ
      await crowdfund.connect(contributor1).contribute({
        value: ethers.parseEther("10.0"),
      });
      // Хугацааг дуусгана
      await time.increase(7 * 24 * 60 * 60 + 1);
    });

    it("Эзэмшигч мөнгийг татан авах боломжтой байх ёстой", async function () {
      const ownerBefore = await ethers.provider.getBalance(owner.address);

      const tx = await crowdfund.connect(owner).claimFunds();
      const receipt = await tx.wait();
      const gasUsed = receipt.gasUsed * receipt.gasPrice;

      const ownerAfter = await ethers.provider.getBalance(owner.address);

      expect(ownerAfter).to.equal(
        ownerBefore + ethers.parseEther("10.0") - gasUsed,
      );
    });

    it("FundsClaimed үйл явдал гарах ёстой", async function () {
      await expect(crowdfund.connect(owner).claimFunds())
        .to.emit(crowdfund, "FundsClaimed")
        .withArgs(owner.address, ethers.parseEther("10.0"));
    });

    it("Эзэмшигч биш хүн татахад revert хийх ёстой", async function () {
      await expect(
        crowdfund.connect(contributor1).claimFunds(),
      ).to.be.revertedWithCustomError(crowdfund, "NotOwner");
    });

    it("Давтан татахад revert хийх ёстой", async function () {
      await crowdfund.connect(owner).claimFunds();

      await expect(
        crowdfund.connect(owner).claimFunds(),
      ).to.be.revertedWithCustomError(crowdfund, "AlreadyClaimed");
    });
  });

  // ── Буцаалтын тест ───────────────────────────────────

  describe("refund", function () {
    beforeEach(async function () {
      // Зорилгод хүрэхгүй хэмжээний ETH хийнэ (5 ETH, зорилго 10 ETH)
      await crowdfund.connect(contributor1).contribute({
        value: ethers.parseEther("5.0"),
      });
      await time.increase(7 * 24 * 60 * 60 + 1);
    });

    it("Хувь нэмэрчид буцааж авах боломжтой байх ёстой", async function () {
      const balanceBefore = await ethers.provider.getBalance(
        contributor1.address,
      );

      const tx = await crowdfund.connect(contributor1).refund();
      const receipt = await tx.wait();
      const gasUsed = receipt.gasUsed * receipt.gasPrice;

      const balanceAfter = await ethers.provider.getBalance(
        contributor1.address,
      );

      expect(balanceAfter).to.equal(
        balanceBefore + ethers.parseEther("5.0") - gasUsed,
      );
    });

    it("Refunded үйл явдал гарах ёстой", async function () {
      await expect(crowdfund.connect(contributor1).refund())
        .to.emit(crowdfund, "Refunded")
        .withArgs(contributor1.address, ethers.parseEther("5.0"));
    });

    it("Буцааж авсны дараа дахин авах боломжгүй байх ёстой", async function () {
      await crowdfund.connect(contributor1).refund();

      await expect(
        crowdfund.connect(contributor1).refund(),
      ).to.be.revertedWithCustomError(crowdfund, "NothingToRefund");
    });

    it("Зорилго биелсэн бол буцааж авах боломжгүй байх ёстой", async function () {
      // Зорилгод хүрэхүйц нэмэлт ETH хийнэ (дахин deploy хийх шаардлагатай)
      const Crowdfund = await ethers.getContractFactory("Crowdfund");
      const fullCrowdfund = await Crowdfund.deploy(5n, 7n); // 5 ETH зорилго

      await fullCrowdfund.connect(contributor1).contribute({
        value: ethers.parseEther("5.0"),
      });
      await time.increase(7 * 24 * 60 * 60 + 1);

      await expect(
        fullCrowdfund.connect(contributor1).refund(),
      ).to.be.revertedWithCustomError(fullCrowdfund, "GoalAlreadyReached");
    });
  });

  // ── Туслах функцүүдийн тест ───────────────────────────

  describe("Туслах функцүүд", function () {
    it("progressPercent зөрчилгүй тооцоолох ёстой", async function () {
      await crowdfund.connect(contributor1).contribute({
        value: ethers.parseEther("5.0"), // 50%
      });

      expect(await crowdfund.progressPercent()).to.equal(50);
    });

    it("timeLeft зөв буцаах ёстой", async function () {
      const left = await crowdfund.timeLeft();
      expect(left).to.be.greaterThan(0);

      await time.increase(7 * 24 * 60 * 60 + 1);
      expect(await crowdfund.timeLeft()).to.equal(0);
    });
  });
});

Тест ажиллуулах

bash
npx hardhat test
код
  Crowdfund
    Байрлуулалт
      ✓ Эзэмшигчийг зөв тохируулах ёстой
      ✓ Зорилтот дүнг зөв тохируулах ёстой
      ✓ Нийт цугларсан дүн 0 байх ёстой
      ✓ Кампанит ажил идэвхтэй байх ёстой
    contribute
      ✓ ETH хийж, баланс шинэчлэгдэх ёстой
      ✓ Contributed үйл явдал гарах ёстой
      ✓ Олон хувь нэмэрчид хандивлаж болно
      ✓ 0 ETH хийхэд revert хийх ёстой
      ✓ Хугацаа дуусмагц хийхэд revert хийх ёстой
    claimFunds
      ✓ Эзэмшигч мөнгийг татан авах боломжтой байх ёстой
      ✓ FundsClaimed үйл явдал гарах ёстой
      ✓ Эзэмшигч биш хүн татахад revert хийх ёстой
      ✓ Давтан татахад revert хийх ёстой
    refund
      ✓ Хувь нэмэрчид буцааж авах боломжтой байх ёстой
      ✓ Refunded үйл явдал гарах ёстой
      ✓ Буцааж авсны дараа дахин авах боломжгүй байх ёстой
      ✓ Зорилго биелсэн бол буцааж авах боломжгүй байх ёстой
    Туслах функцүүд
      ✓ progressPercent зөрчилгүй тооцоолох ёстой
      ✓ timeLeft зөв буцаах ёстой

  19 passing (3s)

Sepolia-д deploy хийх

scripts/deploy-crowdfund.js файл үүсгэнэ:

javascript
const { ethers } = require("hardhat");

async function main() {
  const [deployer] = await ethers.getSigners();
  console.log("Deploy хийж буй хаяг:", deployer.address);

  const GOAL_ETH = 1; // 1 ETH зорилго (testnet тул бага дүн)
  const DURATION_DAYS = 7; // 7 хоног

  const Crowdfund = await ethers.getContractFactory("Crowdfund");
  const crowdfund = await Crowdfund.deploy(GOAL_ETH, DURATION_DAYS);
  await crowdfund.waitForDeployment();

  const address = await crowdfund.getAddress();
  console.log("✅ Crowdfund deploy хийгдлээ:", address);
  console.log("Зорилго:", GOAL_ETH, "ETH");
  console.log("Хугацаа:", DURATION_DAYS, "хоног");
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });
bash
# Deploy
npx hardhat run scripts/deploy-crowdfund.js --network sepolia

# Verify
npx hardhat verify --network sepolia CONTRACT_ADDRESS 1 7

Курс дуусгавар

Та энэ курсын туршид дараах бүх зүйлийг эзэмшлээ:

Үндэс: Blockchain, Ethereum, smart contract, Solidity синтакс

Өгөгдлийн бүтэц: mapping, array, struct, enum

Функц: visibility, modifier, view/pure, event, error handling

Объект хандлагат: inheritance, interface, library

ETH харилцаа: payable, receive, fallback, send/transfer/call

Токен стандарт: ERC-20 интерфэйс, OpenZeppelin ашиглан токен бүтээх

Аюулгүй байдал: reentrancy, Checks-Effects-Interactions, ReentrancyGuard

Хөгжүүлэлтийн орчин: Hardhat, тест бичих, testnet deploy, Etherscan verify

Цаашид судлах: ERC-721 (NFT), DeFi протокол, Layer 2, Foundry тест framework, formal verification.