diff --git a/deployments/PowerToken.abi b/deployments/PowerToken.abi index 81e800e..860b327 100644 --- a/deployments/PowerToken.abi +++ b/deployments/PowerToken.abi @@ -459,6 +459,34 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "purchase", + "inputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "feedId", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "taxBasisPoints", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "removeUser", @@ -754,6 +782,37 @@ ], "anonymous": false }, + { + "type": "event", + "name": "Purchase", + "inputs": [ + { + "name": "from", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "feedId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, { "type": "event", "name": "RoleAdminChanged", @@ -997,6 +1056,11 @@ } ] }, + { + "type": "error", + "name": "AmountIsZero", + "inputs": [] + }, { "type": "error", "name": "ERC20InsufficientAllowance", @@ -1131,12 +1195,7 @@ }, { "type": "error", - "name": "TipAmountIsZero", - "inputs": [] - }, - { - "type": "error", - "name": "TipReceiverIsEmpty", + "name": "ReceiverIsEmpty", "inputs": [] } ] diff --git a/src/PowerToken.sol b/src/PowerToken.sol index 3570853..a785e6d 100644 --- a/src/PowerToken.sol +++ b/src/PowerToken.sol @@ -130,34 +130,21 @@ contract PowerToken is } /// @inheritdoc IPowerToken - function tip(uint256 amount, address to, bytes32 feedId, uint256 taxBasisPoints) + function purchase(uint256 amount, address to, bytes32 feedId, uint256 taxBasisPoints) external override { - if (amount == 0) revert TipAmountIsZero(); - if (feedId == bytes32(0) && to == address(0)) revert TipReceiverIsEmpty(); - if (balanceOf(msg.sender) < amount) revert InsufficientBalanceAndPoints(); - - if (_pointsBalancesV2[msg.sender] >= amount) { - _pointsBalancesV2[msg.sender] -= amount; - } else { - _pointsBalancesV2[msg.sender] = 0; - } - uint256 tax = _getTaxAmount(taxBasisPoints, amount); - - uint256 tipAmount = amount - tax; - - address receiver = to != address(0) ? to : address(this); - if (receiver == address(this)) { - _feedBalances[feedId] += tipAmount; - } + uint256 purchaseAmount = _payWithTax(msg.sender, to, feedId, amount, taxBasisPoints); - if (tax > 0) { - _transfer(msg.sender, ADMIN, tax); - emit TaxCollected(ADMIN, tax); - } + emit Purchase(msg.sender, to, feedId, purchaseAmount); + } - _transfer(msg.sender, receiver, tipAmount); + /// @inheritdoc IPowerToken + function tip(uint256 amount, address to, bytes32 feedId, uint256 taxBasisPoints) + external + override + { + uint256 tipAmount = _payWithTax(msg.sender, to, feedId, amount, taxBasisPoints); emit Tip(msg.sender, to, feedId, tipAmount); } @@ -231,6 +218,43 @@ contract PowerToken is return super.transferFrom(from, to, value); } + function _payWithTax( + address from, + address to, + bytes32 feedId, + uint256 amount, + uint256 taxBasisPoints + ) internal returns (uint256) { + if (amount == 0) revert AmountIsZero(); + + if (balanceOf(from) < amount) revert InsufficientBalanceAndPoints(); + + if (feedId == bytes32(0) && to == address(0)) revert ReceiverIsEmpty(); + + if (_pointsBalancesV2[from] >= amount) { + _pointsBalancesV2[from] -= amount; + } else { + _pointsBalancesV2[from] = 0; + } + uint256 tax = _getTaxAmount(taxBasisPoints, amount); + + if (tax > 0) { + _transfer(msg.sender, ADMIN, tax); + emit TaxCollected(ADMIN, tax); + } + + uint256 tipAmount = amount - tax; + + address receiver = to != address(0) ? to : address(this); + if (receiver == address(this)) { + _feedBalances[feedId] += tipAmount; + } + + _transfer(msg.sender, receiver, tipAmount); + + return tipAmount; + } + /** * @dev Issues points to a specified address by transferring tokens from the token contract. */ diff --git a/src/interfaces/IErrors.sol b/src/interfaces/IErrors.sol index 2ea5de3..2259baa 100644 --- a/src/interfaces/IErrors.sol +++ b/src/interfaces/IErrors.sol @@ -2,14 +2,14 @@ pragma solidity 0.8.22; interface IErrors { - /// @dev Tip parameter is empty. - error TipReceiverIsEmpty(); + /// @dev receiver is empty. + error ReceiverIsEmpty(); /// @dev Points receiver is invalid. error PointsInvalidReceiver(bytes32); - /// @dev Tip amount is zero. - error TipAmountIsZero(); + /// @dev Amount is zero. + error AmountIsZero(); /// @dev Insufficient balance and points. error InsufficientBalanceAndPoints(); diff --git a/src/interfaces/IEvents.sol b/src/interfaces/IEvents.sol index bbee3e9..8aa47eb 100644 --- a/src/interfaces/IEvents.sol +++ b/src/interfaces/IEvents.sol @@ -10,6 +10,13 @@ interface IEvents { * @dev Emitted when points are tipped from one address to another. */ event Tip(address indexed from, address indexed to, bytes32 indexed feedId, uint256 amount); + /** + * @dev Emitted when points are paid from one address to another for some purchase. + */ + event Purchase( + address indexed from, address indexed to, bytes32 indexed feedId, uint256 amount + ); + /** * @dev Emitted when points are airdropped to an address. */ diff --git a/src/interfaces/IPowerToken.sol b/src/interfaces/IPowerToken.sol index 75753da..2403d28 100644 --- a/src/interfaces/IPowerToken.sol +++ b/src/interfaces/IPowerToken.sol @@ -57,9 +57,21 @@ interface IPowerToken { */ function airdrop(address to, uint256 amount, uint256 taxBasisPoints) external; + /** + * @notice Purchases with token points. If token points are not enough, it will try the balance. + * @param amount The amount of token points to send. + * @param to The address to send the token points. It can be empty. + * @param feedId The feed id. It can be empty. + * @param taxBasisPoints The tax basis points. + * @dev The to and feedId are optional, but at least one of them must be provided. + * If both are provided, the `to` will be used. + */ + function purchase(uint256 amount, address to, bytes32 feedId, uint256 taxBasisPoints) + external; + /** * @notice Tips with token points. If token points are not enough, it will try the balance. - * @param amount The amount of token points to send. It can be empty. + * @param amount The amount of token points to send. * @param to The address to send the token points. It can be empty. * @param feedId The feed id. It can be empty. * @param taxBasisPoints The tax basis points. diff --git a/test/PowerToken.t.sol b/test/PowerToken.t.sol index 7c1a203..befdea4 100644 --- a/test/PowerToken.t.sol +++ b/test/PowerToken.t.sol @@ -479,17 +479,20 @@ contract PowerTokenTest is Utils, IErrors, IEvents, ERC20Upgradeable { } function testTipFail() public { - // case 1: TipAmountIsZero - vm.expectRevert(abi.encodeWithSelector(TipAmountIsZero.selector)); + _mintPoints(alice, 100); + vm.startPrank(alice); + // case 1: AmountIsZero + vm.expectRevert(abi.encodeWithSelector(AmountIsZero.selector)); _token.tip(0, bob, "", 0); - // case 2: TipReceiverIsEmpty - vm.expectRevert(abi.encodeWithSelector(TipReceiverIsEmpty.selector)); + // case 2: ReceiverIsEmpty + vm.expectRevert(abi.encodeWithSelector(ReceiverIsEmpty.selector)); _token.tip(1, address(0x0), "", 0); // case 3: InsufficientBalanceAndPoints vm.expectRevert(abi.encodeWithSelector(InsufficientBalanceAndPoints.selector)); - _token.tip(1, bob, "", 0); + _token.tip(101, bob, "", 0); + vm.stopPrank(); // case 4: InsufficientBalanceToTransfer _mintPoints(charlie, 100); @@ -503,6 +506,20 @@ contract PowerTokenTest is Utils, IErrors, IEvents, ERC20Upgradeable { _token.tip(200, david, "", 0); } + function testPurchase() public { + _mintPoints(alice, 100 ether); + + vm.prank(alice); + expectEmit(); + emit Purchase(alice, address(0), "0x1234", 10 ether); + _token.purchase(10 ether, address(0), "0x1234", 0); + + vm.prank(alice); + expectEmit(); + emit Purchase(alice, address(0), "0x1234", 9 ether); + _token.purchase(10 ether, address(0), "0x1234", 1000); + } + function testWithdrawByFeedId(uint256 amount) public { amount = bound(amount, 1, 100 ether); uint256 initialPoints = 10 * amount;