From 9ccb38b9de3c192a5bd11442086a263de5274dab Mon Sep 17 00:00:00 2001 From: Viktoriia Zotova Date: Fri, 17 Apr 2026 16:40:23 -0400 Subject: [PATCH 1/9] Shared subscription and allow list --- .../coordination/SharedAllowList.sol | 133 ++++++ .../subscription/SharedSubscription.sol | 426 ++++++++++++++++++ 2 files changed, 559 insertions(+) create mode 100644 contracts/contracts/coordination/SharedAllowList.sol create mode 100644 contracts/contracts/coordination/subscription/SharedSubscription.sol diff --git a/contracts/contracts/coordination/SharedAllowList.sol b/contracts/contracts/coordination/SharedAllowList.sol new file mode 100644 index 000000000..deb4d387b --- /dev/null +++ b/contracts/contracts/coordination/SharedAllowList.sol @@ -0,0 +1,133 @@ +// 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(address authAdmin => mapping(bytes32 lookupKey => bool)) public authAdmins; + mapping(bytes32 lookupKey => address authAdmin) internal lookupKeys; + + /** + * @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), "Contracts cannot be zero addresses"); + require(_coordinator.numberOfRituals() >= 0, "Invalid coordinator"); + coordinator = _coordinator; + _disableInitializers(); + } + + /** + * @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 { + for (uint256 i = 0; i < addresses.length; i++) { + bytes32 lookupKey = LookupKey.lookupKey(ritualId, addresses[i]); + require( + authAdmins[msg.sender][lookupKey], + "Encryptor has not been previously authorized by the sender" + ); + } + 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 = lookupKeys[lookupKey]; + + IFeeModel feeModel = coordinator.getFeeModel(ritualId); + SharedSubscription(address(feeModel)).beforeIsAuthorized(authAdmin, ritualId); + + return authAdmins[authAdmin][lookupKey]; + } + + 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 + require(authAdmins[msg.sender][lookupKey] != value, "Authorization already set"); + authAdmins[msg.sender][lookupKey] = value; + if (value) { + require( + lookupKeys[lookupKey] == address(0), + "Address authorized by different admin" + ); + lookupKeys[lookupKey] = msg.sender; + } else { + require( + lookupKeys[lookupKey] == msg.sender, + "Address authorized by different admin" + ); + lookupKeys[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..f5e1f4f46 --- /dev/null +++ b/contracts/contracts/coordination/subscription/SharedSubscription.sol @@ -0,0 +1,426 @@ +// 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 "./EncryptorSlotsSubscription.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 AuthAdminInfo { + uint32 startOfSubscription; + uint256 usedEncryptorSlots; + mapping(uint256 periodNumber => Billing billing) billingInfo; + } + + struct Billing { + bool paid; + uint128 encryptorSlots; // pre-paid encryptor slots for the billing period + } + + uint32 public constant INACTIVE_RITUAL_ID = type(uint32).max; + uint256 public constant INCREASE_BASE = 10000; + + Coordinator public immutable coordinator; + IEncryptionAuthorizer public immutable accessController; + IERC20 public immutable feeToken; + + uint32 public immutable subscriptionPeriodDuration; + address public immutable adopterSetter; + + uint256 public immutable initialBaseFeeRate; + uint256 public immutable baseFeeRateIncrease; + uint256 public immutable encryptorFeeRate; + + uint32 public activeRitualId; + mapping(address authAdmin => AuthAdminInfo authAdminStruct) public authAdminInfo; + address public adopter; + + uint256[20] private gap; + + /** + * @notice Emitted when a subscription is spent + * @param treasury The address of the treasury + * @param amount The amount withdrawn + */ + event WithdrawalToTreasury(address indexed treasury, uint256 amount); + + /** + * @notice Emitted when a subscription is paid + * @param subscriber The address of the subscriber + * @param authAdmin Autharization 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, + uint128 encryptorSlots, + uint32 endOfSubscription + ); + + /** + * @notice Emitted when additional encryptor slots are paid + * @param sponsor The address that paid for the slots + * @param authAdmin Autharization admin that was paid + * @param amount The amount paid + * @param encryptorSlots Number of encryptor slots + * @param endOfCurrentPeriod End timestamp of the current billing period + */ + event EncryptorSlotsPaid( + address indexed sponsor, + address indexed authAdmin, + uint256 amount, + uint128 encryptorSlots, + uint32 endOfCurrentPeriod + ); + + /** + * @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 + * @param _initialBaseFeeRate Fee rate per node per second + * @param _baseFeeRateIncrease Increase of base fee rate per each period (fraction of INCREASE_BASE) + * @param _encryptorFeeRate Fee rate per encryptor per second + * @param _subscriptionPeriodDuration Maximum duration of subscription period + */ + constructor( + Coordinator _coordinator, + IEncryptionAuthorizer _accessController, + IERC20 _feeToken, + address _adopterSetter, + uint256 _initialBaseFeeRate, + uint256 _baseFeeRateIncrease, + uint256 _encryptorFeeRate, + uint32 _subscriptionPeriodDuration + ) { + 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( + _baseFeeRateIncrease < INCREASE_BASE, + "Base fee rate increase must be fraction of INCREASE_BASE" + ); + require(address(_coordinator) != address(0), "Coordinator cannot be the zero address"); + coordinator = _coordinator; + feeToken = _feeToken; + adopterSetter = _adopterSetter; + initialBaseFeeRate = _initialBaseFeeRate; + baseFeeRateIncrease = _baseFeeRateIncrease; + encryptorFeeRate = _encryptorFeeRate; + accessController = _accessController; + subscriptionPeriodDuration = _subscriptionPeriodDuration; + _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 + */ + function initialize(address _treasury) external initializer { + activeRitualId = INACTIVE_RITUAL_ID; + __Ownable_init(_treasury); + } + + 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 baseFees(address authAdmin) public view returns (uint256) { + uint256 currentPeriodNumber = getCurrentPeriodNumber(authAdmin); + return baseFees(currentPeriodNumber); + } + + /// @dev potential overflow after 15-16 periods + function baseFees(uint256 periodNumber) public view returns (uint256) { + uint256 baseFeeRate = initialBaseFeeRate * + (INCREASE_BASE + baseFeeRateIncrease) ** periodNumber; + return (baseFeeRate * subscriptionPeriodDuration) / (INCREASE_BASE ** periodNumber); + } + + function encryptorFees(uint128 encryptorSlots, uint32 duration) public view returns (uint256) { + return encryptorFeeRate * duration * encryptorSlots; + } + + function isPeriodPaid(address authAdmin, uint256 periodNumber) public view returns (bool) { + return authAdminInfo[authAdmin].billingInfo[periodNumber].paid; + } + + function getPaidEncryptorSlots( + address authAdmin, + uint256 periodNumber + ) public view returns (uint256) { + return authAdminInfo[authAdmin].billingInfo[periodNumber].encryptorSlots; + } + + /** + * + * @notice Pays for the closest unpaid subscription period (either the current or the next) + * @param encryptorSlots Number of slots for encryptors + */ + function payForSubscription(address authAdmin, uint128 encryptorSlots) external { + uint256 fees = processPaymentForSubscription(authAdmin, encryptorSlots); + feeToken.safeTransferFrom(msg.sender, address(this), fees); + } + + /** + * @notice Process payment for the closest unpaid subscription period (either the current or the next) + * @param encryptorSlots Number of slots for encryptors + */ + function processPaymentForSubscription( + address authAdmin, + uint128 encryptorSlots + ) internal returns (uint256 fees) { + uint256 currentPeriodNumber = getCurrentPeriodNumber(authAdmin); + AuthAdminInfo storage authAdminStruct = authAdminInfo[authAdmin]; + require( + !authAdminStruct.billingInfo[currentPeriodNumber + 1].paid, + "Next billing period already paid" + ); // TODO until we will have refunds + require( + authAdminStruct.startOfSubscription == 0 || + getEndOfSubscription(authAdmin) >= block.timestamp, + "Subscription is over" + ); + + uint256 periodNumber = currentPeriodNumber; + if (authAdminStruct.billingInfo[periodNumber].paid) { + periodNumber++; + } + Billing storage billing = authAdminStruct.billingInfo[periodNumber]; + billing.paid = true; + billing.encryptorSlots = encryptorSlots; + + fees = baseFees(periodNumber) + encryptorFees(encryptorSlots, subscriptionPeriodDuration); + emit SubscriptionPaid( + msg.sender, + authAdmin, + fees, + encryptorSlots, + getEndOfSubscription(authAdmin) + ); + } + + /** + * @notice Pays for additional encryptor slots in the current period + * @param additionalEncryptorSlots Additional number of slots for encryptors + */ + function payForEncryptorSlots(address authAdmin, uint128 additionalEncryptorSlots) external { + uint256 fees = processPaymentForEncryptorSlots(authAdmin, additionalEncryptorSlots); + feeToken.safeTransferFrom(msg.sender, address(this), fees); + } + + /** + * @notice Process payment for additional encryptor slots in the current period + * @param additionalEncryptorSlots Additional number of slots for encryptors + */ + function processPaymentForEncryptorSlots( + address authAdmin, + uint128 additionalEncryptorSlots + ) internal returns (uint256 fees) { + uint256 currentPeriodNumber = getCurrentPeriodNumber(authAdmin); + AuthAdminInfo storage authAdminStruct = authAdminInfo[authAdmin]; + Billing storage billing = authAdminStruct.billingInfo[currentPeriodNumber]; + require(billing.paid, "Current billing period must be paid"); + + uint32 duration = subscriptionPeriodDuration; + uint32 endOfCurrentPeriod = 0; + if (authAdminStruct.startOfSubscription != 0) { + endOfCurrentPeriod = uint32( + authAdminStruct.startOfSubscription + + (currentPeriodNumber + 1) * + subscriptionPeriodDuration + ); + duration = endOfCurrentPeriod - uint32(block.timestamp); + } + + uint256 fees = encryptorFees(additionalEncryptorSlots, duration); + billing.encryptorSlots += additionalEncryptorSlots; + + emit EncryptorSlotsPaid( + msg.sender, + authAdmin, + fees, + additionalEncryptorSlots, + endOfCurrentPeriod + ); + return fees; + } + + /** + * @notice Withdraws the fees to the treasury + */ + function withdrawToTreasury() external { + uint256 amount = feeToken.balanceOf(address(this)); + require(0 < amount, "Insufficient balance available"); + feeToken.safeTransfer(owner(), amount); + emit WithdrawalToTreasury(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 getCurrentPeriodNumber(address authAdmin) public view returns (uint256) { + AuthAdminInfo storage authAdminStruct = authAdminInfo[authAdmin]; + if (authAdminStruct.startOfSubscription == 0) { + return 0; + } + return (block.timestamp - authAdminStruct.startOfSubscription) / subscriptionPeriodDuration; + } + + function getEndOfSubscription( + address authAdmin + ) public view returns (uint32 endOfSubscription) { + AuthAdminInfo storage authAdminStruct = authAdminInfo[authAdmin]; + if (authAdminStruct.startOfSubscription == 0) { + return 0; + } + + uint256 currentPeriodNumber = getCurrentPeriodNumber(authAdmin); + if (currentPeriodNumber == 0 && !isPeriodPaid(authAdmin, currentPeriodNumber)) { + return 0; + } + + if (isPeriodPaid(authAdmin, currentPeriodNumber)) { + while (isPeriodPaid(authAdmin, currentPeriodNumber)) { + currentPeriodNumber++; + } + } else { + while (!isPeriodPaid(authAdmin, currentPeriodNumber)) { + currentPeriodNumber--; + } + currentPeriodNumber++; + } + endOfSubscription = uint32( + authAdminStruct.startOfSubscription + currentPeriodNumber * subscriptionPeriodDuration + ); + } + + function beforeSetAuthorization( + address authAdmin, + uint32, + address[] calldata addresses, + bool value + ) public virtual { + require(block.timestamp <= getEndOfSubscription(authAdmin), "Subscription has expired"); + AuthAdminInfo storage authAdminStruct = authAdminInfo[authAdmin]; + if (value) { + uint256 currentPeriodNumber = getCurrentPeriodNumber(authAdmin); + uint256 encryptorSlots = isPeriodPaid(authAdmin, currentPeriodNumber) + ? getPaidEncryptorSlots(authAdmin, currentPeriodNumber) + : 0; + authAdminStruct.usedEncryptorSlots += addresses.length; + require( + authAdminStruct.usedEncryptorSlots <= encryptorSlots, + "Encryptors slots filled up" + ); + } else { + if (authAdminStruct.usedEncryptorSlots >= addresses.length) { + authAdminStruct.usedEncryptorSlots -= addresses.length; + } else { + authAdminStruct.usedEncryptorSlots = 0; + } + } + } + + function beforeIsAuthorized(address authAdmin, uint32) public view virtual { + require(block.timestamp <= getEndOfSubscription(authAdmin), "Subscription has expired"); + // used encryptor slots must be paid + if (block.timestamp <= getEndOfSubscription(authAdmin)) { + uint256 currentPeriodNumber = getCurrentPeriodNumber(authAdmin); + require( + authAdminInfo[authAdmin].usedEncryptorSlots <= + getPaidEncryptorSlots(authAdmin, currentPeriodNumber), + "Encryptors slots filled up" + ); + } + } + + /** + * @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"); + } +} From 5ed462ab6bc39bcc349aa70902e1d44c529621b2 Mon Sep 17 00:00:00 2001 From: Viktoriia Zotova Date: Mon, 20 Apr 2026 15:45:45 -0400 Subject: [PATCH 2/9] Prevuild packages for subscription and slots --- .../subscription/SharedSubscription.sol | 74 ++++++++----------- 1 file changed, 31 insertions(+), 43 deletions(-) diff --git a/contracts/contracts/coordination/subscription/SharedSubscription.sol b/contracts/contracts/coordination/subscription/SharedSubscription.sol index f5e1f4f46..272789cde 100644 --- a/contracts/contracts/coordination/subscription/SharedSubscription.sol +++ b/contracts/contracts/coordination/subscription/SharedSubscription.sol @@ -29,17 +29,16 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { } uint32 public constant INACTIVE_RITUAL_ID = type(uint32).max; - uint256 public constant INCREASE_BASE = 10000; Coordinator public immutable coordinator; IEncryptionAuthorizer public immutable accessController; IERC20 public immutable feeToken; - uint32 public immutable subscriptionPeriodDuration; + uint32 public immutable subscriptionPackageDuration; + uint32 public immutable subscriptionPackageEncryptors; address public immutable adopterSetter; - uint256 public immutable initialBaseFeeRate; - uint256 public immutable baseFeeRateIncrease; + uint256 public immutable baseFeeRate; uint256 public immutable encryptorFeeRate; uint32 public activeRitualId; @@ -94,20 +93,18 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { * @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 - * @param _initialBaseFeeRate Fee rate per node per second - * @param _baseFeeRateIncrease Increase of base fee rate per each period (fraction of INCREASE_BASE) + * @param _baseFeeRate Fee rate per node per second * @param _encryptorFeeRate Fee rate per encryptor per second - * @param _subscriptionPeriodDuration Maximum duration of subscription period + * @param _subscriptionPackageDuration Duration of subscription package */ constructor( Coordinator _coordinator, IEncryptionAuthorizer _accessController, IERC20 _feeToken, address _adopterSetter, - uint256 _initialBaseFeeRate, - uint256 _baseFeeRateIncrease, + uint256 _baseFeeRate, uint256 _encryptorFeeRate, - uint32 _subscriptionPeriodDuration + uint32 _subscriptionPackageDuration ) { require(address(_feeToken) != address(0), "Fee token cannot be the zero address"); require(_adopterSetter != address(0), "Adopter setter cannot be the zero address"); @@ -115,19 +112,14 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { address(_accessController) != address(0), "Access controller cannot be the zero address" ); - require( - _baseFeeRateIncrease < INCREASE_BASE, - "Base fee rate increase must be fraction of INCREASE_BASE" - ); require(address(_coordinator) != address(0), "Coordinator cannot be the zero address"); coordinator = _coordinator; feeToken = _feeToken; adopterSetter = _adopterSetter; - initialBaseFeeRate = _initialBaseFeeRate; - baseFeeRateIncrease = _baseFeeRateIncrease; + baseFeeRate = _baseFeeRate; encryptorFeeRate = _encryptorFeeRate; accessController = _accessController; - subscriptionPeriodDuration = _subscriptionPeriodDuration; + subscriptionPackageDuration = _subscriptionPackageDuration; _disableInitializers(); } @@ -169,16 +161,9 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { adopter = _adopter; } - function baseFees(address authAdmin) public view returns (uint256) { - uint256 currentPeriodNumber = getCurrentPeriodNumber(authAdmin); - return baseFees(currentPeriodNumber); - } - /// @dev potential overflow after 15-16 periods - function baseFees(uint256 periodNumber) public view returns (uint256) { - uint256 baseFeeRate = initialBaseFeeRate * - (INCREASE_BASE + baseFeeRateIncrease) ** periodNumber; - return (baseFeeRate * subscriptionPeriodDuration) / (INCREASE_BASE ** periodNumber); + function baseFees() public view returns (uint256) { + return baseFeeRate * subscriptionPackageDuration; } function encryptorFees(uint128 encryptorSlots, uint32 duration) public view returns (uint256) { @@ -199,20 +184,20 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { /** * * @notice Pays for the closest unpaid subscription period (either the current or the next) - * @param encryptorSlots Number of slots for encryptors + * @param encryptorPackages Number of encryptor packages */ - function payForSubscription(address authAdmin, uint128 encryptorSlots) external { - uint256 fees = processPaymentForSubscription(authAdmin, encryptorSlots); + function payForSubscription(address authAdmin, uint32 encryptorPackages) external { + uint256 fees = processPaymentForSubscription(authAdmin, encryptorPackages); feeToken.safeTransferFrom(msg.sender, address(this), fees); } /** * @notice Process payment for the closest unpaid subscription period (either the current or the next) - * @param encryptorSlots Number of slots for encryptors + * @param encryptorPackages Number of encryptor packages */ function processPaymentForSubscription( address authAdmin, - uint128 encryptorSlots + uint32 encryptorPackages ) internal returns (uint256 fees) { uint256 currentPeriodNumber = getCurrentPeriodNumber(authAdmin); AuthAdminInfo storage authAdminStruct = authAdminInfo[authAdmin]; @@ -232,51 +217,53 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { } Billing storage billing = authAdminStruct.billingInfo[periodNumber]; billing.paid = true; - billing.encryptorSlots = encryptorSlots; + billing.encryptorSlots = encryptorPackages * subscriptionPackageEncryptors; - fees = baseFees(periodNumber) + encryptorFees(encryptorSlots, subscriptionPeriodDuration); + fees = baseFees() + encryptorFees(billing.encryptorSlots, subscriptionPackageDuration); emit SubscriptionPaid( msg.sender, authAdmin, fees, - encryptorSlots, + billing.encryptorSlots, getEndOfSubscription(authAdmin) ); } /** * @notice Pays for additional encryptor slots in the current period - * @param additionalEncryptorSlots Additional number of slots for encryptors + * @param additionalEncryptorPackages Additional number of encryptor packages */ - function payForEncryptorSlots(address authAdmin, uint128 additionalEncryptorSlots) external { - uint256 fees = processPaymentForEncryptorSlots(authAdmin, additionalEncryptorSlots); + function payForEncryptorSlots(address authAdmin, uint32 additionalEncryptorPackages) external { + uint256 fees = processPaymentForEncryptorSlots(authAdmin, additionalEncryptorPackages); feeToken.safeTransferFrom(msg.sender, address(this), fees); } /** * @notice Process payment for additional encryptor slots in the current period - * @param additionalEncryptorSlots Additional number of slots for encryptors + * @param additionalEncryptorPackages Additional number of encryptor packages */ function processPaymentForEncryptorSlots( address authAdmin, - uint128 additionalEncryptorSlots + uint32 additionalEncryptorPackages ) internal returns (uint256 fees) { uint256 currentPeriodNumber = getCurrentPeriodNumber(authAdmin); AuthAdminInfo storage authAdminStruct = authAdminInfo[authAdmin]; Billing storage billing = authAdminStruct.billingInfo[currentPeriodNumber]; require(billing.paid, "Current billing period must be paid"); - uint32 duration = subscriptionPeriodDuration; + uint32 duration = subscriptionPackageDuration; uint32 endOfCurrentPeriod = 0; if (authAdminStruct.startOfSubscription != 0) { endOfCurrentPeriod = uint32( authAdminStruct.startOfSubscription + (currentPeriodNumber + 1) * - subscriptionPeriodDuration + subscriptionPackageDuration ); duration = endOfCurrentPeriod - uint32(block.timestamp); } + uint128 additionalEncryptorSlots = additionalEncryptorPackages * + subscriptionPackageEncryptors; uint256 fees = encryptorFees(additionalEncryptorSlots, duration); billing.encryptorSlots += additionalEncryptorSlots; @@ -330,7 +317,8 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { if (authAdminStruct.startOfSubscription == 0) { return 0; } - return (block.timestamp - authAdminStruct.startOfSubscription) / subscriptionPeriodDuration; + return + (block.timestamp - authAdminStruct.startOfSubscription) / subscriptionPackageDuration; } function getEndOfSubscription( @@ -357,7 +345,7 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { currentPeriodNumber++; } endOfSubscription = uint32( - authAdminStruct.startOfSubscription + currentPeriodNumber * subscriptionPeriodDuration + authAdminStruct.startOfSubscription + currentPeriodNumber * subscriptionPackageDuration ); } From 0104d47bf1e99ca7e2a7d4cd31093b98894a6f90 Mon Sep 17 00:00:00 2001 From: Viktoriia Zotova Date: Tue, 21 Apr 2026 14:58:55 -0400 Subject: [PATCH 3/9] Removes base fees from SharedSubscription --- .../coordination/subscription/SharedSubscription.sol | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/contracts/contracts/coordination/subscription/SharedSubscription.sol b/contracts/contracts/coordination/subscription/SharedSubscription.sol index 272789cde..70c10847a 100644 --- a/contracts/contracts/coordination/subscription/SharedSubscription.sol +++ b/contracts/contracts/coordination/subscription/SharedSubscription.sol @@ -38,7 +38,6 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { uint32 public immutable subscriptionPackageEncryptors; address public immutable adopterSetter; - uint256 public immutable baseFeeRate; uint256 public immutable encryptorFeeRate; uint32 public activeRitualId; @@ -93,7 +92,6 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { * @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 - * @param _baseFeeRate Fee rate per node per second * @param _encryptorFeeRate Fee rate per encryptor per second * @param _subscriptionPackageDuration Duration of subscription package */ @@ -102,7 +100,6 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { IEncryptionAuthorizer _accessController, IERC20 _feeToken, address _adopterSetter, - uint256 _baseFeeRate, uint256 _encryptorFeeRate, uint32 _subscriptionPackageDuration ) { @@ -116,7 +113,6 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { coordinator = _coordinator; feeToken = _feeToken; adopterSetter = _adopterSetter; - baseFeeRate = _baseFeeRate; encryptorFeeRate = _encryptorFeeRate; accessController = _accessController; subscriptionPackageDuration = _subscriptionPackageDuration; @@ -161,11 +157,6 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { adopter = _adopter; } - /// @dev potential overflow after 15-16 periods - function baseFees() public view returns (uint256) { - return baseFeeRate * subscriptionPackageDuration; - } - function encryptorFees(uint128 encryptorSlots, uint32 duration) public view returns (uint256) { return encryptorFeeRate * duration * encryptorSlots; } @@ -219,7 +210,7 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { billing.paid = true; billing.encryptorSlots = encryptorPackages * subscriptionPackageEncryptors; - fees = baseFees() + encryptorFees(billing.encryptorSlots, subscriptionPackageDuration); + fees = encryptorFees(billing.encryptorSlots, subscriptionPackageDuration); emit SubscriptionPaid( msg.sender, authAdmin, From f4eaa335c2454ce28fc695dd69cb843afd19e9c5 Mon Sep 17 00:00:00 2001 From: Viktoriia Zotova Date: Thu, 23 Apr 2026 13:07:57 -0400 Subject: [PATCH 4/9] Simplify billing structure to store only one package --- .../subscription/SharedSubscription.sol | 230 ++++-------------- 1 file changed, 52 insertions(+), 178 deletions(-) diff --git a/contracts/contracts/coordination/subscription/SharedSubscription.sol b/contracts/contracts/coordination/subscription/SharedSubscription.sol index 70c10847a..1d083ff29 100644 --- a/contracts/contracts/coordination/subscription/SharedSubscription.sol +++ b/contracts/contracts/coordination/subscription/SharedSubscription.sol @@ -17,15 +17,11 @@ import "../IFeeModel.sol"; contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { using SafeERC20 for IERC20; - struct AuthAdminInfo { - uint32 startOfSubscription; - uint256 usedEncryptorSlots; - mapping(uint256 periodNumber => Billing billing) billingInfo; - } - struct Billing { - bool paid; - uint128 encryptorSlots; // pre-paid encryptor slots for the billing period + uint256 encryptorSlots; + uint256 usedEncryptorSlots; + uint256 endOfSubscription; + uint256 encryptorFeeRate; } uint32 public constant INACTIVE_RITUAL_ID = type(uint32).max; @@ -34,14 +30,14 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { IEncryptionAuthorizer public immutable accessController; IERC20 public immutable feeToken; - uint32 public immutable subscriptionPackageDuration; - uint32 public immutable subscriptionPackageEncryptors; + uint256 public immutable subscriptionPackageDuration; + uint256 public immutable subscriptionPackageEncryptors; address public immutable adopterSetter; uint256 public immutable encryptorFeeRate; uint32 public activeRitualId; - mapping(address authAdmin => AuthAdminInfo authAdminStruct) public authAdminInfo; + mapping(address authAdmin => Billing billingInfo) public billing; address public adopter; uint256[20] private gap; @@ -65,24 +61,8 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { address indexed subscriber, address indexed authAdmin, uint256 amount, - uint128 encryptorSlots, - uint32 endOfSubscription - ); - - /** - * @notice Emitted when additional encryptor slots are paid - * @param sponsor The address that paid for the slots - * @param authAdmin Autharization admin that was paid - * @param amount The amount paid - * @param encryptorSlots Number of encryptor slots - * @param endOfCurrentPeriod End timestamp of the current billing period - */ - event EncryptorSlotsPaid( - address indexed sponsor, - address indexed authAdmin, - uint256 amount, - uint128 encryptorSlots, - uint32 endOfCurrentPeriod + uint256 encryptorSlots, + uint256 endOfSubscription ); /** @@ -101,7 +81,7 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { IERC20 _feeToken, address _adopterSetter, uint256 _encryptorFeeRate, - uint32 _subscriptionPackageDuration + uint256 _subscriptionPackageDuration ) { require(address(_feeToken) != address(0), "Fee token cannot be the zero address"); require(_adopterSetter != address(0), "Adopter setter cannot be the zero address"); @@ -157,115 +137,53 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { adopter = _adopter; } - function encryptorFees(uint128 encryptorSlots, uint32 duration) public view returns (uint256) { - return encryptorFeeRate * duration * encryptorSlots; - } - - function isPeriodPaid(address authAdmin, uint256 periodNumber) public view returns (bool) { - return authAdminInfo[authAdmin].billingInfo[periodNumber].paid; - } - - function getPaidEncryptorSlots( - address authAdmin, - uint256 periodNumber + function encryptorFees( + uint256 encryptorFeeRate, + uint256 encryptorSlots, + uint256 duration ) public view returns (uint256) { - return authAdminInfo[authAdmin].billingInfo[periodNumber].encryptorSlots; - } - - /** - * - * @notice Pays for the closest unpaid subscription period (either the current or the next) - * @param encryptorPackages Number of encryptor packages - */ - function payForSubscription(address authAdmin, uint32 encryptorPackages) external { - uint256 fees = processPaymentForSubscription(authAdmin, encryptorPackages); - feeToken.safeTransferFrom(msg.sender, address(this), fees); + return encryptorFeeRate * duration * encryptorSlots; } /** - * @notice Process payment for the closest unpaid subscription period (either the current or the next) + * @notice Process payment for the chosen package + * @param authAdmin Address of the admin * @param encryptorPackages Number of encryptor packages */ - function processPaymentForSubscription( + function payForSubscription( address authAdmin, - uint32 encryptorPackages - ) internal returns (uint256 fees) { - uint256 currentPeriodNumber = getCurrentPeriodNumber(authAdmin); - AuthAdminInfo storage authAdminStruct = authAdminInfo[authAdmin]; + uint256 encryptorPackages + ) external returns (uint256 fees) { + uint256 duration = subscriptionPackageDuration; + Billing storage billingInfo = billing[authAdmin]; require( - !authAdminStruct.billingInfo[currentPeriodNumber + 1].paid, - "Next billing period already paid" - ); // TODO until we will have refunds - require( - authAdminStruct.startOfSubscription == 0 || - getEndOfSubscription(authAdmin) >= block.timestamp, - "Subscription is over" + billingInfo.endOfSubscription < block.timestamp + duration, + "Renewal allowed only to later end of subscription" ); - uint256 periodNumber = currentPeriodNumber; - if (authAdminStruct.billingInfo[periodNumber].paid) { - periodNumber++; - } - Billing storage billing = authAdminStruct.billingInfo[periodNumber]; - billing.paid = true; - billing.encryptorSlots = encryptorPackages * subscriptionPackageEncryptors; - - fees = encryptorFees(billing.encryptorSlots, subscriptionPackageDuration); - emit SubscriptionPaid( - msg.sender, - authAdmin, - fees, - billing.encryptorSlots, - getEndOfSubscription(authAdmin) - ); - } - - /** - * @notice Pays for additional encryptor slots in the current period - * @param additionalEncryptorPackages Additional number of encryptor packages - */ - function payForEncryptorSlots(address authAdmin, uint32 additionalEncryptorPackages) external { - uint256 fees = processPaymentForEncryptorSlots(authAdmin, additionalEncryptorPackages); - feeToken.safeTransferFrom(msg.sender, address(this), fees); - } - - /** - * @notice Process payment for additional encryptor slots in the current period - * @param additionalEncryptorPackages Additional number of encryptor packages - */ - function processPaymentForEncryptorSlots( - address authAdmin, - uint32 additionalEncryptorPackages - ) internal returns (uint256 fees) { - uint256 currentPeriodNumber = getCurrentPeriodNumber(authAdmin); - AuthAdminInfo storage authAdminStruct = authAdminInfo[authAdmin]; - Billing storage billing = authAdminStruct.billingInfo[currentPeriodNumber]; - require(billing.paid, "Current billing period must be paid"); - - uint32 duration = subscriptionPackageDuration; - uint32 endOfCurrentPeriod = 0; - if (authAdminStruct.startOfSubscription != 0) { - endOfCurrentPeriod = uint32( - authAdminStruct.startOfSubscription + - (currentPeriodNumber + 1) * - subscriptionPackageDuration + uint256 discount = 0; + if (billingInfo.endOfSubscription > block.timestamp) { + uint256 restOfSubscription = billingInfo.endOfSubscription - block.timestamp; + discount = encryptorFees( + billingInfo.encryptorFeeRate, + billingInfo.encryptorSlots, + restOfSubscription ); - duration = endOfCurrentPeriod - uint32(block.timestamp); } - uint128 additionalEncryptorSlots = additionalEncryptorPackages * - subscriptionPackageEncryptors; - uint256 fees = encryptorFees(additionalEncryptorSlots, duration); - billing.encryptorSlots += additionalEncryptorSlots; + billingInfo.encryptorSlots = encryptorPackages * subscriptionPackageEncryptors; + billingInfo.endOfSubscription = block.timestamp + duration; + billingInfo.encryptorFeeRate = encryptorFeeRate; - emit EncryptorSlotsPaid( + fees = encryptorFees(encryptorFeeRate, billingInfo.encryptorSlots, duration) - discount; + emit SubscriptionPaid( msg.sender, authAdmin, fees, - additionalEncryptorSlots, - endOfCurrentPeriod + billingInfo.encryptorSlots, + billingInfo.endOfSubscription ); - return fees; + feeToken.safeTransferFrom(msg.sender, address(this), fees); } /** @@ -303,81 +221,37 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { activeRitualId = ritualId; } - function getCurrentPeriodNumber(address authAdmin) public view returns (uint256) { - AuthAdminInfo storage authAdminStruct = authAdminInfo[authAdmin]; - if (authAdminStruct.startOfSubscription == 0) { - return 0; - } - return - (block.timestamp - authAdminStruct.startOfSubscription) / subscriptionPackageDuration; - } - - function getEndOfSubscription( - address authAdmin - ) public view returns (uint32 endOfSubscription) { - AuthAdminInfo storage authAdminStruct = authAdminInfo[authAdmin]; - if (authAdminStruct.startOfSubscription == 0) { - return 0; - } - - uint256 currentPeriodNumber = getCurrentPeriodNumber(authAdmin); - if (currentPeriodNumber == 0 && !isPeriodPaid(authAdmin, currentPeriodNumber)) { - return 0; - } - - if (isPeriodPaid(authAdmin, currentPeriodNumber)) { - while (isPeriodPaid(authAdmin, currentPeriodNumber)) { - currentPeriodNumber++; - } - } else { - while (!isPeriodPaid(authAdmin, currentPeriodNumber)) { - currentPeriodNumber--; - } - currentPeriodNumber++; - } - endOfSubscription = uint32( - authAdminStruct.startOfSubscription + currentPeriodNumber * subscriptionPackageDuration - ); - } - function beforeSetAuthorization( address authAdmin, uint32, address[] calldata addresses, bool value ) public virtual { - require(block.timestamp <= getEndOfSubscription(authAdmin), "Subscription has expired"); - AuthAdminInfo storage authAdminStruct = authAdminInfo[authAdmin]; + Billing storage billingInfo = billing[authAdmin]; + require(block.timestamp <= billingInfo.endOfSubscription, "Subscription has expired"); if (value) { - uint256 currentPeriodNumber = getCurrentPeriodNumber(authAdmin); - uint256 encryptorSlots = isPeriodPaid(authAdmin, currentPeriodNumber) - ? getPaidEncryptorSlots(authAdmin, currentPeriodNumber) - : 0; - authAdminStruct.usedEncryptorSlots += addresses.length; + billingInfo.usedEncryptorSlots += addresses.length; require( - authAdminStruct.usedEncryptorSlots <= encryptorSlots, + billingInfo.usedEncryptorSlots <= billingInfo.encryptorSlots, "Encryptors slots filled up" ); } else { - if (authAdminStruct.usedEncryptorSlots >= addresses.length) { - authAdminStruct.usedEncryptorSlots -= addresses.length; + if (billingInfo.usedEncryptorSlots >= addresses.length) { + billingInfo.usedEncryptorSlots -= addresses.length; } else { - authAdminStruct.usedEncryptorSlots = 0; + billingInfo.usedEncryptorSlots = 0; } } } function beforeIsAuthorized(address authAdmin, uint32) public view virtual { - require(block.timestamp <= getEndOfSubscription(authAdmin), "Subscription has expired"); + Billing storage billingInfo = billing[authAdmin]; + require(block.timestamp <= billingInfo.endOfSubscription, "Subscription has expired"); // used encryptor slots must be paid - if (block.timestamp <= getEndOfSubscription(authAdmin)) { - uint256 currentPeriodNumber = getCurrentPeriodNumber(authAdmin); - require( - authAdminInfo[authAdmin].usedEncryptorSlots <= - getPaidEncryptorSlots(authAdmin, currentPeriodNumber), - "Encryptors slots filled up" - ); - } + require( + billingInfo.usedEncryptorSlots <= billingInfo.encryptorSlots, + "Encryptors slots filled up" + ); } /** From 29217a5a34e9ce2750b3fb9536f318c20634cfba Mon Sep 17 00:00:00 2001 From: Viktoriia Zotova Date: Thu, 23 Apr 2026 13:29:02 -0400 Subject: [PATCH 5/9] Introduce packages for fees in SharedSubscription --- .../subscription/SharedSubscription.sol | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/contracts/contracts/coordination/subscription/SharedSubscription.sol b/contracts/contracts/coordination/subscription/SharedSubscription.sol index 1d083ff29..3b2d811f7 100644 --- a/contracts/contracts/coordination/subscription/SharedSubscription.sol +++ b/contracts/contracts/coordination/subscription/SharedSubscription.sol @@ -30,12 +30,11 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { IEncryptionAuthorizer public immutable accessController; IERC20 public immutable feeToken; - uint256 public immutable subscriptionPackageDuration; - uint256 public immutable subscriptionPackageEncryptors; address public immutable adopterSetter; uint256 public immutable encryptorFeeRate; + uint256[3][10] public feePackages; uint32 public activeRitualId; mapping(address authAdmin => Billing billingInfo) public billing; address public adopter; @@ -72,16 +71,14 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { * @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 - * @param _encryptorFeeRate Fee rate per encryptor per second - * @param _subscriptionPackageDuration Duration of subscription package + * @param _feePackages Fee packages [duration(sec), encryptors, feeRate] */ constructor( Coordinator _coordinator, IEncryptionAuthorizer _accessController, IERC20 _feeToken, address _adopterSetter, - uint256 _encryptorFeeRate, - uint256 _subscriptionPackageDuration + uint256[3][10] memory _feePackages ) { require(address(_feeToken) != address(0), "Fee token cannot be the zero address"); require(_adopterSetter != address(0), "Adopter setter cannot be the zero address"); @@ -93,9 +90,8 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { coordinator = _coordinator; feeToken = _feeToken; adopterSetter = _adopterSetter; - encryptorFeeRate = _encryptorFeeRate; accessController = _accessController; - subscriptionPackageDuration = _subscriptionPackageDuration; + feePackages = _feePackages; _disableInitializers(); } @@ -137,6 +133,19 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { 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, @@ -148,16 +157,17 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { /** * @notice Process payment for the chosen package * @param authAdmin Address of the admin - * @param encryptorPackages Number of encryptor packages + * @param encryptorSlots Number of encryptor slots + * @param packageDuration Requested duration */ function payForSubscription( address authAdmin, - uint256 encryptorPackages + uint256 encryptorSlots, + uint256 packageDuration ) external returns (uint256 fees) { - uint256 duration = subscriptionPackageDuration; Billing storage billingInfo = billing[authAdmin]; require( - billingInfo.endOfSubscription < block.timestamp + duration, + billingInfo.endOfSubscription < block.timestamp + packageDuration, "Renewal allowed only to later end of subscription" ); @@ -171,11 +181,13 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { ); } - billingInfo.encryptorSlots = encryptorPackages * subscriptionPackageEncryptors; - billingInfo.endOfSubscription = block.timestamp + duration; - billingInfo.encryptorFeeRate = encryptorFeeRate; + billingInfo.encryptorSlots = encryptorSlots; + billingInfo.endOfSubscription = block.timestamp + packageDuration; + billingInfo.encryptorFeeRate = getEncryptorFeeRate(encryptorSlots, packageDuration); - fees = encryptorFees(encryptorFeeRate, billingInfo.encryptorSlots, duration) - discount; + fees = + encryptorFees(billingInfo.encryptorFeeRate, encryptorSlots, packageDuration) - + discount; emit SubscriptionPaid( msg.sender, authAdmin, From b13d8bf3129a66eb3a1efd4800395aa0fe3c1a83 Mon Sep 17 00:00:00 2001 From: Viktoriia Zotova Date: Fri, 24 Apr 2026 12:50:04 -0400 Subject: [PATCH 6/9] Add more conditions for shared subscription payment --- .../subscription/SharedSubscription.sol | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/contracts/contracts/coordination/subscription/SharedSubscription.sol b/contracts/contracts/coordination/subscription/SharedSubscription.sol index 3b2d811f7..39c53950b 100644 --- a/contracts/contracts/coordination/subscription/SharedSubscription.sol +++ b/contracts/contracts/coordination/subscription/SharedSubscription.sol @@ -171,23 +171,31 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { "Renewal allowed only to later end of subscription" ); + uint256 encryptorFeeRate = getEncryptorFeeRate(encryptorSlots, packageDuration); uint256 discount = 0; - if (billingInfo.endOfSubscription > block.timestamp) { - uint256 restOfSubscription = billingInfo.endOfSubscription - block.timestamp; - discount = encryptorFees( - billingInfo.encryptorFeeRate, - billingInfo.encryptorSlots, - restOfSubscription - ); - } + if ( + encryptorFeeRate == billingInfo.encryptorFeeRate && + billingInfo.encryptorSlots == encryptorSlots + ) { + 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 = getEncryptorFeeRate(encryptorSlots, packageDuration); + billingInfo.encryptorSlots = encryptorSlots; + billingInfo.endOfSubscription = block.timestamp + packageDuration; + billingInfo.encryptorFeeRate = encryptorFeeRate; + } - fees = - encryptorFees(billingInfo.encryptorFeeRate, encryptorSlots, packageDuration) - - discount; + fees = encryptorFees(encryptorFeeRate, encryptorSlots, packageDuration); + require(discount < fees, "Discount can not be more than new package fees"); + fees -= discount; emit SubscriptionPaid( msg.sender, authAdmin, From f7a287e55ffc3ba9c4ce265bb5fe7013ac28a9ab Mon Sep 17 00:00:00 2001 From: Viktoriia Zotova Date: Wed, 29 Apr 2026 16:00:56 -0400 Subject: [PATCH 7/9] Tests for SharedAllowList --- .../coordination/SharedAllowList.sol | 32 +++--- .../subscription/SharedSubscription.sol | 6 +- contracts/test/SharedAllowListTestSet.sol | 54 +++++++++ tests/test_shared_allow_list.py | 104 ++++++++++++++++++ 4 files changed, 177 insertions(+), 19 deletions(-) create mode 100644 contracts/test/SharedAllowListTestSet.sol create mode 100644 tests/test_shared_allow_list.py diff --git a/contracts/contracts/coordination/SharedAllowList.sol b/contracts/contracts/coordination/SharedAllowList.sol index deb4d387b..e6dd11ec5 100644 --- a/contracts/contracts/coordination/SharedAllowList.sol +++ b/contracts/contracts/coordination/SharedAllowList.sol @@ -19,8 +19,7 @@ contract SharedAllowList is IEncryptionAuthorizer, Initializable { Coordinator public immutable coordinator; uint32 public constant MAX_AUTH_ACTIONS = 100; - mapping(address authAdmin => mapping(bytes32 lookupKey => bool)) public authAdmins; - mapping(bytes32 lookupKey => address authAdmin) internal lookupKeys; + mapping(bytes32 lookupKey => address authAdmin) internal authAdmins; /** * @notice Emitted when an address authorization is set @@ -48,6 +47,11 @@ contract SharedAllowList is IEncryptionAuthorizer, Initializable { _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 @@ -63,13 +67,6 @@ contract SharedAllowList is IEncryptionAuthorizer, Initializable { * @param addresses The addresses to be deauthorized */ function deauthorize(uint32 ritualId, address[] calldata addresses) external { - for (uint256 i = 0; i < addresses.length; i++) { - bytes32 lookupKey = LookupKey.lookupKey(ritualId, addresses[i]); - require( - authAdmins[msg.sender][lookupKey], - "Encryptor has not been previously authorized by the sender" - ); - } setAuthorizations(ritualId, addresses, false); } @@ -88,12 +85,15 @@ contract SharedAllowList is IEncryptionAuthorizer, Initializable { address recoveredAddress = digest.toEthSignedMessageHash().recover(evidence); bytes32 lookupKey = LookupKey.lookupKey(ritualId, recoveredAddress); - address authAdmin = lookupKeys[lookupKey]; + address authAdmin = authAdmins[lookupKey]; + if (authAdmin == address(0)) { + return false; + } IFeeModel feeModel = coordinator.getFeeModel(ritualId); SharedSubscription(address(feeModel)).beforeIsAuthorized(authAdmin, ritualId); - return authAdmins[authAdmin][lookupKey]; + return true; } function setAuthorizations(uint32 ritualId, address[] calldata addresses, bool value) internal { @@ -112,20 +112,18 @@ contract SharedAllowList is IEncryptionAuthorizer, Initializable { for (uint256 i = 0; i < addresses.length; i++) { bytes32 lookupKey = LookupKey.lookupKey(ritualId, addresses[i]); // prevent reusing same address - require(authAdmins[msg.sender][lookupKey] != value, "Authorization already set"); - authAdmins[msg.sender][lookupKey] = value; if (value) { require( - lookupKeys[lookupKey] == address(0), + authAdmins[lookupKey] == address(0), "Address authorized by different admin" ); - lookupKeys[lookupKey] = msg.sender; + authAdmins[lookupKey] = msg.sender; } else { require( - lookupKeys[lookupKey] == msg.sender, + authAdmins[lookupKey] == msg.sender, "Address authorized by different admin" ); - lookupKeys[lookupKey] = address(0); + 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 index 39c53950b..dbb35ae86 100644 --- a/contracts/contracts/coordination/subscription/SharedSubscription.sol +++ b/contracts/contracts/coordination/subscription/SharedSubscription.sol @@ -78,7 +78,7 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { IEncryptionAuthorizer _accessController, IERC20 _feeToken, address _adopterSetter, - uint256[3][10] memory _feePackages + uint256[3][] memory _feePackages ) { require(address(_feeToken) != address(0), "Fee token cannot be the zero address"); require(_adopterSetter != address(0), "Adopter setter cannot be the zero address"); @@ -91,7 +91,9 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { feeToken = _feeToken; adopterSetter = _adopterSetter; accessController = _accessController; - feePackages = _feePackages; + for (uint256 i = 0; i < _feePackages.length && i < feePackages.length; i++) { + feePackages[i] = _feePackages[i]; + } _disableInitializers(); } diff --git a/contracts/test/SharedAllowListTestSet.sol b/contracts/test/SharedAllowListTestSet.sol new file mode 100644 index 000000000..319f82c11 --- /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/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 From 530f34a6e8c66e6febff06221e2a4bc5b138394a Mon Sep 17 00:00:00 2001 From: Viktoriia Zotova Date: Thu, 30 Apr 2026 16:39:34 -0400 Subject: [PATCH 8/9] Tests for SharedSubscription --- .../subscription/SharedSubscription.sol | 35 +- contracts/test/SharedSubscriptionTestSet.sol | 85 +++ tests/test_shared_subscription.py | 513 ++++++++++++++++++ 3 files changed, 617 insertions(+), 16 deletions(-) create mode 100644 contracts/test/SharedSubscriptionTestSet.sol create mode 100644 tests/test_shared_subscription.py diff --git a/contracts/contracts/coordination/subscription/SharedSubscription.sol b/contracts/contracts/coordination/subscription/SharedSubscription.sol index dbb35ae86..119c8972e 100644 --- a/contracts/contracts/coordination/subscription/SharedSubscription.sol +++ b/contracts/contracts/coordination/subscription/SharedSubscription.sol @@ -43,10 +43,10 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { /** * @notice Emitted when a subscription is spent - * @param treasury The address of the treasury + * @param owner The address of the owner * @param amount The amount withdrawn */ - event WithdrawalToTreasury(address indexed treasury, uint256 amount); + event WithdrawalTokens(address indexed owner, uint256 amount); /** * @notice Emitted when a subscription is paid @@ -71,14 +71,12 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { * @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 - * @param _feePackages Fee packages [duration(sec), encryptors, feeRate] */ constructor( Coordinator _coordinator, IEncryptionAuthorizer _accessController, IERC20 _feeToken, - address _adopterSetter, - uint256[3][] memory _feePackages + 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"); @@ -91,9 +89,6 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { feeToken = _feeToken; adopterSetter = _adopterSetter; accessController = _accessController; - for (uint256 i = 0; i < _feePackages.length && i < feePackages.length; i++) { - feePackages[i] = _feePackages[i]; - } _disableInitializers(); } @@ -120,10 +115,14 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { /** * @notice Initialize function for using with OpenZeppelin proxy + * @param _feePackages Fee packages [duration(sec), encryptors, feeRate] */ - function initialize(address _treasury) external initializer { + function initialize(address _owner, uint256[3][] memory _feePackages) external initializer { activeRitualId = INACTIVE_RITUAL_ID; - __Ownable_init(_treasury); + __Ownable_init(_owner); + for (uint256 i = 0; i < _feePackages.length && i < feePackages.length; i++) { + feePackages[i] = _feePackages[i]; + } } function setAdopter(address _adopter) external { @@ -177,7 +176,8 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { uint256 discount = 0; if ( encryptorFeeRate == billingInfo.encryptorFeeRate && - billingInfo.encryptorSlots == encryptorSlots + billingInfo.encryptorSlots == encryptorSlots && + billingInfo.endOfSubscription > block.timestamp ) { billingInfo.endOfSubscription += packageDuration; } else { @@ -211,11 +211,11 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { /** * @notice Withdraws the fees to the treasury */ - function withdrawToTreasury() external { + function withdrawTokens() external { uint256 amount = feeToken.balanceOf(address(this)); require(0 < amount, "Insufficient balance available"); feeToken.safeTransfer(owner(), amount); - emit WithdrawalToTreasury(owner(), amount); + emit WithdrawalTokens(owner(), amount); } function processRitualPayment( @@ -245,10 +245,10 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { function beforeSetAuthorization( address authAdmin, - uint32, + uint32 ritualId, address[] calldata addresses, bool value - ) public virtual { + ) public virtual onlyAccessController onlyActiveRitual(ritualId) { Billing storage billingInfo = billing[authAdmin]; require(block.timestamp <= billingInfo.endOfSubscription, "Subscription has expired"); if (value) { @@ -266,7 +266,10 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { } } - function beforeIsAuthorized(address authAdmin, uint32) public view virtual { + 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 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_subscription.py b/tests/test_shared_subscription.py new file mode 100644 index 000000000..9282a53b5 --- /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 == "WithdrawalTokens"] + assert events == [subscription.WithdrawalTokens(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("Encryptors slots filled up"): + 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("Encryptors slots filled up"): + 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("Encryptors slots filled up"): + allow_list.authorize(ritual_id, [creator], sender=auth_admin) + + subscription.payForSubscription(auth_admin, 15, 14 * ONE_DAY, sender=adopter) + with ape.reverts("Encryptors slots filled up"): + 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("Encryptors slots filled up"): + 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)) From a87542e485e8c95544f435d72e5807ce250bd0e3 Mon Sep 17 00:00:00 2001 From: Viktoriia Date: Mon, 4 May 2026 17:00:29 +0200 Subject: [PATCH 9/9] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Derek Pierre --- .../contracts/coordination/SharedAllowList.sol | 2 +- .../subscription/SharedSubscription.sol | 14 ++++++-------- contracts/test/SharedAllowListTestSet.sol | 2 +- tests/test_shared_subscription.py | 14 +++++++------- 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/contracts/contracts/coordination/SharedAllowList.sol b/contracts/contracts/coordination/SharedAllowList.sol index e6dd11ec5..102452d76 100644 --- a/contracts/contracts/coordination/SharedAllowList.sol +++ b/contracts/contracts/coordination/SharedAllowList.sol @@ -41,7 +41,7 @@ contract SharedAllowList is IEncryptionAuthorizer, Initializable { * @param _coordinator The address of the coordinator contract */ constructor(Coordinator _coordinator) { - require(address(_coordinator) != address(0), "Contracts cannot be zero addresses"); + require(address(_coordinator) != address(0), "Contract cannot be zero addresses"); require(_coordinator.numberOfRituals() >= 0, "Invalid coordinator"); coordinator = _coordinator; _disableInitializers(); diff --git a/contracts/contracts/coordination/subscription/SharedSubscription.sol b/contracts/contracts/coordination/subscription/SharedSubscription.sol index 119c8972e..19e6f22d1 100644 --- a/contracts/contracts/coordination/subscription/SharedSubscription.sol +++ b/contracts/contracts/coordination/subscription/SharedSubscription.sol @@ -6,7 +6,7 @@ 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 "./EncryptorSlotsSubscription.sol"; +import "../Coordinator.sol"; import "../IEncryptionAuthorizer.sol"; import "../IFeeModel.sol"; @@ -32,8 +32,6 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { address public immutable adopterSetter; - uint256 public immutable encryptorFeeRate; - uint256[3][10] public feePackages; uint32 public activeRitualId; mapping(address authAdmin => Billing billingInfo) public billing; @@ -46,12 +44,12 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { * @param owner The address of the owner * @param amount The amount withdrawn */ - event WithdrawalTokens(address indexed owner, uint256 amount); + event TokensWithdrawn(address indexed owner, uint256 amount); /** * @notice Emitted when a subscription is paid * @param subscriber The address of the subscriber - * @param authAdmin Autharization admin that was paid + * @param authAdmin Authorization admin that was paid * @param amount The amount paid * @param encryptorSlots Number of encryptor slots * @param endOfSubscription End timestamp of subscription @@ -215,7 +213,7 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { uint256 amount = feeToken.balanceOf(address(this)); require(0 < amount, "Insufficient balance available"); feeToken.safeTransfer(owner(), amount); - emit WithdrawalTokens(owner(), amount); + emit TokensWithdrawn(owner(), amount); } function processRitualPayment( @@ -255,7 +253,7 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { billingInfo.usedEncryptorSlots += addresses.length; require( billingInfo.usedEncryptorSlots <= billingInfo.encryptorSlots, - "Encryptors slots filled up" + "Insufficient encryptor slots available" ); } else { if (billingInfo.usedEncryptorSlots >= addresses.length) { @@ -275,7 +273,7 @@ contract SharedSubscription is IFeeModel, Initializable, OwnableUpgradeable { // used encryptor slots must be paid require( billingInfo.usedEncryptorSlots <= billingInfo.encryptorSlots, - "Encryptors slots filled up" + "Encryptor slots full" ); } diff --git a/contracts/test/SharedAllowListTestSet.sol b/contracts/test/SharedAllowListTestSet.sol index 319f82c11..3f26da025 100644 --- a/contracts/test/SharedAllowListTestSet.sol +++ b/contracts/test/SharedAllowListTestSet.sol @@ -27,7 +27,7 @@ contract SharedSubscriptionForSharedAllowListMock { } contract CoordinatorForSharedAllowListMock { - uint256 public numberOfRituals = 1; // for check in GlobalAllowLIst constructor + uint256 public numberOfRituals = 1; // for check in GlobalAllowList constructor mapping(uint32 ritualId => address authority) public authorities; address public feeModel; diff --git a/tests/test_shared_subscription.py b/tests/test_shared_subscription.py index 9282a53b5..0dd75bcd5 100644 --- a/tests/test_shared_subscription.py +++ b/tests/test_shared_subscription.py @@ -264,8 +264,8 @@ def test_withdraw(erc20, subscription, adopter, auth_admin, contract_owner): assert erc20.balanceOf(contract_owner) == fees assert erc20.balanceOf(subscription.address) == 0 - events = [event for event in tx.events if event.event_name == "WithdrawalTokens"] - assert events == [subscription.WithdrawalTokens(owner=contract_owner, amount=fees)] + 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( @@ -430,14 +430,14 @@ def test_before_set_authorization( billing = subscription.billing(auth_admin) assert billing.usedEncryptorSlots == 1 - with ape.reverts("Encryptors slots filled up"): + 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("Encryptors slots filled up"): + 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) @@ -452,11 +452,11 @@ def test_before_set_authorization( allow_list.authorize(ritual_id, [creator], sender=adopter) subscription.payForSubscription(auth_admin, 1, ONE_DAY, sender=adopter) - with ape.reverts("Encryptors slots filled up"): + 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("Encryptors slots filled up"): + 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) @@ -506,7 +506,7 @@ def test_before_is_authorized( allow_list.isAuthorized(ritual_id, bytes(signature), bytes(data)) subscription.payForSubscription(auth_admin, 1, ONE_DAY, sender=adopter) - with ape.reverts("Encryptors slots filled up"): + with ape.reverts("Encryptor slots full"): allow_list.isAuthorized(ritual_id, bytes(signature), bytes(data)) allow_list.deauthorize(ritual_id, [creator], sender=auth_admin)