diff --git a/.gitmodules b/.gitmodules index 50eb38c..df53c81 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ +[submodule "nibble/lib/forge-std"] + path = OneByTwo/lib/forge-std + url = https://github.com/foundry-rs/forge-std + [submodule "level/lib/forge-std"] path = level/lib/forge-std url = https://github.com/foundry-rs/forge-std diff --git a/Nibble/.gitignore b/Nibble/.gitignore new file mode 100644 index 0000000..103adc7 --- /dev/null +++ b/Nibble/.gitignore @@ -0,0 +1,16 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env + +node_modules/ diff --git a/Nibble/.gitmodules b/Nibble/.gitmodules new file mode 100644 index 0000000..577d281 --- /dev/null +++ b/Nibble/.gitmodules @@ -0,0 +1,3 @@ +[submodule "packages/contracts/lib/forge-std"] + path = packages/contracts/lib/forge-std + url = https://github.com/foundry-rs/forge-std \ No newline at end of file diff --git a/Nibble/.prettierrc b/Nibble/.prettierrc new file mode 100644 index 0000000..5a938ce --- /dev/null +++ b/Nibble/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "useTabs": false +} diff --git a/Nibble/README.md b/Nibble/README.md new file mode 100644 index 0000000..7d3e8a4 --- /dev/null +++ b/Nibble/README.md @@ -0,0 +1,14 @@ +# Nibble - Restaurant Revenue Sharing + +### Overview + +Nibble allows restaurants to mint tokens that entitle frequent customers to a portion of their revenue. + +**Problem:** +Small businesses rely on strong local communities, but in cities with countless dining options, it's hard to stand out, attract an initial customer base, and maintain long-term engagement. Combined with thin margins emblematic of restaurants and price pressures from corporate competitors, enfranchsing our local businesses requires more than just consumer choice. + +**Insight:** +By letting customers become partial owners, small businesses can foster a deeper sense of loyalty. When customers are invested, they spend more and are more likely to promote the restaurant through word-of-mouth and social media, increasing brand visibility. Additionally, they provide more valuable and constructive feedback, as they want the business to succeed, ultimately leading to better service, menu improvements, and a stronger community around the restaurant. + +**Solution:** +Offer a platform where customer loyalty translates into a share of the restaurant's on-chain, encrypted revenue. diff --git a/Nibble/foundry.toml b/Nibble/foundry.toml new file mode 100644 index 0000000..a2a761a --- /dev/null +++ b/Nibble/foundry.toml @@ -0,0 +1,4 @@ +[profile.default] + src = "src" + out = "out" + libs = ["lib"] \ No newline at end of file diff --git a/Nibble/lib/forge-std b/Nibble/lib/forge-std new file mode 160000 index 0000000..bf909b2 --- /dev/null +++ b/Nibble/lib/forge-std @@ -0,0 +1 @@ +Subproject commit bf909b22fa55e244796dfa920c9639fdffa1c545 diff --git a/Nibble/src/ISRC20.sol b/Nibble/src/ISRC20.sol new file mode 100644 index 0000000..28b50e0 --- /dev/null +++ b/Nibble/src/ISRC20.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.27; + +/* + * Assumption: + * The custom types `saddress` and `suint256` are defined elsewhere. + * They are identical in behavior to address and uint256 respectively, + * but signal that the underlying data is stored privately. + */ + +/*////////////////////////////////////////////////////////////// +// ISRC20 Interface +//////////////////////////////////////////////////////////////*/ + +interface ISRC20 { + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + // event Transfer(address indexed from, address indexed to, uint256 amount); + // event Approval(address indexed owner, address indexed spender, uint256 amount); + + /*////////////////////////////////////////////////////////////// + METADATA FUNCTIONS + //////////////////////////////////////////////////////////////*/ + function name() external view returns (string memory); + function symbol() external view returns (string memory); + function totalSupply() external view returns (uint256); + function decimals() external view returns (uint8); + + /*////////////////////////////////////////////////////////////// + ERC20 FUNCTIONS + //////////////////////////////////////////////////////////////*/ + function balanceOf() external view returns (uint256); + function transfer(saddress to, suint256 amount) external returns (bool); + // owner passed in as msg.sender via signedRead + function allowance(saddress spender) external view returns (uint256); + function approve(saddress spender, suint256 amount) external returns (bool); + function transferFrom(saddress from, saddress to, suint256 amount) external returns (bool); +} diff --git a/Nibble/src/Nibble.sol b/Nibble/src/Nibble.sol new file mode 100644 index 0000000..147b9fe --- /dev/null +++ b/Nibble/src/Nibble.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT License +pragma solidity ^0.8.13; + +/** + * @title Restaurant Revenue Sharing and Token System + * @dev This contract allows restaurants to register, mint tokens, track customer spending, + * and facilitate revenue sharing through token redemption. + */ +import {Rewards20} from "./Rewards20.sol"; + + +/* The current rewards distribution for customers: token rewards are calculated across ETH spend and pre-existing holdings. +Specifically, through the Rewards20 mint function, when rewards are minted user recieve a boost based on their existing balance +This rewards customers for spending early and often, as repeated spends will grow +faster than infrequent, larger spends. It also encourages holding of tokens rather than immediately cashing out. +*/ + +contract Nibble { + /// @notice The total number of registered restaurants. + uint256 public restaurantCount; + + /// @notice Maps a restaurant (owner) address to its respective Rewards20 token contract address. + // mapping(restaurant (owner) address => restaurant's Rewards20 token) + mapping(address => address) public restaurantsTokens; + + /// @dev Maps a restaurant address to its total accumulated revenue. + // mapping(restauraunt address => total revenue) + mapping(address => suint256) internal restaurantTotalRevenue; + + /// @dev Tracks how much each customer has spent at a specific restaurant. + // mapping(restaurant address => mapping(customer address => spend amount)) + mapping(address => mapping(address => suint256)) internal customerSpend; + + /// @notice Emitted when a new restaurant registers and its token is created. + /// @param Restaurant_ The address of the restaurant owner. + /// @param tokenAddress The address of the newly created Rewards20 token contract. + event Register(address Restaurant_, address tokenAddress); + + /// @notice Emitted when a consumer spends at a restaurant. + /// @param Restaurant_ The address of the restaurant where the transaction occurred. + /// @param Consumer_ The address of the consumer who spent money. + event SpentAtRestaurant(address Restaurant_, address Consumer_); //Event of a user spending at a restaurant + + /// @dev Ensures the caller is a registered restaurant. + /// @param _restaurantAddress The address to check. + modifier reqIsRestaurant(address _restaurantAddress) { + if (restaurantsTokens[_restaurantAddress] == address(0)) { + revert("restaurant is not registered"); + } + _; + } + + constructor() {} + + /** + * @notice Registers a new restaurant and mints an associated token. + * @dev Assigns a unique Rewards20 token to the restaurant and updates the count. + * @param name_ The name of the restaurant token. + * @param symbol_ The symbol of the restaurant token. + */ + function registerRestaurant(string calldata name_, string calldata symbol_) public { + //This is a sample - token distribution should ideally be automated around user spend + //events to give larger portions of the tokens to early/regular spenders, while maintaining + //a token pool for the restaurant. Currently, the restaurant has to manually handle distribution. + + if (restaurantsTokens[msg.sender] != address(0)) { + revert("restaurant already registered"); + } + + Rewards20 token = new Rewards20(name_, symbol_, 18, saddress(msg.sender), suint(1e24)); + restaurantsTokens[msg.sender] = address(token); + + restaurantCount++; + + emit Register(msg.sender, address(token)); + } + + /** + * @notice Allows a customer to make a payment at a restaurant. + * @dev Updates revenue tracking and mints corresponding tokens to the consumer. + * @param restaurant_ The address of the restaurant where payment is made. + */ + function spendAtRestaurant(address restaurant_) public payable reqIsRestaurant(restaurant_) { + restaurantTotalRevenue[restaurant_] = restaurantTotalRevenue[restaurant_] + suint256(msg.value); + customerSpend[restaurant_][msg.sender] = customerSpend[restaurant_][msg.sender] + suint256(msg.value); + + // Calculate the number of tokens to mint. + // Here we assume a 1:1 ratio between wei paid and tokens minted. + // You can adjust the conversion factor as needed. + uint256 tokenAmount = msg.value; + + // Mint tokens directly to msg.sender. + // We assume that restaurantTokens[restaurant_] returns the Rewards20 token contract + // associated with this restaurant. + + Rewards20 token = Rewards20(restaurantsTokens[restaurant_]); + token.mint(saddress(msg.sender), suint256(tokenAmount)); + + emit SpentAtRestaurant(restaurant_, msg.sender); + } + + /** + * @notice Retrieves the total revenue accumulated by the restaurant. + * @dev Only callable by the restaurant itself. + * @return The total revenue in suint256. + */ + function checkTotalSpendRestaurant() public view reqIsRestaurant(msg.sender) returns (uint256) { + return uint256(restaurantTotalRevenue[msg.sender]); + } + + /** + * @notice Retrieves the total spending of a specific customer at the caller's restaurant. + * @dev Only callable by the restaurant. + * @param user_ The address of the customer. + * @return The amount spent in suint256. + */ + function checkCustomerSpendRestaurant(address user_) public view reqIsRestaurant(msg.sender) returns (uint256) { + return uint256(customerSpend[msg.sender][user_]); + } + + /** + * @notice Retrieves the caller's total spend at a specific restaurant. + * @dev Only callable by a customer for a restaurant where they have spent. + * @param restaurant_ The address of the restaurant. + * @return The amount spent in suint256. + */ + function checkSpendCustomer(address restaurant_) public view reqIsRestaurant(restaurant_) returns (uint256) { + return uint256(customerSpend[restaurant_][msg.sender]); + } + + /** + * @notice Allows a user to exchange restaurant tokens for a portion of the restaurant's revenue. + * @dev Transfers tokens back to the restaurant and distributes a proportional revenue share. + * @param restaurant_ The address of the restaurant where tokens are redeemed. + * @param amount The amount of tokens to redeem, in suint256. + */ + function checkOut(address restaurant_, suint256 amount) public reqIsRestaurant(restaurant_) { + address tokenAddress = restaurantsTokens[restaurant_]; // get the address of the restaurant's token + Rewards20 token = Rewards20(tokenAddress); + + // decrease msg.sender's allowance by amount so they cannot double checkOut + // note: reverts if amount is more than the user's allowance + token.transferFrom(saddress(msg.sender), saddress(restaurant_), amount); + + // calculate the entitlement + suint256 totalRev = restaurantTotalRevenue[restaurant_]; + uint256 entitlement = uint256(amount * totalRev) / token.totalSupply(); + + // send the entitlement to the customer + bool success = payable(msg.sender).send(uint256(entitlement)); + if (!success) { + revert("Payment Failed"); + } + } +} diff --git a/Nibble/src/Rewards20.sol b/Nibble/src/Rewards20.sol new file mode 100644 index 0000000..2b34e97 --- /dev/null +++ b/Nibble/src/Rewards20.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.27; + +import {ISRC20} from "./ISRC20.sol"; + + +/** + * @title Rewards20 Token + * @dev A customer rewards token implementing the ISRC20 standard. + * + * @notice The contract allows the owner to mint tokens at their discretion. + * @dev When tokens are minted, the owner receives a boost based on their existing balance to reward loyalty. + * @dev Rewards20 tokens are non-transferable, meaning loyalty rewards cannot be shared or pooled. + */ + +/*////////////////////////////////////////////////////////////// +// Rewards20 Contract +//////////////////////////////////////////////////////////////*/ +contract Rewards20 is ISRC20 { + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + // Leaks information to public, will replace with encrypted events + // event Transfer(address indexed from, address indexed to, uint256 amount); + // event Approval( + // address indexed owner, + // address indexed spender, + // uint256 amount + // ); + + /*////////////////////////////////////////////////////////////// + METADATA STORAGE + //////////////////////////////////////////////////////////////*/ + string public name; + string public symbol; + uint8 public immutable decimals; + saddress internal mintTo; + address public owner; + + /*////////////////////////////////////////////////////////////// + ERC20 STORAGE + //////////////////////////////////////////////////////////////*/ + // All storage variables that will be mutated must be confidential to + // preserve functional privacy. + uint256 public totalSupply; + mapping(saddress => suint256) internal balance; + mapping(saddress => mapping(saddress => suint256)) internal _allowance; + + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + constructor(string memory _name, string memory _symbol, uint8 _decimals, saddress mintTo_, suint256 supply_) { + name = _name; + symbol = _symbol; + decimals = _decimals; + _mint(mintTo_, supply_); + mintTo = mintTo_; + owner = msg.sender; + } + + /*////////////////////////////////////////////////////////////// + ERC20 LOGIC + //////////////////////////////////////////////////////////////*/ + function balanceOf() public view virtual returns (uint256) { + return uint256(balance[saddress(msg.sender)]); + } + + function approve(saddress spender, suint256 amount) public virtual returns (bool) { + _allowance[saddress(msg.sender)][spender] = amount; + // emit Approval(msg.sender, address(spender), uint256(amount)); + return true; + } + + function transfer(saddress to, suint256 amount) public virtual returns (bool) { + //Prevents token transfer outside of OG restaurant + if (to != mintTo && saddress(msg.sender) != mintTo) { + revert("Non-transferable outside of Original Restaurant"); + } + + // msg.sender is public information, casting to saddress below doesn't change this + balance[saddress(msg.sender)] -= amount; + unchecked { + balance[to] += amount; + } + // emit Transfer(msg.sender, address(to), uint256(amount)); + return true; + } + + function transferFrom(saddress from, saddress to, suint256 amount) public virtual returns (bool) { + //same as in above + if (to != mintTo && from != mintTo) { + revert("Non-transferable outside of Original Restaurant"); + } + + if (msg.sender != owner) { + suint256 allowed = _allowance[from][saddress(msg.sender)]; // Saves gas for limited approvals. + if (allowed != suint256(type(uint256).max)) { + if (amount > allowed) { + revert("not enough allowance"); + } + _allowance[from][saddress(msg.sender)] = allowed - amount; + } + } + + balance[from] -= amount; + unchecked { + balance[to] += amount; + } + // emit Transfer(msg.sender, address(to), uint256(amount)); + return true; + } + + function mint(saddress to, suint256 amount) external { + // For example, restrict minting so that only the owner can mint. + require(msg.sender == owner, "Only owner can mint tokens"); + suint256 customerBalance = balance[to]; + if (uint256(customerBalance) == 0) { + _mint(to, amount); + } + else{ + _mint(to, amount * customerBalance); + } + } + + /*////////////////////////////////////////////////////////////// + INTERNAL MINT/BURN LOGIC + //////////////////////////////////////////////////////////////*/ + function _mint(saddress to, suint256 amount) internal virtual { + totalSupply += uint256(amount); + unchecked { + balance[to] += amount; + } + // emit Transfer(address(0), address(to), uint256(amount)); + } + + function allowance(saddress spender) external view returns (uint256) { + return uint256(_allowance[saddress(msg.sender)][spender]); + } + + + +} diff --git a/Nibble/test/Nibble.t.sol b/Nibble/test/Nibble.t.sol new file mode 100644 index 0000000..d754fb2 --- /dev/null +++ b/Nibble/test/Nibble.t.sol @@ -0,0 +1,452 @@ +// SPDX-License-Identifier: MIT License +pragma solidity ^0.8.13; + +import {Test, console, Vm} from "forge-std/Test.sol"; +import {Nibble} from "../src/Nibble.sol"; +import {ISRC20} from "../src/ISRC20.sol"; + +contract NibbleTest is Test { + Nibble public nibble; + + // Declare event types for use in event emission tests. + event Register(address Restaurant_, address tokenAddress); + event SpentAtRestaurant(address Restaurant_, address Consumer_); + + function setUp() public { + nibble = new Nibble(); + } + + /// @notice Ensure that the restaurant count increases upon registration. + function test_oneNewRestaurant() public { + uint256 start = nibble.restaurantCount(); + assertEq(start, 0); + nibble.registerRestaurant("Restaurant One", "RONE"); + uint256 finish = nibble.restaurantCount(); + assertEq(finish, 1); + } + + /// @notice A restaurant should not be able to register twice. + function test_registerRestaurantTwice() public { + nibble.registerRestaurant("Restaurant One", "RONE"); + vm.expectRevert("restaurant already registered"); + nibble.registerRestaurant("Restaurant One", "RONE"); + } + + /// @notice After registration, the restaurant’s token address should be set. + function test_restaurantTokenMapping() public { + nibble.registerRestaurant("Restaurant One", "RONE"); + address tokenAddress = nibble.restaurantsTokens(address(this)); + assertTrue(tokenAddress != address(0), "Token address should not be zero"); + } + + /// @notice Spending at an unregistered restaurant should revert. + function test_spendAtRestaurantRevertsForNonRegisteredRestaurant() public { + address unregisteredRestaurant = address(0x123); + vm.expectRevert("restaurant is not registered"); + nibble.spendAtRestaurant(unregisteredRestaurant); + } + + /// @notice Spending at a registered restaurant updates revenue and Customer spend correctly. + function test_spendAtRestaurantUpdatesRevenue() public { + // Use a different address for the restaurant. + address restaurant = address(0x123); + vm.prank(restaurant); + nibble.registerRestaurant("Restaurant One", "RONE"); + + // Simulate a consumer spending 1 ether at the restaurant. + address consumer = address(0x234); + vm.deal(consumer, 2 ether); + uint256 spendAmount = 1 ether; + vm.prank(consumer); + nibble.spendAtRestaurant{value: spendAmount}(restaurant); + + // Check the restaurant’s total revenue (only a registered restaurant can call this). + vm.prank(restaurant); + uint256 totalRevenue = nibble.checkTotalSpendRestaurant(); + assertEq(totalRevenue, spendAmount); + + // Check that the restaurant can view this consumer’s spend. + vm.prank(restaurant); + uint256 CustomerSpendAmount = nibble.checkCustomerSpendRestaurant(consumer); + assertEq(CustomerSpendAmount, spendAmount); + + // Check that the consumer can view his/her spend at the restaurant. + vm.prank(consumer); + uint256 spendCustomer = nibble.checkSpendCustomer(restaurant); + assertEq(spendCustomer, spendAmount); + } + + /// @notice Multiple spends from the same consumer should accumulate. + function test_multipleSpendsAccumulate() public { + address restaurant = address(0x123); + vm.prank(restaurant); + nibble.registerRestaurant("Restaurant One", "RONE"); + + address consumer = address(0x234); + vm.deal(consumer, 4 ether); + uint256 spend1 = 1 ether; + uint256 spend2 = 2 ether; + vm.prank(consumer); + nibble.spendAtRestaurant{value: spend1}(restaurant); + vm.prank(consumer); + nibble.spendAtRestaurant{value: spend2}(restaurant); + + vm.prank(restaurant); + uint256 totalRevenue = nibble.checkTotalSpendRestaurant(); + assertEq(totalRevenue, spend1 + spend2); + + vm.prank(restaurant); + uint256 CustomerSpendAmount = nibble.checkCustomerSpendRestaurant(consumer); + assertEq(CustomerSpendAmount, spend1 + spend2); + + vm.prank(consumer); + uint256 spendCustomer = nibble.checkSpendCustomer(restaurant); + assertEq(spendCustomer, spend1 + spend2); + } + + /// @notice Multiple spends from the different consumers should accumulate. + function test_multipleDifSpendsAccumulate() public { + address restaurant = address(0x123); + vm.prank(restaurant); + nibble.registerRestaurant("Restaurant One", "RONE"); + + address consumer = address(0x234); + address consumer2 = address(0x2345); + vm.deal(consumer, 4 ether); + vm.deal(consumer2, 4 ether); + + uint256 spend1 = 1 ether; + uint256 spend2 = 2 ether; + + vm.prank(consumer); + nibble.spendAtRestaurant{value: spend1}(restaurant); + vm.prank(consumer); + nibble.spendAtRestaurant{value: spend2}(restaurant); + + vm.prank(consumer2); + nibble.spendAtRestaurant{value: spend1}(restaurant); + vm.prank(consumer2); + nibble.spendAtRestaurant{value: spend2}(restaurant); + + vm.prank(restaurant); + uint256 totalRevenue = nibble.checkTotalSpendRestaurant(); + assertEq(totalRevenue, 2 * (spend1 + spend2)); + + vm.prank(restaurant); + uint256 CustomerSpendAmount = nibble.checkCustomerSpendRestaurant(consumer); + assertEq(CustomerSpendAmount, spend1 + spend2); + + vm.prank(restaurant); + uint256 CustomerSpendAmount2 = nibble.checkCustomerSpendRestaurant(consumer2); + assertEq(CustomerSpendAmount2, spend1 + spend2); + + vm.prank(consumer); + uint256 spendCustomer = nibble.checkSpendCustomer(restaurant); + assertEq(spendCustomer, spend1 + spend2); + + vm.prank(consumer2); + uint256 spendCustomer2 = nibble.checkSpendCustomer(restaurant); + assertEq(spendCustomer2, spend1 + spend2); + } + + /// @notice Only a registered restaurant can call checkTotalSpendRestaurant. + function test_checkTotalSpendRestaurantNonRegistered() public { + vm.expectRevert("restaurant is not registered"); + nibble.checkTotalSpendRestaurant(); + } + + /// @notice Only a registered restaurant can call checkCustomerSpendRestaurant. + function test_checkCustomerSpendRestaurantNonRegistered() public { + vm.expectRevert("restaurant is not registered"); + nibble.checkCustomerSpendRestaurant(address(0x234)); + } + + /// @notice A consumer calling checkSpendCustomer for an unregistered restaurant should revert. + function test_checkSpendCustomerNonRegisteredRestaurant() public { + address unregisteredRestaurant = address(0x123); + vm.expectRevert("restaurant is not registered"); + nibble.checkSpendCustomer(unregisteredRestaurant); + } + + /// @notice Test that the SpentAtRestaurant event is emitted with the correct parameters. + function test_spentAtRestaurantEmitsEvent() public { + address restaurant = address(0x123); + vm.prank(restaurant); + nibble.registerRestaurant("Restaurant One", "RONE"); + + address consumer = address(0x234); + vm.deal(consumer, 2 ether); + uint256 spendAmount = 1 ether; + + // Expect the SpentAtRestaurant event with the given restaurant and consumer. + vm.expectEmit(true, true, false, false); + emit SpentAtRestaurant(restaurant, consumer); + + vm.prank(consumer); + nibble.spendAtRestaurant{value: spendAmount}(restaurant); + } + + /// @notice Test that the Register event is emitted when a restaurant registers. + function test_registerRestaurantEmitsEvent() public { + // Record logs so that we can inspect emitted events. + vm.recordLogs(); + nibble.registerRestaurant("Restaurant One", "RONE"); + Vm.Log[] memory entries = vm.getRecordedLogs(); + bool found = false; + // Compute the expected signature of the Register event. + bytes32 expectedSig = keccak256("Register(address,address)"); + for (uint256 i = 0; i < entries.length; i++) { + if (entries[i].topics.length > 0 && entries[i].topics[0] == expectedSig) { + // Decode the event data. + (address restaurant, address token) = abi.decode(entries[i].data, (address, address)); + assertEq(restaurant, address(this)); + assertTrue(token != address(0), "Token address in event should not be zero"); + found = true; + break; + } + } + assertTrue(found, "Register event not found"); + } + + function test_sendTokens() public { + address restaurant = address(0x123); + vm.prank(restaurant); + nibble.registerRestaurant("Restaurant One", "RONE"); + + address consumer = address(0x234); + vm.deal(consumer, 2 ether); + + address tokenAddress = nibble.restaurantsTokens(restaurant); + ISRC20 token = ISRC20(tokenAddress); + + vm.prank(restaurant); + token.transfer(saddress(consumer), suint256(1000)); + + vm.prank(consumer); + uint256 balance = token.balanceOf(); + assertEq(balance, 1000); + } + + function test_sendTokensIllegal() public { + address restaurant = address(0x123); + vm.prank(restaurant); + nibble.registerRestaurant("Restaurant One", "RONE"); + + address consumer = address(0x234); + address consumer2 = address(0x456); + vm.deal(consumer, 2 ether); + + address tokenAddress = nibble.restaurantsTokens(restaurant); + ISRC20 token = ISRC20(tokenAddress); + + vm.prank(restaurant); + token.transfer(saddress(consumer), suint256(1000)); + + vm.prank(consumer); + uint256 balance = token.balanceOf(); + assertEq(balance, 1000); + + vm.prank(consumer); + vm.expectRevert(); + token.transfer(saddress(consumer2), suint256(1000)); + } + + function test_checkOutNoTokens() public { + address restaurant = address(0x123); + vm.prank(restaurant); + nibble.registerRestaurant("Restaurant One", "RONE"); + + address buyer = address(0x234); + address holder = address(0x456); + + vm.deal(buyer, 2 ether); + vm.prank(buyer); + nibble.spendAtRestaurant{value: 1 ether}(restaurant); + + address tokenAddress = nibble.restaurantsTokens(restaurant); + ISRC20 token = ISRC20(tokenAddress); + + vm.prank(holder); + uint256 balance = token.balanceOf(); + assertEq(balance, 0); + + vm.prank(holder); + vm.expectRevert(); + nibble.checkOut(restaurant, suint256(5e8)); + } + + function testUserReceivesTokensAndETHRefund() public { + // ----------- STEP 1: Customer Spends at Restaurant ----------- + // Define how much the customer spends, and where the customer / restaurant are + uint256 spendAmount = 1 ether; + address customer = address(0x234); + address restaurant = address(0x456); + vm.deal(customer, 4 ether); + + vm.prank(restaurant); + nibble.registerRestaurant("Restaurant One", "RONE"); + + address tokenAddress = nibble.restaurantsTokens(restaurant); + ISRC20 token = ISRC20(tokenAddress); + + // Have the customer call spendAtRestaurant sending spendAmount ETH. + // This call should update the revenue, track customer spend, and mint tokens to the customer. + vm.prank(customer); + nibble.spendAtRestaurant{value: spendAmount}(restaurant); + + // Verify that the customer received tokens on a 1:1 basis. + + vm.prank(customer); + uint256 cusotmerBalance = token.balanceOf(); + + assertEq(cusotmerBalance, spendAmount); + + // ----------- STEP 2: Approve and Call checkOut ----------- + // Before checking out, the customer must allow the Nibble contract to transfer tokens + // on their behalf via the token’s transferFrom. + vm.prank(customer); + token.approve(saddress(nibble), suint256(cusotmerBalance)); + + // Record ETH balances for later assertions. + uint256 nibbleBalanceBefore = address(nibble).balance; + uint256 customerEthBefore = customer.balance; + + // The customer now calls checkOut to trade in their tokens for an ETH payback. + // Note: checkOut accepts a parameter of type suint256; here we assume that casting + // the uint256 value to suint256 works in your code. + vm.prank(customer); + nibble.checkOut(restaurant, suint256(cusotmerBalance)); + + // ----------- STEP 3: Calculate and Verify the ETH Refund ----------- + // In checkOut the entitlement is computed as: + // entitlement = (amount * totalRev) / token.totalSupply() + // where: + // - amount = # of tokens the customer wants to cash out + // - totalRev = spendAmount (since spendAtRestaurant updates revenue with msg.value) + // - token.totalSupply() = initial supply (set in registerRestaurant) + tokens minted during spendAtRestaurant + uint256 totalRev = spendAmount; + uint256 tokenTotalSupply = token.totalSupply(); + uint256 expectedEntitlement = (cusotmerBalance * totalRev) / tokenTotalSupply; + + // The Nibble contract should have sent the expectedEntitlement to the customer. + uint256 nibbleBalanceAfter = address(nibble).balance; + uint256 customerEthAfter = customer.balance; + + // Verify that Nibble's ETH balance decreased by expectedEntitlement. + assertEq( + nibbleBalanceAfter, + nibbleBalanceBefore - expectedEntitlement, + "Nibble ETH balance should decrease by the expected entitlement" + ); + + // Verify that the customer's ETH balance increased by the expected entitlement. + assertEq( + customerEthAfter, + customerEthBefore + expectedEntitlement, + "Customer ETH balance should increase by the expected entitlement" + ); + + // ----------- STEP 4: Verify Tokens Were Transferred ----------- + // After checkout, the customer's tokens should have been transferred to the restaurant. + + vm.prank(customer); + uint256 customerTokenBalanceAfter = token.balanceOf(); + + assertEq(customerTokenBalanceAfter, 0, "Customer token balance should be zero after checkout"); + + } + + function testUserReceivesTokensAndETHRefundMultiple() public { + // ----------- STEP 1: Customer Spends at Restaurant ----------- + // Define how much the customer spends, and where the customer / restaurant are + uint256 spendAmount = 1 ether; + address customer = address(0x234); + address restaurant = address(0x456); + vm.deal(customer, 2 ether); + + vm.prank(restaurant); + nibble.registerRestaurant("Restaurant One", "RONE"); + + address tokenAddress = nibble.restaurantsTokens(restaurant); + ISRC20 token = ISRC20(tokenAddress); + + // Have the customer call spendAtRestaurant sending spendAmount/2 ETH. + // This call should update the revenue, track customer spend, and mint tokens to the customer. + vm.prank(customer); + nibble.spendAtRestaurant{value: spendAmount}(restaurant); + + // Verify that the customer received tokens on a 1:1 basis. + vm.prank(customer); + uint256 cusotmerBalance = token.balanceOf(); + + assertEq(cusotmerBalance, spendAmount); + + // Have the customer call spendAtRestaurant a second time, spending spendAmount/2 ETH. + vm.prank(customer); + nibble.spendAtRestaurant{value: spendAmount}(restaurant); + + // Verify that the customer received tokens OVER a 1:1 basis. + vm.prank(customer); + cusotmerBalance = token.balanceOf(); + assert(cusotmerBalance > spendAmount*2); + + } + + function testUserTokenHoldingIsMultiplicative() public { + // ----------- STEP 1: Customer Spends at Restaurant ----------- + // Define how much the customer spends, and where the customer / restaurant are + uint256 spendAmount = 1 ether; + address customer = address(0x234); + address customer2 = address(0x345); + address restaurant = address(0x456); + vm.deal(customer, 4 ether); + vm.deal(customer2, 4 ether); + + vm.prank(restaurant); + nibble.registerRestaurant("Restaurant One", "RONE"); + + address tokenAddress = nibble.restaurantsTokens(restaurant); + ISRC20 token = ISRC20(tokenAddress); + + // Have the customer call spendAtRestaurant sending spendAmount/2 ETH. + // This call should update the revenue, track customer spend, and mint tokens to the customer. + vm.prank(customer); + nibble.spendAtRestaurant{value: spendAmount/2}(restaurant); + + vm.prank(customer2); + nibble.spendAtRestaurant{value: spendAmount/2}(restaurant); + + // Verify that the customer received tokens on a 1:1 basis. + vm.prank(customer); + uint256 cusotmerBalance = token.balanceOf(); + + assertEq(cusotmerBalance, spendAmount/2); + + vm.prank(customer2); + uint256 cusotmer2Balance = token.balanceOf(); + + assertEq(cusotmer2Balance, spendAmount/2); + + // Have customer2 cash out half of their tokens + vm.prank(customer2); + nibble.checkOut(restaurant, suint256(cusotmer2Balance/2)); + + // Have the customer call spendAtRestaurant a second time, spending spendAmount/2 ETH. + vm.prank(customer); + nibble.spendAtRestaurant{value: spendAmount/2}(restaurant); + + vm.prank(customer2); + nibble.spendAtRestaurant{value: spendAmount/2}(restaurant); + + // Verify that the customer received tokens OVER a 1:1 basis. + vm.prank(customer); + cusotmerBalance = token.balanceOf(); + + vm.prank(customer2); + cusotmer2Balance = token.balanceOf(); + + assert(cusotmerBalance > cusotmer2Balance); + + + } +} diff --git a/README.md b/README.md index 09127b6..a119fd4 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. **`Nibble`** + Earn revenue share in your favorite restaurant. ## Contributing