diff --git a/contracts/contracts/coordination/SharedAllowList.sol b/contracts/contracts/coordination/SharedAllowList.sol new file mode 100644 index 000000000..102452d76 --- /dev/null +++ b/contracts/contracts/coordination/SharedAllowList.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin-upgradeable/contracts/proxy/utils/Initializable.sol"; +import "../lib/LookupKey.sol"; +import "./IEncryptionAuthorizer.sol"; +import "./Coordinator.sol"; +import "./subscription/SharedSubscription.sol"; + +/** + * @title SharedAllowList + */ +contract SharedAllowList is IEncryptionAuthorizer, Initializable { + using MessageHashUtils for bytes32; + using ECDSA for bytes32; + + Coordinator public immutable coordinator; + uint32 public constant MAX_AUTH_ACTIONS = 100; + mapping(bytes32 lookupKey => address authAdmin) internal authAdmins; + + /** + * @notice Emitted when an address authorization is set + * @param authAdmin Address that authorized + * @param ritualId The ID of the ritual + * @param _address The address that is authorized + * @param isAuthorized The authorization status + */ + event AddressAuthorizationSet( + address indexed authAdmin, + uint32 indexed ritualId, + address indexed _address, + bool isAuthorized + ); + + /** + * @notice Sets the coordinator contract + * @dev The coordinator contract cannot be a zero address and must have a valid number of rituals + * @param _coordinator The address of the coordinator contract + */ + constructor(Coordinator _coordinator) { + require(address(_coordinator) != address(0), "Contract cannot be zero addresses"); + require(_coordinator.numberOfRituals() >= 0, "Invalid coordinator"); + coordinator = _coordinator; + _disableInitializers(); + } + + function getAuthAdmin(uint32 ritualId, address encryptor) public view returns (address) { + bytes32 lookupKey = LookupKey.lookupKey(ritualId, encryptor); + return authAdmins[lookupKey]; + } + + /** + * @notice Authorizes a list of addresses for a ritual + * @param ritualId The ID of the ritual + * @param addresses The addresses to be authorized + */ + function authorize(uint32 ritualId, address[] calldata addresses) external { + setAuthorizations(ritualId, addresses, true); + } + + /** + * @notice Deauthorizes a list of addresses for a ritual + * @param ritualId The ID of the ritual + * @param addresses The addresses to be deauthorized + */ + function deauthorize(uint32 ritualId, address[] calldata addresses) external { + setAuthorizations(ritualId, addresses, false); + } + + /** + * @param ritualId The ID of the ritual + * @param evidence The evidence provided + * @param ciphertextHeader The header of the ciphertext + * @return The authorization status + */ + function isAuthorized( + uint32 ritualId, + bytes memory evidence, + bytes memory ciphertextHeader + ) external view override returns (bool) { + bytes32 digest = keccak256(ciphertextHeader); + address recoveredAddress = digest.toEthSignedMessageHash().recover(evidence); + + bytes32 lookupKey = LookupKey.lookupKey(ritualId, recoveredAddress); + address authAdmin = authAdmins[lookupKey]; + if (authAdmin == address(0)) { + return false; + } + + IFeeModel feeModel = coordinator.getFeeModel(ritualId); + SharedSubscription(address(feeModel)).beforeIsAuthorized(authAdmin, ritualId); + + return true; + } + + function setAuthorizations(uint32 ritualId, address[] calldata addresses, bool value) internal { + require(coordinator.isRitualActive(ritualId), "Only active rituals can set authorizations"); + + require(addresses.length <= MAX_AUTH_ACTIONS, "Too many addresses"); + + IFeeModel feeModel = coordinator.getFeeModel(ritualId); + SharedSubscription(address(feeModel)).beforeSetAuthorization( + msg.sender, + ritualId, + addresses, + value + ); + + for (uint256 i = 0; i < addresses.length; i++) { + bytes32 lookupKey = LookupKey.lookupKey(ritualId, addresses[i]); + // prevent reusing same address + if (value) { + require( + authAdmins[lookupKey] == address(0), + "Address authorized by different admin" + ); + authAdmins[lookupKey] = msg.sender; + } else { + require( + authAdmins[lookupKey] == msg.sender, + "Address authorized by different admin" + ); + authAdmins[lookupKey] = address(0); + } + emit AddressAuthorizationSet(msg.sender, ritualId, addresses[i], value); + } + } +} diff --git a/contracts/contracts/coordination/subscription/SharedSubscription.sol b/contracts/contracts/coordination/subscription/SharedSubscription.sol new file mode 100644 index 000000000..19e6f22d1 --- /dev/null +++ b/contracts/contracts/coordination/subscription/SharedSubscription.sol @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin-upgradeable/contracts/proxy/utils/Initializable.sol"; +import "@openzeppelin-upgradeable/contracts/access/OwnableUpgradeable.sol"; +import "../Coordinator.sol"; +import "../IEncryptionAuthorizer.sol"; +import "../IFeeModel.sol"; + +/** + * @title SharedSubscription + * @notice Manages the subscription information for rituals. + */ +contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { + using SafeERC20 for IERC20; + + struct Billing { + uint256 encryptorSlots; + uint256 usedEncryptorSlots; + uint256 endOfSubscription; + uint256 encryptorFeeRate; + } + + uint32 public constant INACTIVE_RITUAL_ID = type(uint32).max; + + Coordinator public immutable coordinator; + IEncryptionAuthorizer public immutable accessController; + IERC20 public immutable feeToken; + + address public immutable adopterSetter; + + uint256[3][10] public feePackages; + uint32 public activeRitualId; + mapping(address authAdmin => Billing billingInfo) public billing; + address public adopter; + + uint256[20] private gap; + + /** + * @notice Emitted when a subscription is spent + * @param owner The address of the owner + * @param amount The amount withdrawn + */ + event TokensWithdrawn(address indexed owner, uint256 amount); + + /** + * @notice Emitted when a subscription is paid + * @param subscriber The address of the subscriber + * @param authAdmin Authorization admin that was paid + * @param amount The amount paid + * @param encryptorSlots Number of encryptor slots + * @param endOfSubscription End timestamp of subscription + */ + event SubscriptionPaid( + address indexed subscriber, + address indexed authAdmin, + uint256 amount, + uint256 encryptorSlots, + uint256 endOfSubscription + ); + + /** + * @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 _accessController The address of the global allow list + * @param _feeToken The address of the fee token contract + * @param _adopterSetter Address that can set the adopter address + */ + constructor( + Coordinator _coordinator, + IEncryptionAuthorizer _accessController, + IERC20 _feeToken, + address _adopterSetter + ) { + require(address(_feeToken) != address(0), "Fee token cannot be the zero address"); + require(_adopterSetter != address(0), "Adopter setter cannot be the zero address"); + require( + address(_accessController) != address(0), + "Access controller cannot be the zero address" + ); + require(address(_coordinator) != address(0), "Coordinator cannot be the zero address"); + coordinator = _coordinator; + feeToken = _feeToken; + adopterSetter = _adopterSetter; + accessController = _accessController; + _disableInitializers(); + } + + modifier onlyAccessController() { + require( + msg.sender == address(accessController), + "Only Access Controller can call this method" + ); + _; + } + + modifier onlyActiveRitual(uint32 ritualId) { + require( + activeRitualId != INACTIVE_RITUAL_ID && ritualId == activeRitualId, + "Ritual must be active" + ); + _; + } + + modifier onlyCoordinator() { + require(msg.sender == address(coordinator), "Only the Coordinator can call this method"); + _; + } + + /** + * @notice Initialize function for using with OpenZeppelin proxy + * @param _feePackages Fee packages [duration(sec), encryptors, feeRate] + */ + function initialize(address _owner, uint256[3][] memory _feePackages) external initializer { + activeRitualId = INACTIVE_RITUAL_ID; + __Ownable_init(_owner); + for (uint256 i = 0; i < _feePackages.length && i < feePackages.length; i++) { + feePackages[i] = _feePackages[i]; + } + } + + function setAdopter(address _adopter) external { + require(msg.sender == adopterSetter, "Only adopter setter can set adopter"); + require( + adopter == address(0) && _adopter != address(0), + "Adopter can be set only once with not zero address" + ); + adopter = _adopter; + } + + function getEncryptorFeeRate( + uint256 encryptorSlots, + uint256 duration + ) public view returns (uint256) { + for (uint256 i = 0; i < feePackages.length; i++) { + uint256[3] storage feePackage = feePackages[i]; + if (feePackage[0] == duration && feePackage[1] == encryptorSlots) { + return feePackage[2]; + } + } + revert("Fee package is not available"); + } + + function encryptorFees( + uint256 encryptorFeeRate, + uint256 encryptorSlots, + uint256 duration + ) public view returns (uint256) { + return encryptorFeeRate * duration * encryptorSlots; + } + + /** + * @notice Process payment for the chosen package + * @param authAdmin Address of the admin + * @param encryptorSlots Number of encryptor slots + * @param packageDuration Requested duration + */ + function payForSubscription( + address authAdmin, + uint256 encryptorSlots, + uint256 packageDuration + ) external returns (uint256 fees) { + Billing storage billingInfo = billing[authAdmin]; + require( + billingInfo.endOfSubscription < block.timestamp + packageDuration, + "Renewal allowed only to later end of subscription" + ); + + uint256 encryptorFeeRate = getEncryptorFeeRate(encryptorSlots, packageDuration); + uint256 discount = 0; + if ( + encryptorFeeRate == billingInfo.encryptorFeeRate && + billingInfo.encryptorSlots == encryptorSlots && + billingInfo.endOfSubscription > block.timestamp + ) { + billingInfo.endOfSubscription += packageDuration; + } else { + if (billingInfo.endOfSubscription > block.timestamp) { + uint256 restOfSubscription = billingInfo.endOfSubscription - block.timestamp; + discount = encryptorFees( + billingInfo.encryptorFeeRate, + billingInfo.encryptorSlots, + restOfSubscription + ); + } + + billingInfo.encryptorSlots = encryptorSlots; + billingInfo.endOfSubscription = block.timestamp + packageDuration; + billingInfo.encryptorFeeRate = encryptorFeeRate; + } + + fees = encryptorFees(encryptorFeeRate, encryptorSlots, packageDuration); + require(discount < fees, "Discount can not be more than new package fees"); + fees -= discount; + emit SubscriptionPaid( + msg.sender, + authAdmin, + fees, + billingInfo.encryptorSlots, + billingInfo.endOfSubscription + ); + feeToken.safeTransferFrom(msg.sender, address(this), fees); + } + + /** + * @notice Withdraws the fees to the treasury + */ + function withdrawTokens() external { + uint256 amount = feeToken.balanceOf(address(this)); + require(0 < amount, "Insufficient balance available"); + feeToken.safeTransfer(owner(), amount); + emit TokensWithdrawn(owner(), amount); + } + + function processRitualPayment( + address initiator, + uint32 ritualId, + uint256, + uint32 + ) external onlyCoordinator { + require(initiator == adopter, "Only adopter can initiate ritual"); + require( + address(accessController) != address(0) && + accessController == coordinator.getAccessController(ritualId), + "Access controller for ritual must be approved" + ); + + if (activeRitualId != INACTIVE_RITUAL_ID) { + Coordinator.RitualState state = coordinator.getRitualState(activeRitualId); + require( + state == Coordinator.RitualState.DKG_INVALID || + state == Coordinator.RitualState.DKG_TIMEOUT || + state == Coordinator.RitualState.EXPIRED, // TODO check if it's ok + "Only failed/expired rituals allowed to be reinitiated" + ); + } + activeRitualId = ritualId; + } + + function beforeSetAuthorization( + address authAdmin, + uint32 ritualId, + address[] calldata addresses, + bool value + ) public virtual onlyAccessController onlyActiveRitual(ritualId) { + Billing storage billingInfo = billing[authAdmin]; + require(block.timestamp <= billingInfo.endOfSubscription, "Subscription has expired"); + if (value) { + billingInfo.usedEncryptorSlots += addresses.length; + require( + billingInfo.usedEncryptorSlots <= billingInfo.encryptorSlots, + "Insufficient encryptor slots available" + ); + } else { + if (billingInfo.usedEncryptorSlots >= addresses.length) { + billingInfo.usedEncryptorSlots -= addresses.length; + } else { + billingInfo.usedEncryptorSlots = 0; + } + } + } + + function beforeIsAuthorized( + address authAdmin, + uint32 ritualId + ) public view virtual onlyAccessController onlyActiveRitual(ritualId) { + Billing storage billingInfo = billing[authAdmin]; + require(block.timestamp <= billingInfo.endOfSubscription, "Subscription has expired"); + // used encryptor slots must be paid + require( + billingInfo.usedEncryptorSlots <= billingInfo.encryptorSlots, + "Encryptor slots full" + ); + } + + /** + * @dev This function is called before the setAuthorizations function + */ + function beforeSetAuthorization(uint32, address[] calldata, bool) public virtual override { + revert("Unused"); + } + + /** + * @dev This function is called before the isAuthorized function + */ + function beforeIsAuthorized(uint32) public view virtual override { + revert("Unused"); + } + + function processRitualExtending( + address initiator, + uint32 ritualId, + uint256, + uint32 + ) external view override onlyCoordinator onlyActiveRitual(ritualId) { + require(initiator == adopter, "Only adopter can extend ritual"); + } +} diff --git a/contracts/test/SharedAllowListTestSet.sol b/contracts/test/SharedAllowListTestSet.sol new file mode 100644 index 000000000..3f26da025 --- /dev/null +++ b/contracts/test/SharedAllowListTestSet.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.0; + +import "../contracts/coordination/ITACoRootToChild.sol"; +import "../contracts/coordination/ITACoChildToRoot.sol"; + +contract SharedSubscriptionForSharedAllowListMock { + mapping(address => bool) public authAdmins; + + function setAuthAdmin(address authAdmin, bool value) external { + authAdmins[authAdmin] = value; + } + + function beforeSetAuthorization( + address authAdmin, + uint32, + address[] calldata, + bool + ) public virtual { + require(authAdmins[authAdmin], "Not authorized"); + } + + function beforeIsAuthorized(address authAdmin, uint32) public view { + require(authAdmins[authAdmin], "Not authorized"); + } +} + +contract CoordinatorForSharedAllowListMock { + uint256 public numberOfRituals = 1; // for check in GlobalAllowList constructor + + mapping(uint32 ritualId => address authority) public authorities; + address public feeModel; + + constructor(address _feeModel) { + feeModel = _feeModel; + } + + function initiateRitual(uint32 ritualId, address authority) external { + authorities[ritualId] = authority; + } + + function isRitualActive(uint32) external pure returns (bool) { + return true; + } + + function getFeeModel(uint32) external view returns (address) { + return feeModel; + } + + function getAuthority(uint32 ritualId) external view returns (address) { + return authorities[ritualId]; + } +} diff --git a/contracts/test/SharedSubscriptionTestSet.sol b/contracts/test/SharedSubscriptionTestSet.sol new file mode 100644 index 000000000..3ae26295a --- /dev/null +++ b/contracts/test/SharedSubscriptionTestSet.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.0; + +import "../contracts/coordination/IEncryptionAuthorizer.sol"; +import "../contracts/coordination/Coordinator.sol"; +import "../contracts/coordination/IFeeModel.sol"; + +contract CoordinatorForSharedSubscriptionMock { + struct Ritual { + uint32 endTimestamp; + IEncryptionAuthorizer accessController; + Coordinator.RitualState state; + } + + IFeeModel public feeModel; + + mapping(uint32 => Ritual) public rituals; + + function setFeeModel(IFeeModel _feeModel) external { + feeModel = _feeModel; + } + + function processRitualExtending( + address initiator, + uint32 ritualId, + uint256 numberOfProviders, + uint32 duration + ) external { + feeModel.processRitualExtending(initiator, ritualId, numberOfProviders, duration); + } + + function processRitualPayment( + address initiator, + uint32 ritualId, + uint256 numberOfProviders, + uint32 duration + ) external { + feeModel.processRitualPayment(initiator, ritualId, numberOfProviders, duration); + } + + function setRitual( + uint32 _ritualId, + Coordinator.RitualState _state, + uint32 _endTimestamp, + IEncryptionAuthorizer _accessController + ) external { + Ritual storage ritual = rituals[_ritualId]; + ritual.state = _state; + ritual.endTimestamp = _endTimestamp; + ritual.accessController = _accessController; + } + + function getAccessController(uint32 _ritualId) external view returns (IEncryptionAuthorizer) { + return rituals[_ritualId].accessController; + } + + function getRitualState(uint32 _ritualId) external view returns (Coordinator.RitualState) { + return rituals[_ritualId].state; + } + + function getFeeModel(uint32) external view returns (IFeeModel) { + return feeModel; + } + + function getTimestamps( + uint32 _ritualId + ) external view returns (uint32 initTimestamp, uint32 endTimestamp) { + initTimestamp = 0; + endTimestamp = rituals[_ritualId].endTimestamp; + } + + function numberOfRituals() external pure returns (uint256) { + return 1; + } + + function getAuthority(uint32) external view returns (address) { + // solhint-disable-next-line avoid-tx-origin + return tx.origin; + } + + function isRitualActive(uint32) external view returns (bool) { + return true; + } +} diff --git a/tests/test_shared_allow_list.py b/tests/test_shared_allow_list.py new file mode 100644 index 000000000..97ff57b3b --- /dev/null +++ b/tests/test_shared_allow_list.py @@ -0,0 +1,104 @@ +import os + +import ape +import pytest +from ape.utils import ZERO_ADDRESS +from eth_account.messages import encode_defunct +from web3 import Web3 + + +@pytest.fixture(scope="module") +def initiator(accounts): + initiator_index = 1 + return accounts[initiator_index] + + +@pytest.fixture(scope="module") +def deployer(accounts): + deployer_index = 2 + return accounts[deployer_index] + + +@pytest.fixture() +def fee_model(project, deployer): + contract = project.SharedSubscriptionForSharedAllowListMock.deploy(sender=deployer) + return contract + + +@pytest.fixture() +def coordinator(project, deployer, fee_model): + contract = project.CoordinatorForSharedAllowListMock.deploy( + fee_model.address, + sender=deployer, + ) + return contract + + +@pytest.fixture() +def shared_allow_list(project, deployer, coordinator, oz_dependency): + contract = project.SharedAllowList.deploy(coordinator.address, sender=deployer) + encoded_initializer_function = b"" + proxy = oz_dependency.TransparentUpgradeableProxy.deploy( + contract.address, + deployer, + encoded_initializer_function, + sender=deployer, + ) + proxy_contract = project.SharedAllowList.at(proxy.address) + return proxy_contract + + +def test_authorize(coordinator, fee_model, deployer, initiator, shared_allow_list): + # This block mocks the signature of a threshold decryption request + w3 = Web3() + data = os.urandom(32) + digest = Web3.keccak(data) + signable_message = encode_defunct(digest) + signed_digest = w3.eth.account.sign_message(signable_message, private_key=deployer.private_key) + signature = signed_digest.signature + + ritual_id = 0 + + with ape.reverts("Not authorized"): + shared_allow_list.authorize(ritual_id, [deployer.address], sender=initiator) + + fee_model.setAuthAdmin(initiator.address, True, sender=deployer) + + # Not authorized + assert not shared_allow_list.isAuthorized(ritual_id, bytes(signature), bytes(data)) + + # Negative test cases for authorization + tx = shared_allow_list.authorize(ritual_id, [deployer.address], sender=initiator) + events = [event for event in tx.events if event.event_name == "AddressAuthorizationSet"] + assert events == [ + shared_allow_list.AddressAuthorizationSet( + ritualId=ritual_id, _address=deployer.address, isAuthorized=True + ) + ] + + assert shared_allow_list.getAuthAdmin(ritual_id, deployer.address) == initiator.address + assert shared_allow_list.isAuthorized(ritual_id, bytes(signature), bytes(data)) + + fee_model.setAuthAdmin(initiator.address, False, sender=initiator) + with ape.reverts("Not authorized"): + shared_allow_list.isAuthorized(ritual_id, bytes(signature), bytes(data)) + with ape.reverts("Not authorized"): + shared_allow_list.authorize(ritual_id, [deployer.address], sender=initiator) + + fee_model.setAuthAdmin(initiator.address, True, sender=initiator) + fee_model.setAuthAdmin(deployer.address, True, sender=initiator) + with ape.reverts("Address authorized by different admin"): + shared_allow_list.deauthorize(ritual_id, [deployer.address], sender=deployer) + with ape.reverts("Address authorized by different admin"): + shared_allow_list.authorize(ritual_id, [deployer.address], sender=deployer) + + tx = shared_allow_list.deauthorize(ritual_id, [deployer.address], sender=initiator) + + assert not shared_allow_list.isAuthorized(ritual_id, bytes(signature), bytes(data)) + events = [event for event in tx.events if event.event_name == "AddressAuthorizationSet"] + assert events == [ + shared_allow_list.AddressAuthorizationSet( + ritualId=ritual_id, _address=deployer.address, isAuthorized=False + ) + ] + assert shared_allow_list.getAuthAdmin(ritual_id, deployer.address) == ZERO_ADDRESS diff --git a/tests/test_shared_subscription.py b/tests/test_shared_subscription.py new file mode 100644 index 000000000..0dd75bcd5 --- /dev/null +++ b/tests/test_shared_subscription.py @@ -0,0 +1,513 @@ +""" +This file is part of nucypher. + +nucypher is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +nucypher is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with nucypher. If not, see . +""" +import os + +import ape +import pytest +from ape.utils import ZERO_ADDRESS +from eth_account.messages import encode_defunct +from web3 import Web3 + +from tests.conftest import RitualState + +ONE_DAY = 24 * 60 * 60 +FEE_PACKAGES = [ + # 14 days, 15 encryptors, 150 USDC + [14 * ONE_DAY, 15, 8267195767195], + # 90 days, 500 encryptors, 1000 USDC + [90 * ONE_DAY, 500, 257201646090], + # Test "free" package + [ONE_DAY, 1, 1], +] + +ERC20_SUPPLY = 10**24 + + +@pytest.fixture(scope="module") +def contract_owner(accounts): + return accounts[1] + + +@pytest.fixture(scope="module") +def adopter(accounts): + return accounts[2] + + +@pytest.fixture(scope="module") +def adopter_setter(accounts): + return accounts[3] + + +@pytest.fixture(scope="module") +def auth_admin(accounts): + return accounts[4] + + +@pytest.fixture() +def erc20(project, adopter): + token = project.TestToken.deploy(ERC20_SUPPLY, sender=adopter) + return token + + +@pytest.fixture() +def coordinator(project, creator): + contract = project.CoordinatorForSharedSubscriptionMock.deploy( + sender=creator, + ) + return contract + + +@pytest.fixture() +def allow_list(project, creator, coordinator): + contract = project.SharedAllowList.deploy(coordinator.address, sender=creator) + return contract + + +@pytest.fixture() +def subscription( + project, creator, coordinator, allow_list, erc20, adopter_setter, contract_owner, oz_dependency +): + contract = project.SharedSubscription.deploy( + coordinator.address, + allow_list.address, + erc20.address, + adopter_setter, + sender=creator, + ) + + encoded_initializer_function = b"" + proxy = oz_dependency.TransparentUpgradeableProxy.deploy( + contract.address, + creator, + encoded_initializer_function, + sender=creator, + ) + proxy_contract = project.SharedSubscription.at(proxy.address) + coordinator.setFeeModel(proxy_contract.address, sender=creator) + proxy_contract.initialize(contract_owner.address, FEE_PACKAGES, sender=contract_owner) + return proxy_contract + + +def test_adopter_setter(subscription, adopter_setter, adopter): + with ape.reverts("Only adopter setter can set adopter"): + subscription.setAdopter(adopter, sender=adopter) + with ape.reverts("Adopter can be set only once with not zero address"): + subscription.setAdopter(ZERO_ADDRESS, sender=adopter_setter) + subscription.setAdopter(adopter, sender=adopter_setter) + assert subscription.adopter() == adopter + with ape.reverts("Adopter can be set only once with not zero address"): + subscription.setAdopter(adopter_setter, sender=adopter_setter) + + +def test_get_encryptor_fee_rate(subscription): + assert subscription.feePackages(0, 1) == 15 + assert subscription.feePackages(1, 1) == 500 + assert subscription.getEncryptorFeeRate(15, 14 * ONE_DAY) == FEE_PACKAGES[0][2] + assert subscription.getEncryptorFeeRate(500, 90 * ONE_DAY) == FEE_PACKAGES[1][2] + with ape.reverts("Fee package is not available"): + subscription.getEncryptorFeeRate(15, 15 * ONE_DAY) + with ape.reverts("Fee package is not available"): + subscription.getEncryptorFeeRate(15, 90 * ONE_DAY) + + fees = subscription.encryptorFees(FEE_PACKAGES[0][2], 15, 14 * ONE_DAY) + assert round(fees / 10**18, 0) == 150 # 150 USDC + fees = subscription.encryptorFees(FEE_PACKAGES[1][2], 500, 90 * ONE_DAY) + assert round(fees / 10**18, 0) == 1000 # 1000 USDC + + +def test_pay_subscription(erc20, subscription, adopter, auth_admin, chain): + erc20.approve(subscription.address, ERC20_SUPPLY, sender=adopter) + + with ape.reverts("Fee package is not available"): + subscription.payForSubscription(auth_admin, 15, 15 * ONE_DAY, sender=adopter) + + # First payment + balance_before = erc20.balanceOf(adopter) + tx = subscription.payForSubscription(auth_admin, 15, 14 * ONE_DAY, sender=adopter) + timestamp = chain.pending_timestamp - 1 + end_subscription = timestamp + 14 * ONE_DAY + + billing = subscription.billing(auth_admin) + assert billing.encryptorSlots == 15 + assert billing.usedEncryptorSlots == 0 + assert billing.endOfSubscription == end_subscription + assert billing.encryptorFeeRate == FEE_PACKAGES[0][2] + balance_after = erc20.balanceOf(adopter) + fees = subscription.encryptorFees(FEE_PACKAGES[0][2], 15, 14 * ONE_DAY) + assert balance_after + fees == balance_before + assert erc20.balanceOf(subscription.address) == fees + + events = [event for event in tx.events if event.event_name == "SubscriptionPaid"] + assert events == [ + subscription.SubscriptionPaid( + subscriber=adopter, + authAdmin=auth_admin, + amount=fees, + encryptorSlots=15, + endOfSubscription=end_subscription, + ) + ] + + # Extend + tx = subscription.payForSubscription(auth_admin, 15, 14 * ONE_DAY, sender=adopter) + end_subscription += 14 * ONE_DAY + + billing = subscription.billing(auth_admin) + assert billing.encryptorSlots == 15 + assert billing.usedEncryptorSlots == 0 + assert billing.endOfSubscription == end_subscription + assert billing.encryptorFeeRate == FEE_PACKAGES[0][2] + balance_after = erc20.balanceOf(adopter) + assert balance_after + 2 * fees == balance_before + assert erc20.balanceOf(subscription.address) == 2 * fees + + events = [event for event in tx.events if event.event_name == "SubscriptionPaid"] + assert events == [ + subscription.SubscriptionPaid( + subscriber=adopter, + authAdmin=auth_admin, + amount=fees, + encryptorSlots=15, + endOfSubscription=end_subscription, + ) + ] + + # Extend after some time + chain.pending_timestamp = end_subscription + ONE_DAY + tx = subscription.payForSubscription(auth_admin, 15, 14 * ONE_DAY, sender=adopter) + timestamp = chain.pending_timestamp - 1 + end_subscription = timestamp + 14 * ONE_DAY + + billing = subscription.billing(auth_admin) + assert billing.encryptorSlots == 15 + assert billing.usedEncryptorSlots == 0 + assert billing.endOfSubscription == end_subscription + assert billing.encryptorFeeRate == FEE_PACKAGES[0][2] + balance_after = erc20.balanceOf(adopter) + assert balance_after + 3 * fees == balance_before + assert erc20.balanceOf(subscription.address) == 3 * fees + + events = [event for event in tx.events if event.event_name == "SubscriptionPaid"] + assert events == [ + subscription.SubscriptionPaid( + subscriber=adopter, + authAdmin=auth_admin, + amount=fees, + encryptorSlots=15, + endOfSubscription=end_subscription, + ) + ] + + # Change package + with ape.reverts("Renewal allowed only to later end of subscription"): + subscription.payForSubscription(auth_admin, 1, ONE_DAY, sender=adopter) + chain.pending_timestamp = end_subscription - ONE_DAY + 1 + with ape.reverts("Discount can not be more than new package fees"): + subscription.payForSubscription(auth_admin, 1, ONE_DAY, sender=adopter) + + balance_before = erc20.balanceOf(adopter) + time_left = ONE_DAY - 2 + discount = subscription.encryptorFees( + billing.encryptorFeeRate, billing.encryptorSlots, time_left + ) + tx = subscription.payForSubscription(auth_admin, 500, 90 * ONE_DAY, sender=adopter) + timestamp = chain.pending_timestamp - 1 + end_subscription = timestamp + 90 * ONE_DAY + + billing = subscription.billing(auth_admin) + assert billing.encryptorSlots == 500 + assert billing.usedEncryptorSlots == 0 + assert billing.endOfSubscription == end_subscription + assert billing.encryptorFeeRate == FEE_PACKAGES[1][2] + balance_after = erc20.balanceOf(adopter) + fees = subscription.encryptorFees( + billing.encryptorFeeRate, billing.encryptorSlots, 90 * ONE_DAY + ) + assert balance_after + fees - discount == balance_before + + events = [event for event in tx.events if event.event_name == "SubscriptionPaid"] + assert events == [ + subscription.SubscriptionPaid( + subscriber=adopter, + authAdmin=auth_admin, + amount=fees - discount, + encryptorSlots=500, + endOfSubscription=end_subscription, + ) + ] + + +def test_withdraw(erc20, subscription, adopter, auth_admin, contract_owner): + erc20.approve(subscription.address, ERC20_SUPPLY, sender=adopter) + + with ape.reverts("Insufficient balance available"): + subscription.withdrawTokens(sender=adopter) + + subscription.payForSubscription(auth_admin, 15, 14 * ONE_DAY, sender=adopter) + fees = subscription.encryptorFees(FEE_PACKAGES[0][2], 15, 14 * ONE_DAY) + + tx = subscription.withdrawTokens(sender=adopter) + assert erc20.balanceOf(contract_owner) == fees + assert erc20.balanceOf(subscription.address) == 0 + + events = [event for event in tx.events if event.event_name == "TokensWithdrawn"] + assert events == [subscription.TokensWithdrawn(owner=contract_owner, amount=fees)] + + +def test_process_ritual_payment( + erc20, subscription, coordinator, allow_list, adopter, adopter_setter, contract_owner +): + ritual_id = 7 + number_of_providers = 6 + duration = 100 * ONE_DAY + subscription.setAdopter(adopter, sender=adopter_setter) + + with ape.reverts("Only the Coordinator can call this method"): + subscription.processRitualPayment( + adopter, ritual_id, number_of_providers, duration, sender=contract_owner + ) + with ape.reverts("Only adopter can initiate ritual"): + coordinator.processRitualPayment( + contract_owner, ritual_id, number_of_providers, duration, sender=contract_owner + ) + + coordinator.setRitual( + ritual_id, RitualState.NON_INITIATED, 0, contract_owner, sender=contract_owner + ) + + with ape.reverts("Access controller for ritual must be approved"): + coordinator.processRitualPayment( + adopter, + ritual_id, + number_of_providers, + duration, + sender=contract_owner, + ) + + assert subscription.activeRitualId() == subscription.INACTIVE_RITUAL_ID() + coordinator.setRitual( + ritual_id, + RitualState.DKG_AWAITING_TRANSCRIPTS, + 0, + allow_list.address, + sender=contract_owner, + ) + coordinator.processRitualPayment( + adopter, ritual_id, number_of_providers, duration, sender=contract_owner + ) + assert subscription.activeRitualId() == ritual_id + + new_ritual_id = ritual_id + 1 + coordinator.setRitual( + new_ritual_id, RitualState.ACTIVE, 0, allow_list.address, sender=contract_owner + ) + with ape.reverts("Only failed/expired rituals allowed to be reinitiated"): + coordinator.processRitualPayment( + adopter, new_ritual_id, number_of_providers, duration, sender=contract_owner + ) + + coordinator.setRitual( + ritual_id, RitualState.DKG_INVALID, 0, allow_list.address, sender=contract_owner + ) + coordinator.processRitualPayment( + adopter, new_ritual_id, number_of_providers, duration, sender=contract_owner + ) + assert subscription.activeRitualId() == new_ritual_id + + ritual_id = new_ritual_id + new_ritual_id = ritual_id + 1 + coordinator.setRitual( + new_ritual_id, RitualState.ACTIVE, 0, allow_list.address, sender=contract_owner + ) + coordinator.setRitual( + ritual_id, RitualState.DKG_TIMEOUT, 0, allow_list.address, sender=contract_owner + ) + coordinator.processRitualPayment( + adopter, new_ritual_id, number_of_providers, duration, sender=contract_owner + ) + assert subscription.activeRitualId() == new_ritual_id + + ritual_id = new_ritual_id + new_ritual_id = ritual_id + 1 + coordinator.setRitual( + new_ritual_id, RitualState.ACTIVE, 0, allow_list.address, sender=contract_owner + ) + coordinator.setRitual( + ritual_id, RitualState.EXPIRED, 0, allow_list.address, sender=contract_owner + ) + coordinator.processRitualPayment( + adopter, new_ritual_id, number_of_providers, duration, sender=contract_owner + ) + assert subscription.activeRitualId() == new_ritual_id + + +def test_process_ritual_extending( + subscription, coordinator, adopter, adopter_setter, allow_list, contract_owner +): + ritual_id = 6 + number_of_providers = 7 + duration = ONE_DAY + + with ape.reverts("Only the Coordinator can call this method"): + subscription.processRitualExtending( + adopter, ritual_id, number_of_providers, duration, sender=contract_owner + ) + + subscription.setAdopter(adopter, sender=adopter_setter) + with ape.reverts("Ritual must be active"): + coordinator.processRitualExtending( + adopter, ritual_id, number_of_providers, duration, sender=contract_owner + ) + coordinator.setRitual( + ritual_id, RitualState.ACTIVE, 0, allow_list.address, sender=contract_owner + ) + coordinator.processRitualPayment( + adopter, ritual_id, number_of_providers, duration, sender=contract_owner + ) + with ape.reverts("Only adopter can extend ritual"): + coordinator.processRitualExtending( + contract_owner, ritual_id, number_of_providers, duration, sender=contract_owner + ) + + coordinator.processRitualExtending( + adopter, ritual_id, number_of_providers, duration, sender=adopter + ) + + +def test_before_set_authorization( + erc20, + subscription, + coordinator, + adopter, + adopter_setter, + allow_list, + contract_owner, + auth_admin, + creator, + chain, +): + ritual_id = 6 + number_of_providers = 7 + duration = 1000 * ONE_DAY + erc20.approve(subscription.address, ERC20_SUPPLY, sender=adopter) + subscription.setAdopter(adopter, sender=adopter_setter) + + with ape.reverts("Only Access Controller can call this method"): + subscription.beforeSetAuthorization(auth_admin, 0, [creator], True, sender=adopter) + + with ape.reverts("Ritual must be active"): + allow_list.authorize(0, [creator], sender=auth_admin) + + coordinator.setRitual( + ritual_id, RitualState.ACTIVE, 0, allow_list.address, sender=contract_owner + ) + coordinator.processRitualPayment( + adopter, ritual_id, number_of_providers, duration, sender=contract_owner + ) + + with ape.reverts("Ritual must be active"): + allow_list.authorize(0, [creator], sender=auth_admin) + + with ape.reverts("Subscription has expired"): + allow_list.authorize(ritual_id, [creator], sender=auth_admin) + + subscription.payForSubscription(auth_admin, 1, ONE_DAY, sender=adopter) + allow_list.authorize(ritual_id, [creator], sender=auth_admin) + billing = subscription.billing(auth_admin) + assert billing.usedEncryptorSlots == 1 + + with ape.reverts("Insufficient encryptor slots available"): + allow_list.authorize(ritual_id, [creator], sender=auth_admin) + + allow_list.deauthorize(ritual_id, [creator], sender=auth_admin) + billing = subscription.billing(auth_admin) + assert billing.usedEncryptorSlots == 0 + + with ape.reverts("Insufficient encryptor slots available"): + allow_list.authorize(ritual_id, [creator, adopter], sender=auth_admin) + + subscription.payForSubscription(auth_admin, 15, 14 * ONE_DAY, sender=adopter) + allow_list.authorize(ritual_id, [creator, adopter], sender=auth_admin) + billing = subscription.billing(auth_admin) + assert billing.usedEncryptorSlots == 2 + + end_subscription = billing.endOfSubscription + chain.pending_timestamp = end_subscription + 1 + + with ape.reverts("Subscription has expired"): + allow_list.authorize(ritual_id, [creator], sender=adopter) + + subscription.payForSubscription(auth_admin, 1, ONE_DAY, sender=adopter) + with ape.reverts("Insufficient encryptor slots available"): + allow_list.authorize(ritual_id, [creator], sender=auth_admin) + + subscription.payForSubscription(auth_admin, 15, 14 * ONE_DAY, sender=adopter) + with ape.reverts("Insufficient encryptor slots available"): + allow_list.authorize(ritual_id, [contract_owner] * 14, sender=auth_admin) + allow_list.authorize(ritual_id, [contract_owner], sender=auth_admin) + billing = subscription.billing(auth_admin) + assert billing.usedEncryptorSlots == 3 + + +def test_before_is_authorized( + erc20, + subscription, + coordinator, + adopter, + adopter_setter, + allow_list, + contract_owner, + auth_admin, + creator, + chain, +): + ritual_id = 6 + + w3 = Web3() + data = os.urandom(32) + digest = Web3.keccak(data) + signable_message = encode_defunct(digest) + signed_digest = w3.eth.account.sign_message(signable_message, private_key=adopter.private_key) + signature = signed_digest.signature + + with ape.reverts("Only Access Controller can call this method"): + subscription.beforeIsAuthorized(auth_admin, 0, sender=adopter) + + erc20.approve(subscription.address, ERC20_SUPPLY, sender=adopter) + subscription.payForSubscription(auth_admin, 15, 14 * ONE_DAY, sender=adopter) + + subscription.setAdopter(adopter, sender=adopter_setter) + coordinator.setRitual( + ritual_id, RitualState.ACTIVE, 0, allow_list.address, sender=contract_owner + ) + coordinator.processRitualPayment(adopter, ritual_id, 10, ONE_DAY, sender=contract_owner) + allow_list.authorize(ritual_id, [adopter.address, creator.address], sender=auth_admin) + + assert not allow_list.isAuthorized(0, bytes(signature), bytes(data)) + assert allow_list.isAuthorized(ritual_id, bytes(signature), bytes(data)) + + chain.pending_timestamp += 15 * ONE_DAY + + with ape.reverts("Subscription has expired"): + allow_list.isAuthorized(ritual_id, bytes(signature), bytes(data)) + + subscription.payForSubscription(auth_admin, 1, ONE_DAY, sender=adopter) + with ape.reverts("Encryptor slots full"): + allow_list.isAuthorized(ritual_id, bytes(signature), bytes(data)) + + allow_list.deauthorize(ritual_id, [creator], sender=auth_admin) + assert allow_list.isAuthorized(ritual_id, bytes(signature), bytes(data))