diff --git a/contracts/contracts/coordination/ManagedAllowList.sol b/contracts/contracts/coordination/ManagedAllowList.sol index 67cea162..547c8d45 100644 --- a/contracts/contracts/coordination/ManagedAllowList.sol +++ b/contracts/contracts/coordination/ManagedAllowList.sol @@ -5,7 +5,7 @@ pragma solidity ^0.8.0; import "../lib/LookupKey.sol"; import "./GlobalAllowList.sol"; import "./Coordinator.sol"; -import {UpfrontSubscriptionWithEncryptorsCap} from "./Subscription.sol"; +import {UpfrontSubscriptionWithEncryptorsCap} from "./subscription/Subscription.sol"; /** * @title ManagedAllowList diff --git a/contracts/contracts/coordination/AbstractSubscription.sol b/contracts/contracts/coordination/subscription/AbstractSubscription.sol similarity index 97% rename from contracts/contracts/coordination/AbstractSubscription.sol rename to contracts/contracts/coordination/subscription/AbstractSubscription.sol index 14361c08..b1febfb5 100644 --- a/contracts/contracts/coordination/AbstractSubscription.sol +++ b/contracts/contracts/coordination/subscription/AbstractSubscription.sol @@ -2,8 +2,8 @@ pragma solidity ^0.8.0; -import "./Coordinator.sol"; -import "./IFeeModel.sol"; +import "../Coordinator.sol"; +import "../IFeeModel.sol"; /** * @title Base Subscription contract @@ -16,6 +16,8 @@ abstract contract AbstractSubscription is IFeeModel { uint32 public immutable yellowPeriodDuration; uint32 public immutable redPeriodDuration; + uint256[20] private gap; + /** * @notice Sets subscription parameters * @dev The coordinator and fee token contracts cannot be zero addresses diff --git a/contracts/contracts/coordination/BqETHSubscription.sol b/contracts/contracts/coordination/subscription/BqETHSubscription.sol similarity index 71% rename from contracts/contracts/coordination/BqETHSubscription.sol rename to contracts/contracts/coordination/subscription/BqETHSubscription.sol index aff601c2..b67c074c 100644 --- a/contracts/contracts/coordination/BqETHSubscription.sol +++ b/contracts/contracts/coordination/subscription/BqETHSubscription.sol @@ -4,13 +4,15 @@ pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import "./AbstractSubscription.sol"; +import "@openzeppelin-upgradeable/contracts/proxy/utils/Initializable.sol"; +import "@openzeppelin-upgradeable/contracts/access/OwnableUpgradeable.sol"; +import "./EncryptorSlotsSubscription.sol"; /** * @title BqETH Subscription * @notice Manages the subscription information for rituals. */ -contract BqETHSubscription is AbstractSubscription { +contract BqETHSubscription is EncryptorSlotsSubscription, Initializable, OwnableUpgradeable { using SafeERC20 for IERC20; struct Billing { @@ -22,9 +24,6 @@ contract BqETHSubscription is AbstractSubscription { uint32 public constant INACTIVE_RITUAL_ID = type(uint32).max; - // TODO: DAO Treasury - // TODO: Should it be updatable? - address public immutable beneficiary; address public immutable adopter; uint256 public immutable baseFeeRate; @@ -32,19 +31,17 @@ contract BqETHSubscription is AbstractSubscription { uint256 public immutable maxNodes; IEncryptionAuthorizer public accessController; - - uint32 public startOfSubscription; uint32 public activeRitualId; - - uint256 public usedEncryptorSlots; mapping(uint256 periodNumber => Billing billing) public billingInfo; + uint256[20] private gap; + /** * @notice Emitted when a subscription is spent - * @param beneficiary The address of the beneficiary + * @param treasury The address of the treasury * @param amount The amount withdrawn */ - event WithdrawalToBeneficiary(address indexed beneficiary, uint256 amount); + event WithdrawalToTreasury(address indexed treasury, uint256 amount); /** * @notice Emitted when a subscription is paid @@ -79,7 +76,6 @@ contract BqETHSubscription is AbstractSubscription { * @dev The coordinator and fee token contracts cannot be zero addresses * @param _coordinator The address of the coordinator contract * @param _feeToken The address of the fee token contract - * @param _beneficiary The address of the beneficiary * @param _adopter The address of the adopter * @param _baseFeeRate Fee rate per node per second * @param _encryptorFeeRate Fee rate per encryptor per second @@ -91,7 +87,6 @@ contract BqETHSubscription is AbstractSubscription { constructor( Coordinator _coordinator, IERC20 _feeToken, - address _beneficiary, address _adopter, uint256 _baseFeeRate, uint256 _encryptorFeeRate, @@ -100,7 +95,7 @@ contract BqETHSubscription is AbstractSubscription { uint32 _yellowPeriodDuration, uint32 _redPeriodDuration ) - AbstractSubscription( + EncryptorSlotsSubscription( _coordinator, _subscriptionPeriodDuration, _yellowPeriodDuration, @@ -108,19 +103,13 @@ contract BqETHSubscription is AbstractSubscription { ) { require(address(_feeToken) != address(0), "Fee token cannot be the zero address"); - require(_beneficiary != address(0), "Beneficiary cannot be the zero address"); require(_adopter != address(0), "Adopter cannot be the zero address"); feeToken = _feeToken; - beneficiary = _beneficiary; adopter = _adopter; baseFeeRate = _baseFeeRate; encryptorFeeRate = _encryptorFeeRate; maxNodes = _maxNodes; - } - - modifier onlyBeneficiary() { - require(msg.sender == beneficiary, "Only the beneficiary can call this method"); - _; + _disableInitializers(); } modifier onlyAccessController() override { @@ -139,48 +128,20 @@ contract BqETHSubscription is AbstractSubscription { _; } - function getCurrentPeriodNumber() public view returns (uint256) { - if (startOfSubscription == 0) { - return 0; - } - return (block.timestamp - startOfSubscription) / subscriptionPeriodDuration; - } - - function getEndOfSubscription() public view override returns (uint32 endOfSubscription) { - if (startOfSubscription == 0) { - return 0; - } - - uint256 currentPeriodNumber = getCurrentPeriodNumber(); - Billing storage billing = billingInfo[currentPeriodNumber]; - if (currentPeriodNumber == 0 && !billing.paid) { - return 0; - } - - if (billing.paid) { - while (billing.paid) { - currentPeriodNumber++; - billing = billingInfo[currentPeriodNumber]; - } - } else { - while (!billing.paid) { - currentPeriodNumber--; - billing = billingInfo[currentPeriodNumber]; - } - currentPeriodNumber++; - } - endOfSubscription = uint32( - startOfSubscription + currentPeriodNumber * subscriptionPeriodDuration - ); - } - - function initialize(IEncryptionAuthorizer _accessController) external { + /** + * @notice Initialize function for using with OpenZeppelin proxy + */ + function initialize( + address _treasury, + IEncryptionAuthorizer _accessController + ) external initializer { require( address(accessController) == address(0) && address(_accessController) != address(0), "Access controller not already set and parameter cannot be the zero address" ); accessController = _accessController; activeRitualId = INACTIVE_RITUAL_ID; + __Ownable_init(_treasury); } function baseFees() public view returns (uint256) { @@ -191,7 +152,16 @@ contract BqETHSubscription is AbstractSubscription { return encryptorFeeRate * duration * encryptorSlots; } + function isPeriodPaid(uint256 periodNumber) public view override returns (bool) { + return billingInfo[periodNumber].paid; + } + + function getPaidEncryptorSlots(uint256 periodNumber) public view override returns (uint256) { + return billingInfo[periodNumber].encryptorSlots; + } + /** + * * @notice Pays for the closest unpaid subscription period (either the current or the next) * @param encryptorSlots Number of slots for encryptors */ @@ -244,13 +214,13 @@ contract BqETHSubscription is AbstractSubscription { } /** - * @notice Withdraws the contract balance to the beneficiary + * @notice Withdraws the contract balance to the treasury * @param amount The amount to withdraw */ - function withdrawToBeneficiary(uint256 amount) external onlyBeneficiary { + function withdrawToTreasury(uint256 amount) external onlyOwner { require(amount <= feeToken.balanceOf(address(this)), "Insufficient balance available"); - feeToken.safeTransfer(beneficiary, amount); - emit WithdrawalToBeneficiary(beneficiary, amount); + feeToken.safeTransfer(msg.sender, amount); + emit WithdrawalToTreasury(msg.sender, amount); } function processRitualPayment( @@ -288,31 +258,4 @@ contract BqETHSubscription is AbstractSubscription { } activeRitualId = ritualId; } - - function beforeSetAuthorization( - uint32 ritualId, - address[] calldata addresses, - bool value - ) public override { - super.beforeSetAuthorization(ritualId, addresses, value); - if (value) { - uint256 currentPeriodNumber = getCurrentPeriodNumber(); - Billing storage billing = billingInfo[currentPeriodNumber]; - uint128 encryptorSlots = billing.paid ? billing.encryptorSlots : 0; - usedEncryptorSlots += addresses.length; - require(usedEncryptorSlots <= encryptorSlots, "Encryptors slots filled up"); - } else { - usedEncryptorSlots -= addresses.length; - } - } - - function beforeIsAuthorized(uint32 ritualId) public view override { - super.beforeIsAuthorized(ritualId); - // used encryptor slots must be paid - if (block.timestamp <= getEndOfSubscription()) { - uint256 currentPeriodNumber = getCurrentPeriodNumber(); - Billing storage billing = billingInfo[currentPeriodNumber]; - require(usedEncryptorSlots <= billing.encryptorSlots, "Encryptors slots filled up"); - } - } } diff --git a/contracts/contracts/coordination/subscription/EncryptorSlotsSubscription.sol b/contracts/contracts/coordination/subscription/EncryptorSlotsSubscription.sol new file mode 100644 index 00000000..8522fd20 --- /dev/null +++ b/contracts/contracts/coordination/subscription/EncryptorSlotsSubscription.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.0; + +import "./AbstractSubscription.sol"; + +/** + * @title Subscription that includes payment for enryptor slots + * @notice Manages the subscription information for rituals. + */ +abstract contract EncryptorSlotsSubscription is AbstractSubscription { + uint32 public startOfSubscription; + uint256 public usedEncryptorSlots; + // example of storage layout + // mapping(uint256 periodNumber => Billing billing) public billingInfo; + + uint256[20] private gap; + + /** + * @notice Sets the coordinator and fee token contracts + * @dev The coordinator and fee token contracts cannot be zero addresses + * @param _coordinator The address of the coordinator contract + * @param _subscriptionPeriodDuration Maximum duration of subscription period + * @param _yellowPeriodDuration Duration of yellow period + * @param _redPeriodDuration Duration of red period + */ + constructor( + Coordinator _coordinator, + uint32 _subscriptionPeriodDuration, + uint32 _yellowPeriodDuration, + uint32 _redPeriodDuration + ) + AbstractSubscription( + _coordinator, + _subscriptionPeriodDuration, + _yellowPeriodDuration, + _redPeriodDuration + ) + {} + + function isPeriodPaid(uint256 periodNumber) public view virtual returns (bool); + + function getPaidEncryptorSlots(uint256 periodNumber) public view virtual returns (uint256); + + function getCurrentPeriodNumber() public view returns (uint256) { + if (startOfSubscription == 0) { + return 0; + } + return (block.timestamp - startOfSubscription) / subscriptionPeriodDuration; + } + + function getEndOfSubscription() public view override returns (uint32 endOfSubscription) { + if (startOfSubscription == 0) { + return 0; + } + + uint256 currentPeriodNumber = getCurrentPeriodNumber(); + if (currentPeriodNumber == 0 && !isPeriodPaid(currentPeriodNumber)) { + return 0; + } + + if (isPeriodPaid(currentPeriodNumber)) { + while (isPeriodPaid(currentPeriodNumber)) { + currentPeriodNumber++; + } + } else { + while (!isPeriodPaid(currentPeriodNumber)) { + currentPeriodNumber--; + } + currentPeriodNumber++; + } + endOfSubscription = uint32( + startOfSubscription + currentPeriodNumber * subscriptionPeriodDuration + ); + } + + function beforeSetAuthorization( + uint32 ritualId, + address[] calldata addresses, + bool value + ) public virtual override { + super.beforeSetAuthorization(ritualId, addresses, value); + if (value) { + uint256 currentPeriodNumber = getCurrentPeriodNumber(); + uint256 encryptorSlots = isPeriodPaid(currentPeriodNumber) + ? getPaidEncryptorSlots(currentPeriodNumber) + : 0; + usedEncryptorSlots += addresses.length; + require(usedEncryptorSlots <= encryptorSlots, "Encryptors slots filled up"); + } else { + usedEncryptorSlots -= addresses.length; + } + } + + function beforeIsAuthorized(uint32 ritualId) public view virtual override { + super.beforeIsAuthorized(ritualId); + // used encryptor slots must be paid + if (block.timestamp <= getEndOfSubscription()) { + uint256 currentPeriodNumber = getCurrentPeriodNumber(); + require( + usedEncryptorSlots <= getPaidEncryptorSlots(currentPeriodNumber), + "Encryptors slots filled up" + ); + } + } +} diff --git a/contracts/contracts/coordination/Subscription.sol b/contracts/contracts/coordination/subscription/Subscription.sol similarity index 99% rename from contracts/contracts/coordination/Subscription.sol rename to contracts/contracts/coordination/subscription/Subscription.sol index 65252762..7111a7d9 100644 --- a/contracts/contracts/coordination/Subscription.sol +++ b/contracts/contracts/coordination/subscription/Subscription.sol @@ -4,8 +4,8 @@ pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import "../lib/LookupKey.sol"; -import "./Coordinator.sol"; +import "../../lib/LookupKey.sol"; +import "../Coordinator.sol"; using SafeERC20 for IERC20; diff --git a/tests/test_bqeth_subscription.py b/tests/test_bqeth_subscription.py index 02bb45b5..b9839052 100644 --- a/tests/test_bqeth_subscription.py +++ b/tests/test_bqeth_subscription.py @@ -77,11 +77,10 @@ def coordinator(project, creator): @pytest.fixture() -def subscription(project, creator, coordinator, erc20, treasury, adopter): +def subscription(project, creator, coordinator, erc20, adopter, oz_dependency): contract = project.BqETHSubscription.deploy( coordinator.address, erc20.address, - treasury, adopter, BASE_FEE_RATE, ENCRYPTORS_FEE_RATE, @@ -91,8 +90,17 @@ def subscription(project, creator, coordinator, erc20, treasury, adopter): RED_PERIOD, sender=creator, ) - coordinator.setFeeModel(contract.address, sender=creator) - return contract + + encoded_initializer_function = b"" + proxy = oz_dependency.TransparentUpgradeableProxy.deploy( + contract.address, + creator, + encoded_initializer_function, + sender=creator, + ) + proxy_contract = project.BqETHSubscription.at(proxy.address) + coordinator.setFeeModel(proxy_contract.address, sender=creator) + return proxy_contract @pytest.fixture() @@ -100,7 +108,7 @@ def global_allow_list(project, creator, coordinator, subscription, treasury): contract = project.GlobalAllowList.deploy( coordinator.address, subscription.address, sender=creator ) - subscription.initialize(contract.address, sender=treasury) + subscription.initialize(treasury.address, contract.address, sender=treasury) return contract @@ -297,26 +305,26 @@ def test_pay_encryptor_slots( subscription.payForEncryptorSlots(encryptor_slots, sender=adopter) -def test_withdraw(erc20, subscription, adopter, treasury): +def test_withdraw(erc20, subscription, adopter, treasury, global_allow_list): erc20.approve(subscription.address, 10 * BASE_FEE, sender=adopter) - with ape.reverts("Only the beneficiary can call this method"): - subscription.withdrawToBeneficiary(1, sender=adopter) + with ape.reverts(): + subscription.withdrawToTreasury(1, sender=adopter) with ape.reverts("Insufficient balance available"): - subscription.withdrawToBeneficiary(1, sender=treasury) + subscription.withdrawToTreasury(1, sender=treasury) subscription.payForSubscription(0, sender=adopter) with ape.reverts("Insufficient balance available"): - subscription.withdrawToBeneficiary(BASE_FEE + 1, sender=treasury) + subscription.withdrawToTreasury(BASE_FEE + 1, sender=treasury) - tx = subscription.withdrawToBeneficiary(BASE_FEE, sender=treasury) + tx = subscription.withdrawToTreasury(BASE_FEE, sender=treasury) assert erc20.balanceOf(treasury) == BASE_FEE assert erc20.balanceOf(subscription.address) == 0 - events = subscription.WithdrawalToBeneficiary.from_receipt(tx) - assert events == [subscription.WithdrawalToBeneficiary(beneficiary=treasury, amount=BASE_FEE)] + events = subscription.WithdrawalToTreasury.from_receipt(tx) + assert events == [subscription.WithdrawalToTreasury(treasury=treasury, amount=BASE_FEE)] def test_process_ritual_payment(