diff --git a/.gas-snapshot b/.gas-snapshot new file mode 100644 index 0000000..09b4843 --- /dev/null +++ b/.gas-snapshot @@ -0,0 +1,5 @@ +CrowdfundingTest:test_can_fund() (gas: 130013) +CrowdfundingTest:test_can_withdraw() (gas: 172928) +CrowdfundingTest:test_priceFeedSetCorrectly() (gas: 18677) +CrowdfundingTest:test_revert_fund() (gas: 21329) +CrowdfundingTest:test_revert_withdraw() (gas: 11406) \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 25b918f..90f7c6b 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,5 +2,7 @@ src = "src" out = "out" libs = ["lib"] +remappings = ["@chainlink=lib/chainlink"] +fs_permissions = [{ access = "read", path = "./broadcast" }] # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/chainlink b/lib/chainlink index 6ef1d6e..777b69b 160000 --- a/lib/chainlink +++ b/lib/chainlink @@ -1 +1 @@ -Subproject commit 6ef1d6eb449ee1dc1d7d10d50990de7da55561ee +Subproject commit 777b69b6ea9cd99b35744836c2678fd1c49511e2 diff --git a/lib/foundry-devops b/lib/foundry-devops index 19ea262..df9f90b 160000 --- a/lib/foundry-devops +++ b/lib/foundry-devops @@ -1 +1 @@ -Subproject commit 19ea2626cc584763fb7f20aec56fca92951b109a +Subproject commit df9f90b490423578142b5dd50752db9427efb2ac diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts index 4764ea5..dbb6104 160000 --- a/lib/openzeppelin-contracts +++ b/lib/openzeppelin-contracts @@ -1 +1 @@ -Subproject commit 4764ea50750d8bda9096e833706beba86918b163 +Subproject commit dbb6104ce834628e473d2173bbc9d47f81a9eec3 diff --git a/makefile b/makefile new file mode 100644 index 0000000..ed52074 --- /dev/null +++ b/makefile @@ -0,0 +1,18 @@ +-include .env + +ANVIL_PRIVATE_KEY := 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + +NETWORK_ARGS := --rpc-url http://localhost:8545 --private-key $(ANVIL_PRIVATE_KEY) --broadcast + +ifeq ($(findstring --network sepolia,$(ARGS)),--network sepolia) + NETWORK_ARGS := --rpc-url $(SEPOLIA_RPC_URL) --account $(ACCOUNT) --broadcast --verify --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv +endif + +deploy: + @forge script script/DeployCrowdfunding.s.sol:DeployCrowdfunding $(NETWORK_ARGS) + +fund: + @forge script script/Interactions.s.sol:FundCrowdfunding $(NETWORK_ARGS) + +withdraw: + @forge script script/Interactions.s.sol:WithdrawCrowdfunding $(NETWORK_ARGS) \ No newline at end of file diff --git a/readme.md b/readme.md index 9265b45..b5372eb 100644 --- a/readme.md +++ b/readme.md @@ -1,66 +1,2 @@ -## Foundry - -**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** - -Foundry consists of: - -- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). -- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. -- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. -- **Chisel**: Fast, utilitarian, and verbose solidity REPL. - -## Documentation - -https://book.getfoundry.sh/ - -## Usage - -### Build - -```shell -$ forge build -``` - -### Test - -```shell -$ forge test -``` - -### Format - -```shell -$ forge fmt -``` - -### Gas Snapshots - -```shell -$ forge snapshot -``` - -### Anvil - -```shell -$ anvil -``` - -### Deploy - -```shell -$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key -``` - -### Cast - -```shell -$ cast -``` - -### Help - -```shell -$ forge --help -$ anvil --help -$ cast --help -``` +## Note +- Chainlink Package Version: v2.14.0 \ No newline at end of file diff --git a/script/Constants.sol b/script/Constants.sol new file mode 100644 index 0000000..21a7a70 --- /dev/null +++ b/script/Constants.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +abstract contract Constants { + uint8 public constant DECIMALS = 8; + int256 public constant INITIAL_PRICE = 2000e8; + + // ========== Chain IDs ========== + uint256 public constant ETH_SEPOLIA_CHAIN_ID = 11155111; + uint256 public constant ZKSYNC_SEPOLIA_CHAIN_ID = 300; + uint256 public constant ANVIL_CHAIN_ID = 31337; +} diff --git a/script/Counter.s.sol b/script/Counter.s.sol deleted file mode 100644 index cdc1fe9..0000000 --- a/script/Counter.s.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Script, console} from "forge-std/Script.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterScript is Script { - Counter public counter; - - function setUp() public {} - - function run() public { - vm.startBroadcast(); - - counter = new Counter(); - - vm.stopBroadcast(); - } -} diff --git a/script/DeployCrowdfunding.s.sol b/script/DeployCrowdfunding.s.sol new file mode 100644 index 0000000..0889c2c --- /dev/null +++ b/script/DeployCrowdfunding.s.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Script} from "forge-std/Script.sol"; +import {Crowdfunding} from "src/Crowdfunding.sol"; +import {HelperConfig} from "script/HelperConfig.s.sol"; + +contract DeployCrowdfunding is Script { + function run() external returns (Crowdfunding, HelperConfig) { + HelperConfig helperConfig = new HelperConfig(); + address priceFeed = helperConfig.getConfigByChainId(block.chainid).priceFeed; + + vm.startBroadcast(); + Crowdfunding crowdfunding = new Crowdfunding(priceFeed); + vm.stopBroadcast(); + + return (crowdfunding, helperConfig); + } +} diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol new file mode 100644 index 0000000..e23b8bb --- /dev/null +++ b/script/HelperConfig.s.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Script} from "forge-std/Script.sol"; +import {Constants} from "./Constants.sol"; +import {MockV3Aggregator} from "@chainlink/contracts/src/v0.8/tests/MockV3Aggregator.sol"; + +contract HelperConfig is Constants, Script { + // ========== Errors ========== + error HelperConfig__InvalidChainId(); + + // ========== Types ========== + struct NetworkConfig { + address priceFeed; + } + + // ========== State Variables ========== + NetworkConfig public localNetworkConfig; + mapping(uint256 chainId => NetworkConfig) public networkConfigs; + + // ========== Functions ========== + constructor() { + networkConfigs[ETH_SEPOLIA_CHAIN_ID] = getSepoliaEthConfig(); + } + + function getConfigByChainId(uint256 chainId) public returns (NetworkConfig memory) { + if (networkConfigs[chainId].priceFeed != address(0)) { + return networkConfigs[chainId]; + } else if (chainId == ANVIL_CHAIN_ID) { + return getOrCreateAnvilETHConfig(); + } else { + revert HelperConfig__InvalidChainId(); + } + } + + // ========== On-Chain Configs ========== + function getSepoliaEthConfig() public pure returns (NetworkConfig memory) { + return NetworkConfig({ + priceFeed: 0x694AA1769357215DE4FAC081bf1f309aDC325306 // ETH/USD + }); + } + + // ========== Local Config ========== + function getOrCreateAnvilETHConfig() public returns (NetworkConfig memory) { + if (localNetworkConfig.priceFeed != address(0)) { + return localNetworkConfig; + } + + vm.startBroadcast(); + MockV3Aggregator mockPriceFeed = new MockV3Aggregator(DECIMALS, INITIAL_PRICE); + vm.stopBroadcast(); + + localNetworkConfig = NetworkConfig({priceFeed: address(mockPriceFeed)}); + return localNetworkConfig; + } +} diff --git a/script/Interactions.s.sol b/script/Interactions.s.sol new file mode 100644 index 0000000..4985c36 --- /dev/null +++ b/script/Interactions.s.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Script, console} from "forge-std/Script.sol"; +import {DevOpsTools} from "foundry-devops/src/DevOpsTools.sol"; +import {Crowdfunding} from "src/Crowdfunding.sol"; + +contract FundCrowdfunding is Script { + uint256 SEND_VALUE = 0.1 ether; + + function fundToCrowdfunding(address mostRecentlyDeployed) public { + vm.startBroadcast(); + Crowdfunding(payable(mostRecentlyDeployed)).fund{value: SEND_VALUE}(); + vm.stopBroadcast(); + console.log("Funded to Crowdfunding with %s", SEND_VALUE); + } + + function run() external { + address mostRecentlyDeployed = DevOpsTools.get_most_recent_deployment("Crowdfunding", block.chainid); + fundToCrowdfunding(mostRecentlyDeployed); + } +} + +contract WithdrawCrowdfunding is Script { + function withdrawFromCrowdfunding(address mostRecentlyDeployed) public { + vm.startBroadcast(); + Crowdfunding(payable(mostRecentlyDeployed)).withdraw(); + vm.stopBroadcast(); + console.log("Withdraw FundMe balance!"); + } + + function run() external { + address mostRecentlyDeployed = DevOpsTools.get_most_recent_deployment("Crowdfunding", block.chainid); + withdrawFromCrowdfunding(mostRecentlyDeployed); + } +} diff --git a/src/Counter.sol b/src/Counter.sol deleted file mode 100644 index aded799..0000000 --- a/src/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/src/Crowdfunding.sol b/src/Crowdfunding.sol new file mode 100644 index 0000000..6f7de2e --- /dev/null +++ b/src/Crowdfunding.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {PriceConverter} from "./lib/PriceConverter.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; + +contract Crowdfunding is Ownable { + using PriceConverter for address; + + error InsufficientFunding(); + + uint256 public constant MINIMUM_USD = 5e18; // 5 USD in Wei + + mapping(address => bool) public s_isFunders; + mapping(address => uint256) public s_funderToAmount; + address[] public s_funders; + + address public immutable i_ethPriceFeed; + + event Funded(address indexed funder, uint256 value); + event Withdrawn(uint256 value); + + receive() external payable { + fund(); + } + + fallback() external payable { + fund(); + } + + constructor(address ethPriceFeed) Ownable(msg.sender) { + i_ethPriceFeed = ethPriceFeed; + } + + function fund() public payable { + if (i_ethPriceFeed.getConversionRate(msg.value) < MINIMUM_USD) { + revert InsufficientFunding(); + } + + s_funderToAmount[msg.sender] += msg.value; + bool isFunded = s_isFunders[msg.sender]; + + if (!isFunded) { + s_funders.push(msg.sender); + s_isFunders[msg.sender] = true; + } + + emit Funded(msg.sender, msg.value); + } + + function withdraw() public onlyOwner { + uint256 withdrawBal = address(this).balance; + (bool sent,) = payable(owner()).call{value: withdrawBal}(""); + require(sent, "Failed to send Ether"); + + emit Withdrawn(withdrawBal); + } + + function getETHPriceFeedVersion() public view returns (uint256) { + return AggregatorV3Interface(i_ethPriceFeed).version(); + } +} diff --git a/src/lib/PriceConverter.sol b/src/lib/PriceConverter.sol new file mode 100644 index 0000000..91d4a22 --- /dev/null +++ b/src/lib/PriceConverter.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; + +library PriceConverter { + function getPrice(address _priceFeed) internal view returns (uint256) { + AggregatorV3Interface priceFeed = AggregatorV3Interface(_priceFeed); + (, int256 answer,,,) = priceFeed.latestRoundData(); + require(answer > 0, "Invalid price data"); + // nhận về 8 digits, thêm vào 10 số 0 để lấy thành 18 digits + return uint256(answer) * 1e10; + } + + function getConversionRate(address priceFeed, uint256 ethAmount) internal view returns (uint256) { + uint256 ethPrice = getPrice(priceFeed); + uint256 ethAmountInUsd = (ethPrice * ethAmount) / 1e18; + return ethAmountInUsd; + } +} diff --git a/test/Counter.t.sol b/test/Counter.t.sol deleted file mode 100644 index 54b724f..0000000 --- a/test/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test, console} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/test/intergration/InteractionsTest.t.sol b/test/intergration/InteractionsTest.t.sol new file mode 100644 index 0000000..f094c43 --- /dev/null +++ b/test/intergration/InteractionsTest.t.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {WithdrawCrowdfunding} from "script/Interactions.s.sol"; +import {DeployCrowdfunding} from "script/DeployCrowdfunding.s.sol"; +import {Crowdfunding} from "src/Crowdfunding.sol"; +import {HelperConfig} from "script/HelperConfig.s.sol"; + +contract InteractionsTest is Test { + Crowdfunding public crowdfunding; + HelperConfig public helperConfig; + + uint256 public constant SEND_VALUE = 0.1 ether; + address public constant USER = address(2); + uint256 public constant INITIAL_ETHER_AMOUNT = 100 ether; + + function setUp() external { + DeployCrowdfunding deployCrowdfunding = new DeployCrowdfunding(); + (crowdfunding, helperConfig) = deployCrowdfunding.run(); + + vm.deal(USER, INITIAL_ETHER_AMOUNT); + } + + function test_can_fundAndWithdraw() public { + uint256 beforeUserBalance = address(USER).balance; + uint256 beforeOwnerBalance = address(crowdfunding.owner()).balance; + + vm.prank(USER); + crowdfunding.fund{value: SEND_VALUE}(); + + WithdrawCrowdfunding withdrawCrowdfunding = new WithdrawCrowdfunding(); + withdrawCrowdfunding.withdrawFromCrowdfunding(address(crowdfunding)); + + uint256 afterUserBalance = address(USER).balance; + uint256 afterOwnerBalance = address(crowdfunding.owner()).balance; + + assert(address(crowdfunding).balance == 0); + assertEq(afterUserBalance + SEND_VALUE, beforeUserBalance); + assertEq(beforeOwnerBalance + SEND_VALUE, afterOwnerBalance); + } +} diff --git a/test/unit/CrowdfundingTest.t.sol b/test/unit/CrowdfundingTest.t.sol new file mode 100644 index 0000000..b15595e --- /dev/null +++ b/test/unit/CrowdfundingTest.t.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test, console} from "forge-std/Test.sol"; +import {DeployCrowdfunding} from "script/DeployCrowdfunding.s.sol"; +import {HelperConfig} from "script/HelperConfig.s.sol"; +import {Crowdfunding} from "src/Crowdfunding.sol"; +import {MockV3Aggregator} from "@chainlink/contracts/src/v0.8/tests/MockV3Aggregator.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +contract CrowdfundingTest is Test { + Crowdfunding public crowdfunding; + HelperConfig public helperConfig; + + address public constant USER = address(2); + + uint256 public constant INITIAL_ETHER_AMOUNT = 100 ether; + uint256 public constant ACCEPTABLE_FUND_AMOUNT = 0.1 ether; + + event Funded(address indexed funder, uint256 value); + event Withdrawn(uint256 value); + + function setUp() external { + DeployCrowdfunding deployCrowdfunding = new DeployCrowdfunding(); + (crowdfunding, helperConfig) = deployCrowdfunding.run(); + + vm.deal(USER, INITIAL_ETHER_AMOUNT); + } + + function test_priceFeedSetCorrectly() public { + address retreivedPriceFeed = address(crowdfunding.i_ethPriceFeed()); + address expectedPriceFeed = helperConfig.getConfigByChainId(block.chainid).priceFeed; + assertEq(retreivedPriceFeed, expectedPriceFeed); + } + + function test_revert_fund() public { + // Revert if not send ETH + vm.expectRevert(Crowdfunding.InsufficientFunding.selector); + vm.prank(USER); + crowdfunding.fund(); + } + + function test_can_fund() public { + vm.expectEmit(); + emit Crowdfunding.Funded(USER, ACCEPTABLE_FUND_AMOUNT); + + uint256 contractBalBeforeFund = address(crowdfunding).balance; + console.log("Crowdfunding contract balance before fund", contractBalBeforeFund); + + vm.prank(USER); + crowdfunding.fund{value: ACCEPTABLE_FUND_AMOUNT}(); + + console.log("User funded to crowdfunding contract", ACCEPTABLE_FUND_AMOUNT); + + uint256 contractBalAfterFund = address(crowdfunding).balance; + console.log("Crowdfunding contract balance after fund", contractBalAfterFund); + + assertEq(crowdfunding.s_funderToAmount(USER), ACCEPTABLE_FUND_AMOUNT); + assertEq(crowdfunding.s_funders(0), USER); + assertTrue(crowdfunding.s_isFunders(USER)); + assertEq(contractBalBeforeFund + ACCEPTABLE_FUND_AMOUNT, contractBalAfterFund); + } + + function test_revert_withdraw() public { + // Revert if not owner + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, USER)); + vm.prank(USER); + crowdfunding.withdraw(); + } + + function test_can_withdraw() public { + // Fund before withdraw + test_can_fund(); + + uint256 ownerBalBeforeWithdraw = crowdfunding.owner().balance; + console.log("Owner balance before withdraw", ownerBalBeforeWithdraw); + + vm.expectEmit(); + emit Crowdfunding.Withdrawn(ACCEPTABLE_FUND_AMOUNT); + + vm.prank(crowdfunding.owner()); + crowdfunding.withdraw(); + + uint256 ownerBalAfterWithdraw = crowdfunding.owner().balance; + console.log("Owner balance after withdraw", ownerBalAfterWithdraw); + + assertEq(ownerBalBeforeWithdraw + ACCEPTABLE_FUND_AMOUNT, ownerBalAfterWithdraw); + } +}