From ef447eec45e5a05e3dbd99576d31808332e4327c Mon Sep 17 00:00:00 2001 From: Equious Date: Fri, 31 Jan 2025 12:22:30 -0700 Subject: [PATCH] initial commit --- .DS_Store | Bin 0 -> 6148 bytes .github/workflows/test.yml | 45 ++++++++++++ .gitignore | 14 ++++ .gitmodules | 6 ++ .vscode/settings.json | 5 ++ README.md | 55 +++++++++++++++ foundry.toml | 11 +++ src/LikeRegistry.sol | 86 +++++++++++++++++++++++ src/MultiSig.sol | 83 ++++++++++++++++++++++ src/SoulboundProfileNFT.sol | 104 +++++++++++++++++++++++++++ test/testSoulboundProfileNFT.t.sol | 109 +++++++++++++++++++++++++++++ 11 files changed, 518 insertions(+) create mode 100644 .DS_Store create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 foundry.toml create mode 100644 src/LikeRegistry.sol create mode 100644 src/MultiSig.sol create mode 100644 src/SoulboundProfileNFT.sol create mode 100644 test/testSoulboundProfileNFT.t.sol diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..52b0f12a32391dfc4a72a1bc40d87970f6a4518f GIT binary patch literal 6148 zcmeH~JqiLr422W55Nx)zoW=uqgF*BJUO-kGVZlP|=jgutu;6MfA}^4{Q|0wh2JB=9o? z?A(SeSD}m~KmsH%60rY6ft%LU7V5ta1RnvQ3#8q!_E`d2ECH>lEfg7;Mk_R0)yELa zdpopbT}^GF(Jq?9hvu8rrWlw;yJ*1%rqzXk1V~^)U>^I<&i@1a)BHba;g$qQ;Lix? zVt?H4@KJfTzI~qMPnorKgG0R>;pGy5jUB~nxEr1qTR>}S3q=OTi-2QbAc3zEcmnFH B5n=!U literal 0 HcmV?d00001 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..85198aa --- /dev/null +++ b/.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/.gitmodules b/.gitmodules new file mode 100644 index 0000000..690924b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c0957b1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "cSpell.words": [ + "Soulbound" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f63a106 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Gamma Strategies + + +### Prize Pool + +- Starts: February 06, 2025 Noon UTC +- Ends: February 13, 2025 Noon UTC + +- nSLOC: 197 + +[//]: # (contest-details-open) + +## About the Project + +Roses are red, violets are blue, use this DatingDapp and love will find you.! Dating Dapp lets users mint a soulbound NFT as their verified dating profile. To express interest in someone, they pay 1 ETH to "like" their profile. If the like is mutual, all their previous like payments (minus a 10% fee) are pooled into a shared multisig wallet, which both users can access for their first date. This system ensures genuine connections, and turns every match into a meaningful, on-chain commitment. + +[//]: # (contest-details-close) + +[//]: # (scope-open) + +## Scope (contracts) + +```js +src/ +├── LikeRegistry.sol +├── MultiSig.sol +├── SoulboundProfileNFT.sol +``` + +## Compatibilities + +Chains: +- Ethereum/EVM Equivalent + +Tokens: +- ERC721 standard + + +[//]: # (scope-close) + +[//]: # (getting-started-open) + +## Setup + +Coming soon! + +[//]: # (getting-started-close) + +[//]: # (known-issues-open) + +## Known Issues + +None Reported + +[//]: # (known-issues-close) \ No newline at end of file diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..86432ff --- /dev/null +++ b/foundry.toml @@ -0,0 +1,11 @@ +[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 + +remapping = [ + "/lib/openzeppelin-contracts/=@openzeppelin/", + "/lib/forge-std/src/=@forge-std/", +] diff --git a/src/LikeRegistry.sol b/src/LikeRegistry.sol new file mode 100644 index 0000000..8fb0ae8 --- /dev/null +++ b/src/LikeRegistry.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "./SoulboundProfileNFT.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "./MultiSig.sol"; + +contract LikeRegistry is Ownable{ + struct Like { + address liker; + address liked; + uint256 timestamp; + } + + SoulboundProfileNFT public profileNFT; + + uint256 immutable FIXEDFEE = 10; + uint256 totalFees; + + mapping(address => mapping(address => bool)) public likes; + mapping(address => address[]) public matches; + mapping(address => uint256) public userBalances; + + event Liked(address indexed liker, address indexed liked); + event Matched(address indexed user1, address indexed user2); + + constructor(address _profileNFT) Ownable(msg.sender){ + profileNFT = SoulboundProfileNFT(_profileNFT); + } + + function likeUser( + address liked + ) external payable { + require(msg.value >= 1 ether, "Must send at least 1 ETH"); + require(!likes[msg.sender][liked], "Already liked"); + require(msg.sender != liked, "Cannot like yourself"); + require(profileNFT.profileToToken(msg.sender) != 0, "Must have a profile NFT"); + require(profileNFT.profileToToken(liked) != 0, "Liked user must have a profile NFT"); + + likes[msg.sender][liked] = true; + emit Liked(msg.sender, liked); + + // Check if mutual like + if (likes[liked][msg.sender]) { + matches[msg.sender].push(liked); + matches[liked].push(msg.sender); + emit Matched(msg.sender, liked); + matchRewards(liked, msg.sender); + } + } + + function matchRewards(address from, address to) internal { + uint256 matchUserOne = userBalances[from]; + uint256 matchUserTwo = userBalances[to]; + userBalances[from] = 0; + userBalances[to] = 0; + + uint256 totalRewards = matchUserOne + matchUserTwo; + uint256 matchingFees = (totalRewards * FIXEDFEE ) / 100; + uint256 rewards = totalRewards - matchingFees; + totalFees += matchingFees; + + // Deploy a MultiSig contract for the matched users + MultiSigWallet multiSigWallet = new MultiSigWallet(from, to); + + // Send ETH to the deployed multisig wallet + (bool success, ) = payable(address(multiSigWallet)).call{value: rewards}(""); + require(success, "Transfer failed"); + } + + function getMatches() external view returns (address[] memory) { + return matches[msg.sender]; + } + + function withdrawFees() external onlyOwner { + require(totalFees > 0, "No fees to withdraw"); + uint256 totalFeesToWithdraw = totalFees; + + totalFees = 0; + (bool success, ) = payable(owner()).call{value: totalFeesToWithdraw}(""); + require(success, "Transfer failed"); + } + + /// @notice Allows the contract to receive ETH + receive() external payable {} +} diff --git a/src/MultiSig.sol b/src/MultiSig.sol new file mode 100644 index 0000000..19dcc41 --- /dev/null +++ b/src/MultiSig.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +contract MultiSigWallet { + error NotAnOwner(); + error AlreadyApproved(); + error NotEnoughApprovals(); + error InvalidRecipient(); + error InvalidAmount(); + + address public owner1; + address public owner2; + + struct Transaction { + address to; + uint256 value; + bool approvedByOwner1; + bool approvedByOwner2; + bool executed; + } + + Transaction[] public transactions; + + event TransactionCreated(uint256 indexed txId, address indexed to, uint256 value); + event TransactionApproved(uint256 indexed txId, address indexed owner); + event TransactionExecuted(uint256 indexed txId, address indexed to, uint256 value); + + modifier onlyOwners() { + if (msg.sender != owner1 && msg.sender != owner2) revert NotAnOwner(); + _; + } + + constructor(address _owner1, address _owner2) { + require(_owner1 != address(0) && _owner2 != address(0), "Invalid owner address"); + require(_owner1 != _owner2, "Owners must be different"); + owner1 = _owner1; + owner2 = _owner2; + } + + /// @notice Submit a transaction for approval + function submitTransaction(address _to, uint256 _value) external onlyOwners { + if (_to == address(0)) revert InvalidRecipient(); + if (_value == 0) revert InvalidAmount(); + + transactions.push(Transaction(_to, _value, false, false, false)); + uint256 txId = transactions.length - 1; + emit TransactionCreated(txId, _to, _value); + } + + /// @notice Approve a pending transaction + function approveTransaction(uint256 _txId) external onlyOwners { + require(_txId < transactions.length, "Invalid transaction ID"); + Transaction storage txn = transactions[_txId]; + require(!txn.executed, "Transaction already executed"); + + if (msg.sender == owner1) { + if (txn.approvedByOwner1) revert AlreadyApproved(); + txn.approvedByOwner1 = true; + } else { + if (txn.approvedByOwner2) revert AlreadyApproved(); + txn.approvedByOwner2 = true; + } + + emit TransactionApproved(_txId, msg.sender); + } + + /// @notice Execute a fully-approved transaction + function executeTransaction(uint256 _txId) external onlyOwners { + require(_txId < transactions.length, "Invalid transaction ID"); + Transaction storage txn = transactions[_txId]; + require(!txn.executed, "Transaction already executed"); + require(txn.approvedByOwner1 && txn.approvedByOwner2, "Not enough approvals"); + + txn.executed = true; + (bool success, ) = payable(txn.to).call{value: txn.value}(""); + require(success, "Transaction failed"); + + emit TransactionExecuted(_txId, txn.to, txn.value); + } + + /// @notice Allows the contract to receive ETH + receive() external payable {} +} diff --git a/src/SoulboundProfileNFT.sol b/src/SoulboundProfileNFT.sol new file mode 100644 index 0000000..cc738eb --- /dev/null +++ b/src/SoulboundProfileNFT.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; + + +contract SoulboundProfileNFT is ERC721, Ownable { + error ERC721Metadata__URI_QueryFor_NonExistentToken(); + error SoulboundTokenCannotBeTransferred(); + + uint256 private _nextTokenId; + + struct Profile { + string name; + uint8 age; + string profileImage; // IPFS or other hosted image URL + } + + mapping(address => uint256) public profileToToken; // Maps user to their profile NFT + mapping(uint256 => Profile) private _profiles; // Stores profile metadata + + event ProfileMinted(address indexed user, uint256 tokenId, string name, uint8 age, string profileImage); + event ProfileBurned(address indexed user, uint256 tokenId); + + constructor() ERC721("DatingDapp", "DTN") Ownable(msg.sender) {} + + /// @notice Mint a soulbound NFT representing the user's profile. + function mintProfile(string memory name, uint8 age, string memory profileImage) external { + require(profileToToken[msg.sender] == 0, "Profile already exists"); + + uint256 tokenId = ++_nextTokenId; + _safeMint(msg.sender, tokenId); + + // Store metadata on-chain + _profiles[tokenId] = Profile(name, age, profileImage); + profileToToken[msg.sender] = tokenId; + + emit ProfileMinted(msg.sender, tokenId, name, age, profileImage); + } + + /// @notice Allow users to delete their profile (burn the NFT). + function burnProfile() external { + uint256 tokenId = profileToToken[msg.sender]; + require(tokenId != 0, "No profile found"); + require(ownerOf(tokenId) == msg.sender, "Not profile owner"); + + _burn(tokenId); + delete profileToToken[msg.sender]; + delete _profiles[tokenId]; + + emit ProfileBurned(msg.sender, tokenId); + } + + /// @notice App owner can block users + function blockProfile(address blockAddress) external onlyOwner { + uint256 tokenId = profileToToken[blockAddress]; + require(tokenId != 0, "No profile found"); + + _burn(tokenId); + delete profileToToken[blockAddress]; + delete _profiles[tokenId]; + + emit ProfileBurned(blockAddress, tokenId); + } + + /// @notice Override of transferFrom to prevent any transfer. + function transferFrom(address, address, uint256) public pure override{ + // Soulbound token cannot be transferred + revert SoulboundTokenCannotBeTransferred(); + } + + function safeTransferFrom(address, address, uint256, bytes memory) public pure override{ + // Soulbound token cannot be transferred + revert SoulboundTokenCannotBeTransferred(); + } + + /// @notice Return on-chain metadata + function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { + if (ownerOf(tokenId) == address(0)) { + revert ERC721Metadata__URI_QueryFor_NonExistentToken(); + } + string memory profileName = _profiles[tokenId].name; + uint256 profileAge = _profiles[tokenId].age; + string memory imageURI = _profiles[tokenId].profileImage; + return string( + abi.encodePacked( + _baseURI(), + Base64.encode( + bytes( // bytes casting actually unnecessary as 'abi.encodePacked()' returns a bytes + abi.encodePacked( + '{"name":"', profileName, '", ', + '"description":"A soulbound dating profile NFT.", ', + '"attributes": [{"trait_type": "Age", "value": ', Strings.toString(profileAge), '}], ', + '"image":"', imageURI, '"}' + ) + ) + ) + ) + ); + } +} diff --git a/test/testSoulboundProfileNFT.t.sol b/test/testSoulboundProfileNFT.t.sol new file mode 100644 index 0000000..e9a5504 --- /dev/null +++ b/test/testSoulboundProfileNFT.t.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../src/SoulboundProfileNFT.sol"; + +contract SoulboundProfileNFTTest is Test { + SoulboundProfileNFT soulboundNFT; + address user = address(0x123); + address user2 = address(0x456); + address owner = address(this); // Test contract acts as the owner + + function setUp() public { + soulboundNFT = new SoulboundProfileNFT(); + } + + function testMintProfile() public { + vm.prank(user); // Simulates user calling the function + soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage"); + + uint256 tokenId = soulboundNFT.profileToToken(user); + assertEq(tokenId, 1, "Token ID should be 1"); + + string memory uri = soulboundNFT.tokenURI(tokenId); + assertTrue(bytes(uri).length > 0, "Token URI should be set"); + } + + function testMintDuplicateProfile() public { + vm.prank(user); + soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage"); + + vm.prank(user); + vm.expectRevert("Profile already exists"); + soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage"); + + } + + function testTokenURI() public { + vm.prank(user); + soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage"); + + uint256 tokenId = soulboundNFT.profileToToken(user); + string memory uri = soulboundNFT.tokenURI(tokenId); + + assertTrue(bytes(uri).length > 0, "Token URI should be encoded in Base64"); + } + + function testTransferShouldRevert() public { + vm.prank(user); + soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage"); + + uint256 tokenId = soulboundNFT.profileToToken(user); + + vm.prank(user); + vm.expectRevert(); + soulboundNFT.transferFrom(user, user2, tokenId); // Should revert + } + + function testSafeTransferShouldRevert() public { + vm.prank(user); + soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage"); + + uint256 tokenId = soulboundNFT.profileToToken(user); + + vm.prank(user); + vm.expectRevert(); + soulboundNFT.safeTransferFrom(user, user2, tokenId); // Should revert + } + + function testBurnProfile() public { + vm.prank(user); + soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage"); + + uint256 tokenId = soulboundNFT.profileToToken(user); + assertEq(tokenId, 1, "Token ID should be 1 before burning"); + + vm.prank(user); + soulboundNFT.burnProfile(); + + uint256 newTokenId = soulboundNFT.profileToToken(user); + assertEq(newTokenId, 0, "Token should be removed after burning"); + } + + function testBlockProfileAsOwner() public { + vm.prank(user); + soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage"); + + uint256 tokenId = soulboundNFT.profileToToken(user); + assertEq(tokenId, 1, "Token should exist before blocking"); + + vm.prank(owner); + soulboundNFT.blockProfile(user); + + uint256 newTokenId = soulboundNFT.profileToToken(user); + assertEq(newTokenId, 0, "Token should be removed after blocking"); + } + + function testNonOwnerCannotBlockProfile() public { + vm.prank(user); + soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage"); + + uint256 tokenId = soulboundNFT.profileToToken(user); + assertEq(tokenId, 1, "Token should exist before blocking"); + + vm.prank(user2); + vm.expectRevert(); + soulboundNFT.blockProfile(user); // Should revert + } +}