From 6ebad4669fa83f0db422c6d23601a2085da06edb Mon Sep 17 00:00:00 2001 From: Justin Bell Date: Sat, 14 Sep 2024 20:53:41 +0000 Subject: [PATCH] First commit --- .github/workflows/test.yml | 45 +++++++++++++++++ .gitignore | 17 +++++++ .gitmodules | 3 ++ README.md | 87 ++++++++++++++++++++++++++++++++ foundry.toml | 6 +++ lib/forge-std | 1 + script/Ballot.s.sol | 24 +++++++++ src/Ballot.sol | 100 +++++++++++++++++++++++++++++++++++++ test/Ballot.sol | 58 +++++++++++++++++++++ 9 files changed, 341 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 README.md create mode 100644 foundry.toml create mode 160000 lib/forge-std create mode 100644 script/Ballot.s.sol create mode 100644 src/Ballot.sol create mode 100644 test/Ballot.sol diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..762a296 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + strategy: + fail-fast: true + + name: Foundry project + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Show Forge version + run: | + forge --version + + - name: Run Forge fmt + run: | + forge fmt --check + id: fmt + + - name: Run Forge build + run: | + forge build --sizes + id: build + + - name: Run Forge tests + run: | + forge test -vvv + id: test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0478803 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env + +# Ignores snapshots +.gas-snapshot diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..888d42d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/README.md b/README.md new file mode 100644 index 0000000..c86ae41 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# Ballot + +## The Contract + +The Ballot contract showcases many Solidity features. +It implements a voting contract. +It creates one contract per ballot, providing a short name for each option. +The creator of the contract - the chairperson - gives the right to vote to each address individually. +The persons behind the addresses can then choose to either vote themselves or to delegate their vote to someone else. +winningProposal() returns the proposal with the largest number of votes. + +The source code for the contract is found at ./src/Ballot.sol + +## Requirements + +### Foundry + +Install foundry by running the following command and following the instructions. + +```sh +curl -L https://foundry.paradigm.xyz | bash +``` + +Foundry is a toolkit for Ethereum application development written in Rust. +It consists of several tools but we'll focus on **forge**: an ethereum build and testing framework (like Truffle or Hardhat). + +Read https://book.getfoundry.sh/ to learn more. + +## Intro + +The following command will build and run the tests for your smart contracts. + +``` +forge test +``` + +### Challenge + +1. Write tests for the delegate function. +2. Write a Ballot function that allows the chairperson to give multiple addresses the right to vote. + +## Experimenting + +Foundry also comes with a local testnet node, **anvil**. +The following command will start the node, expose an rpc endpoint on `127.0.0.1:8545`, and initialize 10 accounts with some ETH balance. + +```sh +anvil --acccounts 10 +``` + +You can then use the provided ./script/Ballot.s.sol to deploy a Ballot with two proposals. +The `BALLOT_DEPLOYER_PRIVATEKEY` envvar is used referenced in the script. +By default, anvil creates the private key with the value presented below. + +```sh +export BALLOT_RPCURL=127.0.0.1:8545 +export BALLOT_DEPLOYER_PRIVATEKEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 +forge script script/Ballot.s.sol:BallotScript --rpc-url $BALLOT_RPCURL --broadcast +``` + +The output above will include the contract address. +Copy this to an environment variable for further use. + +``` +export BALLOT_CONTRACTADDRESS= +``` + +**cast** is Foundry’s command-line tool for Ethereum RPC calls. +The following sends a vote from the deployer address. + +```sh +cast send --private-key $BALLOT_DEPLOYER_PRIVATEKEY \ + $BALLOT_CONTRACTADDRESS "vote(uint256)" 1 \ + --rpc-url $BALLOT_RPCURL +``` + +Read-only commands don't require a private key. + +```sh +cast call $BALLOT_CONTRACTADDRESS "getVoteCount(uint256)" 1 \ + --rpc-url $BALLOT_RPCURL +``` + +### Challenge + +1. Give another address the right to vote and use that address. +2. Get the winning proposal name. This will be in bytes32 so use `cast parse-bytes32-string` to convert it to a string [link](https://book.getfoundry.sh/reference/cast/cast-parse-bytes32-string) \ No newline at end of file diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..25b918f --- /dev/null +++ b/foundry.toml @@ -0,0 +1,6 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..1714bee --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 1714bee72e286e73f76e320d110e0eaf5c4e649d diff --git a/script/Ballot.s.sol b/script/Ballot.s.sol new file mode 100644 index 0000000..7d210ff --- /dev/null +++ b/script/Ballot.s.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script, console} from "forge-std/Script.sol"; +import {Ballot} from "../src/Ballot.sol"; + +contract BallotScript is Script { + Ballot public ballot; + + function setUp() public {} + + function run() public { + uint256 deployerPrivateKey = vm.envUint("BALLOT_DEPLOYER_PRIVATEKEY"); + vm.startBroadcast(deployerPrivateKey); + + bytes32[] memory proposals = new bytes32[](3); + proposals[0] = bytes32("prop0"); + proposals[1] = bytes32("prop1"); + + ballot = new Ballot(proposals); + + vm.stopBroadcast(); + } +} diff --git a/src/Ballot.sol b/src/Ballot.sol new file mode 100644 index 0000000..04ea95e --- /dev/null +++ b/src/Ballot.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.7.0 <0.9.0; + +/// @title Voting with delegation. +contract Ballot { + + // This declares a new complex type which will + // be used for variables later. + // It will represent a single voter. + struct Voter { + uint256 weight; // weight is accumulated by delegation + bool voted; // if true, that person already voted + address delegate; // person delegated to + uint256 vote; // index of the voted proposal + } + + // This is a type for a single proposal. + struct Proposal { + bytes32 name; // short name (up to 32 bytes) + uint256 voteCount; // number of accumulated votes + } + + address public chairperson; + + // This declares a state variable that + // stores a `Voter` struct for each possible address. + mapping(address => Voter) public voters; + + // A dynamically-sized array of `Proposal` structs. + Proposal[] public proposals; + + /// Create a new ballot to choose one of `proposalNames`. + constructor(bytes32[] memory proposalNames) { + chairperson = msg.sender; + voters[chairperson].weight = 1; + + // For each of the provided proposal names, + // create a new proposal object and add it + // to the end of the array. + for (uint256 i = 0; i < proposalNames.length; i++) { + // `Proposal({...})` creates a temporary Proposal object and `proposals.push(...)` + // appends it to the end of `proposals`. + proposals.push(Proposal({name: proposalNames[i], voteCount: 0})); + } + } + + // Give `voter` the right to vote on this ballot. + // May only be called by the `chairperson` + function giveRightToVote(address voter) external { + require(msg.sender == chairperson, "Only chairperson can give right to vote."); + require(!voters[voter].voted, "voter has already voted."); + require(voters[voter].weight == 0); + + voters[voter].weight = 1; + } + + // Check if the voter is still allowed to vote. + function hasRightToVote(address voter) public view returns (bool) { + return voters[voter].weight > 0; + } + + /// Give your vote (including votes delegated to you) + /// to proposal `proposals[proposal].name`. + function vote(uint256 proposal) external { + Voter storage sender = voters[msg.sender]; + require(!sender.voted, "sender has already voted"); + require(sender.weight > 0, "sender does not have right to vote"); + sender.voted = true; + sender.vote = proposal; + + // If `proposal` is out of the range of the array, + // this will throw automatically and revert all + // changes. + proposals[proposal].voteCount += sender.weight; + } + + /// Get the vote count for a proposal. + function getVoteCount(uint256 proposal) public view returns (uint256) { + return proposals[proposal].voteCount; + } + + /// @dev Computes the winning proposal taking all + /// previous votes into account. + function winningProposal() public view returns (uint256 winningProposal_) { + uint256 winningVoteCount = 0; + for (uint256 p = 0; p < proposals.length; p++) { + if (proposals[p].voteCount > winningVoteCount) { + winningVoteCount = proposals[p].voteCount; + winningProposal_ = p; + } + } + } + + // Calls winningProposal() function to get the index + // of the winner contained in the proposals array and then + // returns the name of the winner + function winningProposalName() public view returns (bytes32 winningProposalName_) { + return proposals[winningProposal()].name; + } +} diff --git a/test/Ballot.sol b/test/Ballot.sol new file mode 100644 index 0000000..f5becf5 --- /dev/null +++ b/test/Ballot.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console} from "forge-std/Test.sol"; +import {Ballot} from "../src/Ballot.sol"; + +contract BallotTest is Test { + Ballot public ballot; + + function setUp() public { + bytes32[] memory proposals = new bytes32[](3); + proposals[0] = bytes32("prop0"); + proposals[1] = bytes32("prop1"); + + ballot = new Ballot(proposals); + } + + function test_Vote() public { + ballot.vote(0); + assertEq(ballot.getVoteCount(0), 1); + } + + function test_NoRightToVote_RevertVote() public { + // Given... + // the voter hasn't been given the right to vote + address voter = address(2); + vm.startPrank(voter); + + // Expect... + vm.expectRevert("sender does not have right to vote"); + + // When... + // the voter tries to vote + ballot.vote(1); + + vm.stopPrank(); + } + + function test_AlreadyVoted_RevertVote() public { + // Given... + // the voter has the right to vote + address voter = address(1); + ballot.giveRightToVote(voter); + + // the voter has already voted + vm.startPrank(voter); + ballot.vote(0); + + // Expect... + vm.expectRevert("sender has already voted"); + + // When... + // the voter tries to vote again + ballot.vote(1); + + vm.stopPrank(); + } +}