diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..7af24b7 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.11.0 diff --git a/package.json b/package.json index 78fa219..3c29fdf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,10 @@ { - "name": "se-2", - "version": "0.0.1", + "name": "yousplit-contract", + "version": "0.1.0", + "description": "An ethereum smart contract to split YouTube royalties between a bunch of beneficiaries.", + "repository": "https://github.com/partagexyz/YouSplit", + "author": "jcarbonnell", + "license": "MIT", "private": true, "workspaces": { "packages": [ diff --git a/packages/hardhat/contracts/YouSplit.sol b/packages/hardhat/contracts/YouSplit.sol new file mode 100644 index 0000000..03c4024 --- /dev/null +++ b/packages/hardhat/contracts/YouSplit.sol @@ -0,0 +1,121 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.0 <0.9.0; + +// Useful for debugging. Remove when deploying to a live network. +import "hardhat/console.sol"; + +// Use openzeppelin to inherit battle-tested implementations (ERC20, ERC721, etc) +// import "@openzeppelin/contracts/access/Ownable.sol"; + +/// @title YouSplit +/// @author jcarbonnell (partage.xyz) +/// @notice An ethereum smart contract to split YouTube royalties between a bunch of beneficiaries. + +contract YouSplit { + // State Variables + address public immutable owner; + uint256 public totalShares; + uint256 public totalBalance; + + struct Beneficiary { + uint256 shares; + uint256 withdrawn; + bool isEligible; + } + + mapping(address => Beneficiary) public beneficiaries; + + // Events: a way to emit log statements from smart contract that can be listened to by external parties + event ContractFunded(address indexed sender, uint256 amount); + event BeneficiaryAdded(address indexed beneficiary, uint256 shares); + event BeneficiaryUpdated(address indexed beneficiary, uint256 newShares, bool newEligibility); + event BeneficiaryDeleted(address indexed beneficiary); + event Withdrawal(address indexed beneficiary, uint256 amount); + + // Constructor: Called once on contract deployment + // Check packages/hardhat/deploy/00_deploy_your_contract.ts + constructor(address[] memory _beneficiaries, uint256[] memory _shares) { + require(_beneficiaries.length == _shares.length, "Must provide equal number of beneficiaries and shares."); + owner = msg.sender; + totalShares = 100; + + // Ensure the onwer gets 5% of the total shares + uint256 ownerShares = 5; + beneficiaries[owner] = Beneficiary({ + shares: ownerShares, + withdrawn: 0, + isEligible: true + }); + emit BeneficiaryAdded(owner, ownerShares); + + // Add the rest of the beneficiaries + uint256 remainingShares = 95; + for (uint i = 0; i < _beneficiaries.length; i++) { + uint256 sharePercentage = (_shares[i] * remainingShares) / 100; + beneficiaries[_beneficiaries[i]] = Beneficiary({ + shares: sharePercentage, + withdrawn: 0, + isEligible: true + }); + emit BeneficiaryAdded(_beneficiaries[i], sharePercentage); + } + } + + // Function to receive ETH + receive() external payable { + totalBalance += msg.value; + emit ContractFunded(msg.sender, msg.value); + } + + // Function to check balance and eligibility + function getBeneficiaryInfo(address _beneficiary) public view returns (uint256 shares, uint256 withdrawn, uint256 eligibleAmount, bool isEligible) { + Beneficiary memory beneficiary = beneficiaries[_beneficiary]; + uint256 sharePercentage = beneficiary.shares * 100 / totalShares; + return (beneficiary.shares, beneficiary.withdrawn, (totalBalance * sharePercentage) / 100, beneficiary.isEligible); + } + + // Function to withdraw funds + function withdraw() public { + Beneficiary storage beneficiary = beneficiaries[msg.sender]; + require(beneficiary.isEligible, "You are not eligible to withdraw."); + uint256 sharePercentage = beneficiary.shares * 100 / totalShares; + uint256 amount = (totalBalance * sharePercentage) / 100 - beneficiary.withdrawn; + + require(amount > 0, "You have no funds to withdraw."); + beneficiary.withdrawn += amount; + totalBalance -= amount; + payable(msg.sender).transfer(amount); + emit Withdrawal(msg.sender, amount); + } + + // Function to add or update beneficiary + function setBeneficiary(address _beneficiary, uint256 _shares, bool _isEligible) public { + require(msg.sender == owner, "Only the owner can add or update beneficiaries."); + if (_beneficiary != owner) { + if(beneficiaries[_beneficiary].shares > 0) { + totalShares -= beneficiaries[_beneficiary].shares; + } + beneficiaries[_beneficiary] = Beneficiary({ + shares: _shares, + withdrawn: 0, + isEligible: _isEligible + }); + totalShares += _shares; + emit BeneficiaryAdded(_beneficiary, _shares); + } + } + + // Function to remove beneficiary + function removeBeneficiary(address _beneficiary) public { + require(msg.sender == owner, "Only the owner can remove beneficiaries."); + require(_beneficiary != owner, "You cannot remove the owner."); + totalShares -= beneficiaries[_beneficiary].shares; + delete beneficiaries[_beneficiary]; + emit BeneficiaryDeleted(_beneficiary); + } + + // Function to get total funds in the contract + function getTotalFunds() public view returns (uint256) { + return address(this).balance; + } +} \ No newline at end of file diff --git a/packages/hardhat/contracts/YourContract.sol b/packages/hardhat/contracts/YourContract.sol deleted file mode 100644 index 3d364a0..0000000 --- a/packages/hardhat/contracts/YourContract.sol +++ /dev/null @@ -1,87 +0,0 @@ -//SPDX-License-Identifier: MIT -pragma solidity >=0.8.0 <0.9.0; - -// Useful for debugging. Remove when deploying to a live network. -import "hardhat/console.sol"; - -// Use openzeppelin to inherit battle-tested implementations (ERC20, ERC721, etc) -// import "@openzeppelin/contracts/access/Ownable.sol"; - -/** - * A smart contract that allows changing a state variable of the contract and tracking the changes - * It also allows the owner to withdraw the Ether in the contract - * @author BuidlGuidl - */ -contract YourContract { - // State Variables - address public immutable owner; - string public greeting = "Building Unstoppable Apps!!!"; - bool public premium = false; - uint256 public totalCounter = 0; - mapping(address => uint) public userGreetingCounter; - - // Events: a way to emit log statements from smart contract that can be listened to by external parties - event GreetingChange( - address indexed greetingSetter, - string newGreeting, - bool premium, - uint256 value - ); - - // Constructor: Called once on contract deployment - // Check packages/hardhat/deploy/00_deploy_your_contract.ts - constructor(address _owner) { - owner = _owner; - } - - // Modifier: used to define a set of rules that must be met before or after a function is executed - // Check the withdraw() function - modifier isOwner() { - // msg.sender: predefined variable that represents address of the account that called the current function - require(msg.sender == owner, "Not the Owner"); - _; - } - - /** - * Function that allows anyone to change the state variable "greeting" of the contract and increase the counters - * - * @param _newGreeting (string memory) - new greeting to save on the contract - */ - function setGreeting(string memory _newGreeting) public payable { - // Print data to the hardhat chain console. Remove when deploying to a live network. - console.log( - "Setting new greeting '%s' from %s", - _newGreeting, - msg.sender - ); - - // Change state variables - greeting = _newGreeting; - totalCounter += 1; - userGreetingCounter[msg.sender] += 1; - - // msg.value: built-in global variable that represents the amount of ether sent with the transaction - if (msg.value > 0) { - premium = true; - } else { - premium = false; - } - - // emit: keyword used to trigger an event - emit GreetingChange(msg.sender, _newGreeting, msg.value > 0, msg.value); - } - - /** - * Function that allows the owner to withdraw all the Ether in the contract - * The function can only be called by the owner of the contract as defined by the isOwner modifier - */ - function withdraw() public isOwner { - (bool success, ) = owner.call{ value: address(this).balance }(""); - require(success, "Failed to send Ether"); - } - - /** - * Function that allows the contract to receive ETH - */ - receive() external payable {} -} diff --git a/packages/hardhat/deploy/00_deploy_your_contract.ts b/packages/hardhat/deploy/00_deploy_YouSplit.ts similarity index 59% rename from packages/hardhat/deploy/00_deploy_your_contract.ts rename to packages/hardhat/deploy/00_deploy_YouSplit.ts index 716fec7..1a8dbd6 100644 --- a/packages/hardhat/deploy/00_deploy_your_contract.ts +++ b/packages/hardhat/deploy/00_deploy_YouSplit.ts @@ -3,12 +3,12 @@ import { DeployFunction } from "hardhat-deploy/types"; import { Contract } from "ethers"; /** - * Deploys a contract named "YourContract" using the deployer account and + * Deploys a contract named "YouSplit" using the deployer account and * constructor arguments set to the deployer address * * @param hre HardhatRuntimeEnvironment object. */ -const deployYourContract: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { +const deployYouSplit: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { /* On localhost, the deployer account is the one that comes with Hardhat, which is already funded. @@ -22,10 +22,20 @@ const deployYourContract: DeployFunction = async function (hre: HardhatRuntimeEn const { deployer } = await hre.getNamedAccounts(); const { deploy } = hre.deployments; - await deploy("YourContract", { + // Example of beneficiaries and their share percentages + // Note: The shares should add up to 9500 (95%) when considering all beneficiaries combined. + const signers = await hre.ethers.getSigners(); + const beneficiary1 = signers[1]; + const beneficiary2 = signers[2]; + + const beneficiaries = [beneficiary1.address, beneficiary2.address]; + // we assume each beneficiary gets an equal share of the remaining 95% after the owner's 5% + const shares =[475, 475]; + + await deploy("YouSplit", { from: deployer, // Contract constructor arguments - args: [deployer], + args: [beneficiaries, shares], log: true, // autoMine: can be passed to the deploy function to make the deployment process faster on local networks by // automatically mining the contract deployment transaction. There is no effect on live networks. @@ -33,12 +43,12 @@ const deployYourContract: DeployFunction = async function (hre: HardhatRuntimeEn }); // Get the deployed contract to interact with it after deploying. - const yourContract = await hre.ethers.getContract("YourContract", deployer); - console.log("👋 Initial greeting:", await yourContract.greeting()); + const youSplit = await hre.ethers.getContract("YouSplit", deployer); + console.log("YouSplit Contract deployed successfully"); }; -export default deployYourContract; +export default deployYouSplit; // Tags are useful if you have multiple deploy files and only want to run one of them. -// e.g. yarn deploy --tags YourContract -deployYourContract.tags = ["YourContract"]; +// e.g. yarn deploy --tags YouSplit +deployYouSplit.tags = ["YouSplit"]; diff --git a/packages/hardhat/test/YouSplit.ts b/packages/hardhat/test/YouSplit.ts new file mode 100644 index 0000000..0d3e75a --- /dev/null +++ b/packages/hardhat/test/YouSplit.ts @@ -0,0 +1,84 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { YouSplit } from "../typechain-types/YouSplit"; +import { YouSplit__factory } from "../typechain-types/factories/YouSplit__factory"; + +describe("YouSplit", function () { + let youSplit: YouSplit; + let owner: any; + let beneficiary1: any; + let beneficiary2: any; + + before(async () => { + // signers for different roles in the contract + const signers = await ethers.getSigners(); + owner = signers[0]; + beneficiary1 = signers[1]; + beneficiary2 = signers[2]; + + // deploy the contract + const youSplitFactory = await ethers.getContractFactory("YouSplit"); + youSplit = await youSplitFactory.deploy([beneficiary1.address, beneficiary2.address],[475, 475]); + await youSplit.waitForDeployment(); + }); + + describe("Deployment", function () { + it("Should set the correct owner", async function () { + expect(await youSplit.owner()).to.equal(owner.address); + }); + + it("Should initialize with correct share distribution", async function () { + // Check owner shares + const { shares: ownerShares } = await youSplit.beneficiaries(owner.address); + expect(ownerShares).to.equal(5); // Owner should have 5% of shares + + // Check beneficiary shares + const { shares: beneficiary1Shares } = await youSplit.beneficiaries(beneficiary1.address); + expect(beneficiary1Shares).to.equal(475); // 95% / 2 = 47.5% (represented as 475 out of 10000) + + const { shares: beneficiary2Shares } = await youSplit.beneficiaries(beneficiary2.address); + expect(beneficiary2Shares).to.equal(475); + }); + + it("Should start with zero withdrawn for all beneficiaries", async function () { + expect((await youSplit.beneficiaries(owner.address)).withdrawn).to.equal(0); + expect((await youSplit.beneficiaries(beneficiary1.address)).withdrawn).to.equal(0); + expect((await youSplit.beneficiaries(beneficiary2.address)).withdrawn).to.equal(0); + }); + + it("Should set all beneficiaries as eligible", async function () { + expect((await youSplit.beneficiaries(owner.address)).isEligible).to.equal(true); + expect((await youSplit.beneficiaries(beneficiary1.address)).isEligible).to.equal(true); + expect((await youSplit.beneficiaries(beneficiary2.address)).isEligible).to.equal(true); + }); + + it("Should start with zero balance", async function () { + expect(await youSplit.totalBalance()).to.equal(0); + }); + + it("Should allow funds to be sent to the contract", async function () { + const amount = ethers.parseEther("1"); + await owner.sendTransaction({ to: await youSplit.getAddress(), value: amount }); + + expect(await youSplit.totalBalance()).to.equal(amount); + }); + + it("Should allow beneficiaries to withdraw their funds", async function () { + const initialBalance = await ethers.provider.getBalance(beneficiary1.address); + await youSplit.connect(beneficiary1).withdraw(); + const afterWithdrawBalance = await ethers.provider.getBalance(beneficiary1.address); + + // Check if balance increased after withdrawal + expect(afterWithdrawBalance > initialBalance).to.be.true; + + // Check the withdrawn amount for beneficiary1 + const { withdrawn: withdrawnAmount } = await youSplit.beneficiaries(beneficiary1.address); + expect(withdrawnAmount).to.be.gt(0); + }); + + it("Should not allow non-eligible beneficiaries to withdraw", async function () { + await youSplit.setBeneficiary(beneficiary2.address, 0, false); // Make beneficiary2 ineligible + await expect(youSplit.connect(beneficiary2).withdraw()).to.be.revertedWith("You are not eligible to withdraw."); + }); + }); +}); diff --git a/packages/hardhat/test/YourContract.ts b/packages/hardhat/test/YourContract.ts deleted file mode 100644 index a44cf04..0000000 --- a/packages/hardhat/test/YourContract.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { expect } from "chai"; -import { ethers } from "hardhat"; -import { YourContract } from "../typechain-types"; - -describe("YourContract", function () { - // We define a fixture to reuse the same setup in every test. - - let yourContract: YourContract; - before(async () => { - const [owner] = await ethers.getSigners(); - const yourContractFactory = await ethers.getContractFactory("YourContract"); - yourContract = (await yourContractFactory.deploy(owner.address)) as YourContract; - await yourContract.waitForDeployment(); - }); - - describe("Deployment", function () { - it("Should have the right message on deploy", async function () { - expect(await yourContract.greeting()).to.equal("Building Unstoppable Apps!!!"); - }); - - it("Should allow setting a new message", async function () { - const newGreeting = "Learn Scaffold-ETH 2! :)"; - - await yourContract.setGreeting(newGreeting); - expect(await yourContract.greeting()).to.equal(newGreeting); - }); - }); -}); diff --git a/packages/nextjs/app/page.tsx b/packages/nextjs/app/page.tsx index 4017710..e02f878 100644 --- a/packages/nextjs/app/page.tsx +++ b/packages/nextjs/app/page.tsx @@ -31,7 +31,7 @@ const Home: NextPage = () => {

Edit your smart contract{" "} - YourContract.sol + YouSplit.sol {" "} in{" "} diff --git a/packages/nextjs/contracts/deployedContracts.ts b/packages/nextjs/contracts/deployedContracts.ts index 008d4eb..11a5e14 100644 --- a/packages/nextjs/contracts/deployedContracts.ts +++ b/packages/nextjs/contracts/deployedContracts.ts @@ -4,6 +4,288 @@ */ import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract"; -const deployedContracts = {} as const; +const deployedContracts = { + 31337: { + YouSplit: { + address: "0x5FbDB2315678afecb367f032d93F642f64180aa3", + abi: [ + { + inputs: [ + { + internalType: "address[]", + name: "_beneficiaries", + type: "address[]", + }, + { + internalType: "uint256[]", + name: "_shares", + type: "uint256[]", + }, + ], + stateMutability: "nonpayable", + type: "constructor", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "beneficiary", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "shares", + type: "uint256", + }, + ], + name: "BeneficiaryAdded", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "beneficiary", + type: "address", + }, + ], + name: "BeneficiaryDeleted", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "beneficiary", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "newShares", + type: "uint256", + }, + { + indexed: false, + internalType: "bool", + name: "newEligibility", + type: "bool", + }, + ], + name: "BeneficiaryUpdated", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "sender", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "amount", + type: "uint256", + }, + ], + name: "ContractFunded", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "beneficiary", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "amount", + type: "uint256", + }, + ], + name: "Withdrawal", + type: "event", + }, + { + inputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + name: "beneficiaries", + outputs: [ + { + internalType: "uint256", + name: "shares", + type: "uint256", + }, + { + internalType: "uint256", + name: "withdrawn", + type: "uint256", + }, + { + internalType: "bool", + name: "isEligible", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_beneficiary", + type: "address", + }, + ], + name: "getBeneficiaryInfo", + outputs: [ + { + internalType: "uint256", + name: "shares", + type: "uint256", + }, + { + internalType: "uint256", + name: "withdrawn", + type: "uint256", + }, + { + internalType: "uint256", + name: "eligibleAmount", + type: "uint256", + }, + { + internalType: "bool", + name: "isEligible", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "getTotalFunds", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "owner", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_beneficiary", + type: "address", + }, + ], + name: "removeBeneficiary", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_beneficiary", + type: "address", + }, + { + internalType: "uint256", + name: "_shares", + type: "uint256", + }, + { + internalType: "bool", + name: "_isEligible", + type: "bool", + }, + ], + name: "setBeneficiary", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "totalBalance", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "totalShares", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "withdraw", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + stateMutability: "payable", + type: "receive", + }, + ], + inheritedFunctions: {}, + }, + }, +} as const; export default deployedContracts satisfies GenericContractsDeclaration; diff --git a/yarn.lock b/yarn.lock index 1d68d3c..6e16a08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11833,15 +11833,6 @@ __metadata: languageName: node linkType: hard -"se-2@workspace:.": - version: 0.0.0-use.local - resolution: "se-2@workspace:." - dependencies: - husky: ~8.0.3 - lint-staged: ~13.2.2 - languageName: unknown - linkType: soft - "secp256k1@npm:^4.0.1": version: 4.0.4 resolution: "secp256k1@npm:4.0.4" @@ -14266,6 +14257,15 @@ __metadata: languageName: node linkType: hard +"yousplit-contract@workspace:.": + version: 0.0.0-use.local + resolution: "yousplit-contract@workspace:." + dependencies: + husky: ~8.0.3 + lint-staged: ~13.2.2 + languageName: unknown + linkType: soft + "zksync-ethers@npm:^5.0.0": version: 5.10.0 resolution: "zksync-ethers@npm:5.10.0"