From dcc9f612ff9fcbe42d17d07609abc925931321a5 Mon Sep 17 00:00:00 2001 From: Ruben Dinis Date: Mon, 27 May 2024 21:49:34 +0100 Subject: [PATCH 1/2] Add BUILD smart contract --- contracts/build/BuyPoints.sol | 90 +++++++++++++++++++ test/contracts/build/BuyPoints.ts | 140 ++++++++++++++++++++++++++++++ test/shared/artifacts.ts | 4 +- 3 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 contracts/build/BuyPoints.sol create mode 100644 test/contracts/build/BuyPoints.ts diff --git a/contracts/build/BuyPoints.sol b/contracts/build/BuyPoints.sol new file mode 100644 index 00000000..e44835d3 --- /dev/null +++ b/contracts/build/BuyPoints.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +contract BuyPoints is Ownable { + using SafeERC20 for IERC20Metadata; + + // wallet => amount bought + mapping(address => uint256) public walletBoughtAmount; + + // totalNumberOfBuys + uint256 public totalBuys; + + // totalAmountBought + uint256 public totalAmountBought; + + // flag to enable or disable the contract + bool public enabled = true; + + // the accepted safeAddress + address public safeAddress; + + // the accepted token + address public buildAddress; + + // A new buy has been created + event Buy(address indexed wallet, uint256 amount, uint256 walletTotalAmountBought); + + constructor(address contractOwner, address _safeAddress, address _buildAddress) Ownable(contractOwner) { + transferOwnership(contractOwner); + safeAddress = _safeAddress; + buildAddress = _buildAddress; + } + + modifier onlyWhileEnabled() { + require(enabled, "The contract is disabled."); + _; + } + + function buy(uint256 _amount) public onlyWhileEnabled { + require(_amount > 0, "Invalid amount"); + require(IERC20Metadata(buildAddress).balanceOf(msg.sender) >= _amount, "You don't have enough balance"); + require( + IERC20Metadata(buildAddress).allowance(msg.sender, address(this)) >= _amount, + "Must approve contract first" + ); + + // transfer from sender => contract + IERC20Metadata(buildAddress).safeTransferFrom(msg.sender, safeAddress, _amount); + + // setup the internal state + totalBuys = totalBuys + 1; + totalAmountBought = totalAmountBought + _amount; + uint256 _totalWalletBoughtAmount = walletBoughtAmount[msg.sender] + _amount; + walletBoughtAmount[msg.sender] = _totalWalletBoughtAmount; + + // emit event + emit Buy(msg.sender, _amount, _totalWalletBoughtAmount); + } + + // Safe method in case someone sends tokens to the smart contract + function adminWithdraw(uint256 _amount, address _token, address wallet) public onlyOwner { + require(_amount > 0, "Invalid amount"); + + require(IERC20Metadata(_token).balanceOf(address(this)) >= _amount, "Not enough funds on contract"); + + IERC20Metadata(_token).safeTransfer(wallet, _amount); + } + + // Admin + + /** + * @notice Disables the contract, disabling future sponsorships. + * @dev Can only be called by the owner. + */ + function disable() public onlyWhileEnabled onlyOwner { + enabled = false; + } + + /** + * @notice Enables the contract, enabling new sponsors. + * @dev Can only be called by the owner. + */ + function enable() public onlyOwner { + enabled = true; + } +} diff --git a/test/contracts/build/BuyPoints.ts b/test/contracts/build/BuyPoints.ts new file mode 100644 index 00000000..9b7a67dd --- /dev/null +++ b/test/contracts/build/BuyPoints.ts @@ -0,0 +1,140 @@ +import chai from "chai"; +import { ethers, waffle } from "hardhat"; +import { solidity } from "ethereum-waffle"; + +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; + +import { ERC20Mock, BuyPoints } from "../../../typechain-types"; +import { Artifacts } from "../../shared"; + +import { findEvent } from "../../shared/utils"; + +chai.use(solidity); + +const { expect } = chai; +const { parseUnits } = ethers.utils; +const { deployContract } = waffle; + +describe("BuyPoints", () => { + let admin: SignerWithAddress; + let buyer: SignerWithAddress; + let safe: SignerWithAddress; + + let contract: BuyPoints; + let build: ERC20Mock; + + beforeEach(async () => { + [admin, buyer, safe] = await ethers.getSigners(); + + build = (await deployContract(admin, Artifacts.ERC20Mock, ["Build", "BUILD"])) as ERC20Mock; + + await build.connect(admin).transfer(buyer.address, parseUnits("10000")); + }); + + async function builder() { + return deployContract(admin, Artifacts.BuyPoints, [admin.address, safe.address, build.address]); + } + + describe("behaviour", () => { + beforeEach(async () => { + contract = (await builder()) as BuyPoints; + }); + + it("is created with no state", async () => { + expect(await contract.totalBuys()).to.eq(0); + expect(await contract.totalAmountBought()).to.eq(0); + }); + + it("emits a buy event everytime a buy is created", async () => { + const amount = parseUnits("10"); + await build.connect(buyer).approve(contract.address, amount); + + const tx = await contract.connect(buyer).buy(amount); + + const event = await findEvent(tx, "Buy"); + + expect(event).to.exist; + expect(event?.args?.wallet).to.eq(buyer.address); + expect(event?.args?.amount).to.eq(amount); + expect(event?.args?.walletTotalAmountBought).to.eq(amount); + + expect(await build.balanceOf(safe.address)).to.eq(amount); + + expect(await contract.totalBuys()).to.eq(1); + expect(await contract.totalAmountBought()).to.eq(amount); + }); + }); + + describe("testing contract enable and disable", () => { + beforeEach(async () => { + contract = (await builder()) as BuyPoints; + }); + + it("allows the contract owner to disable and enable the contract", async () => { + expect(await contract.enabled()).to.be.equal(true); + + await contract.connect(admin).disable(); + + expect(await contract.enabled()).to.be.equal(false); + + await contract.connect(admin).enable(); + + expect(await contract.enabled()).to.be.equal(true); + }); + + it("prevents other accounts to disable the contract", async () => { + expect(await contract.enabled()).to.be.equal(true); + + const action = contract.connect(buyer).disable(); + + await expect(action).to.be.reverted; + + expect(await contract.enabled()).to.be.equal(true); + }); + + it("prevents other accounts to enable the contract", async () => { + const action = contract.connect(buyer).enable(); + + await expect(action).to.be.reverted; + }); + + it("prevents disable when the contract is already disabled", async () => { + expect(await contract.enabled()).to.be.equal(true); + + await contract.connect(admin).disable(); + + const action = contract.connect(admin).disable(); + + await expect(action).to.be.reverted; + }); + + it("prevents new buys when the contract is disabled", async () => { + expect(await contract.enabled()).to.be.equal(true); + + await contract.connect(admin).disable(); + + expect(await contract.enabled()).to.be.equal(false); + + const amount = parseUnits("10"); + await build.connect(buyer).approve(contract.address, amount); + + const action = contract.connect(buyer).buy(amount); + + await expect(action).to.be.revertedWith("The contract is disabled."); + }); + + it("allows the owner to withdraw", async () => { + const amount = parseUnits("100"); + + const initialBalance = await build.balanceOf(buyer.address); + + await build.connect(buyer).approve(contract.address, amount); + + await build.connect(buyer).transfer(contract.address, amount); + + await contract.connect(admin).adminWithdraw(amount, build.address, buyer.address); + + expect(await build.balanceOf(buyer.address)).to.eq(initialBalance); + }); + }); +}); diff --git a/test/shared/artifacts.ts b/test/shared/artifacts.ts index 117d4d55..229a5198 100644 --- a/test/shared/artifacts.ts +++ b/test/shared/artifacts.ts @@ -2,5 +2,7 @@ import PassportRegistry from "../../artifacts/contracts/passport/PassportRegistr import TalentProtocolToken from "../../artifacts/contracts/talent/TalentProtocolToken.sol/TalentProtocolToken.json"; import TalentRewardClaim from "../../artifacts/contracts/talent/TalentRewardClaim.sol/TalentRewardClaim.json"; import PassportBuilderScore from "../../artifacts/contracts/passport/PassportBuilderScore.sol/PassportBuilderScore.json"; +import BuyPoints from "../../artifacts/contracts/build/BuyPoints.sol/BuyPoints.json"; +import ERC20Mock from "../../artifacts/contracts/test/ERC20Mock.sol/ERC20Mock.json"; -export { PassportRegistry, TalentProtocolToken, TalentRewardClaim, PassportBuilderScore }; +export { PassportRegistry, TalentProtocolToken, TalentRewardClaim, PassportBuilderScore, ERC20Mock, BuyPoints }; From 4e748d73f43e0b3c7bdd52ad7784d54f816e87fb Mon Sep 17 00:00:00 2001 From: Ruben Dinis Date: Mon, 27 May 2024 21:59:23 +0100 Subject: [PATCH 2/2] Add script to deploy buy points contract --- scripts/build/deployBuyPoints.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 scripts/build/deployBuyPoints.ts diff --git a/scripts/build/deployBuyPoints.ts b/scripts/build/deployBuyPoints.ts new file mode 100644 index 00000000..d07bb681 --- /dev/null +++ b/scripts/build/deployBuyPoints.ts @@ -0,0 +1,26 @@ +import { ethers, network } from "hardhat"; + +async function main() { + console.log(`Deploying buy points at ${network.name}`); + + const [admin] = await ethers.getSigners(); + + console.log(`Admin will be ${admin.address}`); + + const buyPoints = await ethers.getContractFactory("BuyPoints"); + + const contract = await buyPoints.deploy(); + await contract.deployed(); + + console.log(`BuyPoints Address: ${contract.address}`); + console.log(`BuyPoints owner: ${await contract.owner()}`); + + console.log("Done"); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + });