Initial commit with 🏗️ create-eth @ 2.0.4

This commit is contained in:
han
2026-01-11 17:24:19 +07:00
commit 64378512ba
128 changed files with 27844 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
# Write tests for your smart contract in this directory
# Example: YourContract.ts

View File

@@ -0,0 +1,368 @@
//
// This script executes when you run 'yarn test'
//
import { ethers, network } from "hardhat";
import { expect } from "chai";
import { FundingRecipient, CrowdFund } from "../typechain-types";
describe("🚩 Challenge: 📣 Crowdfunding App", function () {
let fundingRecipient: FundingRecipient;
let crowdFundContract: CrowdFund;
describe("CrowdFund", function () {
const contractAddress = process.env.CONTRACT_ADDRESS;
let contractArtifact: string;
if (contractAddress) {
// For the autograder.
contractArtifact = `contracts/download-${contractAddress}.sol:CrowdFund`;
} else {
contractArtifact = "contracts/CrowdFund.sol:CrowdFund";
}
const deployContracts = async () => {
const FundingRecipientFactory = await ethers.getContractFactory("FundingRecipient");
fundingRecipient = (await FundingRecipientFactory.deploy()) as FundingRecipient;
const CrowdFundFactory = await ethers.getContractFactory(contractArtifact);
crowdFundContract = (await CrowdFundFactory.deploy(await fundingRecipient.getAddress())) as CrowdFund;
};
describe("Checkpoint 1: 🤝 Contributing 💵", function () {
beforeEach(async function () {
await deployContracts();
});
const getContributionEventsFromReceipt = (receipt: any) => {
// We avoid chai matchers like `.to.emit` so this works in minimal environments.
const parsed = receipt.logs
.map((log: any) => {
try {
return crowdFundContract.interface.parseLog(log);
} catch {
return undefined;
}
})
.filter(Boolean);
return parsed.filter((p: any) => p.name === "Contribution");
};
it("Checkpoint1: balances should go up when you contribute()", async function () {
const [owner] = await ethers.getSigners();
const startingBalance = await crowdFundContract.balances(owner.address);
const amount = ethers.parseEther("0.001");
const contributeTx = await crowdFundContract.contribute({ value: amount });
const receipt = await contributeTx.wait();
expect(receipt?.status).to.equal(1);
const newBalance = await crowdFundContract.balances(owner.address);
expect(newBalance).to.equal(startingBalance + amount);
});
it("Checkpoint1: should emit a Contribution event with contributor + amount", async function () {
const [owner] = await ethers.getSigners();
const amount = ethers.parseEther("0.001");
const tx = await crowdFundContract.contribute({ value: amount });
const receipt = await tx.wait();
expect(receipt?.status).to.equal(1);
const contributionEvents = getContributionEventsFromReceipt(receipt);
expect(contributionEvents.length).to.equal(1);
const evt = contributionEvents[0];
expect(evt.args[0]).to.equal(owner.address);
expect(evt.args[1]).to.equal(amount);
});
it("Checkpoint1: should accumulate multiple contributions from the same user", async function () {
const [owner] = await ethers.getSigners();
const starting = await crowdFundContract.balances(owner.address);
const a1 = ethers.parseEther("0.001");
const a2 = ethers.parseEther("0.002");
await (await crowdFundContract.contribute({ value: a1 })).wait();
await (await crowdFundContract.contribute({ value: a2 })).wait();
const ending = await crowdFundContract.balances(owner.address);
expect(ending).to.equal(starting + a1 + a2);
});
it("Checkpoint1: should track balances independently per contributor", async function () {
const [owner, secondAccount] = await ethers.getSigners();
const a1 = ethers.parseEther("0.001");
const a2 = ethers.parseEther("0.002");
await (await crowdFundContract.connect(owner).contribute({ value: a1 })).wait();
await (await crowdFundContract.connect(secondAccount).contribute({ value: a2 })).wait();
expect(await crowdFundContract.balances(owner.address)).to.equal(a1);
expect(await crowdFundContract.balances(secondAccount.address)).to.equal(a2);
});
it("Checkpoint1: contract ETH balance should increase when someone contributes", async function () {
const startContractBal = await ethers.provider.getBalance(await crowdFundContract.getAddress());
const amount = ethers.parseEther("0.001");
await (await crowdFundContract.contribute({ value: amount })).wait();
const endContractBal = await ethers.provider.getBalance(await crowdFundContract.getAddress());
expect(endContractBal).to.equal(startContractBal + amount);
});
});
describe("Checkpoint 2: 📤 Withdrawing Funds", function () {
beforeEach(async function () {
await deployContracts();
});
const setOpenToWithdrawTrue = async () => {
// Checkpoint 2 doesn't include a setter for `openToWithdraw`, so we toggle it directly in storage.
//
// Don't worry if you don't understand the wizardry that happens here.
//
// Important: `openToWithdraw` might be:
// - in its own storage slot (bool uses the least-significant byte of the slot), OR
// - packed into an existing slot (e.g. declared right after an `address`, so it shares slot 0),
// in which case flipping `0x...01` for the whole slot DOES NOT necessarily flip the bool.
//
// So we probe (slot, byteOffset) by mutating ONE byte at a time and checking which mutation makes
// `openToWithdraw()` return true. This effectively answers: "which storage location impacts this variable?"
const target = await crowdFundContract.getAddress();
// If it's already open, nothing to do.
if (await crowdFundContract.openToWithdraw()) return;
const writeStorageAt = async (slot: bigint, value: string) => {
const slotHex = ethers.zeroPadValue(ethers.toBeHex(slot), 32);
await network.provider.send("hardhat_setStorageAt", [target, slotHex, value]);
};
const hexToBytes32 = (hex: string) => {
// Expect 0x-prefixed 32-byte hex from `getStorage`.
const normalized = hex.startsWith("0x") ? hex.slice(2) : hex;
const padded = normalized.padStart(64, "0");
const out: number[] = [];
for (let i = 0; i < 64; i += 2) out.push(parseInt(padded.slice(i, i + 2), 16));
if (out.length !== 32) throw new Error("Expected 32 bytes");
return out;
};
const bytes32ToHex = (bytes: number[]) => {
if (bytes.length !== 32) throw new Error("Expected 32 bytes");
const hex = bytes.map(b => b.toString(16).padStart(2, "0")).join("");
return "0x" + hex;
};
// Probe a reasonable number of slots so re-ordering / adding variables doesn't break tests.
// (Mapping `balances` lives at a slot too, but since we always revert unsuccessful writes,
// it's safe to probe it.)
for (let s = 0n; s <= 20n; s++) {
const original = await ethers.provider.getStorage(target, s);
const originalBytes = hexToBytes32(original);
try {
// Try flipping each byte to 0x01 (leaving all other bytes unchanged).
// Storage words are represented big-endian in hex, but since we probe ALL 32 bytes,
// we don't need to reason about endianness/packing direction here.
for (let byteIdx = 0; byteIdx < 32; byteIdx++) {
const mutated = [...originalBytes];
mutated[byteIdx] = 0x01;
await writeStorageAt(s, bytes32ToHex(mutated));
const isOpen = await crowdFundContract.openToWithdraw();
if (isOpen === true) {
// Found the (slot, byteIdx) that impacts `openToWithdraw`.
// We intentionally keep this storage mutation for the test.
return;
}
// Not the right byte → restore original before continuing.
await writeStorageAt(s, original);
}
} catch {
// If the function doesn't exist yet, nothing to do here.
}
// Ensure we leave storage exactly as we found it before moving to the next slot.
await writeStorageAt(s, original);
}
throw new Error("Could not locate `openToWithdraw` storage slot to toggle it for tests.");
};
it("Checkpoint2: withdraw should revert with NotOpenToWithdraw() when withdrawals are not open", async function () {
await expect(crowdFundContract.withdraw()).to.be.revertedWithCustomError(
crowdFundContract,
"NotOpenToWithdraw",
);
});
it("Checkpoint2: withdraw should send your full balance and zero-out your recorded balance", async function () {
const [, contributor] = await ethers.getSigners();
const amount = ethers.parseEther("0.001");
await (await crowdFundContract.connect(contributor).contribute({ value: amount })).wait();
expect(await crowdFundContract.balances(contributor.address)).to.equal(amount);
// We modify storage directly to set openToWithdraw to true since we have not implemented the setter yet.
await setOpenToWithdrawTrue();
const startingBalance = await ethers.provider.getBalance(contributor.address);
const withdrawTx = await crowdFundContract.connect(contributor).withdraw();
const tx = await ethers.provider.getTransaction(withdrawTx.hash);
if (!tx) throw new Error("Cannot resolve transaction");
const receipt = await ethers.provider.getTransactionReceipt(withdrawTx.hash);
if (!receipt) throw new Error("Cannot resolve receipt");
const gasCost = tx.gasPrice * receipt.gasUsed;
const endingBalance = await ethers.provider.getBalance(contributor.address);
expect(endingBalance).to.equal(startingBalance + amount - gasCost);
expect(await crowdFundContract.balances(contributor.address)).to.equal(0n);
});
it("Checkpoint2: withdrawing twice should not let you withdraw more than you contributed", async function () {
const [, contributor] = await ethers.getSigners();
const amount = ethers.parseEther("0.001");
await (await crowdFundContract.connect(contributor).contribute({ value: amount })).wait();
// We modify storage directly to set openToWithdraw to true since we have not implemented the setter yet.
await setOpenToWithdrawTrue();
await (await crowdFundContract.connect(contributor).withdraw()).wait();
const balanceAfterFirst = await ethers.provider.getBalance(contributor.address);
// Second withdraw should refund 0; only gas should change wallet balance.
const secondTx = await crowdFundContract.connect(contributor).withdraw();
const tx = await ethers.provider.getTransaction(secondTx.hash);
if (!tx) throw new Error("Cannot resolve transaction");
const receipt = await ethers.provider.getTransactionReceipt(secondTx.hash);
if (!receipt) throw new Error("Cannot resolve receipt");
const gasCost = tx.gasPrice * receipt.gasUsed;
const balanceAfterSecond = await ethers.provider.getBalance(contributor.address);
expect(balanceAfterSecond).to.equal(balanceAfterFirst - gasCost);
expect(await crowdFundContract.balances(contributor.address)).to.equal(0n);
});
});
describe("Checkpoint 3: 🔬 State Machine / Timing ⏱", function () {
beforeEach(async function () {
await deployContracts();
});
it("Checkpoint3: execute() should revert with TooEarly() if called before the deadline", async function () {
await expect(crowdFundContract.execute()).to.be.revertedWithCustomError(crowdFundContract, "TooEarly");
});
it("Checkpoint3: timeLeft should decrease as time moves forward (until it hits 0)", async function () {
const t1 = await crowdFundContract.timeLeft();
expect(Number(t1)).to.be.greaterThan(0);
await network.provider.send("evm_increaseTime", [5]);
await network.provider.send("evm_mine");
const t2 = await crowdFundContract.timeLeft();
expect(Number(t2)).to.be.lessThan(Number(t1));
expect(Number(t2)).to.be.greaterThanOrEqual(0);
});
it("Checkpoint3: if enough is contributed and time has passed, execute() should complete()", async function () {
const timeLeft1 = await crowdFundContract.timeLeft();
expect(
Number(timeLeft1),
"timeLeft not greater than 0. Did you implement the timeLeft() function correctly?",
).to.greaterThan(0);
const amount = ethers.parseEther("1");
await crowdFundContract.contribute({ value: amount });
await network.provider.send("evm_increaseTime", [72 * 3600]);
await network.provider.send("evm_mine");
const timeLeft2 = await crowdFundContract.timeLeft();
expect(
Number(timeLeft2),
"timeLeft not equal to 0. Did you implement the timeLeft() function correctly?",
).to.equal(0);
const startRecipientBal = await ethers.provider.getBalance(await fundingRecipient.getAddress());
const startContractBal = await ethers.provider.getBalance(await crowdFundContract.getAddress());
await crowdFundContract.execute();
const result = await fundingRecipient.completed();
expect(result).to.equal(true);
const endRecipientBal = await ethers.provider.getBalance(await fundingRecipient.getAddress());
const endContractBal = await ethers.provider.getBalance(await crowdFundContract.getAddress());
// Funds should have moved into the FundingRecipient via `complete{value: ...}()`
expect(endRecipientBal).to.equal(startRecipientBal + startContractBal);
expect(endContractBal).to.equal(0n);
});
it("Checkpoint3: if not enough is contributed and time has passed, execute() should enable withdraw", async function () {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [owner, secondAccount] = await ethers.getSigners();
await crowdFundContract.connect(secondAccount).contribute({
value: ethers.parseEther("0.001"),
});
await network.provider.send("evm_increaseTime", [72 * 3600]);
await network.provider.send("evm_mine");
await crowdFundContract.execute();
const result = await fundingRecipient.completed();
expect(result).to.equal(false);
// If `openToWithdraw` is implemented (Checkpoint 2/3), it should now be true.
// We use `as any` so earlier checkpoints can still compile this test file.
if ((crowdFundContract as any).openToWithdraw) {
const isOpen = await (crowdFundContract as any).openToWithdraw();
expect(isOpen).to.equal(true);
}
const startingBalance = await ethers.provider.getBalance(secondAccount.address);
const withdrawTx = await crowdFundContract.connect(secondAccount).withdraw();
const tx = await ethers.provider.getTransaction(withdrawTx.hash);
if (!tx) throw new Error("Cannot resolve transaction");
const receipt = await ethers.provider.getTransactionReceipt(withdrawTx.hash);
if (!receipt) throw new Error("Cannot resolve receipt");
const gasCost = tx.gasPrice * receipt.gasUsed;
const endingBalance = await ethers.provider.getBalance(secondAccount.address);
expect(endingBalance).to.equal(startingBalance + ethers.parseEther("0.001") - gasCost);
});
});
describe("Checkpoint 4: 💵 Receive Function / UX 🙎", function () {
beforeEach(async function () {
await deployContracts();
});
it("Checkpoint4: sending ETH directly to the contract should behave like contribute()", async function () {
const [owner] = await ethers.getSigners();
const startingBalance = await crowdFundContract.balances(owner.address);
const amount = ethers.parseEther("0.001");
const sendTx = await owner.sendTransaction({ to: await crowdFundContract.getAddress(), value: amount });
await sendTx.wait();
const newBalance = await crowdFundContract.balances(owner.address);
expect(newBalance).to.equal(startingBalance + amount);
});
});
});
});