diff --git a/.env.example b/.env.example index 0f0a948d9..267469422 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,22 @@ MASTER_MINTER_OWNER_ADDRESS= # The percentage to multiply gas usage estimations by (eg. 200 to double the estimation). Defaults to 130. GAS_MULTIPLIER=110 +################################ +# Celo Specific Configurations # +################################ + +# [OPTIONAL] The address to a deployed FiatTokenCelo implementation contract. +# FIAT_TOKEN_CELO_IMPLEMENTATION_ADDRESS= + +# [OPTIONAL] The address to a deployed FiatTokenProxy contract for FiatTokenCeloV2_2. Required for Celo Fee Adapter deployment. +# FIAT_TOKEN_CELO_PROXY_ADDRESS= + +# [OPTIONAL] The address of the Fee Adapter Proxy's admin. Required for Celo Fee Adapter deployment. +# FEE_ADAPTER_PROXY_ADMIN_ADDRESS= + +# [OPTIONAL] The number of decimals to scale the USDC contract to. Required for Celo Fee Adapter deployment. +# FEE_ADAPTER_DECIMALS= + ################################ # Miscellaneous Configurations # ################################ @@ -79,3 +95,6 @@ BLACKLIST_FILE_NAME=blacklist.remote.json # [OPTIONAL] The API key to an Etherscan flavor block explorer. # ETHERSCAN_KEY= + +# [OPTIONAL] The number of runs the Solidity optimizers should perform. Defaults to 10000000. +# OPTIMIZER_RUNS= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b25ca126f..1d1baf8e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,11 @@ on: branches: [master] pull_request: +# Celo USDC contracts violate Spurious Dragon with existing configs, so +# we need to lower the number of runs to decrease the contract size. +env: + OPTIMIZER_RUNS: 81250 + jobs: run_ci_tests: runs-on: ubuntu-latest diff --git a/@types/AnyFiatTokenV2Instance.d.ts b/@types/AnyFiatTokenV2Instance.d.ts index ad49b8938..a1457e2b7 100644 --- a/@types/AnyFiatTokenV2Instance.d.ts +++ b/@types/AnyFiatTokenV2Instance.d.ts @@ -19,6 +19,7 @@ import { FiatTokenV2Instance } from "./generated/FiatTokenV2"; import { FiatTokenV2_1Instance } from "./generated/FiatTokenV2_1"; import { FiatTokenV2_2Instance } from "./generated/FiatTokenV2_2"; +import { FiatTokenCeloV2_2Instance } from "./generated/FiatTokenCeloV2_2"; export interface FiatTokenV2_2InstanceExtended extends FiatTokenV2_2Instance { permit?: typeof FiatTokenV2Instance.permit; @@ -27,7 +28,17 @@ export interface FiatTokenV2_2InstanceExtended extends FiatTokenV2_2Instance { cancelAuthorization?: typeof FiatTokenV2Instance.cancelAuthorization; } +export interface FiatTokenCeloV2_2InstanceExtended + extends FiatTokenCeloV2_2Instance { + permit?: typeof FiatTokenV2Instance.permit; + transferWithAuthorization?: typeof FiatTokenV2Instance.transferWithAuthorization; + receiveWithAuthorization?: typeof FiatTokenV2Instance.receiveWithAuthorization; + cancelAuthorization?: typeof FiatTokenV2Instance.cancelAuthorization; + mint: typeof FiatTokenV2Instance.mint; +} + export type AnyFiatTokenV2Instance = | FiatTokenV2Instance | FiatTokenV2_1Instance - | FiatTokenV2_2InstanceExtended; + | FiatTokenV2_2InstanceExtended + | FiatTokenCeloV2_2InstanceExtended; diff --git a/CHANGELOG.md b/CHANGELOG.md index ece046729..1d04f7160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2.2.0, Celo variant (2024-04-08) + +- Add `ICeloGasToken` and `IFiatTokenFeeAdapter` per Celo documentation +- Add `FiatTokenFeeAdapterProxy` and `FiatTokenFeeAdapterV1` to support USDC as + gas on Celo +- Implement `debitGasFees` and `creditGasFees` in `FiatTokenCeloV2_2` + ## 2.2.0 (2023-11-09) - Add ERC-1271 signature validation support to EIP-2612 and EIP-3009 functions diff --git a/README.md b/README.md index 5f14a2283..76407b430 100644 --- a/README.md +++ b/README.md @@ -223,3 +223,4 @@ address. - [Deployment process](./doc/deployment.md) - [Preparing an upgrade](./doc/upgrade.md) - [Upgrading from v2.1 to v2.2](./doc/v2.2_upgrade.md) +- [Celo FiatToken extension](./doc/celo.md) diff --git a/contracts/interface/celo/ICeloGasToken.sol b/contracts/interface/celo/ICeloGasToken.sol new file mode 100644 index 000000000..a9906caa8 --- /dev/null +++ b/contracts/interface/celo/ICeloGasToken.sol @@ -0,0 +1,68 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pragma solidity 0.6.12; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @dev Interface of the Celo gas token standard for contracts + * as defined at https://docs.celo.org/learn/add-gas-currency. + */ +interface ICeloGasToken is IERC20 { + /** + * @notice Reserve balance for making payments for gas in this FiatToken currency. + * @param from The address from which to reserve balance. + * @param value The amount of balance to reserve. + * @dev This function is called by the Celo protocol when paying for transaction fees in this + * currency. After the transaction is executed, unused gas is refunded to the sender and credited + * to the various fee recipients via a call to `creditGasFees`. The events emitted by `creditGasFees` + * reflect the *net* gas fee payments for the transaction. + */ + function debitGasFees(address from, uint256 value) external; + + /** + * @notice Credit balances of original payer and various fee recipients + * after having made payments for gas in the form of this FiatToken currency. + * @param from The original payer address from which balance was reserved via `debitGasFees`. + * @param feeRecipient The main fee recipient address. + * @param gatewayFeeRecipient Gateway address. + * @param communityFund Celo Community Fund address. + * @param refund Amount to be refunded by the VM to `from`. + * @param tipTxFee Amount to distribute to `feeRecipient`. + * @param gatewayFee Amount to distribute to `gatewayFeeRecipient`; this is deprecated and will always be 0. + * @param baseTxFee Amount to distribute to `communityFund`. + * @dev This function is called by the Celo protocol when paying for transaction fees in this + * currency. After the transaction is executed, unused gas is refunded to the sender and credited + * to the various fee recipients via a call to `creditGasFees`. The events emitted by `creditGasFees` + * reflect the *net* gas fee payments for the transaction. As an invariant, the original debited amount + * will always equal (refund + tipTxFee + gatewayFee + baseTxFee). Though the amount debited in debitGasFees + * is always equal to (refund + tipTxFee + gatewayFee + baseTxFee), in practice, the gateway fee is never + * used (0) and should ideally be ignored except in the function signature to optimize gas savings. + */ + function creditGasFees( + address from, + address feeRecipient, + address gatewayFeeRecipient, + address communityFund, + uint256 refund, + uint256 tipTxFee, + uint256 gatewayFee, + uint256 baseTxFee + ) external; +} diff --git a/contracts/interface/celo/IDecimals.sol b/contracts/interface/celo/IDecimals.sol new file mode 100644 index 000000000..68954b616 --- /dev/null +++ b/contracts/interface/celo/IDecimals.sol @@ -0,0 +1,29 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pragma solidity 0.6.12; + +/** + * @dev Interface for a contract, namely a currency token, that + * exposes how many decimals it has. While IERC20 does not define + * a `decimals` field, in practice, almost all standard ERC20s do + * themselves have a `decimals` field. + */ +interface IDecimals { + function decimals() external view returns (uint8); +} diff --git a/contracts/interface/celo/IFiatTokenFeeAdapter.sol b/contracts/interface/celo/IFiatTokenFeeAdapter.sol new file mode 100644 index 000000000..7a1f5f996 --- /dev/null +++ b/contracts/interface/celo/IFiatTokenFeeAdapter.sol @@ -0,0 +1,79 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pragma solidity 0.6.12; + +/** + * @dev Barebones interface of the fee currency adapter standard for + * ERC-20 gas tokens that do not operate with 18 decimals. At a mini- + * mum, an implementation must support balance queries, debiting, and + * crediting to work with the Celo VM. + */ +interface IFiatTokenFeeAdapter { + /** + * @notice Return the balance of the address specified, but this balance + * is scaled appropriately to the number of decimals on this adapter. + * @dev The Celo VM calls balanceOf during its fee calculations on custom + * currencies to ensure that the holder has enough; since the VM debits + * and credits upscaled values, it needs to reference upscaled balances + * as well. See + * https://github.com/celo-org/celo-blockchain/blob/3808c45addf56cf547581599a1cb059bc4ae5089/core/state_transition.go#L321. + */ + function balanceOf(address account) external view returns (uint256); + + /** + * @notice Reserve *adapted* balance for making payments for gas in this FiatToken currency. + * @param from The address from which to reserve balance. + * @param value The amount of balance to reserve. + * @dev This function is called by the Celo protocol when paying for transaction fees in this + * currency. After the transaction is executed, unused gas is refunded to the sender and credited + * to the various fee recipients via a call to `creditGasFees`. The events emitted by `creditGasFees` + * reflect the *net* gas fee payments for the transaction. + */ + function debitGasFees(address from, uint256 value) external; + + /** + * @notice Credit *adapted* balances of original payer and various fee recipients + * after having made payments for gas in the form of this FiatToken currency. + * @param from The original payer address from which balance was reserved via `debitGasFees`. + * @param feeRecipient The main fee recipient address. + * @param gatewayFeeRecipient Gateway address. + * @param communityFund Celo Community Fund address. + * @param refund Amount to be refunded by the VM to `from`. + * @param tipTxFee Amount to distribute to `feeRecipient`. + * @param gatewayFee Amount to distribute to `gatewayFeeRecipient`; this is deprecated and will always be 0. + * @param baseTxFee Amount to distribute to `communityFund`. + * @dev This function is called by the Celo protocol when paying for transaction fees in this + * currency. After the transaction is executed, unused gas is refunded to the sender and credited + * to the various fee recipients via a call to `creditGasFees`. The events emitted by `creditGasFees` + * reflect the *net* gas fee payments for the transaction. As an invariant, the original debited amount + * will always equal (refund + tipTxFee + gatewayFee + baseTxFee). Though the amount debited in debitGasFees + * is always equal to (refund + tipTxFee + gatewayFee + baseTxFee), in practice, the gateway fee is never + * used (0) and should ideally be ignored except in the function signature to optimize gas savings. + */ + function creditGasFees( + address from, + address feeRecipient, + address gatewayFeeRecipient, + address communityFund, + uint256 refund, + uint256 tipTxFee, + uint256 gatewayFee, + uint256 baseTxFee + ) external; +} diff --git a/contracts/test/celo/MockFiatTokenCeloWithExposedFunctions.sol b/contracts/test/celo/MockFiatTokenCeloWithExposedFunctions.sol new file mode 100644 index 000000000..081a6fe8d --- /dev/null +++ b/contracts/test/celo/MockFiatTokenCeloWithExposedFunctions.sol @@ -0,0 +1,47 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pragma solidity 0.6.12; + +import { FiatTokenCeloV2_2 } from "../../v2/celo/FiatTokenCeloV2_2.sol"; + +// solhint-disable func-name-mixedcase + +/** + * @dev This contract is the same as FiatTokenCeloV2_2, except, for testing, + * it allows us to call internal sensitive functions for testing. These + * external test functions are prefixed with "internal_" to differentiate + * them from the main internal functions. + */ +contract MockFiatTokenCeloWithExposedFunctions is FiatTokenCeloV2_2 { + function internal_debitedValue() external view returns (uint256) { + return _debitedValue(); + } + + function internal_transferReservedGas( + address from, + address to, + uint256 value + ) external onlyFeeCaller { + _transferReservedGas(from, to, value); + } + + function internal_setBalance(address account, uint256 balance) external { + _setBalance(account, balance); + } +} diff --git a/contracts/test/celo/MockFiatTokenFeeAdapterWithExposedFunctions.sol b/contracts/test/celo/MockFiatTokenFeeAdapterWithExposedFunctions.sol new file mode 100644 index 000000000..b48aa85d6 --- /dev/null +++ b/contracts/test/celo/MockFiatTokenFeeAdapterWithExposedFunctions.sol @@ -0,0 +1,57 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pragma solidity 0.6.12; + +import { FiatTokenFeeAdapterV1 } from "../../v2/celo/FiatTokenFeeAdapterV1.sol"; + +// solhint-disable func-name-mixedcase + +/** + * @dev This contract is the same as FiatTokenFeeAdapterV1, except, for testing, + * it allows us to call the internal upscaling and downscaling functions and + * allows us to override the call originator on debiting and crediting, as Web3JS + * and Ganache do not allow us to impersonate 0x0 (vm.prank) for tests. + */ +contract MockFiatTokenFeeAdapterWithExposedFunctions is FiatTokenFeeAdapterV1 { + address private _vmCallerAddress; + + modifier onlyCeloVm() override { + require( + msg.sender == _vmCallerAddress, + "FiatTokenFeeAdapterV1: caller is not VM" + ); + _; + } + + function setVmCallerAddress(address newVmCallerAddress) external { + _vmCallerAddress = newVmCallerAddress; + } + + function internal_debitedValue() external view returns (uint256) { + return _debitedValue; + } + + function internal_upscale(uint256 value) external view returns (uint256) { + return _upscale(value); + } + + function internal_downscale(uint256 value) external view returns (uint256) { + return _downscale(value); + } +} diff --git a/contracts/v2/celo/FiatTokenCeloV2_2.sol b/contracts/v2/celo/FiatTokenCeloV2_2.sol new file mode 100644 index 000000000..e7550803a --- /dev/null +++ b/contracts/v2/celo/FiatTokenCeloV2_2.sol @@ -0,0 +1,190 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pragma solidity 0.6.12; + +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; +import { FiatTokenV2_2 } from "../FiatTokenV2_2.sol"; +import { ICeloGasToken } from "../../interface/celo/ICeloGasToken.sol"; + +contract FiatTokenCeloV2_2 is FiatTokenV2_2, ICeloGasToken { + using SafeMath for uint256; + event FeeCallerChanged(address indexed newAddress); + + /** + * @notice Constant containing the storage slot indicating the current fee caller of + * `debitGasFees` and `creditGasFees`. Only the fee caller should be able to represent + * this FiatToken during a gas lifecycle. This slot starts off indicating address(0) + * as the allowed fee caller, as the storage slot is empty. + * @dev This constant is the Keccak-256 hash of "com.circle.fiattoken.celo.feecaller" and is + * validated in the contract constructor. It does not occupy any storage slots, since + * constants are embedded in the bytecode of a smart contract. This is intentionally done + * so that the Celo variant of FiatToken can accommodate new state variables that may be + * added in future FiatToken versions. + */ + bytes32 + private constant FEE_CALLER_SLOT = 0xdca914aef3e4e19727959ebb1e70b58822e2c7b796d303902adc19513fcb4af5; + + /** + * @notice Returns the current fee caller address allowed on `debitGasFees` and `creditGasFees`. + * @dev Though Solidity generates implicit viewers/getters on contract state, because we + * store the fee caller in a custom Keccak256 slot instead of a standard declaration, we + * need an explicit getter for that slot. + */ + function feeCaller() public view returns (address value) { + assembly { + value := sload(FEE_CALLER_SLOT) + } + } + + modifier onlyFeeCaller() virtual { + require( + msg.sender == feeCaller(), + "FiatTokenCeloV2_2: caller is not the fee caller" + ); + _; + } + + /** + * @notice Updates the fee caller address. + * @param _newFeeCaller The address of the new pauser. + */ + function updateFeeCaller(address _newFeeCaller) external onlyOwner { + assembly { + sstore(FEE_CALLER_SLOT, _newFeeCaller) + } + emit FeeCallerChanged(_newFeeCaller); + } + + /** + * @notice Constant containing the storage slot indicating the debited value currently + * reserved for a transaction's gas paid in this token. This value also serves as a flag + * indicating whether a debit is ongoing. Before and after every unique transaction on + * the network, this slot should store a value of zero. + * @dev This constant is the Keccak-256 hash of "com.circle.fiattoken.celo.debit" and is + * validated in the contract constructor. It does not occupy any storage slots, since + * constants are embedded in the bytecode of a smart contract. This is intentionally done + * so that the Celo variant of FiatToken can accommodate new state variables that may be + * added in future FiatToken versions. + */ + bytes32 + private constant DEBITED_VALUE_SLOT = 0xd90dccaa76fe7208f2f477143b6adabfeb5d4a5136982894dfc51177fa8eda28; + + function _debitedValue() internal view returns (uint256 value) { + assembly { + value := sload(DEBITED_VALUE_SLOT) + } + } + + constructor() public { + assert( + DEBITED_VALUE_SLOT == keccak256("com.circle.fiattoken.celo.debit") + ); + assert( + FEE_CALLER_SLOT == keccak256("com.circle.fiattoken.celo.feecaller") + ); + } + + function debitGasFees(address from, uint256 value) + external + override + onlyFeeCaller + whenNotPaused + notBlacklisted(from) + { + require( + _debitedValue() == 0, + "FiatTokenCeloV2_2: Must fully credit before debit" + ); + require(from != address(0), "ERC20: transfer from the zero address"); + + _transferReservedGas(from, address(0), value); + assembly { + sstore(DEBITED_VALUE_SLOT, value) + } + } + + function creditGasFees( + address from, + address feeRecipient, + // solhint-disable-next-line no-unused-vars + address gatewayFeeRecipient, + address communityFund, + uint256 refund, + uint256 tipTxFee, + // solhint-disable-next-line no-unused-vars + uint256 gatewayFee, + uint256 baseTxFee + ) + external + override + onlyFeeCaller + whenNotPaused + notBlacklisted(from) + notBlacklisted(feeRecipient) + notBlacklisted(communityFund) + { + uint256 creditValue = refund.add(tipTxFee).add(baseTxFee); + + // Because the Celo VM follows 1) debit, 2) main execution, and + // 3) credit atomically as part of a single on-chain transaction, + // we must ensure that the credit step attempts to credit pre- + // cisely what was debited prior. + require( + _debitedValue() == creditValue, + "FiatTokenCeloV2_2: Either no debit or mismatched debit" + ); + + // The credit portion of the gas lifecycle can be summarized + // by the three Transfer events emitted here: + // 0x0 to debitee, + _transferReservedGas(address(0), from, creditValue); + // debitee to validator, + _transfer(from, feeRecipient, tipTxFee); + // and debitee to Celo community fund. + _transfer(from, communityFund, baseTxFee); + + // Mark the end of this debit-credit cycle. + assembly { + sstore(DEBITED_VALUE_SLOT, 0) + } + } + + /** + * @dev This function differs from the standard _transfer function in that + * it does *not* check against the from and the to addresses being 0x0. + * This is needed for the usage of 0x0 as the gas intermediary. + * Further, this function validates that _value is > 0. For a comparison, + * see the FiatTokenV1#_transfer function. + */ + function _transferReservedGas( + address _from, + address _to, + uint256 _value + ) internal { + require(_value > 0, "FiatTokenCeloV2_2: Must reserve > 0 gas"); + require( + _value <= _balanceOf(_from), + "ERC20: transfer amount exceeds balance" + ); + + _setBalance(_from, _balanceOf(_from).sub(_value)); + _setBalance(_to, _balanceOf(_to).add(_value)); + emit Transfer(_from, _to, _value); + } +} diff --git a/contracts/v2/celo/FiatTokenFeeAdapterProxy.sol b/contracts/v2/celo/FiatTokenFeeAdapterProxy.sol new file mode 100644 index 000000000..97e4d41f8 --- /dev/null +++ b/contracts/v2/celo/FiatTokenFeeAdapterProxy.sol @@ -0,0 +1,34 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pragma solidity 0.6.12; + +import { + AdminUpgradeabilityProxy +} from "../../upgradeability/AdminUpgradeabilityProxy.sol"; + +/** + * @title FiatTokenFeeAdapterProxy + * @dev This contract proxies FiatTokenFeeAdapter calls and enables FiatTokenFeeAdapter upgrades. + */ +contract FiatTokenFeeAdapterProxy is AdminUpgradeabilityProxy { + constructor(address implementationContract) + public + AdminUpgradeabilityProxy(implementationContract) + {} +} diff --git a/contracts/v2/celo/FiatTokenFeeAdapterV1.sol b/contracts/v2/celo/FiatTokenFeeAdapterV1.sol new file mode 100644 index 000000000..41194effc --- /dev/null +++ b/contracts/v2/celo/FiatTokenFeeAdapterV1.sol @@ -0,0 +1,173 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pragma solidity 0.6.12; + +import { + IFiatTokenFeeAdapter +} from "../../interface/celo/IFiatTokenFeeAdapter.sol"; +import { ICeloGasToken } from "../../interface/celo/ICeloGasToken.sol"; +import { IDecimals } from "../../interface/celo/IDecimals.sol"; +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; + +contract FiatTokenFeeAdapterV1 is IFiatTokenFeeAdapter { + using SafeMath for uint256; + + ICeloGasToken public adaptedToken; + + uint8 internal _initializedVersion; + uint8 public adapterDecimals; + uint8 public tokenDecimals; + uint256 public upscaleFactor; + // This debited value matches the value stored on the + // underlying token and is calculated by downscaling. + uint256 internal _debitedValue = 0; + + modifier onlyCeloVm() virtual { + require( + msg.sender == address(0), + "FiatTokenFeeAdapterV1: caller is not VM" + ); + _; + } + + function initializeV1(address _adaptedToken, uint8 _adapterDecimals) + public + virtual + { + // solhint-disable-next-line reason-string + require(_initializedVersion == 0); + + tokenDecimals = IDecimals(_adaptedToken).decimals(); + require( + tokenDecimals < _adapterDecimals, + "FiatTokenFeeAdapterV1: Token decimals must be < adapter decimals" + ); + require( + // uint256 supports a max value of ~1.1579e77. Having the upscale + // factor be 1e78 would overflow, but SafeMath does not implement + // a `pow` function, so we must be careful here instead. + _adapterDecimals - tokenDecimals < 78, + "FiatTokenFeeAdapterV1: Digit difference too large" + ); + upscaleFactor = uint256(10)**uint256(_adapterDecimals - tokenDecimals); + + adapterDecimals = _adapterDecimals; + adaptedToken = ICeloGasToken(_adaptedToken); + + _initializedVersion = 1; + } + + function balanceOf(address account) + external + override + view + returns (uint256) + { + return _upscale(adaptedToken.balanceOf(account)); + } + + function debitGasFees(address from, uint256 value) + external + override + onlyCeloVm + { + require( + _debitedValue == 0, + "FiatTokenFeeAdapterV1: Must fully credit before debit" + ); + uint256 valueScaled = _downscale(value); + adaptedToken.debitGasFees(from, valueScaled); + _debitedValue = valueScaled; + } + + function creditGasFees( + address refundRecipient, + address feeRecipient, + // solhint-disable-next-line no-unused-vars + address gatewayFeeRecipient, + address communityFund, + uint256 refund, + uint256 tipTxFee, + // solhint-disable-next-line no-unused-vars + uint256 gatewayFee, + uint256 baseTxFee + ) external override onlyCeloVm { + if (_debitedValue == 0) { + // When eth.estimateGas is called, this function is + // called, but we don't want to credit anything, as + // the edge case also violates the invariant. See + // https://github.com/celo-org/celo-blockchain/blob/6388f0ec88fb7a2a82ee41b0e2c9cb7e2cff87e2/internal/ethapi/api.go#L1018-L1022. + return; + } + + uint256 refundScaled = _downscale(refund); + uint256 tipTxFeeScaled = _downscale(tipTxFee); + uint256 baseTxFeeScaled = _downscale(baseTxFee); + uint256 creditValueScaled = refundScaled.add(tipTxFeeScaled).add( + baseTxFeeScaled + ); + + require( + creditValueScaled <= _debitedValue, + "FiatTokenFeeAdapterV1: Cannot credit more than debited" + ); + + // When downscaling, data can be lost, leading to inaccurate sums. + uint256 roundingError = _debitedValue.sub(creditValueScaled); + if (roundingError > 0) { + // In this case, allocate the remainder to the community fund (base fee). + // Instead of allocating to the validator (tipTxFee), we do this to prevent + // the risk of actors gaming the scaling system (even if the actual difference + // is expected to be very small). + baseTxFeeScaled = baseTxFeeScaled.add(roundingError); + } + + adaptedToken.creditGasFees( + refundRecipient, + feeRecipient, + address(0), + communityFund, + refundScaled, + tipTxFeeScaled, + 0, + baseTxFeeScaled + ); + + _debitedValue = 0; + } + + /** + * @notice Upscales a given value to logically have the same number of decimals as this adapter. + * @param value The value to upscale. + * @dev The caller is responsible for preconditions, as uint256 does not provide decimals. + */ + function _upscale(uint256 value) internal view returns (uint256) { + return value.mul(upscaleFactor); + } + + /** + * @notice Downscales a given value consistent with this adapter to its original factor. + * @param value The value to downscale. + * @dev The caller is responsible for preconditions, as uint256 does not provide decimals. + * This downscaling will round down on the division operator. + */ + function _downscale(uint256 value) internal view returns (uint256) { + return value.div(upscaleFactor); + } +} diff --git a/doc/celo.md b/doc/celo.md new file mode 100644 index 000000000..45d3a74f8 --- /dev/null +++ b/doc/celo.md @@ -0,0 +1,54 @@ +# Circle's Celo `FiatToken` design + +This documentation explains the Celo-specific logic implemented by Circle for +supporting `FiatToken` on the [Celo](https://celo.org/) network. + +The overall process for adding a new supported gas token to the Celo network is +provided on +[official documentation](https://docs.celo.org/learn/add-gas-currency), which +covers the token implementation, oracle work required, and the governance +proposal process. + +## Overview of debiting and crediting + +To see the interface, refer to `ICeloGasToken`. The Celo virtual machine calls +`debitGasFees` and `creditGasFees` atomically as part of a transaction from the +core Celo VM's state transition algorithm. See +[the source code](https://github.com/celo-org/celo-blockchain/blob/3808c45addf56cf547581599a1cb059bc4ae5089/core/state_transition.go#L426-L526) +from `celo-org/celo-blockchain`, notably on lines 481 (`payFees`) and 517 +(`distributeTxFees`). + +Only the Celo VM, which calls through `address(0)`, should be able to call +`debitGasFees` and `creditGasFees`, which necessitates the use of a special +modifier, an example of which can be found in +[Celo's monorepo](https://github.com/celo-org/celo-monorepo/blob/fff103a6b5bbdcfe1e8231c2eef20524a748ed07/packages/protocol/contracts/common/CalledByVm.sol#L3). + +## Overview of `FiatTokenFeeAdapter` + +To see the interface, refer to `IFiatTokenFeeAdapter`. The Celo chain supports +using ERC-20 tokens to pay for gas. For ERC-20 tokens that have a decimal field +other than 18, the Celo chain uses the +[FeeCurrencyAdapter](https://github.com/celo-org/celo-monorepo/blob/release/core-contracts/11/packages/protocol/contracts-0.8/stability/FeeCurrencyAdapter.sol) +strategy to ensure that the decimal conversion is fair. The deployed USDC +adapters can be found in +[Celo's official documentation](https://docs.celo.org/protocol/transaction/erc20-transaction-fees#tokens-with-adapters). + +## Deployment + +`FiatTokenCeloV2_2`'s deployment process is the same as the base `FiatTokenV2_2` +[deployment process](./../README.md). Follow all the steps described in the +deployment process, but be sure to run `deploy-fiat-token-celo` script instead +of the base `deploy-fiat-token` script: + +```sh +yarn forge:simulate scripts/deploy/celo/deploy-fiat-token-celo.s.sol --rpc-url +``` + +For the `FiatTokenFeeAdapter` deployment, be sure to fill in the required fields +in the `.env` file. Namely, `FIAT_TOKEN_CELO_PROXY_ADDRESS`, +`FEE_ADAPTER_PROXY_ADMIN_ADDRESS`, and `FEE_ADAPTER_DECIMALS` must be filled. +Then, deploy by running the following command: + +```sh +yarn forge:simulate scripts/deploy/celo/deploy-fee-adapter.s.sol --rpc-url +``` diff --git a/hardhat.config.ts b/hardhat.config.ts index 69ddeecff..3bf1fbbc6 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -51,7 +51,7 @@ const hardhatConfig: HardhatUserConfig = { settings: { optimizer: { enabled: true, - runs: 10000000, + runs: parseInt(process.env.OPTIMIZER_RUNS || "10000000"), }, }, }, diff --git a/scripts/deploy/DeployImpl.sol b/scripts/deploy/DeployImpl.sol index ea4bf00ad..9bff83e6e 100644 --- a/scripts/deploy/DeployImpl.sol +++ b/scripts/deploy/DeployImpl.sol @@ -19,6 +19,9 @@ pragma solidity 0.6.12; import { FiatTokenV2_2 } from "../../contracts/v2/FiatTokenV2_2.sol"; +import { + FiatTokenCeloV2_2 +} from "../../contracts/v2/celo/FiatTokenCeloV2_2.sol"; /** * @notice A utility contract that exposes a re-useable getOrDeployImpl function. @@ -63,4 +66,45 @@ contract DeployImpl { return fiatTokenV2_2; } + + /** + * @notice helper function that either + * 1) deploys the implementation contract if the input is the zero address, or + * 2) loads an instance of an existing contract when input is not the zero address. + * + * @param impl configured of the implementation contract, where address(0) represents a new instance should be deployed + * @return FiatTokenCeloV2_2 newly deployed or loaded instance + */ + function getOrDeployImplCelo(address impl) + internal + returns (FiatTokenCeloV2_2) + { + FiatTokenCeloV2_2 fiatTokenCeloV2_2; + + if (impl == address(0)) { + fiatTokenCeloV2_2 = new FiatTokenCeloV2_2(); + + // Initializing the implementation contract with dummy values here prevents + // the contract from being reinitialized later on with different values. + // Dummy values can be used here as the proxy contract will store the actual values + // for the deployed token. + fiatTokenCeloV2_2.initialize( + "", + "", + "", + 0, + THROWAWAY_ADDRESS, + THROWAWAY_ADDRESS, + THROWAWAY_ADDRESS, + THROWAWAY_ADDRESS + ); + fiatTokenCeloV2_2.initializeV2(""); + fiatTokenCeloV2_2.initializeV2_1(THROWAWAY_ADDRESS); + fiatTokenCeloV2_2.initializeV2_2(new address[](0), ""); + } else { + fiatTokenCeloV2_2 = FiatTokenCeloV2_2(impl); + } + + return fiatTokenCeloV2_2; + } } diff --git a/scripts/deploy/celo/deploy-fee-adapter.s.sol b/scripts/deploy/celo/deploy-fee-adapter.s.sol new file mode 100644 index 000000000..ce242c589 --- /dev/null +++ b/scripts/deploy/celo/deploy-fee-adapter.s.sol @@ -0,0 +1,113 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pragma solidity 0.6.12; + +import "forge-std/console.sol"; // solhint-disable no-global-import, no-console +import { Script } from "forge-std/Script.sol"; +import { + FiatTokenFeeAdapterProxy +} from "../../../contracts/v2/celo/FiatTokenFeeAdapterProxy.sol"; +import { + FiatTokenFeeAdapterV1 +} from "../../../contracts/v2/celo/FiatTokenFeeAdapterV1.sol"; + +/** + * A utility script to directly deploy Celo-specific fee adapter contract with the latest implementation. + * The fee adapter contract sits behind a fee adapter proxy, which is also deployed in this script. + */ +contract DeployFeeAdapter is Script { + address private adapterProxyAdminAddress; + address payable private fiatTokenProxyAddress; + + uint8 private feeAdapterDecimals; + + uint256 private deployerPrivateKey; + + /** + * @notice initialize variables from environment + */ + function setUp() public { + adapterProxyAdminAddress = vm.envAddress( + "FEE_ADAPTER_PROXY_ADMIN_ADDRESS" + ); + fiatTokenProxyAddress = payable( + vm.envAddress("FIAT_TOKEN_CELO_PROXY_ADDRESS") + ); + + feeAdapterDecimals = uint8(vm.envUint("FEE_ADAPTER_DECIMALS")); + + deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + + console.log( + "FEE_ADAPTER_PROXY_ADMIN_ADDRESS: '%s'", + adapterProxyAdminAddress + ); + console.log( + "FIAT_TOKEN_CELO_PROXY_ADDRESS: '%s'", + fiatTokenProxyAddress + ); + console.log("FEE_ADAPTER_DECIMALS: '%s'", feeAdapterDecimals); + } + + /** + * @dev For testing only: splitting deploy logic into an internal function to expose for testing + */ + function _deploy() + internal + returns (FiatTokenFeeAdapterV1, FiatTokenFeeAdapterProxy) + { + vm.startBroadcast(deployerPrivateKey); + + // Deploy the implementation of the fee adapter + FiatTokenFeeAdapterV1 feeAdapter = new FiatTokenFeeAdapterV1(); + // Initialize with some values, we only rely on the implementation + // deployment for delegatecall logic, not for actual state storage. + feeAdapter.initializeV1(fiatTokenProxyAddress, feeAdapterDecimals); + + // Deploy the proxy contract for the fee adapter + FiatTokenFeeAdapterProxy feeAdapterProxy = new FiatTokenFeeAdapterProxy( + address(feeAdapter) + ); + + // Reassign the admin on the adapter proxy, as proxy admins aren't allowed + // to call the fallback (delegate) functions. The call to initializeV1 won't + // work if this isn't done. + feeAdapterProxy.changeAdmin(adapterProxyAdminAddress); + + // Initialize the adapter proxy with proper values. + FiatTokenFeeAdapterV1 proxyAsV1 = FiatTokenFeeAdapterV1( + address(feeAdapterProxy) + ); + proxyAsV1.initializeV1(fiatTokenProxyAddress, feeAdapterDecimals); + + vm.stopBroadcast(); + + return (feeAdapter, feeAdapterProxy); + } + + /** + * @notice main function that will be run by forge + */ + function run() + external + returns (FiatTokenFeeAdapterV1, FiatTokenFeeAdapterProxy) + { + return _deploy(); + } +} diff --git a/scripts/deploy/celo/deploy-fiat-token-celo.s.sol b/scripts/deploy/celo/deploy-fiat-token-celo.s.sol new file mode 100644 index 000000000..8ac1e1739 --- /dev/null +++ b/scripts/deploy/celo/deploy-fiat-token-celo.s.sol @@ -0,0 +1,176 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pragma solidity 0.6.12; + +import "forge-std/console.sol"; // solhint-disable no-global-import, no-console +import { Script } from "forge-std/Script.sol"; +import { DeployImpl } from "../DeployImpl.sol"; +import { FiatTokenProxy } from "../../../contracts/v1/FiatTokenProxy.sol"; +import { + FiatTokenCeloV2_2 +} from "../../../contracts/v2/celo/FiatTokenCeloV2_2.sol"; +import { MasterMinter } from "../../../contracts/minting/MasterMinter.sol"; + +/** + * A utility script to directly deploy Fiat Token contract with the latest implementation + * + * @dev The proxy needs to be deployed before the master minter; the proxy cannot + * be initialized until the master minter is deployed. + */ +contract DeployFiatTokenCelo is Script, DeployImpl { + address private immutable THROWAWAY_ADDRESS = address(1); + + address private impl; + address private masterMinterOwner; + address private proxyAdmin; + address private owner; + address private pauser; + address private blacklister; + address private lostAndFound; + + string private tokenName; + string private tokenSymbol; + string private tokenCurrency; + uint8 private tokenDecimals; + + uint256 private deployerPrivateKey; + + /** + * @notice initialize variables from environment + */ + function setUp() public { + tokenName = vm.envString("TOKEN_NAME"); + tokenSymbol = vm.envString("TOKEN_SYMBOL"); + tokenCurrency = vm.envString("TOKEN_CURRENCY"); + tokenDecimals = uint8(vm.envUint("TOKEN_DECIMALS")); + + impl = vm.envOr("FIAT_TOKEN_CELO_IMPLEMENTATION_ADDRESS", address(0)); + proxyAdmin = vm.envAddress("PROXY_ADMIN_ADDRESS"); + masterMinterOwner = vm.envAddress("MASTER_MINTER_OWNER_ADDRESS"); + owner = vm.envAddress("OWNER_ADDRESS"); + + // Pauser, blacklister, and lost and found addresses can default to owner address + pauser = vm.envOr("PAUSER_ADDRESS", owner); + blacklister = vm.envOr("BLACKLISTER_ADDRESS", owner); + lostAndFound = vm.envOr("LOST_AND_FOUND_ADDRESS", owner); + + deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + + console.log("TOKEN_NAME: '%s'", tokenName); + console.log("TOKEN_SYMBOL: '%s'", tokenSymbol); + console.log("TOKEN_CURRENCY: '%s'", tokenCurrency); + console.log("TOKEN_DECIMALS: '%s'", tokenDecimals); + console.log("FIAT_TOKEN_IMPLEMENTATION_ADDRESS: '%s'", impl); + console.log("PROXY_ADMIN_ADDRESS: '%s'", proxyAdmin); + console.log("MASTER_MINTER_OWNER_ADDRESS: '%s'", masterMinterOwner); + console.log("OWNER_ADDRESS: '%s'", owner); + console.log("PAUSER_ADDRESS: '%s'", pauser); + console.log("BLACKLISTER_ADDRESS: '%s'", blacklister); + console.log("LOST_AND_FOUND_ADDRESS: '%s'", lostAndFound); + } + + /** + * @dev For testing only: splitting deploy logic into an internal function to expose for testing + */ + function _deploy(address _impl) + internal + returns ( + FiatTokenCeloV2_2, + MasterMinter, + FiatTokenProxy + ) + { + vm.startBroadcast(deployerPrivateKey); + + // If there is an existing implementation contract, + // we can simply point the newly deployed proxy contract to it. + // Otherwise, deploy the latest implementation contract code to the network. + FiatTokenCeloV2_2 fiatTokenCeloV2_2 = getOrDeployImplCelo(_impl); + + FiatTokenProxy proxy = new FiatTokenProxy(address(fiatTokenCeloV2_2)); + + // Now that the proxy contract has been deployed, we can deploy the master minter. + MasterMinter masterMinter = new MasterMinter(address(proxy)); + + // Change the master minter to be owned by the master minter owner + masterMinter.transferOwnership(masterMinterOwner); + + // Now that the master minter is set up, we can go back to setting up the proxy and + // implementation contracts. + // Need to change admin first, or the call to initialize won't work + // since admin can only call methods in the proxy, and not forwarded methods + proxy.changeAdmin(proxyAdmin); + + // Do the initial (V1) initialization. + // Note that this takes in the master minter contract's address as the master minter. + // The master minter contract's owner is a separate address. + FiatTokenCeloV2_2 proxyAsV2_2 = FiatTokenCeloV2_2(address(proxy)); + proxyAsV2_2.initialize( + tokenName, + tokenSymbol, + tokenCurrency, + tokenDecimals, + address(masterMinter), + pauser, + blacklister, + owner + ); + + // Do the V2 initialization + proxyAsV2_2.initializeV2(tokenName); + + // Do the V2_1 initialization + proxyAsV2_2.initializeV2_1(lostAndFound); + + // Do the V2_2 initialization + proxyAsV2_2.initializeV2_2(new address[](0), tokenSymbol); + + vm.stopBroadcast(); + + return (fiatTokenCeloV2_2, masterMinter, proxy); + } + + /** + * @dev For testing only: Helper function that runs deploy script with a specific implementation address + */ + function deploy(address _impl) + external + returns ( + FiatTokenCeloV2_2, + MasterMinter, + FiatTokenProxy + ) + { + return _deploy(_impl); + } + + /** + * @notice main function that will be run by forge + */ + function run() + external + returns ( + FiatTokenCeloV2_2, + MasterMinter, + FiatTokenProxy + ) + { + return _deploy(impl); + } +} diff --git a/test/helpers/index.ts b/test/helpers/index.ts index 4874b0896..9e9029087 100644 --- a/test/helpers/index.ts +++ b/test/helpers/index.ts @@ -26,6 +26,7 @@ import { FiatTokenV2_1Instance, FiatTokenV2_2Instance, FiatTokenV2Instance, + FiatTokenCeloV2_2Instance, } from "../../@types/generated"; import _ from "lodash"; @@ -140,7 +141,8 @@ export async function initializeToVersion( | FiatTokenV1_1Instance | FiatTokenV2Instance | FiatTokenV2_1Instance - | FiatTokenV2_2Instance, + | FiatTokenV2_2Instance + | FiatTokenCeloV2_2Instance, version: "1" | "1.1" | "2" | "2.1" | "2.2", fiatTokenOwner: string, lostAndFound: string, diff --git a/test/scripts/deploy/TestUtils.sol b/test/scripts/deploy/TestUtils.sol index e67cf4580..8b5e69122 100644 --- a/test/scripts/deploy/TestUtils.sol +++ b/test/scripts/deploy/TestUtils.sol @@ -26,6 +26,9 @@ import { FiatTokenV1 } from "../../../contracts/v1/FiatTokenV1.sol"; import { AbstractV2Upgrader } from "../../../contracts/v2/upgrader/AbstractV2Upgrader.sol"; +import { + FiatTokenCeloV2_2 +} from "../../../contracts/v2/celo/FiatTokenCeloV2_2.sol"; contract TestUtils is Test { uint256 internal deployerPrivateKey = 1; @@ -47,6 +50,7 @@ contract TestUtils is Test { uint8 internal decimals = 6; string internal tokenName = "USDC"; string internal tokenSymbol = "USDC"; + string internal tokenCurrency = "USD"; string internal blacklistFileName = "test.blacklist.remote.json"; @@ -58,7 +62,7 @@ contract TestUtils is Test { function setUp() public virtual { vm.setEnv("TOKEN_NAME", tokenName); vm.setEnv("TOKEN_SYMBOL", tokenSymbol); - vm.setEnv("TOKEN_CURRENCY", "USD"); + vm.setEnv("TOKEN_CURRENCY", tokenCurrency); vm.setEnv("TOKEN_DECIMALS", "6"); vm.setEnv("DEPLOYER_PRIVATE_KEY", vm.toString(deployerPrivateKey)); vm.setEnv("PROXY_ADMIN_ADDRESS", vm.toString(proxyAdmin)); @@ -79,6 +83,38 @@ contract TestUtils is Test { vm.setEnv("FIAT_TOKEN_PROXY_ADDRESS", vm.toString(address(proxy))); vm.setEnv("BLACKLIST_FILE_NAME", blacklistFileName); + + setUpCelo(); + } + + function setUpCelo() internal { + // Deploy and initialize FiatTokenCeloV2_2 and it's proxy. + vm.startPrank(deployer); + FiatTokenCeloV2_2 celoV2_2 = new FiatTokenCeloV2_2(); + FiatTokenProxy proxy = new FiatTokenProxy(address(celoV2_2)); + FiatTokenCeloV2_2 proxyAsV2_2 = FiatTokenCeloV2_2(address(proxy)); + MasterMinter masterMinter = new MasterMinter(address(proxy)); + masterMinter.transferOwnership(masterMinterOwner); + proxy.changeAdmin(proxyAdmin); + // This is required since the FiatTokenFeeAdapter needs the decimals field. + proxyAsV2_2.initialize( + tokenName, + tokenSymbol, + tokenCurrency, + decimals, + address(masterMinter), + pauser, + blacklister, + owner + ); + proxyAsV2_2.initializeV2(tokenName); + proxyAsV2_2.initializeV2_1(lostAndFound); + proxyAsV2_2.initializeV2_2(new address[](0), tokenSymbol); + vm.stopPrank(); + + vm.setEnv("FIAT_TOKEN_CELO_PROXY_ADDRESS", vm.toString(address(proxy))); + vm.setEnv("FEE_ADAPTER_PROXY_ADMIN_ADDRESS", vm.toString(proxyAdmin)); + vm.setEnv("FEE_ADAPTER_DECIMALS", "18"); } function validateImpl(FiatTokenV1 impl) internal { diff --git a/test/scripts/deploy/celo/deploy-fee-adapter.t.sol b/test/scripts/deploy/celo/deploy-fee-adapter.t.sol new file mode 100644 index 000000000..13057f810 --- /dev/null +++ b/test/scripts/deploy/celo/deploy-fee-adapter.t.sol @@ -0,0 +1,83 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pragma solidity 0.6.12; +pragma experimental ABIEncoderV2; // needed for compiling older solc versions: https://github.com/foundry-rs/foundry/issues/4376 + +import { TestUtils } from "./../TestUtils.sol"; +import { + DeployFiatToken +} from "../../../../scripts/deploy/deploy-fiat-token.s.sol"; +import { + DeployFeeAdapter +} from "../../../../scripts/deploy/celo/deploy-fee-adapter.s.sol"; + +import { FiatTokenProxy } from "../../../../contracts/v1/FiatTokenProxy.sol"; +import { FiatTokenV2_2 } from "../../../../contracts/v2/FiatTokenV2_2.sol"; +import { + FiatTokenCeloV2_2 +} from "../../../../contracts/v2/celo/FiatTokenCeloV2_2.sol"; +import { + FiatTokenFeeAdapterProxy +} from "../../../../contracts/v2/celo/FiatTokenFeeAdapterProxy.sol"; +import { + FiatTokenFeeAdapterV1 +} from "../../../../contracts/v2/celo/FiatTokenFeeAdapterV1.sol"; +import { MasterMinter } from "../../../../contracts/minting/MasterMinter.sol"; + +// solhint-disable func-name-mixedcase + +contract DeployFeeAdapterTest is TestUtils { + DeployFeeAdapter private deployScript; + + function setUp() public override { + TestUtils.setUp(); + + vm.prank(deployer); + deployScript = new DeployFeeAdapter(); + deployScript.setUp(); + } + + function test_deployFeeAdapter() public { + ( + FiatTokenFeeAdapterV1 v1, + FiatTokenFeeAdapterProxy proxy + ) = deployScript.run(); + + validateImpl(v1); + validateProxy(proxy, address(v1)); + } + + function validateImpl(FiatTokenFeeAdapterV1 impl) internal { + assert(impl.adapterDecimals() == 18); + assert(impl.tokenDecimals() == 6); + assert(impl.upscaleFactor() == 1000000000000); + } + + function validateProxy(FiatTokenFeeAdapterProxy proxy, address impl) + internal + { + assertEq(proxy.admin(), proxyAdmin); + assertEq(proxy.implementation(), impl); + + FiatTokenFeeAdapterV1 proxyAsV1 = FiatTokenFeeAdapterV1(address(proxy)); + assert(proxyAsV1.adapterDecimals() == 18); + assert(proxyAsV1.tokenDecimals() == 6); + assert(proxyAsV1.upscaleFactor() == 1000000000000); + } +} diff --git a/test/scripts/deploy/celo/deploy-fiat-token-celo.t.sol b/test/scripts/deploy/celo/deploy-fiat-token-celo.t.sol new file mode 100644 index 000000000..c222b14a6 --- /dev/null +++ b/test/scripts/deploy/celo/deploy-fiat-token-celo.t.sol @@ -0,0 +1,86 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pragma solidity 0.6.12; +pragma experimental ABIEncoderV2; // needed for compiling older solc versions: https://github.com/foundry-rs/foundry/issues/4376 + +import { TestUtils } from "./../TestUtils.sol"; +import { + DeployFiatTokenCelo +} from "../../../../scripts/deploy/celo/deploy-fiat-token-celo.s.sol"; +import { MasterMinter } from "../../../../contracts/minting/MasterMinter.sol"; +import { FiatTokenProxy } from "../../../../contracts/v1/FiatTokenProxy.sol"; +import { + FiatTokenCeloV2_2 +} from "../../../../contracts/v2/celo/FiatTokenCeloV2_2.sol"; + +// solhint-disable func-name-mixedcase + +contract DeployFiatTokenCeloTest is TestUtils { + DeployFiatTokenCelo private deployScript; + + function setUp() public override { + TestUtils.setUp(); + + vm.prank(deployer); + deployScript = new DeployFiatTokenCelo(); + deployScript.setUp(); + } + + function test_deployFiatTokenWithEnvConfigured() public { + ( + FiatTokenCeloV2_2 v2_2, + MasterMinter masterMinter, + FiatTokenProxy proxy + ) = deployScript.run(); + + validateImpl(v2_2); + validateMasterMinter(masterMinter, address(proxy)); + validateProxy(proxy, address(v2_2), address(masterMinter)); + } + + function test_deployFiatTokenWithPredeployedImpl() public { + vm.prank(deployer); + FiatTokenCeloV2_2 predeployedImpl = new FiatTokenCeloV2_2(); + + (, MasterMinter masterMinter, FiatTokenProxy proxy) = deployScript + .deploy(address(predeployedImpl)); + + validateMasterMinter(masterMinter, address(proxy)); + validateProxy(proxy, address(predeployedImpl), address(masterMinter)); + } + + function validateProxy( + FiatTokenProxy proxy, + address _impl, + address _masterMinter + ) internal { + assertEq(proxy.admin(), proxyAdmin); + assertEq(proxy.implementation(), _impl); + + FiatTokenCeloV2_2 proxyAsV2_2 = FiatTokenCeloV2_2(address(proxy)); + assertEq(proxyAsV2_2.name(), "USDC"); + assertEq(proxyAsV2_2.symbol(), "USDC"); + assertEq(proxyAsV2_2.currency(), "USD"); + assert(proxyAsV2_2.decimals() == 6); + assertEq(proxyAsV2_2.owner(), owner); + assertEq(proxyAsV2_2.pauser(), pauser); + assertEq(proxyAsV2_2.blacklister(), blacklister); + assertEq(proxyAsV2_2.masterMinter(), _masterMinter); + } +} diff --git a/test/v2/FiatTokenV2_2.test.ts b/test/v2/FiatTokenV2_2.test.ts index 2492c39e5..45e95de7f 100644 --- a/test/v2/FiatTokenV2_2.test.ts +++ b/test/v2/FiatTokenV2_2.test.ts @@ -20,6 +20,7 @@ import BN from "bn.js"; import { AnyFiatTokenV2Instance, FiatTokenV2_2InstanceExtended, + FiatTokenCeloV2_2InstanceExtended, } from "../../@types/AnyFiatTokenV2Instance"; import { expectRevert, @@ -236,7 +237,7 @@ describe("FiatTokenV2_2", () => { * here we re-assign the overloaded method definition to the method name shorthand. */ export function initializeOverloadedMethods( - fiatToken: FiatTokenV2_2InstanceExtended, + fiatToken: FiatTokenV2_2InstanceExtended | FiatTokenCeloV2_2InstanceExtended, signatureBytesType: SignatureBytesType ): void { if (signatureBytesType == SignatureBytesType.Unpacked) { diff --git a/test/v2/celo/FiatTokenCeloV2_2.test.ts b/test/v2/celo/FiatTokenCeloV2_2.test.ts new file mode 100644 index 000000000..209bae9cf --- /dev/null +++ b/test/v2/celo/FiatTokenCeloV2_2.test.ts @@ -0,0 +1,159 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { usesOriginalStorageSlotPositions } from "../../helpers/storageSlots.behavior"; + +import { + expectRevert, + initializeToVersion, + linkLibraryToTokenContract, +} from "../../helpers"; +import { behavesLikeFiatTokenV2 } from "./../v2.behavior"; +import { behavesLikeFiatTokenV22 } from "./../v2_2.behavior"; +import { initializeOverloadedMethods } from "../FiatTokenV2_2.test"; +import { SignatureBytesType } from "../GasAbstraction/helpers"; +import { + AnyFiatTokenV2Instance, + FiatTokenCeloV2_2InstanceExtended, +} from "../../../@types/AnyFiatTokenV2Instance"; +import { FeeCallerChanged } from "../../../@types/generated/FiatTokenCeloV2_2"; +import { HARDHAT_ACCOUNTS } from "../../helpers/constants"; + +const FiatTokenCeloV2_2 = artifacts.require("FiatTokenCeloV2_2"); + +// See FiatTokenCeloV2_2#FEE_CALLER_SLOT. +async function getFeeCaller(fiatTokenCelo: FiatTokenCeloV2_2InstanceExtended) { + const feeCaller = await web3.eth.getStorageAt( + fiatTokenCelo.address, + "0xdca914aef3e4e19727959ebb1e70b58822e2c7b796d303902adc19513fcb4af5" + ); + return web3.utils.toChecksumAddress("0x" + feeCaller.slice(26)); +} + +describe("FiatTokenCeloV2_2", () => { + const fiatTokenOwner = HARDHAT_ACCOUNTS[9]; + const lostAndFound = HARDHAT_ACCOUNTS[2]; + const from = HARDHAT_ACCOUNTS[1]; + const feeRecipient = HARDHAT_ACCOUNTS[5]; + const gatewayFeeRecipient = HARDHAT_ACCOUNTS[5]; + const communityFund = HARDHAT_ACCOUNTS[5]; + const feeCaller = fiatTokenOwner; + + let fiatTokenCelo: FiatTokenCeloV2_2InstanceExtended; + + const getFiatToken = ( + signatureBytesType: SignatureBytesType + ): (() => AnyFiatTokenV2Instance) => { + return () => { + initializeOverloadedMethods(fiatTokenCelo, signatureBytesType); + return fiatTokenCelo; + }; + }; + + before(async () => { + await linkLibraryToTokenContract(FiatTokenCeloV2_2); + }); + + beforeEach(async () => { + fiatTokenCelo = await FiatTokenCeloV2_2.new(); + await initializeToVersion( + fiatTokenCelo, + "2.2", + fiatTokenOwner, + lostAndFound + ); + // Ensure we set the debit/credit fee caller for testing. + await fiatTokenCelo.updateFeeCaller(feeCaller, { from: fiatTokenOwner }); + }); + + describe("initialized FiatTokenCeloV2_2 contract", async () => { + await fiatTokenCelo.initializeV2_2([], "CELOUSDC"); + + behavesLikeFiatTokenV22(getFiatToken(SignatureBytesType.Unpacked)); + behavesLikeFiatTokenV2(2.2, getFiatToken(SignatureBytesType.Packed)); + // Verify that the Celo interface and implementation + // has not interfered with existing storage slots. + // DEBITED_MUTEX_SLOT should be statically embedded. + usesOriginalStorageSlotPositions({ + Contract: FiatTokenCeloV2_2, + version: 2.2, + }); + }); + + describe("onlyFeeCaller", () => { + const errorMessage = "FiatTokenCeloV2_2: caller is not the fee caller"; + + it("should fail to call debitGasFees when caller is not fee caller", async () => { + await expectRevert( + fiatTokenCelo.debitGasFees(from, 1, { from: from }), + errorMessage + ); + }); + + it("should fail to call creditGasFees when caller is not fee caller", async () => { + await expectRevert( + fiatTokenCelo.creditGasFees( + from, + feeRecipient, + gatewayFeeRecipient, + communityFund, + 1, + 1, + 1, + 1, + { from: from } + ), + errorMessage + ); + }); + }); + + describe("feeCaller", () => { + it("should return correct feeCaller", async () => { + expect(await fiatTokenCelo.feeCaller()) + .to.equal(feeCaller) + .to.equal( + web3.utils.toChecksumAddress(await getFeeCaller(fiatTokenCelo)) + ); + }); + }); + + describe("updateFeeCaller", () => { + it("should fail to update fee caller when sender is not token owner", async () => { + const ownableError = "Ownable: caller is not the owner"; + await expectRevert( + fiatTokenCelo.updatePauser(from, { from: from }), + ownableError + ); + }); + + it("should emit FeeCallerChanged event", async () => { + const newFeeCaller = communityFund; + const updateFeeCallerEvent = await fiatTokenCelo.updateFeeCaller( + newFeeCaller, + { from: fiatTokenOwner } + ); + + const log = updateFeeCallerEvent.logs[0] as Truffle.TransactionLog< + FeeCallerChanged + >; + assert.strictEqual(log.event, "FeeCallerChanged"); + assert.strictEqual(log.args.newAddress, newFeeCaller); + }); + }); +}); diff --git a/test/v2/celo/FiatTokenFeeAdapterV1.test.ts b/test/v2/celo/FiatTokenFeeAdapterV1.test.ts new file mode 100644 index 000000000..a7ff6d9e8 --- /dev/null +++ b/test/v2/celo/FiatTokenFeeAdapterV1.test.ts @@ -0,0 +1,147 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const FiatTokenFeeAdapterProxy = artifacts.require("FiatTokenFeeAdapterProxy"); +const FiatTokenFeeAdapterV1 = artifacts.require("FiatTokenFeeAdapterV1"); +const FiatTokenCeloV2_2 = artifacts.require("FiatTokenCeloV2_2"); + +import { BN } from "ethereumjs-util"; +import { FiatTokenFeeAdapterV1Instance } from "../../../@types/generated"; +import { FiatTokenCeloV2_2Instance } from "../../../@types/generated/FiatTokenCeloV2_2"; +import { + expectRevert, + initializeToVersion, + linkLibraryToTokenContract, +} from "../../helpers"; +import { HARDHAT_ACCOUNTS } from "../../helpers/constants"; + +describe("FiatTokenFeeAdapterV1", () => { + const tokenOwner = HARDHAT_ACCOUNTS[0]; + const adapterProxyAdmin = HARDHAT_ACCOUNTS[14]; + const additionalDecimals = 12; + + let feeAdapter: FiatTokenFeeAdapterV1Instance; + let fiatToken: FiatTokenCeloV2_2Instance; + + let tokenDecimals: number, adapterDecimals: number; + + before(async () => { + await linkLibraryToTokenContract(FiatTokenCeloV2_2); + }); + + beforeEach(async () => { + fiatToken = await FiatTokenCeloV2_2.new(); + await initializeToVersion(fiatToken, "2.2", tokenOwner, tokenOwner); + + tokenDecimals = (await fiatToken.decimals()).toNumber(); + adapterDecimals = tokenDecimals + additionalDecimals; + + const adapterImplementation = await FiatTokenFeeAdapterV1.new(); + const adapterProxy = await FiatTokenFeeAdapterProxy.new( + adapterImplementation.address + ); + await adapterProxy.changeAdmin(adapterProxyAdmin); + feeAdapter = await FiatTokenFeeAdapterV1.at(adapterProxy.address); + }); + + describe("onlyCeloVm", () => { + const errorMessage = "FiatTokenFeeAdapterV1: caller is not VM"; + + it("should fail to call debitGasFees when caller is not 0x0", async () => { + await feeAdapter.initializeV1(fiatToken.address, adapterDecimals); + await expectRevert(feeAdapter.debitGasFees(tokenOwner, 1), errorMessage); + }); + + it("should fail to call creditGasFees when caller is not 0x0", async () => { + await feeAdapter.initializeV1(fiatToken.address, adapterDecimals); + await expectRevert( + feeAdapter.creditGasFees( + tokenOwner, + tokenOwner, + tokenOwner, + tokenOwner, + 1, + 1, + 1, + 1 + ), + errorMessage + ); + }); + }); + + describe("initializeV1", () => { + const decimalsError = + "FiatTokenFeeAdapterV1: Token decimals must be < adapter decimals"; + const digitDifferenceError = + "FiatTokenFeeAdapterV1: Digit difference too large"; + + it("should fail to initialize again", async () => { + await feeAdapter.initializeV1(fiatToken.address, adapterDecimals); + await expectRevert(feeAdapter.initializeV1(fiatToken.address, 1)); + }); + + it("should fail to initialize if digit difference can overflow", async () => { + await expectRevert( + feeAdapter.initializeV1( + fiatToken.address, + (await fiatToken.decimals()).add(new BN(100)) + ), + digitDifferenceError + ); + }); + + it("should fail when token has same decimals as adapter (redundant)", async () => { + await expectRevert( + feeAdapter.initializeV1( + fiatToken.address, + (await fiatToken.decimals()).toNumber() + ), + decimalsError + ); + }); + + it("should fail when token has more decimals than adapter", async () => { + await expectRevert( + feeAdapter.initializeV1( + fiatToken.address, + (await fiatToken.decimals()).toNumber() - 1 + ), + decimalsError + ); + }); + }); + + describe("balanceOf", () => { + it("should return upscaled balance", async () => { + await feeAdapter.initializeV1(fiatToken.address, adapterDecimals); + + const value = 1e6; + await fiatToken.configureMinter(tokenOwner, value); + await fiatToken.mint(tokenOwner, value, { from: tokenOwner }); + expect((await fiatToken.balanceOf(tokenOwner)).toNumber()).to.equal( + value + ); + expect((await feeAdapter.balanceOf(tokenOwner)).toString()).to.equal( + // The adapter should have padded the balance by the additional number + // of decimals needed (adapter decimals - token decimals). + (value * 10 ** additionalDecimals).toString() + ); + }); + }); +}); diff --git a/test/v2/celo/MockFiatTokenCeloWithExposedFunctions.test.ts b/test/v2/celo/MockFiatTokenCeloWithExposedFunctions.test.ts new file mode 100644 index 000000000..17c0acaee --- /dev/null +++ b/test/v2/celo/MockFiatTokenCeloWithExposedFunctions.test.ts @@ -0,0 +1,392 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + expectRevert, + initializeToVersion, + linkLibraryToTokenContract, +} from "../../helpers"; + +const MockFiatTokenCeloWithExposedFunctions = artifacts.require( + "MockFiatTokenCeloWithExposedFunctions" +); + +import { MockFiatTokenCeloWithExposedFunctionsInstance } from "../../../@types/generated"; +import { + HARDHAT_ACCOUNTS, + MAX_UINT256_BN, + POW_2_255_MINUS1_HEX, + ZERO_ADDRESS, +} from "../../helpers/constants"; + +describe("MockFiatTokenCeloWithExposedFunctions", () => { + // See FiatTokenCeloV2_2#DEBITED_VALUE_SLOT. + const debitedValueSlot = + "0xd90dccaa76fe7208f2f477143b6adabfeb5d4a5136982894dfc51177fa8eda28"; + + const fiatTokenOwner = HARDHAT_ACCOUNTS[0]; + const lostAndFound = HARDHAT_ACCOUNTS[1]; + const from = HARDHAT_ACCOUNTS[2]; + const feeRecipient = HARDHAT_ACCOUNTS[3]; + const gatewayFeeRecipient = HARDHAT_ACCOUNTS[4]; + const communityFund = HARDHAT_ACCOUNTS[5]; + // For these tests, explicitly declare blacklister, master minter, and pauser + // variables, even though they'll be initialized to the same address as the owner. + const blacklister = fiatTokenOwner; + const masterMinter = fiatTokenOwner; + const pauser = fiatTokenOwner; + const feeCaller = fiatTokenOwner; + + const debitAmount = 1e6; + + const pausedError = "Pausable: paused"; + const blacklistedError = "Blacklistable: account is blacklisted"; + const debitInvariantError = + "FiatTokenCeloV2_2: Must fully credit before debit"; + const creditInvariantError = + "FiatTokenCeloV2_2: Either no debit or mismatched debit"; + const additionOverflowError = "SafeMath: addition overflow"; + const transferFromZeroError = "ERC20: transfer from the zero address"; + + let fiatToken: MockFiatTokenCeloWithExposedFunctionsInstance; + + before(async () => { + await linkLibraryToTokenContract(MockFiatTokenCeloWithExposedFunctions); + }); + + beforeEach(async () => { + fiatToken = await MockFiatTokenCeloWithExposedFunctions.new(); + await initializeToVersion(fiatToken, "2.2", fiatTokenOwner, lostAndFound); + + // Configure some minter allowance (1M) ahead of time. + const mintAllowance = 1000000e6; + await fiatToken.configureMinter(feeCaller, mintAllowance, { + from: masterMinter, + }); + // Set the from address up with some funding. + await fiatToken.mint(from, 1000e6, { from: feeCaller }); + + await fiatToken.updateFeeCaller(feeCaller); + }); + + describe("debitGasFees", () => { + it("should debit with correct fee caller address", async () => { + const balanceFrom = await fiatToken.balanceOf(from); + + await fiatToken.debitGasFees(from, debitAmount, { from: feeCaller }); + expect((await fiatToken.balanceOf(from)).toNumber()).to.equal( + balanceFrom.toNumber() - debitAmount + ); + expect((await fiatToken.balanceOf(ZERO_ADDRESS)).toNumber()).to.equal( + debitAmount + ); + expect( + web3.utils.hexToNumber( + await web3.eth.getStorageAt(fiatToken.address, debitedValueSlot) + ) + ) + .to.equal(debitAmount) + .to.equal((await fiatToken.internal_debitedValue()).toNumber()); + }); + + it("should fail to debit again with ongoing debit", async () => { + await fiatToken.debitGasFees(from, debitAmount, { from: feeCaller }); + await expectRevert( + fiatToken.debitGasFees(from, debitAmount, { from: feeCaller }), + debitInvariantError + ); + }); + + it("should fail to debit from the zero address", async () => { + await expectRevert( + fiatToken.debitGasFees(ZERO_ADDRESS, debitAmount, { from: feeCaller }), + transferFromZeroError + ); + }); + + it("should fail when contract is paused", async () => { + await fiatToken.pause({ from: pauser }); + await expectRevert( + fiatToken.debitGasFees(from, debitAmount, { from: feeCaller }), + pausedError + ); + }); + + it("should fail when `from` is blacklisted", async () => { + await fiatToken.blacklist(from, { from: blacklister }); + await expectRevert( + fiatToken.debitGasFees(from, debitAmount, { from: feeCaller }), + blacklistedError + ); + }); + }); + + describe("creditGasFees", () => { + const tipTxFee = 1e5; + // Invariant: the network does not actually make use of the gateway fee. + const gatewayFee = 0; + const baseTxFee = 3e5; + + // The original debit always equals refund + tipTxFee + gatewayFee + baseTxFee. + const refund: number = debitAmount - tipTxFee - gatewayFee - baseTxFee; + + it("should credit after debit with correct fee caller address", async () => { + const fromBalancePre = (await fiatToken.balanceOf(from)).toNumber(); + const feeRecipientBalancePre = ( + await fiatToken.balanceOf(feeRecipient) + ).toNumber(); + const gatewayFeeRecipientBalancePre = ( + await fiatToken.balanceOf(gatewayFeeRecipient) + ).toNumber(); + const communityFundBalancePre = ( + await fiatToken.balanceOf(communityFund) + ).toNumber(); + + // In practice, this debiting and crediting sequence should always be atomic + // within the Celo VM core state transition code itself. See L481 and L517 at + // https://github.com/celo-org/celo-blockchain/blob/3808c45addf56cf547581599a1cb059bc4ae5089/core/state_transition.go#L426-L526. + await fiatToken.debitGasFees(from, debitAmount, { from: feeCaller }); + expect( + web3.utils.hexToNumber( + await web3.eth.getStorageAt(fiatToken.address, debitedValueSlot) + ) + ) + .to.equal(debitAmount) + .to.equal((await fiatToken.internal_debitedValue()).toNumber()); + + await fiatToken.creditGasFees( + from, + feeRecipient, + gatewayFeeRecipient, + communityFund, + refund, + tipTxFee, + gatewayFee, + baseTxFee, + { from: feeCaller } + ); + expect( + web3.utils.hexToNumber( + await web3.eth.getStorageAt(fiatToken.address, debitedValueSlot) + ) + ) + .to.equal(0) + .to.equal((await fiatToken.internal_debitedValue()).toNumber()); + const fromBalancePost = (await fiatToken.balanceOf(from)).toNumber(); + const feeRecipientBalancePost = ( + await fiatToken.balanceOf(feeRecipient) + ).toNumber(); + const gatewayFeeRecipientBalancePost = ( + await fiatToken.balanceOf(gatewayFeeRecipient) + ).toNumber(); + const communityFundBalancePost = ( + await fiatToken.balanceOf(communityFund) + ).toNumber(); + + expect(fromBalancePost).to.equal(fromBalancePre - debitAmount + refund); + expect(feeRecipientBalancePost).to.equal( + feeRecipientBalancePre + tipTxFee + ); + expect(gatewayFeeRecipientBalancePost).to.equal( + gatewayFeeRecipientBalancePre + gatewayFee + ); + expect(communityFundBalancePost).to.equal( + communityFundBalancePre + baseTxFee + ); + }); + + it("should fail to credit with mismatched debit amount", async () => { + // (_debitedValue != refund + tipTxFee + gatewayFee + baseTxFee) + await fiatToken.debitGasFees(from, debitAmount, { from: feeCaller }); + await expectRevert( + fiatToken.creditGasFees( + from, + feeRecipient, + gatewayFeeRecipient, + communityFund, + // Mess with the refund here to break the invariant. + refund - 1, + tipTxFee, + gatewayFee, + baseTxFee, + { from: feeCaller } + ), + creditInvariantError + ); + }); + + it("should fail to credit with no ongoing debit", async () => { + // (_debitedValue = 0) + await expectRevert( + fiatToken.creditGasFees( + from, + feeRecipient, + gatewayFeeRecipient, + communityFund, + refund, + tipTxFee, + gatewayFee, + baseTxFee, + { from: feeCaller } + ), + creditInvariantError + ); + }); + + it("should fail to credit when total amount can overflow (SafeMath)", async () => { + await fiatToken.debitGasFees(from, debitAmount, { from: feeCaller }); + await expectRevert( + fiatToken.creditGasFees( + from, + feeRecipient, + gatewayFeeRecipient, + communityFund, + MAX_UINT256_BN, + MAX_UINT256_BN, + MAX_UINT256_BN, + MAX_UINT256_BN, + { from: feeCaller } + ), + additionOverflowError + ); + }); + + it("should fail when contract is paused", async () => { + await fiatToken.debitGasFees(from, debitAmount, { from: feeCaller }); + await fiatToken.pause({ from: pauser }); + await expectRevert( + fiatToken.creditGasFees( + from, + feeRecipient, + gatewayFeeRecipient, + communityFund, + refund, + tipTxFee, + gatewayFee, + baseTxFee, + { from: feeCaller } + ), + pausedError + ); + }); + + it("should fail when `from` is blacklisted", async () => { + await fiatToken.debitGasFees(from, debitAmount, { from: feeCaller }); + await fiatToken.blacklist(from, { from: blacklister }); + await expectRevert( + fiatToken.creditGasFees( + from, + feeRecipient, + gatewayFeeRecipient, + communityFund, + refund, + tipTxFee, + gatewayFee, + baseTxFee, + { from: feeCaller } + ), + blacklistedError + ); + }); + + it("should fail when `feeRecipient` is blacklisted", async () => { + await fiatToken.debitGasFees(from, debitAmount, { from: feeCaller }); + await fiatToken.blacklist(feeRecipient, { from: blacklister }); + await expectRevert( + fiatToken.creditGasFees( + from, + feeRecipient, + gatewayFeeRecipient, + communityFund, + refund, + tipTxFee, + gatewayFee, + baseTxFee, + { from: feeCaller } + ), + blacklistedError + ); + }); + + it("should fail when `communityFund` is blacklisted", async () => { + await fiatToken.debitGasFees(from, debitAmount, { from: feeCaller }); + await fiatToken.blacklist(communityFund, { from: blacklister }); + await expectRevert( + fiatToken.creditGasFees( + from, + feeRecipient, + gatewayFeeRecipient, + communityFund, + refund, + tipTxFee, + gatewayFee, + baseTxFee, + { from: feeCaller } + ), + blacklistedError + ); + }); + }); + + describe("_transferReservedGas", () => { + const zeroGasError = "FiatTokenCeloV2_2: Must reserve > 0 gas"; + const balanceExceededError = "FiatTokenV2_2: Balance exceeds (2^255 - 1)"; + const balanceInsufficientError = "ERC20: transfer amount exceeds balance"; + + const validTransferAmt = 1e4; + + it("should fail when 0 reserved gas is requested", async () => { + await expectRevert( + fiatToken.internal_transferReservedGas(from, ZERO_ADDRESS, 0, { + from: feeCaller, + }), + zeroGasError + ); + }); + + it("should fail when _to's balance will exceed 2^255 - 1", async () => { + // Granting enough balance to feeCaller + await fiatToken.internal_setBalance(feeCaller, POW_2_255_MINUS1_HEX); + await fiatToken.internal_setBalance(from, validTransferAmt); + + await expectRevert( + fiatToken.internal_transferReservedGas( + feeCaller, + from, + POW_2_255_MINUS1_HEX, + { from: feeCaller } + ), + balanceExceededError + ); + }); + + it("should fail when _from's balance < _value", async () => { + await expectRevert( + fiatToken.internal_transferReservedGas( + feeCaller, + from, + (await fiatToken.balanceOf(feeCaller)).toNumber() + 100, + { + from: feeCaller, + } + ), + balanceInsufficientError + ); + }); + }); +}); diff --git a/test/v2/celo/MockFiatTokenFeeAdapterWithExposedFunctions.test.ts b/test/v2/celo/MockFiatTokenFeeAdapterWithExposedFunctions.test.ts new file mode 100644 index 000000000..7a9dcf71f --- /dev/null +++ b/test/v2/celo/MockFiatTokenFeeAdapterWithExposedFunctions.test.ts @@ -0,0 +1,246 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const MockFiatTokenFeeAdapterWithExposedFunctions = artifacts.require( + "MockFiatTokenFeeAdapterWithExposedFunctions" +); +const FiatTokenCeloV2_2 = artifacts.require("FiatTokenCeloV2_2"); + +import { BN } from "ethereumjs-util"; +import { MockFiatTokenFeeAdapterWithExposedFunctionsInstance } from "../../../@types/generated"; +import { FiatTokenCeloV2_2Instance } from "../../../@types/generated/FiatTokenCeloV2_2"; +import { + expectRevert, + initializeToVersion, + linkLibraryToTokenContract, +} from "../../helpers"; +import { + HARDHAT_ACCOUNTS, + MAX_UINT256_BN, + POW_2_255_MINUS1_HEX, + ZERO_ADDRESS, +} from "../../helpers/constants"; + +describe("MockFiatTokenFeeAdapterWithExposedFunctions", () => { + const vm = HARDHAT_ACCOUNTS[0]; + // BN doesn't play nicely with scientific notation, so use string constructors instead. + // This debit amount will be downscaled appropriately to the token. + const debitAmount = new BN("1000000000000000000"); // 1e18 + const mintAmount = new BN("1000000000000000000000000"); // 1e24 + + const owner = HARDHAT_ACCOUNTS[1]; + const from = HARDHAT_ACCOUNTS[2]; + + const additionalDecimals = 12; + + let feeAdapter: MockFiatTokenFeeAdapterWithExposedFunctionsInstance; + let fiatToken: FiatTokenCeloV2_2Instance; + + let tokenDecimals: number, adapterDecimals: number; + + before(async () => { + await linkLibraryToTokenContract(FiatTokenCeloV2_2); + }); + + beforeEach(async () => { + fiatToken = await FiatTokenCeloV2_2.new(); + // This initializes with 6 decimals. + await initializeToVersion(fiatToken, "2.2", owner, owner); + await fiatToken.configureMinter(owner, POW_2_255_MINUS1_HEX, { + from: owner, + }); + // Set up some funds ahead of time. + await fiatToken.mint(from, mintAmount, { from: owner }); + + tokenDecimals = (await fiatToken.decimals()).toNumber(); + adapterDecimals = tokenDecimals + additionalDecimals; + + // Set up the adapter. + feeAdapter = await MockFiatTokenFeeAdapterWithExposedFunctions.new(); + await feeAdapter.initializeV1(fiatToken.address, adapterDecimals); + await feeAdapter.setVmCallerAddress(vm); + + // Make sure we can actually start the call chain through the adapter. + await fiatToken.updateFeeCaller(feeAdapter.address, { from: owner }); + }); + + describe("_upscale", () => { + it("should return proper upscaled value", async () => { + const valueUpscaled = await feeAdapter.internal_upscale(1 ** 0); + expect(valueUpscaled.toNumber()).to.equal(10 ** additionalDecimals); + }); + + it("should revert upscaling when overflowed", async () => { + await expectRevert( + feeAdapter.internal_upscale(MAX_UINT256_BN), + "SafeMath: multiplication overflow" + ); + }); + }); + + describe("_downscale", () => { + it("should return proper downscaled value", async () => { + // Web3JS doesn't play nice with very large raw number variables; + // passing a nonstring here will result in an error. + const value = (10 ** adapterDecimals).toString(); + const valueDownscaled = await feeAdapter.internal_downscale(value); + expect(valueDownscaled.toNumber()).to.equal( + 10 ** (adapterDecimals - additionalDecimals) + ); + }); + + it("should return zero if value is small enough", async () => { + // Since this value has less additional decimals added, it should + // be stripped down to 0. + const value = 10 ** (additionalDecimals - 1); + expect((await feeAdapter.internal_downscale(value)).toNumber()).to.equal( + 0 + ); + }); + }); + + describe("debitGasFees", () => { + it("should debit with correct fee caller address", async () => { + const balanceInitialFrom = await fiatToken.balanceOf(from); + const balanceInitialFromUpscaled = await feeAdapter.balanceOf(from); + + const debitAmountDownscaled = debitAmount.divRound( + new BN(10).pow(new BN(additionalDecimals)) + ); + + await feeAdapter.debitGasFees(from, debitAmount, { from: vm }); + // Triple-compare: both contracts store the true debited value. + expect((await feeAdapter.internal_debitedValue()).toString()) + .to.equal(debitAmountDownscaled.toString()) + .to.equal( + web3.utils.hexToNumberString( + await web3.eth.getStorageAt( + fiatToken.address, + // FiatTokenCeloV2_2#DEBITED_VALUE_SLOT Keccak256 hash. + "0xd90dccaa76fe7208f2f477143b6adabfeb5d4a5136982894dfc51177fa8eda28" + ) + ) + ); + + // Assert balances affected. + expect((await feeAdapter.balanceOf(from)).toString()).to.equal( + balanceInitialFromUpscaled.sub(debitAmount).toString() + ); + expect((await fiatToken.balanceOf(from)).toString()).to.equal( + balanceInitialFrom.sub(debitAmountDownscaled).toString() + ); + expect((await fiatToken.balanceOf(ZERO_ADDRESS)).toString()).to.equal( + debitAmountDownscaled.toString() + ); + }); + + it("should fail to debit again", async () => { + await feeAdapter.debitGasFees(from, debitAmount, { + from: vm, + }); + await expectRevert( + feeAdapter.debitGasFees(from, debitAmount, { + from: vm, + }), + "FiatTokenFeeAdapterV1: Must fully credit before debit" + ); + }); + }); + + describe("creditGasFees", () => { + it("should should credit with no rounding error after debit", async () => { + const debitAmount = new BN("200000000000000000000000"); + const refund = new BN("100000000000000000000000"); + const tipTxFee = new BN("90000000000000000000000"); + const baseTxFee = new BN("10000000000000000000000"); + + const fromBalancePre = await feeAdapter.balanceOf(from); + await feeAdapter.debitGasFees(from, debitAmount, { from: vm }); + await feeAdapter.creditGasFees( + from, + from, + from, + from, + refund, + tipTxFee, + 0, + baseTxFee, + { from: vm } + ); + const fromBalancePost = await feeAdapter.balanceOf(from); + + expect(fromBalancePre.toString()).to.equal(fromBalancePost.toString()); + }); + + it("should should credit properly with rounding error after debit", async () => { + const debitAmount = new BN("1234560000000000000000"); + const refund = new BN("1000009999999999999999"); // Trailing 9s will be dropped. + const tipTxFee = new BN("234550000000000000001"); // Trailing 1 will be dropped. + const baseTxFee = new BN("1"); // This will be downscaled to 0. + + const fromBalancePre = await feeAdapter.balanceOf(from); + await feeAdapter.debitGasFees(from, debitAmount, { from: vm }); + // Send all back to the from address for testing. + await feeAdapter.creditGasFees( + from, + from, + from, + from, + refund, + tipTxFee, + 0, + baseTxFee, + { from: vm } + ); + const fromBalancePost = await feeAdapter.balanceOf(from); + + // There should be no change in amounts even after all the scaling operations. + expect(fromBalancePre.toString()).to.equal(fromBalancePost.toString()); + }); + + it("should do nothing if debited is exactly 0", async () => { + const balanceInitialFrom = await fiatToken.balanceOf(from); + + await feeAdapter.creditGasFees(from, from, from, from, 1, 1, 1, 1, { + from: vm, + }); + expect((await fiatToken.balanceOf(from)).toString()).to.equal( + balanceInitialFrom.toString() + ); + }); + + it("should fail to credit more than debited", async () => { + await feeAdapter.debitGasFees(from, debitAmount, { + from: vm, + }); + await expectRevert( + feeAdapter.creditGasFees( + from, + from, + from, + from, + debitAmount, + debitAmount, + debitAmount, + debitAmount + ), + "FiatTokenFeeAdapterV1: Cannot credit more than debited" + ); + }); + }); +});