diff --git a/.gitignore b/.gitignore index 18612c0..ceca940 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,4 @@ docs/ .env .DS_Store -node_modules/ +node_modules/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index df53c81..0405715 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ +[submodule "folio/lib/forge-std"] + path = folio/lib/forge-std + url = https://github.com/foundry-rs/forge-std + [submodule "nibble/lib/forge-std"] path = OneByTwo/lib/forge-std url = https://github.com/foundry-rs/forge-std diff --git a/README.md b/README.md index a119fd4..2b23507 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Below is a quick summary of each prototype currently available in this repositor Pay your rent with a yield-bearing stablecoin. 1. **`RIFF`** Listen to a bonding curve. +1. **`Folio`** + Participate in a global pay-it-forward chain. 1. **`Nibble`** Earn revenue share in your favorite restaurant. diff --git a/folio/.gitignore b/folio/.gitignore new file mode 100644 index 0000000..85198aa --- /dev/null +++ b/folio/.gitignore @@ -0,0 +1,14 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/folio/README.md b/folio/README.md new file mode 100644 index 0000000..3bf126e --- /dev/null +++ b/folio/README.md @@ -0,0 +1,12 @@ +# Folio +### Pay it forward, get paid back + +### Problem +Traditional pay-it-forward chains can raise charitable funds, but they are often vulnerable to exploitation. Additionally, they tend to lack excitement, and most efforts remain local rather than global-limiting their overall impact. + +### Insight +By gamifying the pay-it-forward process, we can make chains more engaging and encourage increased donations. People are motivated when they believe they will be a part of something big. By using a blockchain, we can expand the scope to be worldwide, amplifying reach and participation. + + +### Solution +Create a global pay-it-forward competition on consumer payment rails. The potential to win a portion of the prize pot adds a competitive spark, increasing excitement and contributions. By keeping the chain encrypted, we avoid the case where people exploit the system by only contributing to the winning chain. This way every participant (new or returning) will feel that they have a fair chance to be a winner. \ No newline at end of file diff --git a/folio/foundry.toml b/folio/foundry.toml new file mode 100644 index 0000000..25b918f --- /dev/null +++ b/folio/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/folio/lib/forge-std b/folio/lib/forge-std new file mode 160000 index 0000000..3b20d60 --- /dev/null +++ b/folio/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 3b20d60d14b343ee4f908cb8079495c07f5e8981 diff --git a/folio/src/ChainTracker.sol b/folio/src/ChainTracker.sol new file mode 100644 index 0000000..4561435 --- /dev/null +++ b/folio/src/ChainTracker.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +/// @title Pay-it-Forward Chain Management Contract +/// @notice This contract manages the process of tracking chains and chain details during a pay-it-forward competition. +contract ChainTracker { + /// @notice Sets up the manager + address manager; + + constructor() { + manager = msg.sender; + } + + /// @notice Modifier to ensure only the competition contract can call certain functions + modifier competitionOnly() { + require(msg.sender == manager, "Only the competition smart contract can call this."); + _; + } + + /** + * @dev Represents a "pay-it-forward" chain. + * Tracks participants, their contributions, and overall chain statistics. + */ + struct ChainStats { + suint chainId; // Unique identifier for the chain + mapping(saddress => suint) links; // Tracks number of contributions for each participant/business + suint uniqueBusinesses; // Total distinct businesses involved in the chain + suint uniqueParticipants; // Total distinct participants in the chain + suint chainLength; // Total number of transactions in the chain + } + + /** + * @dev Stores competition-wide statistics of terminated (nuked) chains. + * Tracks scores, lengths, and the highest-scoring chain. + */ + struct CompStats { + mapping(suint => suint) chainFinalScores; // Chain ID => Final score of that chain + mapping(suint => suint) chainFinalLengths; // Chain ID => Total length of that chain + suint overallWinningChainId; // The ID of the highest-scoring chain + } + + /** + * @dev Stores per-participant and per-business statistics for chain participation. + * Tracks their latest chains, and best chains and their contributions. + */ + struct UserStats { + mapping(saddress => suint) latestChain; // Address => Last chain participated in + mapping(saddress => suint) bestChain; // Address => Best (longest) chain participated in + mapping(saddress => suint) bestChainLinks; // Address => Number of contributions in the best chain + } + + ChainStats activeChain; // The currently active chain + CompStats comp; // Tracks global competition statistics + UserStats user; // Tracks user-specific statistics + + /** + * @notice Adds transaction details to the active chain. + * Updates participation records for both the participant and the business. + * If the participant or business is contributing for the first time, their records are updated accordingly. + * @param pAddr The participant's address. + * @param bAddr The business's address. + */ + function forgeLink(saddress pAddr, saddress bAddr) public competitionOnly { + // If this is the participant's first time contributing to this chain, update records. + if (user.latestChain[pAddr] != activeChain.chainId) { + updateToCurrentChain(pAddr); + activeChain.uniqueParticipants++; + } + activeChain.links[pAddr]++; // Increment participant’s contribution count + + // If this is the business's first time being added to this chain, update records. + if (user.latestChain[bAddr] != activeChain.chainId) { + updateToCurrentChain(bAddr); + activeChain.uniqueBusinesses++; + } + activeChain.links[bAddr]++; // Increment business’s contribution count + activeChain.chainLength++; // Increment chain length + } + + /** + * @dev Resets a participant/business’s contribution count and updates their latest chain. + * @param addr The address of the participant/business. + */ + function updateToCurrentChain(saddress addr) internal { + checkIsChainLongest(addr); // Check if their latest chain was their longest + activeChain.links[addr] = suint(0); // Reset contribution count + user.latestChain[addr] = activeChain.chainId; // Assign the latest chain + } + + /** + * @dev Checks if the participant/business’s latest chain had the highest score. + * If so, update their best chain records. + * This is public due to the need to check the final chain after the competition ends + * @param addr The address of the participant/business. + */ + function checkIsChainLongest(saddress addr) public competitionOnly { + suint latestScore = comp.chainFinalScores[user.latestChain[addr]]; + suint bestScore = comp.chainFinalScores[user.bestChain[addr]]; + if (latestScore > bestScore) { + user.bestChain[addr] = user.latestChain[addr]; // Update best chain + user.bestChainLinks[addr] = activeChain.links[addr]; // Update best chain's contribution count + } + } + + /** + * @notice Ends the active chain, records its statistics, and resets it to be used as the next chain. + * Updates the overall highest-scoring chain if applicable. + */ + function nuke() public competitionOnly { + // Record final score and length of the nuked chain + comp.chainFinalScores[activeChain.chainId] = + calcChainScore(activeChain.uniqueParticipants, activeChain.uniqueBusinesses); + comp.chainFinalLengths[activeChain.chainId] = activeChain.chainLength; + + // Update the overall winning chain if this one has a higher score + if (comp.chainFinalScores[activeChain.chainId] > comp.chainFinalScores[comp.overallWinningChainId]) { + comp.overallWinningChainId = activeChain.chainId; + } + + // Reset the active chain for a fresh start + activeChain.chainLength = suint(0); + activeChain.uniqueParticipants = suint(0); + activeChain.uniqueBusinesses = suint(0); + activeChain.chainId++; + } + + /** + * @dev Calculates a chain's final score based on distinct participants and businesses. + * @param pScore The number of distinct participants. + * @param bScore The number of distinct businesses. + * @return The calculated final score. + */ + function calcChainScore(suint pScore, suint bScore) internal pure returns (suint) { + // Formula: finalScore = unique businesses * (1 + (unique participants / 10)) + return bScore * (suint(1) + (pScore / suint(10))); + } + + /// @notice Returns the ID of the highest-scoring chain. + function getWinningChainId() public view competitionOnly returns (uint256) { + return uint256(comp.overallWinningChainId); + } + + /// @notice Returns the total length of the highest-scoring chain. + function getWinningChainLength() public view competitionOnly returns (uint256) { + return uint256(comp.chainFinalLengths[comp.overallWinningChainId]); + } + + /// @notice Returns the best (highest-scoring) chain a participant/business has contributed to. + /// @param addr The address of the participant/business. + function getBestChainId(saddress addr) public view competitionOnly returns (uint256) { + return uint256(user.bestChain[addr]); + } + + /// @notice Returns the number of times a participant/business contributed to their best chain. + /// @param addr The address of the participant/business. + function getBestChainCount(saddress addr) public view competitionOnly returns (uint256) { + return uint256(user.bestChainLinks[addr]); + } + + /** + * @dev Resets a participant/business contribution count on the winning chain + * Prevents them from claiming rewards multiple times. + * @param addr The address of the participant/business. + */ + function walletHasBeenPaid(saddress addr) public competitionOnly { + user.bestChainLinks[addr] = suint(0); + } +} diff --git a/folio/src/Competition.sol b/folio/src/Competition.sol new file mode 100644 index 0000000..44cfe54 --- /dev/null +++ b/folio/src/Competition.sol @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +import "./ChainTracker.sol"; +import "./IStoreFront.sol"; + +/// @title Pay-it-Forward Competition Manager Contract +/// @notice Manages the different phases of a pay-it-forward competition. +contract Competition { + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~Constants and Modifiers~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + /// @notice Creates the manager, chain, charity, and list of approved businesses. + address manager; + ChainTracker public chain; + address charity; + mapping(address => bool) approvedBusinesses; + + /** + * @notice Details the preset competition rules, made public for verification sake /// outlining/verifying the competition rules. + * @dev Initializes the public variables determining the competition framework including: + * Setting the percent portion each group (participant, business, charity) gets of the prize pool + * Setting the amount of eth per transaction that will be added to the prize pot + * Setting up the phases of the competiton and the starting time and duration the competition will run for + */ + uint256 public constant participantPerc = 50; + uint256 public constant businessPerc = 15; + uint256 public constant charityPerc = 35; + uint256 public constant prizePotAllotment = 1; + CompetitionPhases public competition = CompetitionPhases.PRE; + uint256 public competitionStartTime; + uint256 public duration = 2592000; //this should be read as seconds, i.e. 2592000 seconds = 30 days + + /** + * @notice Internal variables used for storing relevant competiton information. + * @dev Initializes the variables assisting the competition, including: + * Determining a single link's worth of payout at the end of a competition for participants and businesses + * Determining the amount being donated to a charity + * setting an important flag to keep track of the most recent transaction's decision to pay it forward + */ + uint256 pWinnings; + uint256 bWinnings; + uint256 donation; + sbool lastPifChoice = sbool(false); + + /// @notice Enum to manage the stages of the competition. + /// @dev The competition progresses through these phases: `PRE`, `DURING`, and `POST`. + enum CompetitionPhases { + PRE, + DURING, + POST + } + + /// @notice Modifier to ensure only the organizer can call certain functions. + /// Organizer is the person that selects the charity, approves businesses, and starts the competition + modifier organizerOnly() { + require(msg.sender == manager, "The competition manager must call this."); + _; + } + + /// @notice Modifier to ensure that the restricted functions are only involving approved businesses. + modifier approvedBusinessesOnly(address businessAddr) { + require(approvedBusinesses[businessAddr], "This business is not approved."); + _; + } + + /// @notice Modifier to ensure that the function is only called during the specified competition phase. + modifier atStage(CompetitionPhases phase) { + require(phase == competition, "This function is is invalid at this stage."); + _; + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~constructor~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + constructor(address charityAddr) { + manager = msg.sender; + chain = new ChainTracker(); + selectCharity(charityAddr); + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~pre competition~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + /** + * @notice Sets the charity we will be donating to at the end of the competition. + * @dev Checks that the charity address is not the burn address + * @param charityAddr The address of the charity + */ + function selectCharity(address charityAddr) public organizerOnly atStage(CompetitionPhases.PRE) { + require(charityAddr != address(0x0), "Invalid address."); + charity = charityAddr; + } + + /** + * @notice Process to approve a business to participate in the competition. + * @param businessAddr The address of the business being approved. + * This prevents random addresses claiming themselves as businesses + */ + function businessApprovalProcess(address businessAddr) public organizerOnly atStage(CompetitionPhases.PRE) { + approvedBusinesses[businessAddr] = true; + } + + /// @notice Allows an approved business to donate to the prize pool, if they so choose. + function businessContribution() + external + payable + approvedBusinessesOnly(msg.sender) + atStage(CompetitionPhases.PRE) + { + require(msg.value > 0, "Contribution has been rejected."); + } + + /** + * @notice Function for the manager to start the competition. + * @dev Performs the necessary actions to begin the competition, specifically: + * Checks that a charity has been selected + * Changes the enum phase to DURING + * Sets the competition starting time to the current time + */ + function startCompetition() public organizerOnly atStage(CompetitionPhases.PRE) { + require(charity != address(0x0), "Charity has not been selected."); + competitionStartTime = block.timestamp; + competition = CompetitionPhases.DURING; + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~during competition~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + /** + * @notice Processes the financial transactions customers purchase products. + * @dev Handles transaction data for participants at businesses + * Checks that the transaction occurred at an approved business + * Processes the product price and sends the money to the business, via the storefront the business will have set up + * Processes the money paid forward and the prize pot and gives rebate depending on the current state of the chain (determined by currentPifChoice and lastPifChoice) + * Updates the chain to reflect the current transaction + * Ends the competition if applicable + * @param currentPifChoice The current transactions decision to pay it forward + * @param business The address of the business the transaction is occurring at + * @param prodID THe ID of the product being purchased in the transaction + */ + function makeCompTransaction(sbool currentPifChoice, address business, suint prodID) + external + payable + approvedBusinessesOnly(business) + atStage(CompetitionPhases.DURING) + { + if (lastPifChoice && currentPifChoice) { + // case: last person pif, current person pif + uint256 prodPrice = msg.value - (5 + (prizePotAllotment / 100)); //subtract pif amount and prize pot amount + IStoreFront(business).purchase{value: prodPrice}(prodID); // store implements their own purchase logic + + // rebate: yes, last person pif + (bool success,) = msg.sender.call{value: 5}(""); + require(success, "Rebate failed."); + + // update chain with new pif information + chain.forgeLink(saddress(msg.sender), saddress(business)); + + // don't update lastPifChoice because it is the same + } else if (!lastPifChoice && currentPifChoice) { + // case: last person did not pif, current person pif + uint256 prodPrice = msg.value - (5 + (prizePotAllotment / 100)); //subtract pif amount and prize pot amount + IStoreFront(business).purchase{value: prodPrice}(prodID); // store implements their own purchase logic + + // rebate: no, last person didn't pif + + // update chain with new pif information + chain.forgeLink(saddress(msg.sender), saddress(business)); + + //update lastPifChoice to new choice + lastPifChoice = currentPifChoice; + } else if (lastPifChoice && !currentPifChoice) { + // case: last person pif, current person did not pif + uint256 prodPrice = msg.value; // amount sent is exact + IStoreFront(business).purchase{value: prodPrice}(prodID); // store implements their own purchase logic + + // rebate: yes, last person pif + (bool success,) = msg.sender.call{value: 5}(""); + require(success, "Rebate failed."); + + // nuke the existing chain + chain.nuke(); + + // update lastPifChoice to new choice + lastPifChoice = currentPifChoice; + } else { + // case: last person did not pif, you did not pif + uint256 prodPrice = msg.value; // since current person didn't pif, they send the exact amount + IStoreFront(business).purchase{value: prodPrice}(prodID); // store implements their own purchase logic + + // rebate: no, last person didn't pif + + // no update/nuke because chain is default + + //don't update last PIF because it is the same + } + } + /** + * @notice Ends the competition if the set duration has passed. + * @dev Check if required durtation has passed since the competitions starting timestamp + * Changes the enum phase to POST + */ + + function endCompetition() public organizerOnly() atStage(CompetitionPhases.DURING) { + if (block.timestamp >= competitionStartTime + duration) { + competition = CompetitionPhases.POST; + setupPostCompetition(); + } + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~post competition~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + /** + * @notice Sets up the post competition phase. + * @dev Executes final nuke to determine the overall highest-scoring chain + * Sets up the payouts + */ + function setupPostCompetition() internal atStage(CompetitionPhases.POST) { + chain.nuke(); + setupPayout(); + } + + /** + * @dev Helper function to determine the amount of payout per link in the chain for participants and businesses + * Calculates the charity donation + */ + function setupPayout() internal atStage(CompetitionPhases.POST) { + // calc prize pot amount of a single link for participant/business + pWinnings = ((address(this).balance * participantPerc) / (chain.getWinningChainLength() * 100)); + bWinnings = ((address(this).balance * businessPerc) / (chain.getWinningChainLength() * 100)); + + // calc amount of prize pot being donated to charity + donation = ((address(this).balance * charityPerc) / 100); + } + + /** + * @notice Claim function for the charity to recieve it's payout of the winning chain. + * @dev Checks that the one calling the function is the charity + * Checks again that the charity address is not the burn address + * Sends the determined amount to the charity + */ + function claimDonation() external payable atStage(CompetitionPhases.POST) { + // check if charity address if valid + require(msg.sender == charity, "You are not the charity."); + require(charity != address(0x0), "Invalid address."); + // payout charity + (bool paid,) = charity.call{value: donation}(""); + require(paid, "Donation failed."); + } + + /** + * @notice Claim function for participants and businesses to recieve their payouts of the winning chain. + * @dev Performs final check to determine the participants/businesses best chain + * Checks if their best chain is the winning chain, and if so fetches the count of links they had in the winning chain + * Checks whether the wallet is a participant or a business and pays out accordingly + * Updates the number of links they had in the winning chain to prevent repeated payouts + */ + function payout() external payable atStage(CompetitionPhases.POST) { + chain.checkIsChainLongest(saddress(msg.sender)); + if (chain.getBestChainId(saddress(msg.sender)) == chain.getWinningChainId()) { + uint256 linkCount = chain.getBestChainCount(saddress(msg.sender)); + if (!approvedBusinesses[msg.sender]) { + // payout to participant + (bool paid,) = msg.sender.call{value: linkCount * pWinnings}(""); + require(paid, "Payout failed."); + } else { + // payout to business + (bool paid,) = msg.sender.call{value: linkCount * bWinnings}(""); + require(paid, "Payout failed."); + } + chain.walletHasBeenPaid(saddress(msg.sender)); + } + } + + function getChainTrackerAddress() public view returns (address) { + return address(chain); + } + + function getManagerAddress() public view returns (address) { + return address(manager); + } +} diff --git a/folio/src/IStoreFront.sol b/folio/src/IStoreFront.sol new file mode 100644 index 0000000..a7e0afd --- /dev/null +++ b/folio/src/IStoreFront.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title StoreFront Interface +/// @dev This interface defines the function required for purchasing a product in a businesses storefront system. +interface IStoreFront { + /** + * @notice Allows a business to set up their own personal storefront + * @dev This function accepts a payment to complete the purchase of a product and requires the product ID as input to identify which product to purchase. + * @param prodId The ID of the product to be purchased. + */ + function purchase(suint prodId) external payable; +} diff --git a/folio/test/Chain.t.sol b/folio/test/Chain.t.sol new file mode 100644 index 0000000..9e54f01 --- /dev/null +++ b/folio/test/Chain.t.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT License +pragma solidity ^0.8.13; + +import {Test, console} from "forge-std/Test.sol"; +import {ChainTracker} from "../src/ChainTracker.sol"; + +contract TestChain is Test { + // create test Chain and dummy wallets to set transactions + ChainTracker public chain; + saddress participantA; + saddress participantB; + saddress businessA; + saddress businessB; + saddress rando; + + // set up new chain object as well as the dummy addresses we will be using + // owner address is address(this) + function setUp() public { + chain = new ChainTracker(); + participantA = saddress(0x111); + participantB = saddress(0x222); + businessA = saddress(0x100); + businessB = saddress(0x200); + rando = saddress(0x999); + //print list of addresses for visibility + console.log(address(this)); + console.log(address(participantA)); + console.log(address(participantB)); + console.log(address(businessA)); + console.log(address(businessB)); + console.log(address(rando)); + } + + // test our getter functions for logic and ownership + function test_getters() public { + // chain length getter + chain.getWinningChainLength(); //should pass + vm.expectRevert(); + vm.prank(address(rando)); + chain.getWinningChainLength(); // should fail + // user best chain getter + chain.getBestChainId(participantA); // should pass + vm.expectRevert(); + vm.prank(address(rando)); + chain.getBestChainId(participantA); // should fail + } + + //test the update function for logic and ownership + function test_forgeLink() public { + chain.forgeLink(participantA, businessA); // should pass and set new mapping entries + chain.forgeLink(participantA, businessB); // should pass and set new business mapping entry + chain.forgeLink(participantB, businessA); // should pass and set new participant mapping entry + chain.forgeLink(participantB, businessB); // should pass and not set new mapping entries + //check ownership + vm.expectRevert(); + vm.prank(address(rando)); + chain.forgeLink(participantA, businessA); // should fail and not update the chain + + console.log(chain.getWinningChainLength()); + console.log(chain.getBestChainId(participantA)); + console.log(chain.getBestChainId(businessB)); + console.log(chain.getBestChainId(saddress(0x555))); + } + + // test the nuke function for logic and ownership + function test_nuke() public { + // Add links to the chain + chain.forgeLink(participantA, businessA); + chain.forgeLink(participantB, businessB); + + // Nuke the chain + chain.nuke(); + + // Check that the active chain is reset + uint256 winningChainId = chain.getWinningChainId(); + assertEq(winningChainId, 0, "Winning chain ID should be 0 after nuking the first chain."); + + uint256 winningChainLength = chain.getWinningChainLength(); + assertEq(winningChainLength, 2, "Winning chain length should be 2 after nuking the first chain."); + + // Check ownership + vm.expectRevert(); + vm.prank(address(rando)); + chain.nuke(); // should fail and not nuke the chain + } + + // test the walletHasBeenPaid function for logic and ownership + function test_walletHasBeenPaid() public { + // Add links to the chain + chain.forgeLink(participantA, businessA); + chain.forgeLink(participantA, businessB); + + // Mark the wallet as paid + chain.walletHasBeenPaid(participantA); + + // Check that the best chain count is reset + uint256 bestChainCount = chain.getBestChainCount(participantA); + assertEq(bestChainCount, 0, "Best chain count should be 0 after walletHasBeenPaid."); + + // Check ownership + vm.expectRevert(); + vm.prank(address(rando)); + chain.walletHasBeenPaid(participantA); // should fail and not reset the count + } + + // test the checkIsChainLongest function for logic and ownership + function test_checkIsChainLongest() public { + // Add links to the chain + chain.forgeLink(participantA, businessA); + chain.forgeLink(participantA, businessB); + + // Check if the latest chain is the longest + chain.checkIsChainLongest(participantA); + + // Check that the best chain is updated + uint256 bestChainId = chain.getBestChainId(participantA); + assertEq(bestChainId, 0, "Best chain ID should be 0 after first chain."); + + // Check ownership + vm.expectRevert(); + vm.prank(address(rando)); + chain.checkIsChainLongest(participantA); // should fail and not update the best chain + } +} \ No newline at end of file diff --git a/folio/test/Competition.t.sol b/folio/test/Competition.t.sol new file mode 100644 index 0000000..fe662b6 --- /dev/null +++ b/folio/test/Competition.t.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Test, console} from "forge-std/Test.sol"; +import {Competition} from "../src/Competition.sol"; +import {ChainTracker} from "../src/ChainTracker.sol"; +import {IStoreFront} from "../src/IStoreFront.sol"; +import {MockStore} from "./utils/MockStore.sol"; + +contract TestCompetition is Test { + address managerAddress; + address charityAddress = address(0x2); + address participantAddress = address(0x4); + MockStore store; + ChainTracker chain; + + Competition public competition; + + function setUp() public { + store = new MockStore(); + competition = new Competition(charityAddress); + competition.businessApprovalProcess(address(store)); + competition.startCompetition(); + chain = ChainTracker(competition.getChainTrackerAddress()); + managerAddress = competition.getManagerAddress(); + } + + // Test that only the manager can start the competition + function testOnlyManagerCanStartCompetition() public { + address nonManager = address(0x5); + vm.prank(nonManager); + vm.expectRevert("The competition manager must call this."); + competition.startCompetition(); + } + + // Test that only approved businesses can participate + function testOnlyApprovedBusinessesCanParticipate() public { + address unapprovedBusiness = address(0x6); + vm.prank(participantAddress); + vm.expectRevert("This business is not approved."); + competition.makeCompTransaction(sbool(true), unapprovedBusiness, suint(1)); + } + + // Test that a participant can make a transaction with an approved business + function testMakeCompTransaction() public { + vm.prank(participantAddress); + // Initializes 40 ether to participant wallet address + vm.deal(participantAddress, 40 ether); + competition.makeCompTransaction{value: 10 ether}(sbool(true), address(store), suint(1)); + + // Verify that the chain has been updated + vm.prank(address(competition)); + uint256 bestChainId = chain.getBestChainId(saddress(participantAddress)); + assertEq(bestChainId, 0, "Best chain ID should be 0 after first transaction."); + } + + // Test that the competition can be ended after the duration has passed + function testEndCompetition() public { + // Initializes 40 ether to participant wallet address + vm.deal(participantAddress, 40 ether); + + // Make a transaction to trigger the end of the competition + vm.prank(participantAddress); + competition.makeCompTransaction{value: 10 ether}(sbool(true), address(store), suint(1)); + + // Fast-forward time to the end of the competition + vm.warp(block.timestamp + competition.duration()); + + // End the competition + vm.prank(managerAddress); + competition.endCompetition(); + + // Verify that the competition has ended + assertEq(uint256(competition.competition()), 2, "Competition should be in POST phase."); + } +} \ No newline at end of file diff --git a/folio/test/utils/MockStore.sol b/folio/test/utils/MockStore.sol new file mode 100644 index 0000000..b3ff2c3 --- /dev/null +++ b/folio/test/utils/MockStore.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT License +pragma solidity ^0.8.13; + +import {IStoreFront} from "../../src/IStoreFront.sol"; +import {Test, console} from "forge-std/Test.sol"; + +contract MockStore is IStoreFront { + + // fullfilling the IStoreFront interface + function purchase(suint prodId) external payable override { + console.log("entered purchase"); + } + + // recieving external payments + // this is neeeded for testing if a business can recieve a payout for winning the competition + receive() external payable { + console.log("received"); + } + +} \ No newline at end of file