From e304536b7c559a997d413faf0b5d213675904ec6 Mon Sep 17 00:00:00 2001 From: Shuhui Luo <107524008+shuhuiluo@users.noreply.github.com> Date: Fri, 7 Feb 2025 15:29:32 -0500 Subject: [PATCH] feat(contract): add points for tipping (#2272) Co-authored-by: g <5714678+giuseppecrj@users.noreply.github.com> --- .../gamma/base/addresses/pointsFacet.json | 2 +- .../gamma/base/addresses/tippingFacet.json | 2 +- .../omega/base/addresses/pointsFacet.json | 2 +- .../omega/base/addresses/tippingFacet.json | 2 +- contracts/src/airdrop/points/ITownsPoints.sol | 3 +- contracts/src/airdrop/points/TownsPoints.sol | 6 ++ .../spaces/facets/points/PointsProxyLib.sol | 39 ++++++++++++ .../spaces/facets/tipping/TippingFacet.sol | 44 ++++++++++++- contracts/test/spaces/tipping/Tipping.t.sol | 63 +++++++++---------- 9 files changed, 123 insertions(+), 40 deletions(-) create mode 100644 contracts/src/spaces/facets/points/PointsProxyLib.sol diff --git a/contracts/deployments/gamma/base/addresses/pointsFacet.json b/contracts/deployments/gamma/base/addresses/pointsFacet.json index dae41e7a63..c3082e8ff1 100644 --- a/contracts/deployments/gamma/base/addresses/pointsFacet.json +++ b/contracts/deployments/gamma/base/addresses/pointsFacet.json @@ -1,3 +1,3 @@ { - "address": "0x1265FD7f05E9700DA0A4d459BcE38376bb3a1475" + "address": "0x2E0F3c30e5164dA369B40C531b7EDAa497734807" } \ No newline at end of file diff --git a/contracts/deployments/gamma/base/addresses/tippingFacet.json b/contracts/deployments/gamma/base/addresses/tippingFacet.json index f2fba395c8..927f01dc8b 100644 --- a/contracts/deployments/gamma/base/addresses/tippingFacet.json +++ b/contracts/deployments/gamma/base/addresses/tippingFacet.json @@ -1,3 +1,3 @@ { - "address": "0xE5e358e11986E48461652ebc2111ddce05AaA0d8" + "address": "0x09e710567B7fB466E3e904a70a8bD60aFcE51f70" } \ No newline at end of file diff --git a/contracts/deployments/omega/base/addresses/pointsFacet.json b/contracts/deployments/omega/base/addresses/pointsFacet.json index 3546f704db..c5763229ce 100644 --- a/contracts/deployments/omega/base/addresses/pointsFacet.json +++ b/contracts/deployments/omega/base/addresses/pointsFacet.json @@ -1,3 +1,3 @@ { - "address": "0x6b2179482Ee8432a3650A0742e3968c96b1F487f" + "address": "0x32a6c393a7F2434975B29f1Ff7dF838A03610F31" } \ No newline at end of file diff --git a/contracts/deployments/omega/base/addresses/tippingFacet.json b/contracts/deployments/omega/base/addresses/tippingFacet.json index 8f8190ed2e..f476f0480f 100644 --- a/contracts/deployments/omega/base/addresses/tippingFacet.json +++ b/contracts/deployments/omega/base/addresses/tippingFacet.json @@ -1,3 +1,3 @@ { - "address": "0xc6F202b4A06FAbf5c838289514e41fd37611C9e1" + "address": "0x98323458f8A214c805a0615764e66CA4b3c26A7c" } \ No newline at end of file diff --git a/contracts/src/airdrop/points/ITownsPoints.sol b/contracts/src/airdrop/points/ITownsPoints.sol index 37eeb2a540..f31855c834 100644 --- a/contracts/src/airdrop/points/ITownsPoints.sol +++ b/contracts/src/airdrop/points/ITownsPoints.sol @@ -4,7 +4,8 @@ pragma solidity ^0.8.23; interface ITownsPointsBase { enum Action { JoinSpace, - CheckIn + CheckIn, + Tip } /// @notice Emitted when a user successfully checks in and receives points diff --git a/contracts/src/airdrop/points/TownsPoints.sol b/contracts/src/airdrop/points/TownsPoints.sol index 7b8c88af1d..f339c25299 100644 --- a/contracts/src/airdrop/points/TownsPoints.sol +++ b/contracts/src/airdrop/points/TownsPoints.sol @@ -96,6 +96,12 @@ contract TownsPoints is IERC20Metadata, ITownsPoints, OwnableBase, Facet { ); (points, ) = CheckIn.getPointsAndStreak(lastCheckIn, streak, currentTime); } + + if (action == Action.Tip) { + uint256 protocolFee = abi.decode(data, (uint256)); + // 1 pt per 0.0003 ETH + points = (protocolFee * 10_000) / 3; + } } /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ diff --git a/contracts/src/spaces/facets/points/PointsProxyLib.sol b/contracts/src/spaces/facets/points/PointsProxyLib.sol new file mode 100644 index 0000000000..87c24c6079 --- /dev/null +++ b/contracts/src/spaces/facets/points/PointsProxyLib.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +// interfaces +import {IImplementationRegistry} from "contracts/src/factory/facets/registry/IImplementationRegistry.sol"; +import {ITownsPoints} from "contracts/src/airdrop/points/ITownsPoints.sol"; + +// libraries + +// contracts +import {MembershipStorage} from "contracts/src/spaces/facets/membership/MembershipStorage.sol"; + +/// @title PointsProxyLib +/// @notice Library for interacting with the TownsPoints contract +library PointsProxyLib { + /// @dev The implementation ID for the TownsPoints contract + bytes32 internal constant POINTS_DIAMOND = bytes32("RiverAirdrop"); + + // ============================================================= + // GETTERS + // ============================================================= + + function airdropDiamond() internal view returns (address) { + return + IImplementationRegistry(MembershipStorage.layout().spaceFactory) + .getLatestImplementation(POINTS_DIAMOND); + } + + function getPoints( + ITownsPoints.Action action, + bytes memory data + ) internal view returns (uint256) { + return ITownsPoints(airdropDiamond()).getPoints(action, data); + } + + function mint(address to, uint256 amount) internal { + ITownsPoints(airdropDiamond()).mint(to, amount); + } +} diff --git a/contracts/src/spaces/facets/tipping/TippingFacet.sol b/contracts/src/spaces/facets/tipping/TippingFacet.sol index f208a0bb1c..ac49486b52 100644 --- a/contracts/src/spaces/facets/tipping/TippingFacet.sol +++ b/contracts/src/spaces/facets/tipping/TippingFacet.sol @@ -3,22 +3,29 @@ pragma solidity ^0.8.23; // interfaces import {ITipping} from "./ITipping.sol"; +import {ITownsPointsBase} from "contracts/src/airdrop/points/ITownsPoints.sol"; +import {IPlatformRequirements} from "contracts/src/factory/facets/platform/requirements/IPlatformRequirements.sol"; // libraries import {TippingBase} from "./TippingBase.sol"; import {CustomRevert} from "contracts/src/utils/libraries/CustomRevert.sol"; +import {PointsProxyLib} from "contracts/src/spaces/facets/points/PointsProxyLib.sol"; +import {MembershipStorage} from "contracts/src/spaces/facets/membership/MembershipStorage.sol"; +import {BasisPoints} from "contracts/src/utils/libraries/BasisPoints.sol"; +import {CurrencyTransfer} from "contracts/src/utils/libraries/CurrencyTransfer.sol"; // contracts import {ERC721ABase} from "contracts/src/diamond/facets/token/ERC721A/ERC721ABase.sol"; import {Facet} from "@river-build/diamond/src/facets/Facet.sol"; +import {ReentrancyGuard} from "solady/utils/ReentrancyGuard.sol"; -contract TippingFacet is ITipping, ERC721ABase, Facet { +contract TippingFacet is ITipping, ERC721ABase, Facet, ReentrancyGuard { function __Tipping_init() external onlyInitializing { _addInterface(type(ITipping).interfaceId); } /// @inheritdoc ITipping - function tip(TipRequest calldata tipRequest) external payable { + function tip(TipRequest calldata tipRequest) external payable nonReentrant { _validateTipRequest( msg.sender, tipRequest.receiver, @@ -26,12 +33,26 @@ contract TippingFacet is ITipping, ERC721ABase, Facet { tipRequest.amount ); + uint256 tipAmount = tipRequest.amount; + + if (tipRequest.currency == CurrencyTransfer.NATIVE_TOKEN) { + uint256 protocolFee = _payProtocol(msg.sender, tipRequest.amount); + tipAmount = tipRequest.amount - protocolFee; + + uint256 points = PointsProxyLib.getPoints( + ITownsPointsBase.Action.Tip, + abi.encode(protocolFee) + ); + + PointsProxyLib.mint(msg.sender, points); + } + TippingBase.tip( msg.sender, tipRequest.receiver, tipRequest.tokenId, tipRequest.currency, - tipRequest.amount + tipAmount ); emit Tip( @@ -87,4 +108,21 @@ contract TippingFacet is ITipping, ERC721ABase, Facet { if (sender == receiver) CustomRevert.revertWith(CannotTipSelf.selector); if (amount == 0) CustomRevert.revertWith(AmountIsZero.selector); } + + function _payProtocol( + address sender, + uint256 amount + ) internal returns (uint256 protocolFee) { + MembershipStorage.Layout storage ds = MembershipStorage.layout(); + IPlatformRequirements platform = IPlatformRequirements(ds.spaceFactory); + + protocolFee = BasisPoints.calculate(amount, 50); // 0.5% + + CurrencyTransfer.transferCurrency( + CurrencyTransfer.NATIVE_TOKEN, + sender, + platform.getFeeRecipient(), + protocolFee + ); + } } diff --git a/contracts/test/spaces/tipping/Tipping.t.sol b/contracts/test/spaces/tipping/Tipping.t.sol index a24384ec92..7b7293608c 100644 --- a/contracts/test/spaces/tipping/Tipping.t.sol +++ b/contracts/test/spaces/tipping/Tipping.t.sol @@ -2,12 +2,16 @@ pragma solidity ^0.8.23; // interfaces +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ITippingBase} from "contracts/src/spaces/facets/tipping/ITipping.sol"; import {IERC721AQueryable} from "contracts/src/diamond/facets/token/ERC721A/extensions/IERC721AQueryable.sol"; import {IERC721ABase} from "contracts/src/diamond/facets/token/ERC721A/IERC721A.sol"; +import {ITownsPoints, ITownsPointsBase} from "contracts/src/airdrop/points/ITownsPoints.sol"; +import {IPlatformRequirements} from "contracts/src/factory/facets/platform/requirements/IPlatformRequirements.sol"; // libraries import {CurrencyTransfer} from "contracts/src/utils/libraries/CurrencyTransfer.sol"; +import {BasisPoints} from "contracts/src/utils/libraries/BasisPoints.sol"; // contracts import {TippingFacet} from "contracts/src/spaces/facets/tipping/TippingFacet.sol"; @@ -27,14 +31,20 @@ contract TippingTest is BaseSetup, ITippingBase, IERC721ABase { MembershipFacet internal membership; IERC721AQueryable internal token; MockERC20 internal mockERC20; + ITownsPoints internal points; + + address internal platformRecipient; function setUp() public override { super.setUp(); + tipping = TippingFacet(everyoneSpace); introspection = IntrospectionFacet(everyoneSpace); membership = MembershipFacet(everyoneSpace); token = IERC721AQueryable(everyoneSpace); mockERC20 = MockERC20(deployERC20.deploy(deployer)); + points = ITownsPoints(riverAirdrop); + platformRecipient = IPlatformRequirements(spaceFactory).getFeeRecipient(); } modifier givenUsersAreMembers(address sender, address receiver) { @@ -61,11 +71,17 @@ contract TippingTest is BaseSetup, ITippingBase, IERC721ABase { bytes32 messageId, bytes32 channelId ) external givenUsersAreMembers(sender, receiver) { - amount = bound(amount, 0.01 ether, 1 ether); + vm.assume(sender != platformRecipient); + vm.assume(receiver != platformRecipient); + amount = bound(amount, 0.0003 ether, 1 ether); uint256 initialBalance = receiver.balance; - uint256[] memory tokens = token.tokensOfOwner(receiver); - uint256 tokenId = tokens[0]; + uint256 initialPointBalance = IERC20(address(points)).balanceOf(sender); + uint256 tokenId = token.tokensOfOwner(receiver)[0]; + + uint256 protocolFee = BasisPoints.calculate(amount, 50); // 0.5% + uint256 tipAmount = amount - protocolFee; + hoax(sender, amount); vm.expectEmit(address(tipping)); emit Tip( @@ -88,19 +104,24 @@ contract TippingTest is BaseSetup, ITippingBase, IERC721ABase { channelId: channelId }) ); - uint256 gasUsed = vm.stopSnapshotGas(); - assertLt(gasUsed, 200_000); - assertEq(receiver.balance - initialBalance, amount); - assertEq(sender.balance, 0); + assertLt(vm.stopSnapshotGas(), 400_000); + assertEq(receiver.balance - initialBalance, tipAmount, "receiver balance"); + assertEq(platformRecipient.balance, protocolFee, "protocol fee"); + assertEq(sender.balance, 0, "sender balance"); + assertEq( + IERC20(address(points)).balanceOf(sender) - initialPointBalance, + (protocolFee * 10_000) / 3, + "points minted" + ); assertEq( tipping.tipsByCurrencyAndTokenId(tokenId, CurrencyTransfer.NATIVE_TOKEN), - amount + tipAmount ); assertEq(tipping.totalTipsByCurrency(CurrencyTransfer.NATIVE_TOKEN), 1); assertEq( tipping.tipAmountByCurrency(CurrencyTransfer.NATIVE_TOKEN), - amount + tipAmount ); assertContains(tipping.tippingCurrencies(), CurrencyTransfer.NATIVE_TOKEN); } @@ -145,7 +166,7 @@ contract TippingTest is BaseSetup, ITippingBase, IERC721ABase { uint256 gasUsed = vm.stopSnapshotGas(); vm.stopPrank(); - assertLt(gasUsed, 200_000); + assertLt(gasUsed, 300_000); assertEq(mockERC20.balanceOf(sender), 0); assertEq(mockERC20.balanceOf(receiver), amount); assertEq( @@ -157,28 +178,6 @@ contract TippingTest is BaseSetup, ITippingBase, IERC721ABase { assertContains(tipping.tippingCurrencies(), address(mockERC20)); } - // function test_revertWhenTokenDoesNotExist( - // uint256 tokenId, - // uint256 amount, - // address receiver, - // bytes32 messageId, - // bytes32 channelId - // ) external { - // vm.assume(tokenId != 0); // tokenId cannot be 0 because that would be the founder token id - - // vm.expectRevert(OwnerQueryForNonexistentToken.selector); - // tipping.tip( - // TipRequest({ - // receiver: receiver, - // tokenId: tokenId, - // currency: CurrencyTransfer.NATIVE_TOKEN, - // amount: amount, - // messageId: messageId, - // channelId: channelId - // }) - // ); - // } - function test_revertWhenCurrencyIsZero( address sender, address receiver,