From 375e0196fd9e809ea5bb2c5b9cb94d355dd7cc3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Volpe?= Date: Wed, 24 Jan 2024 22:56:49 -0300 Subject: [PATCH] Moved over wrapper --- .../stability/FeeCurrencyWrapper.sol | 173 ++++++++++++ .../stability/interfaces/IDecimals.sol | 7 + .../stability/interfaces/IFeeCurrency.sol | 72 +++++ .../stability/FeeCurrencyWrapper.t.sol | 255 ++++++++++++++++++ 4 files changed, 507 insertions(+) create mode 100644 packages/protocol/contracts-0.8/stability/FeeCurrencyWrapper.sol create mode 100644 packages/protocol/contracts-0.8/stability/interfaces/IDecimals.sol create mode 100644 packages/protocol/contracts-0.8/stability/interfaces/IFeeCurrency.sol create mode 100644 packages/protocol/test-sol/stability/FeeCurrencyWrapper.t.sol diff --git a/packages/protocol/contracts-0.8/stability/FeeCurrencyWrapper.sol b/packages/protocol/contracts-0.8/stability/FeeCurrencyWrapper.sol new file mode 100644 index 00000000000..7ae40884fb4 --- /dev/null +++ b/packages/protocol/contracts-0.8/stability/FeeCurrencyWrapper.sol @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.7 <0.8.20; + +import "@openzeppelin/contracts8/access/Ownable.sol"; +import "@openzeppelin/contracts8/token/ERC20/IERC20.sol"; +import "forge-std/console.sol"; + +import "../../contracts/common/CalledByVm.sol"; +import "../../contracts/common/Initializable.sol"; +import "../../contracts/common/interfaces/ICeloVersionedContract.sol"; +import "../../contracts/common/FixidityLib.sol"; +import "../../contracts/stability/interfaces/ISortedOracles.sol"; +import "./IFeeCurrency.sol"; +import "./IDecimals.sol"; + +contract FeeCurrencyWrapper is Initializable, CalledByVm { + IFeeCurrency public wrappedToken; + + uint96 public digitDifference; + + uint256 public debited; + + string public name; + string public symbol; + + /** + * @notice Sets initialized == true on implementation contracts + * @param test Set to true to skip implementation initialization + */ + constructor(bool test) public Initializable(test) {} + + /** + * @notice Returns the storage, major, minor, and patch version of the contract. + * @return Storage version of the contract. + * @return Major version of the contract. + * @return Minor version of the contract. + * @return Patch version of the contract. + */ + function getVersionNumber() external pure returns (uint256, uint256, uint256, uint256) { + return (1, 1, 0, 0); + } + + /** + * @notice Used in place of the constructor to allow the contract to be upgradable via proxy. + * @param _wrappedToken The address of the wrapped token. + * @param _name The name of the wrapped token. + * @param _symbol The symbol of the wrapped token. + * @param _expectedDecimals The expected number of decimals of the wrapped token. + */ + function initialize(address _wrappedToken, string memory _name, string memory _symbol, uint8 _expectedDecimals) + external + initializer + { + wrappedToken = IFeeCurrency(_wrappedToken); + name = _name; + symbol = _symbol; + uint8 decimals = IDecimals(_wrappedToken).decimals(); + digitDifference = uint96(10**(_expectedDecimals - decimals)); + } + + /** + * @notice Gets the balance of the specified address with correct digits. + * @param account The address to query the balance of. + * @return The balance of the specified address. + */ + function balanceOf(address account) public view returns (uint256) { + return upscale(wrappedToken.balanceOf(account)); + } + + /** + * Downscales value to the wrapped token's native digits and debits it. + * @param from from address + * @param value Debited value in the wrapped digits. + */ + function debitGasFees(address from, uint256 value) external onlyVm { + uint256 toDebit = downscale(value); + require(toDebit > 0, "Must debit at least one token."); + debited = toDebit; + wrappedToken.debitGasFees(from, toDebit); + } + + /** + * Downscales value to the wrapped token's native digits and credits it. + * @param recipients The recipients + * @param amounts The amounts (in wrapped token digits) + */ + function creditGasFees(address[] calldata recipients, uint256[] calldata amounts) public onlyVm { + if (debited == 0) { + return; + } + require(recipients.length == amounts.length, "Recipients and amounts must be the same length."); + + uint256[] memory scaledAmounts = new uint256[](amounts.length); + + uint256 totalToBeCredited = 0; + + for (uint256 i = 0; i < amounts.length; i++) { + scaledAmounts[i] = downscale(amounts[i]); + totalToBeCredited += scaledAmounts[i]; + } + + require(totalToBeCredited <= debited, "Cannot credit more than debited."); + + uint256 roundingError = debited - totalToBeCredited; + if (roundingError > 0) { + scaledAmounts[0] += roundingError; + } + + wrappedToken.creditGasFees(recipients, scaledAmounts); + debited = 0; + } + + /** + * Downscales value to the wrapped token's native digits and credits it. + * @param refundRecipient The recipient of the refund. + * @param tipRecipient The recipient of the tip. + * @param _gatewayFeeRecipient The recipient of the gateway fee. Unused. + * @param baseFeeRecipient The recipient of the base fee. + * @param refundAmount The amount to refund (in wrapped token digits). + * @param tipAmount The amount to tip (in wrapped token digits). + * @param _gatewayFeeAmount The amount of the gateway fee (in wrapped token digits). Unused. + * @param baseFeeAmount The amount of the base fee (in wrapped token digits). + */ + function creditGasFees( + address refundRecipient, + address tipRecipient, + address _gatewayFeeRecipient, + address baseFeeRecipient, + uint256 refundAmount, + uint256 tipAmount, + uint256 _gatewayFeeAmount, + uint256 baseFeeAmount + ) public onlyVm { + if (debited == 0) { + return; + } + + uint256 refundScaled = downscale(refundAmount); + uint256 tipTxFeeScaled = downscale(tipAmount); + uint256 baseTxFeeScaled = downscale(baseFeeAmount); + + require( + refundScaled + tipTxFeeScaled + baseTxFeeScaled <= debited, + "Cannot credit more than debited." + ); + + uint256 roundingError = debited - (refundScaled + tipTxFeeScaled + baseTxFeeScaled); + + if (roundingError > 0) { + baseTxFeeScaled += roundingError; + } + wrappedToken.creditGasFees( + refundRecipient, + tipRecipient, + address(0), + baseFeeRecipient, + refundScaled, + tipTxFeeScaled, + 0, + baseTxFeeScaled + ); + + debited = 0; + } + + function upscale(uint256 value) internal view returns (uint256) { + return value * digitDifference; + } + + function downscale(uint256 value) internal view returns (uint256) { + return value / digitDifference; + } +} \ No newline at end of file diff --git a/packages/protocol/contracts-0.8/stability/interfaces/IDecimals.sol b/packages/protocol/contracts-0.8/stability/interfaces/IDecimals.sol new file mode 100644 index 00000000000..8d23885cb6a --- /dev/null +++ b/packages/protocol/contracts-0.8/stability/interfaces/IDecimals.sol @@ -0,0 +1,7 @@ +pragma solidity ^0.8.13; + +import "@openzeppelin/contracts8/token/ERC20/IERC20.sol"; + +interface IDecimals is IERC20 { + function decimals() external view returns (uint8); +} \ No newline at end of file diff --git a/packages/protocol/contracts-0.8/stability/interfaces/IFeeCurrency.sol b/packages/protocol/contracts-0.8/stability/interfaces/IFeeCurrency.sol new file mode 100644 index 00000000000..e4abc05e7bd --- /dev/null +++ b/packages/protocol/contracts-0.8/stability/interfaces/IFeeCurrency.sol @@ -0,0 +1,72 @@ +pragma solidity ^0.8.13; + +import "@openzeppelin/contracts8/token/ERC20/IERC20.sol"; + +interface IFeeCurrency is IERC20 { + /* + This interface should be implemented for tokens which are supposed to + act as fee currencies on the Celo blockchain, meaning that they can be + used to pay gas fees for CIP-64 transactions (and some older tx types). + See https://github.com/celo-org/celo-proposals/blob/master/CIPs/cip-0064.md + + Before executing a tx with non-empty feeCurrency field, the fee + currency's `debitGasFees` function is called to reserve the maximum + amount that tx can spend on gas. After the tx has been executed, the + `creditGasFees` function is called to refund the unused gas and credit + the spent fees to the correct recipients. Events which are raised inside + these functions will show up for every transaction using the token as a + fee currency. + + Requirements: + - The functions will be called by the blockchain client with `msg.sender + == address(0)`. If this condition is not met, the functions must + revert to prevent malicious users from crediting their accounts directly. + - `creditGasFees` must credit all specified amounts. If it impossible to + credit one of the recipients for some reason, add the amount to the + value credited to the first valid recipient. This is important to keep + the debited and credited amounts consistent. + + Notes on compatibility: + - There are two versions of `creditGasFees`: one for the current + (2024-01-16) blockchain implementation and a more future-proof version + that avoids deprecated fields and allows new recipients that might + become necessary on later blockchain implementations. Both versions + should be implemented to increase compatibility. + - Future Celo blockchain implementations might provide a way for plain + ERC-20 tokens to be used as gas currencies without implementing this + interface. If this sounds preferable to you, please contact cLabs + before implementing this interface for your token. + */ + + // Called before transaction execution to reserve the maximum amount of gas + // that can be used by the transaction. + // - The implementation must reduce `from`'s balance by `value`. + // - Must revert if `msg.sender` is not the zero address. + function debitGasFees(address from, uint256 value) external; + + // New function signature, will be used when all fee currencies have migrated. + // Credited amounts are gas refund, base fee and tip. Additional components + // might be added, like an L1 gas fee when Celo becomes and L2. + // - The implementation must increase each `recipient`'s balance by respective `value`. + // - Must revert if `msg.sender` is not the zero address. + // - Must revert if `recipients` and `amounts` have different lengths. + function creditGasFees(address[] calldata recipients, uint256[] calldata amounts) external; + + // Old function signature for backwards compatibility + // - Must revert if `msg.sender` is not the zero address. + // - `refund` must be credited to `from` + // - `tipTxFee` must be credited to `feeRecipient` + // - `baseTxFee` must be credited to `communityFund` + // - `gatewayFeeRecipient` and `gatewayFee` only exist for backwards + // compatibility reasons and will always be zero. + function creditGasFees( + address from, + address feeRecipient, + address gatewayFeeRecipient, + address communityFund, + uint256 refund, + uint256 tipTxFee, + uint256 gatewayFee, + uint256 baseTxFee + ) external; +} \ No newline at end of file diff --git a/packages/protocol/test-sol/stability/FeeCurrencyWrapper.t.sol b/packages/protocol/test-sol/stability/FeeCurrencyWrapper.t.sol new file mode 100644 index 00000000000..0684d45216b --- /dev/null +++ b/packages/protocol/test-sol/stability/FeeCurrencyWrapper.t.sol @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.7 <0.8.20; + +import "celo-foundry-8/Test.sol"; + +import "@celo-contracts/common/FixidityLib.sol"; + +import "@celo-contracts/common/interfaces/IRegistry.sol"; + +// Contract to test +import "@celo-contracts-8/stability/FeeCurrencyWrapper.sol"; +import "@celo-contracts-8/stability/IFeeCurrency.sol"; +import "@openzeppelin/contracts8/token/ERC20/ERC20.sol"; +import "forge-std/console.sol"; + +contract FeeCurrency6DecimalsTest is ERC20, IFeeCurrency { + constructor(uint256 initialSupply) ERC20("ExampleFeeCurrency", "EFC") { + _mint(msg.sender, initialSupply); + } + + function debitGasFees(address from, uint256 value) external { + _burn(from, value); + } + + // New function signature, will be used when all fee currencies have migrated + function creditGasFees(address[] calldata recipients, uint256[] calldata amounts) public { + require(recipients.length == amounts.length, "Recipients and amounts must be the same length."); + + for (uint256 i = 0; i < recipients.length; i++) { + _mint(recipients[i], amounts[i]); + } + } + + // Old function signature for backwards compatibility + function creditGasFees( + address from, + address feeRecipient, + address, // gatewayFeeRecipient, unused + address communityFund, + uint256 refund, + uint256 tipTxFee, + uint256, // gatewayFee, unused + uint256 baseTxFee + ) public { + // Calling the new creditGasFees would make sense here, but that is not + // possible due to its calldata arguments. + _mint(from, refund); + _mint(feeRecipient, tipTxFee); + _mint(communityFund, baseTxFee); + } + + function decimals() public pure override returns (uint8) { + return 6; + } +} + +contract FeeCurrencyWrapperTestContract is FeeCurrencyWrapper { + constructor(bool test) FeeCurrencyWrapper(test) {} + + function upscaleVisible(uint256 value) external view returns (uint256) { + return upscale(value); + } + + function downscaleVisible(uint256 value) external view returns (uint256) { + return downscale(value); + } +} + +contract FeeCurrencyWrapperTest is Test { + using FixidityLib for FixidityLib.Fraction; + + FeeCurrencyWrapperTestContract public feeCurrencyWrapper; + address owner; + address nonOwner; + IFeeCurrency feeCurrency; + + uint256 initialSupply = 10_000; + + function setUp() public virtual { + owner = address(this); + nonOwner = actor("nonOwner"); + + feeCurrencyWrapper = new FeeCurrencyWrapperTestContract(true); + address feeCurrencyAddress = actor("feeCurrency"); + + string memory name = "tokenName"; + string memory symbol = "tN"; + + deployCodeTo( + "CeloERC20.sol:FiatTokenCeloV2_2", + abi.encode(name, symbol, initialSupply), + feeCurrencyAddress + ); + feeCurrency = IFeeCurrency(feeCurrencyAddress); + + feeCurrencyWrapper.initialize(address(feeCurrency), "wrapper", "wr", 18); + } +} + +contract ERC20TokenWrapperTest_Initialize is FeeCurrencyWrapperTest { + function test_ShouldSetDigitDifference() public { + assertEq(feeCurrencyWrapper.digitDifference(), 10**12); + } + + function test_shouldRevertWhenCalledAgain() public { + vm.expectRevert("contract already initialized"); + feeCurrencyWrapper.initialize(address(feeCurrency), "wrapper", "wr", 18); + } +} + +contract ERC20TokenWrapperTest_BalanceOf is FeeCurrencyWrapperTest { + function test_shouldReturnBalanceOf() public { + assertEq(feeCurrency.balanceOf(address(this)), initialSupply); + assertEq(feeCurrencyWrapper.balanceOf(address(this)), initialSupply * 1e12); + } +} + +contract ERC20TokenWrapperTest_DebitGasFees is FeeCurrencyWrapperTest { + function test_shouldDebitGasFees() public { + uint256 amount = 1000 * 1e12; + vm.prank(address(0)); + feeCurrencyWrapper.debitGasFees(address(this), amount); + assertEq(feeCurrency.balanceOf(address(this)), initialSupply - amount / 1e12); + assertEq(feeCurrencyWrapper.balanceOf(address(this)), (initialSupply * 1e12 - amount)); + assertEq(feeCurrencyWrapper.debited(), amount / 1e12); + } + + function test_shouldRevert_WhenNotCalledByVm() public { + vm.expectRevert("Only VM can call"); + feeCurrencyWrapper.debitGasFees(address(this), 1000); + } + +} + +contract ERC20TokenWrapperTest_CreditGasFees is FeeCurrencyWrapperTest { + function test_shouldCreditGasFees() public { + uint256 amount = 1000 * 1e12; + vm.prank(address(0)); + feeCurrencyWrapper.debitGasFees(address(this), amount); + + vm.prank(address(0)); + feeCurrencyWrapper.creditGasFees( + address(this), + address(this), + address(0), + address(this), + amount / 4, + amount / 4, + 0, + amount / 4 + ); + assertEq(feeCurrency.balanceOf(address(this)), initialSupply); + assertEq(feeCurrencyWrapper.balanceOf(address(this)), initialSupply * 1e12); + } + + function test_shouldRevert_WhenTryingToCreditMoreThanBurned() public { + uint256 amount = 1 * 1e12; + vm.prank(address(0)); + feeCurrencyWrapper.debitGasFees(address(this), amount); + + vm.expectRevert("Cannot credit more than debited."); + vm.prank(address(0)); + feeCurrencyWrapper.creditGasFees( + address(this), + address(this), + address(this), + address(this), + 1 ether, + 1 ether, + 1 ether, + 1 ether + ); + } + + function test_shouldRevert_WhenNotCalledByVm() public { + vm.expectRevert("Only VM can call"); + feeCurrencyWrapper.creditGasFees( + address(this), + address(this), + address(this), + address(this), + 1000, + 1000, + 1000, + 1000 + ); + } + + function test_shouldNotRunFunctionBody_WhenDebitedIs0() public { + vm.prank(address(0)); + feeCurrencyWrapper.creditGasFees(new address[](1), new uint256[](2)); + } + + function test_shouldRevert_WhenRecipientsAndAmountsAreDifferentLengths_New() public { + uint256 amount = 1000 * 1e12; + vm.prank(address(0)); + feeCurrencyWrapper.debitGasFees(address(this), amount); + + vm.prank(address(0)); + vm.expectRevert("Recipients and amounts must be the same length."); + feeCurrencyWrapper.creditGasFees(new address[](1), new uint256[](2)); + } + + function test_shouldRevert_WhenNotCalledByVm_New() public { + vm.expectRevert("Only VM can call"); + feeCurrencyWrapper.creditGasFees(new address[](1), new uint256[](2)); + } + + function test_shouldRevert_WhenTryingToCreditMoreThanBurned_New() public { + uint256 amount = 1 * 1e12; + vm.prank(address(0)); + feeCurrencyWrapper.debitGasFees(address(this), amount); + + vm.expectRevert("Cannot credit more than debited."); + vm.prank(address(0)); + address[] memory recipients = new address[](2); + recipients[0] = address(this); + recipients[1] = address(this); + uint256[] memory amounts = new uint256[](2); + amounts[0] = 1 ether; + amounts[1] = 1 ether; + + feeCurrencyWrapper.creditGasFees(recipients, amounts); + } + +} + +contract ERC20TokenWrapperTest_UpscaleAndDownScaleTests is FeeCurrencyWrapperTest { + function test_shouldUpscale() public { + assertEq(feeCurrencyWrapper.upscaleVisible(1), 1e12); + assertEq(feeCurrencyWrapper.upscaleVisible(1e6), 1e18); + assertEq(feeCurrencyWrapper.upscaleVisible(1e12), 1e24); + } + +function test_ShouldRevertUpscale_WhenOverflow() public { + uint256 digitDifference = 10**12; + uint256 maxValue = type(uint256).max; + uint256 boundaryValue = maxValue / digitDifference + 1; + + vm.expectRevert(); + feeCurrencyWrapper.upscaleVisible(boundaryValue); +} + + function test_shouldDownscale() public { + assertEq(feeCurrencyWrapper.downscaleVisible(1e12), 1); + assertEq(feeCurrencyWrapper.downscaleVisible(1e18), 1e6); + assertEq(feeCurrencyWrapper.downscaleVisible(1e24), 1e12); + } + + function test_ShouldReturn0_WhenSmallEnough() public { + assertEq(feeCurrencyWrapper.downscaleVisible(1), 0); + assertEq(feeCurrencyWrapper.downscaleVisible(1e6 - 1), 0); + assertEq(feeCurrencyWrapper.downscaleVisible(1e12 - 1), 0); + } +} \ No newline at end of file