Эцсийн төсөл — Crowdfunding Smart Contract
Энэ хичээлд курсын туршид сурсан бүх мэдлэгийг нэгтгэж, бүрэн ажиллах crowdfunding (олон нийтийн санхүүжилт) smart contract бүтээнэ. Тест бичиж, Sepolia testnet-д deploy хийнэ.
Шаардлага
| Функц | Тайлбар | | ----------------------- | -------------------------------------------- | | Зорилтот дүн тохируулах | Эзэмшигч зорилтот дүн, хугацаа тогтооно | | Хувь нэмэр оруулах | Хэн ч ETH хийж болно | | Зорилго биелсэн бол | Хугацаа дуусмагц эзэмшигч бүгдийг татаж авна | | Зорилго биелээгүй бол | Хувь нэмэрчид өөрийн мөнгийг буцааж авна |
Contract бичих
contracts/Crowdfund.sol файл үүсгэнэ:
// 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 файл үүсгэнэ:
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);
});
});
});
Тест ажиллуулах
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 файл үүсгэнэ:
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);
});
# 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.