From 9f6c704d8e2a1e280a3b216aef0ff94b98ffb3c4 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:43:43 +0000 Subject: [PATCH 1/4] feat: default issuance allocation for Issuance Allocator The default allocation receives issuance that is not explicitly allocated to another target. - Added setDefaultAllocationAddress() to set. - getTotalAllocation() excludes default when it is the zero address. - Tests and documentation updated. --- .../IIssuanceAllocationAdministration.sol | 7 + .../contracts/allocate/IssuanceAllocator.sol | 162 ++++- .../tests/allocate/DefaultAllocation.test.ts | 556 ++++++++++++++++++ .../allocate/InterfaceIdStability.test.ts | 2 +- .../tests/allocate/IssuanceAllocator.test.ts | 265 +++++---- .../tests/allocate/IssuanceSystem.test.ts | 14 +- .../test/tests/allocate/optimizedFixtures.ts | 8 +- 7 files changed, 847 insertions(+), 167 deletions(-) create mode 100644 packages/issuance/test/tests/allocate/DefaultAllocation.test.ts diff --git a/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocationAdministration.sol b/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocationAdministration.sol index 23bc7ea05..919cea168 100644 --- a/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocationAdministration.sol +++ b/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocationAdministration.sol @@ -72,6 +72,13 @@ interface IIssuanceAllocationAdministration { */ function forceTargetNoChangeNotificationBlock(address target, uint256 blockNumber) external returns (uint256); + /** + * @notice Set the address that receives the default portion of issuance not allocated to other targets + * @param newAddress The new default allocation address (can be address(0)) + * @return True if successful + */ + function setDefaultAllocationAddress(address newAddress) external returns (bool); + /** * @notice Distribute any pending accumulated issuance to allocator-minting targets. * @return Block number up to which issuance has been distributed diff --git a/packages/issuance/contracts/allocate/IssuanceAllocator.sol b/packages/issuance/contracts/allocate/IssuanceAllocator.sol index e6e9ba62c..9f6110bea 100644 --- a/packages/issuance/contracts/allocate/IssuanceAllocator.sol +++ b/packages/issuance/contracts/allocate/IssuanceAllocator.sol @@ -25,6 +25,14 @@ import { ERC165Upgradeable } from "@openzeppelin/contracts-upgradeable/utils/int * of the protocol. It calculates issuance for all targets based on their configured proportions * and handles minting for allocator-minting portions. * + * @dev The contract maintains a 100% allocation invariant through a default allocation mechanism: + * - A default allocation target exists at targetAddresses[0] (initialized to address(0)) + * - The default allocation automatically receives any unallocated portion of issuance + * - Total allocation across all targets always equals 100% (MILLION PPM) + * - The default allocation address can be changed via setDefaultAllocationAddress() + * - When the default address is address(0), the unallocated portion is not minted + * - Regular targets cannot be set as the default allocation address + * * @dev The contract supports two types of allocation for each target: * 1. Allocator-minting allocation: The IssuanceAllocator calculates and mints tokens directly to targets * for this portion of their allocation. @@ -77,10 +85,13 @@ contract IssuanceAllocator is /// @param lastAccumulationBlock Last block when pending issuance was accumulated /// @dev Design invariant: lastDistributionBlock <= lastAccumulationBlock /// @param allocationTargets Mapping of target addresses to their allocation data - /// @param targetAddresses Array of all target addresses with non-zero allocation - /// @param totalAllocatorMintingPPM Total allocator-minting allocation (in PPM) across all targets + /// @param targetAddresses Array of all target addresses (including default allocation at index 0) /// @param totalSelfMintingPPM Total self-minting allocation (in PPM) across all targets /// @param pendingAccumulatedAllocatorIssuance Accumulated but not distributed issuance for allocator-minting from lastDistributionBlock to lastAccumulationBlock + /// @dev Design invariant: Total allocation across all targets always equals MILLION (100%) + /// @dev Design invariant: targetAddresses[0] is always the default allocation address + /// @dev Design invariant: 1 <= targetAddresses.length (default allocation always exists) + /// @dev Design invariant: Default allocation (targetAddresses[0]) is automatically adjusted to maintain 100% total /// @custom:storage-location erc7201:graphprotocol.storage.IssuanceAllocator struct IssuanceAllocatorData { uint256 issuancePerBlock; @@ -88,7 +99,6 @@ contract IssuanceAllocator is uint256 lastAccumulationBlock; mapping(address => AllocationTarget) allocationTargets; address[] targetAddresses; - uint256 totalAllocatorMintingPPM; uint256 totalSelfMintingPPM; uint256 pendingAccumulatedAllocatorIssuance; } @@ -122,6 +132,12 @@ contract IssuanceAllocator is /// @notice Thrown when toBlockNumber is out of valid range for accumulation error ToBlockOutOfRange(); + /// @notice Thrown when attempting to set allocation for the default allocation target + error CannotSetAllocationForDefaultTarget(); + + /// @notice Thrown when attempting to set default allocation address to a normally allocated target + error CannotSetDefaultToAllocatedTarget(); + // -- Events -- /// @notice Emitted when issuance is distributed to a target @@ -143,6 +159,11 @@ contract IssuanceAllocator is event IssuancePerBlockUpdated(uint256 oldIssuancePerBlock, uint256 newIssuancePerBlock); // solhint-disable-line gas-indexed-events // Do not need to index issuance per block values + /// @notice Emitted when the default allocation address is updated + /// @param oldAddress The previous default allocation address + /// @param newAddress The new default allocation address + event DefaultAllocationAddressUpdated(address indexed oldAddress, address indexed newAddress); + // -- Constructor -- /** @@ -159,9 +180,17 @@ contract IssuanceAllocator is /** * @notice Initialize the IssuanceAllocator contract * @param _governor Address that will have the GOVERNOR_ROLE + * @dev Initializes with a default allocation at index 0 set to address(0) with 100% allocation */ function initialize(address _governor) external virtual initializer { __BaseUpgradeable_init(_governor); + + IssuanceAllocatorData storage $ = _getIssuanceAllocatorStorage(); + + // Initialize default allocation at index 0 with address(0) and 100% allocator-minting + $.targetAddresses.push(address(0)); + $.allocationTargets[address(0)].allocatorMintingPPM = MILLION; + $.allocationTargets[address(0)].selfMintingPPM = 0; } // -- Core Functionality -- @@ -219,6 +248,10 @@ contract IssuanceAllocator is if (0 < newIssuance) { for (uint256 i = 0; i < $.targetAddresses.length; ++i) { address target = $.targetAddresses[i]; + + // Skip minting to zero address (default allocation when not configured) + if (target == address(0)) continue; + AllocationTarget storage targetData = $.allocationTargets[target]; if (0 < targetData.allocatorMintingPPM) { @@ -272,9 +305,12 @@ contract IssuanceAllocator is * Use forceTargetNoChangeNotificationBlock to skip notification for malfunctioning targets. * * @param target Address of the target to notify - * @return True if notification was sent or already sent for this block + * @return True if notification was sent or already sent for this block. Always returns true for address(0) without notifying. */ function _notifyTarget(address target) private returns (bool) { + // Skip notification for zero address (default allocation when unset) + if (target == address(0)) return true; + IssuanceAllocatorData storage $ = _getIssuanceAllocatorStorage(); AllocationTarget storage targetData = $.allocationTargets[target]; @@ -384,6 +420,43 @@ contract IssuanceAllocator is return _setTargetAllocation(target, allocatorMintingPPM, selfMintingPPM, evenIfDistributionPending); } + /** + * @inheritdoc IIssuanceAllocationAdministration + * @dev The default allocation automatically receives the portion of issuance not allocated to other targets + * @dev This maintains the invariant that total allocation is always 100% + * @dev Reverts if attempting to set to an address that has a normal (non-default) allocation + * @dev No-op if setting to the same address + */ + function setDefaultAllocationAddress(address newAddress) external override onlyRole(GOVERNOR_ROLE) returns (bool) { + IssuanceAllocatorData storage $ = _getIssuanceAllocatorStorage(); + + address oldAddress = $.targetAddresses[0]; + + // No-op if setting to same address + if (newAddress == oldAddress) return true; + + // Cannot set default allocation to a normally allocated target + // Check if newAddress is in targetAddresses (excluding index 0 which is the default) + // Note: This is O(n) for the number of targets, which could become expensive as targets increase. + // However, other operations (distribution, notifications) already loop through all targets and + // would encounter gas issues first. Recovery mechanisms exist (pause, per-target notification control). + for (uint256 i = 1; i < $.targetAddresses.length; ++i) { + require($.targetAddresses[i] != newAddress, CannotSetDefaultToAllocatedTarget()); + } + + // Notify both old and new addresses of the allocation change + _notifyTarget(oldAddress); + _notifyTarget(newAddress); + + // Update the default allocation address at index 0 + $.targetAddresses[0] = newAddress; + $.allocationTargets[newAddress] = $.allocationTargets[oldAddress]; + delete $.allocationTargets[oldAddress]; + + emit DefaultAllocationAddressUpdated(oldAddress, newAddress); + return true; + } + /** * @notice Internal implementation for setting target allocation * @param target Address of the target to update @@ -404,8 +477,16 @@ contract IssuanceAllocator is _notifyTarget(target); + // Total allocation calculation and check is delayed until after notifications. + // Distributing and notifying unnecessarily is harmless, but we need to prevent + // reentrancy from looping and changing allocations mid-calculation. + // (Would not be likely to be exploitable due to only governor being able to + // make a call to set target allocation, but better to be paranoid.) + // Validate totals and auto-adjust default allocation BEFORE updating target data + // so we can read the old allocation values _validateAndUpdateTotalAllocations(target, allocatorMintingPPM, selfMintingPPM); + // Then update the target's allocation data _updateTargetAllocationData(target, allocatorMintingPPM, selfMintingPPM); emit TargetAllocationUpdated(target, allocatorMintingPPM, selfMintingPPM); @@ -427,6 +508,9 @@ contract IssuanceAllocator is require(target != address(0), TargetAddressCannotBeZero()); IssuanceAllocatorData storage $ = _getIssuanceAllocatorStorage(); + + require(target != $.targetAddresses[0], CannotSetAllocationForDefaultTarget()); + AllocationTarget storage targetData = $.allocationTargets[target]; if (targetData.allocatorMintingPPM == allocatorMintingPPM && targetData.selfMintingPPM == selfMintingPPM) @@ -467,10 +551,12 @@ contract IssuanceAllocator is } /** - * @notice Updates global allocation totals and validates they don't exceed maximum + * @notice Updates global allocation totals and auto-adjusts default allocation to maintain 100% invariant * @param target Address of the target being updated * @param allocatorMintingPPM New allocator-minting allocation for the target (in PPM) * @param selfMintingPPM New self-minting allocation for the target (in PPM) + * @dev The default allocation (at targetAddresses[0]) is automatically adjusted to ensure total allocation equals MILLION + * @dev This function is called BEFORE the target's allocation data has been updated so we can read old values */ function _validateAndUpdateTotalAllocations( address target, @@ -479,18 +565,18 @@ contract IssuanceAllocator is ) private { IssuanceAllocatorData storage $ = _getIssuanceAllocatorStorage(); AllocationTarget storage targetData = $.allocationTargets[target]; + AllocationTarget storage defaultTarget = $.allocationTargets[$.targetAddresses[0]]; - // Total allocation calculation and check is delayed until after notifications. - // Distributing and notifying unnecessarily is harmless, but we need to prevent - // reentrancy looping changing allocations mid-calculation. - // (Would not be likely to be exploitable due to only governor being able to - // make a call to set target allocation, but better to be paranoid.) - $.totalAllocatorMintingPPM = $.totalAllocatorMintingPPM - targetData.allocatorMintingPPM + allocatorMintingPPM; - $.totalSelfMintingPPM = $.totalSelfMintingPPM - targetData.selfMintingPPM + selfMintingPPM; + // Calculation is done here after notifications to prevent reentrancy issues - // Ensure the new total allocation doesn't exceed MILLION as in PPM. + uint256 availablePPM = defaultTarget.allocatorMintingPPM + + targetData.allocatorMintingPPM + + targetData.selfMintingPPM; // solhint-disable-next-line gas-strict-inequalities - require(($.totalAllocatorMintingPPM + $.totalSelfMintingPPM) <= MILLION, InsufficientAllocationAvailable()); + require(allocatorMintingPPM + selfMintingPPM <= availablePPM, InsufficientAllocationAvailable()); + + defaultTarget.allocatorMintingPPM = availablePPM - allocatorMintingPPM - selfMintingPPM; + $.totalSelfMintingPPM = $.totalSelfMintingPPM - targetData.selfMintingPPM + selfMintingPPM; } /** @@ -498,23 +584,24 @@ contract IssuanceAllocator is * @param target Address of the target being updated * @param allocatorMintingPPM New allocator-minting allocation for the target (in PPM) * @param selfMintingPPM New self-minting allocation for the target (in PPM) + * @dev This function is never called for the default allocation (at index 0), which is handled separately */ function _updateTargetAllocationData(address target, uint256 allocatorMintingPPM, uint256 selfMintingPPM) private { IssuanceAllocatorData storage $ = _getIssuanceAllocatorStorage(); AllocationTarget storage targetData = $.allocationTargets[target]; // Internal design invariants: - // - targetAddresses contains all targets with non-zero allocation. - // - targetAddresses does not contain targets with zero allocation. - // - targetAddresses does not contain duplicates. - // - allocationTargets mapping contains all targets in targetAddresses with a non-zero allocation. - // - allocationTargets mapping allocations are zero for targets not in targetAddresses. + // - targetAddresses[0] is always the default allocation and is never removed + // - targetAddresses[1..n] contains all non-default targets with explicitly set non-zero allocations + // - targetAddresses does not contain duplicates + // - allocationTargets mapping contains allocation data for all targets in targetAddresses + // - Default allocation is automatically adjusted by _validateAndUpdateTotalAllocations // - Governance actions can create allocationTarget mappings with lastChangeNotifiedBlock set for targets not in targetAddresses. This is valid. // Therefore: - // - Only add a target to the list if it previously had no allocation. - // - Remove a target from the list when setting both allocations to 0. - // - Delete allocationTargets mapping entry when removing a target from targetAddresses. - // - Do not set lastChangeNotifiedBlock in this function. + // - Only add a non-default target to the list if it previously had no allocation + // - Remove a non-default target from the list when setting both allocations to 0 + // - Delete allocationTargets mapping entry when removing a target from targetAddresses + // - Do not set lastChangeNotifiedBlock in this function if (allocatorMintingPPM != 0 || selfMintingPPM != 0) { // Add to list if previously had no allocation if (targetData.allocatorMintingPPM == 0 && targetData.selfMintingPPM == 0) $.targetAddresses.push(target); @@ -586,10 +673,14 @@ contract IssuanceAllocator is if (pendingAmount == 0) return $.lastDistributionBlock; $.pendingAccumulatedAllocatorIssuance = 0; - if ($.totalAllocatorMintingPPM == 0) return $.lastDistributionBlock; + if ($.totalSelfMintingPPM == MILLION) return $.lastDistributionBlock; for (uint256 i = 0; i < $.targetAddresses.length; ++i) { address target = $.targetAddresses[i]; + + // Skip minting to zero address (default allocation when not configured) + if (target == address(0)) continue; + AllocationTarget storage targetData = $.allocationTargets[target]; if (0 < targetData.allocatorMintingPPM) { @@ -727,13 +818,32 @@ contract IssuanceAllocator is /** * @inheritdoc IIssuanceAllocationStatus + * @dev For reporting purposes, if the default allocation target is address(0), its allocation + * @dev is treated as "unallocated" since address(0) cannot receive minting. + * @dev When default is address(0): returns actual allocated amounts (may be less than 100%) + * @dev When default is a real address: returns 100% total allocation + * @dev Note: Internally, the contract always maintains 100% allocation invariant, even when default is address(0) */ function getTotalAllocation() external view override returns (Allocation memory) { IssuanceAllocatorData storage $ = _getIssuanceAllocatorStorage(); + + uint256 totalAllocatorMinting = MILLION - $.totalSelfMintingPPM; + uint256 totalAllocation = MILLION; + + // If default is address(0), exclude its allocation from reported totals + // since it doesn't actually receive minting (effectively unallocated) + address defaultAddress = $.targetAddresses[0]; + if (defaultAddress == address(0)) { + AllocationTarget storage defaultTarget = $.allocationTargets[defaultAddress]; + uint256 defaultAllocation = defaultTarget.allocatorMintingPPM; + totalAllocatorMinting -= defaultAllocation; + totalAllocation -= defaultAllocation; + } + return Allocation({ - totalAllocationPPM: $.totalAllocatorMintingPPM + $.totalSelfMintingPPM, - allocatorMintingPPM: $.totalAllocatorMintingPPM, + totalAllocationPPM: totalAllocation, + allocatorMintingPPM: totalAllocatorMinting, selfMintingPPM: $.totalSelfMintingPPM }); } diff --git a/packages/issuance/test/tests/allocate/DefaultAllocation.test.ts b/packages/issuance/test/tests/allocate/DefaultAllocation.test.ts new file mode 100644 index 000000000..d59fb457e --- /dev/null +++ b/packages/issuance/test/tests/allocate/DefaultAllocation.test.ts @@ -0,0 +1,556 @@ +import { expect } from 'chai' +import hre from 'hardhat' +const { ethers } = hre + +import { deployTestGraphToken, getTestAccounts } from '../common/fixtures' +import { deployDirectAllocation, deployIssuanceAllocator } from './fixtures' +import { expectCustomError } from './optimizationHelpers' + +describe('IssuanceAllocator - Default Allocation', () => { + let accounts + let graphToken + let issuanceAllocator + let target1 + let target2 + let target3 + let addresses + + const MILLION = 1_000_000n + const issuancePerBlock = ethers.parseEther('100') + + beforeEach(async () => { + accounts = await getTestAccounts() + + // Deploy fresh contracts for each test + graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + + issuanceAllocator = await deployIssuanceAllocator(graphTokenAddress, accounts.governor, issuancePerBlock) + + target1 = await deployDirectAllocation(graphTokenAddress, accounts.governor) + target2 = await deployDirectAllocation(graphTokenAddress, accounts.governor) + target3 = await deployDirectAllocation(graphTokenAddress, accounts.governor) + + addresses = { + issuanceAllocator: await issuanceAllocator.getAddress(), + target1: await target1.getAddress(), + target2: await target2.getAddress(), + target3: await target3.getAddress(), + graphToken: graphTokenAddress, + } + + // Grant minter role to issuanceAllocator + await (graphToken as any).addMinter(addresses.issuanceAllocator) + }) + + describe('Initialization', () => { + it('should initialize with default allocation at index 0', async () => { + const targetCount = await issuanceAllocator.getTargetCount() + expect(targetCount).to.equal(1n) + + const defaultAddress = await issuanceAllocator.getTargetAt(0) + expect(defaultAddress).to.equal(ethers.ZeroAddress) + }) + + it('should initialize with 100% allocation to default target', async () => { + const defaultAddress = await issuanceAllocator.getTargetAt(0) + const allocation = await issuanceAllocator.getTargetAllocation(defaultAddress) + + expect(allocation.totalAllocationPPM).to.equal(MILLION) + expect(allocation.allocatorMintingPPM).to.equal(MILLION) + expect(allocation.selfMintingPPM).to.equal(0n) + }) + + it('should report total allocation as 0% when default is address(0)', async () => { + const totalAllocation = await issuanceAllocator.getTotalAllocation() + + // When default is address(0), it is treated as unallocated for reporting purposes + expect(totalAllocation.totalAllocationPPM).to.equal(0n) + expect(totalAllocation.allocatorMintingPPM).to.equal(0n) + expect(totalAllocation.selfMintingPPM).to.equal(0n) + }) + }) + + describe('100% Allocation Invariant', () => { + it('should auto-adjust default allocation when setting normal target allocation', async () => { + const allocation1PPM = 300_000n // 30% + + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target1, allocation1PPM) + + // Check target1 has correct allocation + const target1Allocation = await issuanceAllocator.getTargetAllocation(addresses.target1) + expect(target1Allocation.totalAllocationPPM).to.equal(allocation1PPM) + + // Check default allocation was auto-adjusted + const defaultAddress = await issuanceAllocator.getTargetAt(0) + const defaultAllocation = await issuanceAllocator.getTargetAllocation(defaultAddress) + expect(defaultAllocation.totalAllocationPPM).to.equal(MILLION - allocation1PPM) + + // Check reported total (excludes default since it's address(0)) + const totalAllocation = await issuanceAllocator.getTotalAllocation() + expect(totalAllocation.totalAllocationPPM).to.equal(allocation1PPM) + }) + + it('should maintain 100% invariant with multiple targets', async () => { + const allocation1PPM = 200_000n // 20% + const allocation2PPM = 350_000n // 35% + const allocation3PPM = 150_000n // 15% + + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target1, allocation1PPM) + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target2, allocation2PPM) + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target3, allocation3PPM) + + // Check default allocation is 30% (100% - 20% - 35% - 15%) + const defaultAddress = await issuanceAllocator.getTargetAt(0) + const defaultAllocation = await issuanceAllocator.getTargetAllocation(defaultAddress) + const expectedDefault = MILLION - allocation1PPM - allocation2PPM - allocation3PPM + expect(defaultAllocation.totalAllocationPPM).to.equal(expectedDefault) + + // Check reported total (excludes default since it's address(0)) + const totalAllocation = await issuanceAllocator.getTotalAllocation() + expect(totalAllocation.totalAllocationPPM).to.equal(allocation1PPM + allocation2PPM + allocation3PPM) + }) + + it('should allow 0% default allocation when all allocation is assigned', async () => { + const allocation1PPM = 600_000n // 60% + const allocation2PPM = 400_000n // 40% + + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target1, allocation1PPM) + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target2, allocation2PPM) + + // Check default allocation is 0% + const defaultAddress = await issuanceAllocator.getTargetAt(0) + const defaultAllocation = await issuanceAllocator.getTargetAllocation(defaultAddress) + expect(defaultAllocation.totalAllocationPPM).to.equal(0n) + + // Check reported total is 100% (default has 0%, so exclusion doesn't matter) + const totalAllocation = await issuanceAllocator.getTotalAllocation() + expect(totalAllocation.totalAllocationPPM).to.equal(MILLION) + }) + + it('should revert if non-default allocations exceed 100%', async () => { + const allocation1PPM = 600_000n // 60% + const allocation2PPM = 500_000n // 50% (total would be 110%) + + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target1, allocation1PPM) + + await expectCustomError( + issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target2, allocation2PPM), + issuanceAllocator, + 'InsufficientAllocationAvailable', + ) + }) + + it('should adjust default when removing a target allocation', async () => { + // Set up initial allocations + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target1, 300_000n) + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target2, 200_000n) + + // Default should be 50% + let defaultAddress = await issuanceAllocator.getTargetAt(0) + let defaultAllocation = await issuanceAllocator.getTargetAllocation(defaultAddress) + expect(defaultAllocation.totalAllocationPPM).to.equal(500_000n) + + // Remove target1 allocation + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256,bool)'](addresses.target1, 0, 0, false) + + // Default should now be 80% + defaultAddress = await issuanceAllocator.getTargetAt(0) + defaultAllocation = await issuanceAllocator.getTargetAllocation(defaultAddress) + expect(defaultAllocation.totalAllocationPPM).to.equal(800_000n) + + // Reported total excludes default (only target2's 20% is reported) + const totalAllocation = await issuanceAllocator.getTotalAllocation() + expect(totalAllocation.totalAllocationPPM).to.equal(200_000n) + }) + + it('should handle self-minting allocations correctly in 100% invariant', async () => { + const allocator1 = 200_000n + const self1 = 100_000n + const allocator2 = 300_000n + const self2 = 50_000n + + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256)'](addresses.target1, allocator1, self1) + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256)'](addresses.target2, allocator2, self2) + + // Total non-default: 20% + 10% + 30% + 5% = 65% + // Default should be: 35% + const defaultAddress = await issuanceAllocator.getTargetAt(0) + const defaultAllocation = await issuanceAllocator.getTargetAllocation(defaultAddress) + expect(defaultAllocation.totalAllocationPPM).to.equal(350_000n) + + // Reported total excludes default (only target1+target2's 65% is reported) + const totalAllocation = await issuanceAllocator.getTotalAllocation() + expect(totalAllocation.totalAllocationPPM).to.equal(allocator1 + self1 + allocator2 + self2) + expect(totalAllocation.selfMintingPPM).to.equal(self1 + self2) + }) + }) + + describe('setDefaultAllocationAddress', () => { + it('should allow governor to change default allocation address', async () => { + const newDefaultAddress = addresses.target1 + + await issuanceAllocator.connect(accounts.governor).setDefaultAllocationAddress(newDefaultAddress) + + const defaultAddress = await issuanceAllocator.getTargetAt(0) + expect(defaultAddress).to.equal(newDefaultAddress) + }) + + it('should maintain allocation when changing default address', async () => { + // Set a target allocation first + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target2, 400_000n) + + // Default should be 60% + let defaultAddress = await issuanceAllocator.getTargetAt(0) + let defaultAllocation = await issuanceAllocator.getTargetAllocation(defaultAddress) + expect(defaultAllocation.totalAllocationPPM).to.equal(600_000n) + + // Change default address + await issuanceAllocator.connect(accounts.governor).setDefaultAllocationAddress(addresses.target1) + + // Check new address has the same allocation + defaultAddress = await issuanceAllocator.getTargetAt(0) + expect(defaultAddress).to.equal(addresses.target1) + defaultAllocation = await issuanceAllocator.getTargetAllocation(addresses.target1) + expect(defaultAllocation.totalAllocationPPM).to.equal(600_000n) + + // Old address should have zero allocation + const oldAllocation = await issuanceAllocator.getTargetAllocation(ethers.ZeroAddress) + expect(oldAllocation.totalAllocationPPM).to.equal(0n) + }) + + it('should emit DefaultAllocationAddressUpdated event', async () => { + const newDefaultAddress = addresses.target1 + + await expect(issuanceAllocator.connect(accounts.governor).setDefaultAllocationAddress(newDefaultAddress)) + .to.emit(issuanceAllocator, 'DefaultAllocationAddressUpdated') + .withArgs(ethers.ZeroAddress, newDefaultAddress) + }) + + it('should be no-op when setting to same address', async () => { + const currentAddress = await issuanceAllocator.getTargetAt(0) + + const tx = await issuanceAllocator.connect(accounts.governor).setDefaultAllocationAddress(currentAddress) + const receipt = await tx.wait() + + // Should not emit event when no-op + const events = receipt!.logs.filter((log: any) => { + try { + return issuanceAllocator.interface.parseLog(log)?.name === 'DefaultAllocationAddressUpdated' + } catch { + return false + } + }) + expect(events.length).to.equal(0) + }) + + it('should revert when non-governor tries to change default address', async () => { + await expect( + issuanceAllocator.connect(accounts.user).setDefaultAllocationAddress(addresses.target1), + ).to.be.revertedWithCustomError(issuanceAllocator, 'AccessControlUnauthorizedAccount') + }) + + it('should revert when trying to set default to a normally allocated target', async () => { + // Set target1 as a normal allocation + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target1, 300_000n) + + // Try to set target1 as default should fail + await expectCustomError( + issuanceAllocator.connect(accounts.governor).setDefaultAllocationAddress(addresses.target1), + issuanceAllocator, + 'CannotSetDefaultToAllocatedTarget', + ) + }) + + it('should allow changing back to zero address', async () => { + // Change to target1 + await issuanceAllocator.connect(accounts.governor).setDefaultAllocationAddress(addresses.target1) + + // Change back to zero address + await issuanceAllocator.connect(accounts.governor).setDefaultAllocationAddress(ethers.ZeroAddress) + + const defaultAddress = await issuanceAllocator.getTargetAt(0) + expect(defaultAddress).to.equal(ethers.ZeroAddress) + }) + }) + + describe('setTargetAllocation restrictions', () => { + it('should revert with zero address error when default target is address(0)', async () => { + const defaultAddress = await issuanceAllocator.getTargetAt(0) + expect(defaultAddress).to.equal(ethers.ZeroAddress) + + // When default is address(0), the zero address check happens first + await expectCustomError( + issuanceAllocator.connect(accounts.governor)['setTargetAllocation(address,uint256)'](defaultAddress, 500_000n), + issuanceAllocator, + 'TargetAddressCannotBeZero', + ) + }) + + it('should revert when trying to set allocation for changed default target', async () => { + // Change default to target1 + await issuanceAllocator.connect(accounts.governor).setDefaultAllocationAddress(addresses.target1) + + // Should not be able to set allocation for target1 now + await expectCustomError( + issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target1, 500_000n), + issuanceAllocator, + 'CannotSetAllocationForDefaultTarget', + ) + }) + + it('should allow setting allocation for previous default address after it changes', async () => { + // Change default to target1 + await issuanceAllocator.connect(accounts.governor).setDefaultAllocationAddress(addresses.target1) + + // Should now be able to set allocation for old default (zero address would fail for other reasons, use target2) + await issuanceAllocator.connect(accounts.governor).setDefaultAllocationAddress(addresses.target2) + + // Now target1 is no longer default, should be able to allocate to it + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target1, 300_000n) + + const allocation = await issuanceAllocator.getTargetAllocation(addresses.target1) + expect(allocation.totalAllocationPPM).to.equal(300_000n) + }) + + it('should revert when trying to set allocation for address(0) when default is not address(0)', async () => { + // Change default to target1 + await issuanceAllocator.connect(accounts.governor).setDefaultAllocationAddress(addresses.target1) + + // Try to set allocation for address(0) directly should fail + await expectCustomError( + issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](ethers.ZeroAddress, 300_000n), + issuanceAllocator, + 'TargetAddressCannotBeZero', + ) + }) + }) + + describe('Distribution with default allocation', () => { + it('should not mint to zero address when default is unset', async () => { + // Set a normal target allocation (this is block 1) + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target1, 400_000n) + + // Distribute (this is block 2, so we distribute for block 1->2 = 1 block since last distribution) + await issuanceAllocator.distributeIssuance() + + // Target1 should receive 40% of issuance for the block between setTargetAllocation and distributeIssuance + const target1Balance = await graphToken.balanceOf(addresses.target1) + const expectedTarget1 = (issuancePerBlock * 400_000n) / MILLION + expect(target1Balance).to.equal(expectedTarget1) + + // Zero address should have nothing (cannot be minted to) + const zeroBalance = await graphToken.balanceOf(ethers.ZeroAddress) + expect(zeroBalance).to.equal(0n) + + // The 60% for default (zero address) is effectively burned/not minted + }) + + it('should mint to default address when it is set', async () => { + // Distribute any pending issuance first to start fresh + await issuanceAllocator.distributeIssuance() + + // Change default to target3 + await issuanceAllocator.connect(accounts.governor).setDefaultAllocationAddress(addresses.target3) + + // Set target1 allocation using evenIfDistributionPending to avoid premature distribution + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256,bool)'](addresses.target1, 300_000n, 0n, true) + + // Distribute once (exactly 1 block with the new allocations) + await issuanceAllocator.distributeIssuance() + + // Target1 should receive 30% for 1 block (from last distributeIssuance call) + const target1Balance = await graphToken.balanceOf(addresses.target1) + const expectedTarget1 = (issuancePerBlock * 300_000n) / MILLION + expect(target1Balance).to.equal(expectedTarget1) + + // Target3 (default) should receive: + // - 100% for 2 blocks (from initial distributeIssuance to setTargetAllocation) + // - 70% for 1 block (from setTargetAllocation to final distributeIssuance) + const target3Balance = await graphToken.balanceOf(addresses.target3) + const expectedTarget3 = issuancePerBlock * 2n + (issuancePerBlock * 700_000n) / MILLION + expect(target3Balance).to.equal(expectedTarget3) + }) + + it('should distribute correctly with multiple targets and default', async () => { + // Distribute any pending issuance first to start fresh + await issuanceAllocator.distributeIssuance() + + // Set default to target3 + await issuanceAllocator.connect(accounts.governor).setDefaultAllocationAddress(addresses.target3) + + // Set allocations using evenIfDistributionPending to avoid premature distributions + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256,bool)'](addresses.target1, 200_000n, 0n, true) // 20% + + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256,bool)'](addresses.target2, 300_000n, 0n, true) // 30% + // Default (target3) gets 50% + + // Distribute once (exactly 1 block with the final allocations) + await issuanceAllocator.distributeIssuance() + + // Check all balances accounting for block accumulation: + // - target1 gets 20% for 2 blocks (from first setTargetAllocation onwards) + // - target2 gets 30% for 1 block (from second setTargetAllocation onwards) + // - target3 (default) gets 100% for 2 blocks + 80% for 1 block + 50% for 1 block + const target1Balance = await graphToken.balanceOf(addresses.target1) + const target2Balance = await graphToken.balanceOf(addresses.target2) + const target3Balance = await graphToken.balanceOf(addresses.target3) + + const expectedTarget1 = (issuancePerBlock * 200_000n * 2n) / MILLION + const expectedTarget2 = (issuancePerBlock * 300_000n) / MILLION + const expectedTarget3 = + issuancePerBlock * 2n + (issuancePerBlock * 800_000n) / MILLION + (issuancePerBlock * 500_000n) / MILLION + + expect(target1Balance).to.equal(expectedTarget1) + expect(target2Balance).to.equal(expectedTarget2) + expect(target3Balance).to.equal(expectedTarget3) + + // Total minted should equal 4 blocks of issuance + const totalMinted = target1Balance + target2Balance + target3Balance + expect(totalMinted).to.equal(issuancePerBlock * 4n) + }) + + it('should handle distribution when default allocation is 0%', async () => { + // Distribute any pending issuance first to start fresh + await issuanceAllocator.distributeIssuance() + + // Default is address(0), which doesn't receive minting + // Allocate 100% to explicit targets + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target1, 600_000n) + // At this point target1 has 60%, default has 40% + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target2, 400_000n) + // Now target1 has 60%, target2 has 40%, default has 0% + + // Distribute (1 block since last setTargetAllocation) + await issuanceAllocator.distributeIssuance() + + // Zero address (default) should receive nothing + const zeroBalance = await graphToken.balanceOf(ethers.ZeroAddress) + expect(zeroBalance).to.equal(0n) + + // Target1 receives: 0% (from first distributeIssuance to first setTargetAllocation) + // + 60% (from first setTargetAllocation to second setTargetAllocation) + // + 60% (from second setTargetAllocation to final distributeIssuance) + // = 120% of one block = 60% * 2 blocks + const target1Balance = await graphToken.balanceOf(addresses.target1) + expect(target1Balance).to.equal((issuancePerBlock * 600_000n * 2n) / MILLION) + + // Target2 receives: 40% (from second setTargetAllocation to final distributeIssuance) + const target2Balance = await graphToken.balanceOf(addresses.target2) + expect(target2Balance).to.equal((issuancePerBlock * 400_000n) / MILLION) + + // Default allocation is now 0% + const defaultAddress = await issuanceAllocator.getTargetAt(0) + const defaultAllocation = await issuanceAllocator.getTargetAllocation(defaultAddress) + expect(defaultAllocation.totalAllocationPPM).to.equal(0n) + }) + }) + + describe('View functions', () => { + it('should return correct target count including default', async () => { + let count = await issuanceAllocator.getTargetCount() + expect(count).to.equal(1n) // Just default + + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target1, 300_000n) + + count = await issuanceAllocator.getTargetCount() + expect(count).to.equal(2n) // Default + target1 + + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target2, 200_000n) + + count = await issuanceAllocator.getTargetCount() + expect(count).to.equal(3n) // Default + target1 + target2 + }) + + it('should include default in getTargets array', async () => { + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target1, 300_000n) + + const targets = await issuanceAllocator.getTargets() + expect(targets.length).to.equal(2) + expect(targets[0]).to.equal(ethers.ZeroAddress) // Default at index 0 + expect(targets[1]).to.equal(addresses.target1) + }) + + it('should return correct data for default target', async () => { + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target1, 400_000n) + + const defaultAddress = await issuanceAllocator.getTargetAt(0) + const data = await issuanceAllocator.getTargetData(defaultAddress) + + expect(data.allocatorMintingPPM).to.equal(600_000n) + expect(data.selfMintingPPM).to.equal(0n) + }) + + it('should report 100% total allocation when default is a real address', async () => { + // Set target1 allocation first + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256)'](addresses.target1, 300_000n) + + // Change default to target2 (a real address, not address(0)) + await issuanceAllocator.connect(accounts.governor).setDefaultAllocationAddress(addresses.target2) + + // When default is a real address, it should report 100% total allocation + const totalAllocation = await issuanceAllocator.getTotalAllocation() + expect(totalAllocation.totalAllocationPPM).to.equal(MILLION) + expect(totalAllocation.allocatorMintingPPM).to.equal(MILLION) // target1=30% + target2=70% = 100% + expect(totalAllocation.selfMintingPPM).to.equal(0n) + }) + }) +}) diff --git a/packages/issuance/test/tests/allocate/InterfaceIdStability.test.ts b/packages/issuance/test/tests/allocate/InterfaceIdStability.test.ts index e6ee54260..fcb07ea85 100644 --- a/packages/issuance/test/tests/allocate/InterfaceIdStability.test.ts +++ b/packages/issuance/test/tests/allocate/InterfaceIdStability.test.ts @@ -26,7 +26,7 @@ describe('Allocate Interface ID Stability', () => { }) it('IIssuanceAllocationAdministration should have stable interface ID', () => { - expect(IIssuanceAllocationAdministration__factory.interfaceId).to.equal('0x36759695') + expect(IIssuanceAllocationAdministration__factory.interfaceId).to.equal('0x069d5a27') }) it('IIssuanceAllocationStatus should have stable interface ID', () => { diff --git a/packages/issuance/test/tests/allocate/IssuanceAllocator.test.ts b/packages/issuance/test/tests/allocate/IssuanceAllocator.test.ts index 8ecc20509..599f9b334 100644 --- a/packages/issuance/test/tests/allocate/IssuanceAllocator.test.ts +++ b/packages/issuance/test/tests/allocate/IssuanceAllocator.test.ts @@ -74,11 +74,12 @@ describe('IssuanceAllocator', () => { const { issuanceAllocator } = sharedContracts - // Remove all existing allocations + // Remove all existing allocations (except default at index 0) try { const targetCount = await issuanceAllocator.getTargetCount() - for (let i = 0; i < targetCount; i++) { - const targetAddr = await issuanceAllocator.getTargetAt(0) // Always remove first + // Skip index 0 (default allocation) and remove from index 1 onwards + for (let i = 1; i < targetCount; i++) { + const targetAddr = await issuanceAllocator.getTargetAt(1) // Always remove index 1 await issuanceAllocator .connect(accounts.governor) ['setTargetAllocation(address,uint256,uint256,bool)'](targetAddr, 0, 0, false) @@ -305,6 +306,7 @@ describe('IssuanceAllocator', () => { const target1Allocation = await issuanceAllocator.getTargetAllocation(addresses.target1) expect(target1Allocation.totalAllocationPPM).to.equal(allocation) const totalAlloc = await issuanceAllocator.getTotalAllocation() + // With default as address(0), only non-default allocations are reported expect(totalAlloc.totalAllocationPPM).to.equal(allocation) // Remove target by setting allocation to 0 @@ -312,11 +314,11 @@ describe('IssuanceAllocator', () => { .connect(accounts.governor) ['setTargetAllocation(address,uint256,uint256,bool)'](addresses.target1, 0, 0, false) - // Verify target is removed + // Verify target is removed (only default remains) const targets = await issuanceAllocator.getTargets() - expect(targets.length).to.equal(0) + expect(targets.length).to.equal(1) // Only default allocation - // Verify total allocation is updated + // Verify reported total is 0% (default has it all, but isn't reported) { const totalAlloc = await issuanceAllocator.getTotalAllocation() expect(totalAlloc.totalAllocationPPM).to.equal(0) @@ -341,24 +343,25 @@ describe('IssuanceAllocator', () => { expect(target2Allocation.totalAllocationPPM).to.equal(400000) { const totalAlloc = await issuanceAllocator.getTotalAllocation() + // With default as address(0), only non-default allocations are reported (70%) expect(totalAlloc.totalAllocationPPM).to.equal(700000) } - // Get initial target addresses + // Get initial target addresses (including default) const initialTargets = await issuanceAllocator.getTargets() - expect(initialTargets.length).to.equal(2) + expect(initialTargets.length).to.equal(3) // default + target1 + target2 // Remove target2 by setting allocation to 0 (tests the swap-and-pop logic in the contract) await issuanceAllocator .connect(accounts.governor) ['setTargetAllocation(address,uint256,uint256,bool)'](addresses.target2, 0, 0, false) - // Verify target2 is removed but target1 remains + // Verify target2 is removed but target1 and default remain const remainingTargets = await issuanceAllocator.getTargets() - expect(remainingTargets.length).to.equal(1) - expect(remainingTargets[0]).to.equal(addresses.target1) + expect(remainingTargets.length).to.equal(2) // default + target1 + expect(remainingTargets).to.include(addresses.target1) - // Verify total allocation is updated (only target1's allocation remains) + // Verify reported total excludes default (only target1's 30% is reported) { const totalAlloc = await issuanceAllocator.getTotalAllocation() expect(totalAlloc.totalAllocationPPM).to.equal(300000) @@ -386,7 +389,7 @@ describe('IssuanceAllocator', () => { expect(target1Info.selfMintingPPM).to.equal(0) expect(target2Info.selfMintingPPM).to.equal(0) - // Verify total allocation is updated correctly + // Verify reported total excludes default (only target1+target2's 70% is reported) { const totalAlloc = await issuanceAllocator.getTotalAllocation() expect(totalAlloc.totalAllocationPPM).to.equal(300000) @@ -396,16 +399,7 @@ describe('IssuanceAllocator', () => { it('should validate setTargetAllocation parameters and constraints', async () => { const { issuanceAllocator, addresses } = sharedContracts - // Test 1: Should revert when setting allocation for target with address zero - await expectCustomError( - issuanceAllocator - .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](ethers.ZeroAddress, 100000, 0, false), - issuanceAllocator, - 'TargetAddressCannotBeZero', - ) - - // Test 2: Should revert when setting non-zero allocation for target that does not support IIssuanceTarget + // Test 1: Should revert when setting non-zero allocation for target that does not support IIssuanceTarget const nonExistentTarget = accounts.nonGovernor.address // When trying to set allocation for an EOA, the IERC165 call will revert await expect( @@ -414,13 +408,13 @@ describe('IssuanceAllocator', () => { ['setTargetAllocation(address,uint256,uint256,bool)'](nonExistentTarget, 500_000, 0, false), ).to.be.reverted - // Test 3: Should revert when total allocation would exceed 100% + // Test 2: Should revert when total allocation would exceed 100% // Set allocation for target1 to 60% await issuanceAllocator .connect(accounts.governor) ['setTargetAllocation(address,uint256,uint256,bool)'](addresses.target1, 600_000, 0, false) - // Try to set allocation for target2 to 50%, which would exceed 100% + // Try to set allocation for target2 to 50%, which would exceed 100% (60% + 50% > 100%) await expectCustomError( issuanceAllocator .connect(accounts.governor) @@ -841,13 +835,13 @@ describe('IssuanceAllocator', () => { await issuanceAllocator.connect(accounts.governor).grantRole(PAUSE_ROLE, accounts.governor.address) await issuanceAllocator.connect(accounts.governor).setIssuancePerBlock(ethers.parseEther('100'), false) - // Mix of targets: 30% allocator-minting, 70% self-minting + // Mix of targets: 20% allocator-minting, 5% self-minting (leaving 75% for default, total 95% allocator) await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await target1.getAddress(), 300000, 0, false) // 30% allocator-minting + ['setTargetAllocation(address,uint256,uint256,bool)'](await target1.getAddress(), 200000, 0, false) // 20% allocator-minting await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await target2.getAddress(), 0, 700000, false) // 70% self-minting + ['setTargetAllocation(address,uint256,uint256,bool)'](await target2.getAddress(), 0, 50000, false) // 5% self-minting // Initialize distribution await issuanceAllocator.connect(accounts.governor).distributeIssuance() @@ -859,24 +853,25 @@ describe('IssuanceAllocator', () => { await ethers.provider.send('evm_mine', []) await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await target2.getAddress(), 0, 600000, true) // Change self-minting from 70% to 60% + ['setTargetAllocation(address,uint256,uint256,bool)'](await target2.getAddress(), 0, 0, true) // Change self-minting from 5% to 0% // Accumulation should happen from lastIssuanceDistributionBlock to current block const blockAfterAccumulation = await ethers.provider.getBlockNumber() const pendingAmount = await issuanceAllocator.pendingAccumulatedAllocatorIssuance() const lastDistributionBlock = await issuanceAllocator.lastIssuanceDistributionBlock() - const allocation = await issuanceAllocator.getTotalAllocation() // Calculate what accumulation SHOULD be from lastDistributionBlock + // During accumulation: 20% (target1) + 75% (default) = 95% allocator-minting, 5% self-minting + // Accumulated issuance is based on the 95% allocator-minting that was active during accumulation const blocksFromDistribution = BigInt(blockAfterAccumulation) - BigInt(lastDistributionBlock) + const allocatorMintingDuringAccumulation = 950000n // 95% in PPM const expectedFromDistribution = calculateExpectedAccumulation( parseEther('100'), blocksFromDistribution, - allocation.allocatorMintingPPM, + allocatorMintingDuringAccumulation, ) - // This will fail, but we can see which calculation matches the actual result expect(pendingAmount).to.equal(expectedFromDistribution) // Now test distribution of pending issuance to cover the self-minter branch @@ -903,20 +898,20 @@ describe('IssuanceAllocator', () => { await issuanceAllocator.connect(accounts.governor).grantRole(PAUSE_ROLE, accounts.governor.address) await issuanceAllocator.connect(accounts.governor).setIssuancePerBlock(ethers.parseEther('1000'), false) - // Mix of targets: 20% and 30% allocator-minting (50% total), 50% self-minting + // Mix of targets: 15% and 25% allocator-minting (40% total), 10% self-minting (leaving 50% for default, total 90% allocator) await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await target1.getAddress(), 200000, 0, false) // 20% allocator-minting + ['setTargetAllocation(address,uint256,uint256,bool)'](await target1.getAddress(), 150000, 0, false) // 15% allocator-minting await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await target2.getAddress(), 300000, 0, false) // 30% allocator-minting + ['setTargetAllocation(address,uint256,uint256,bool)'](await target2.getAddress(), 250000, 0, false) // 25% allocator-minting // Add a self-minting target to create the mixed scenario const MockTarget = await ethers.getContractFactory('MockSimpleTarget') const selfMintingTarget = await MockTarget.deploy() await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await selfMintingTarget.getAddress(), 0, 500000, false) // 50% self-minting + ['setTargetAllocation(address,uint256,uint256,bool)'](await selfMintingTarget.getAddress(), 0, 100000, false) // 10% self-minting // Initialize and pause await issuanceAllocator.connect(accounts.governor).distributeIssuance() @@ -930,23 +925,24 @@ describe('IssuanceAllocator', () => { await ethers.provider.send('evm_mine', []) await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await selfMintingTarget.getAddress(), 0, 400000, true) // Change self-minting from 50% to 40% + ['setTargetAllocation(address,uint256,uint256,bool)'](await selfMintingTarget.getAddress(), 0, 0, true) // Change self-minting from 10% to 0% // Calculate actual blocks accumulated (from block 0 since lastIssuanceAccumulationBlock starts at 0) const blockAfterAccumulation = await ethers.provider.getBlockNumber() - // Verify accumulation: 50% allocator-minting allocation (500000 PPM) + // Verify accumulation: 90% allocator-minting allocation (150000 + 250000 + 500000 default = 900000 PPM) // Accumulation should happen from lastIssuanceDistributionBlock to current block const pendingAmount = await issuanceAllocator.pendingAccumulatedAllocatorIssuance() const lastDistributionBlock = await issuanceAllocator.lastIssuanceDistributionBlock() // Calculate expected accumulation from when issuance was last distributed + // During accumulation: 15% (target1) + 25% (target2) + 50% (default) = 90% allocator-minting, 10% self-minting const blocksToAccumulate = BigInt(blockAfterAccumulation) - BigInt(lastDistributionBlock) - const allocation = await issuanceAllocator.getTotalAllocation() + const allocatorMintingDuringAccumulation = 900000n // 90% in PPM const expectedPending = calculateExpectedAccumulation( parseEther('1000'), blocksToAccumulate, - allocation.allocatorMintingPPM, + allocatorMintingDuringAccumulation, ) expect(pendingAmount).to.equal(expectedPending) @@ -959,11 +955,11 @@ describe('IssuanceAllocator', () => { const finalBalance2 = await (graphToken as any).balanceOf(await target2.getAddress()) // Calculate expected distributions: - // Total allocator-minting allocation: 200000 + 300000 = 500000 - // target1 should get: 2000 * (200000 / 500000) = 800 tokens from pending (doubled due to known issue) - // target2 should get: 2000 * (300000 / 500000) = 1200 tokens from pending (doubled due to known issue) - const expectedTarget1Pending = ethers.parseEther('800') - const expectedTarget2Pending = ethers.parseEther('1200') + // Total allocator-minting allocation after change: 150000 + 250000 + 600000 (default) = 1000000 (100%) + // target1 should get: 2000 * (150000 / 1000000) = 300 tokens from pending (doubled due to known issue) + // target2 should get: 2000 * (250000 / 1000000) = 500 tokens from pending (doubled due to known issue) + const expectedTarget1Pending = ethers.parseEther('300') + const expectedTarget2Pending = ethers.parseEther('500') // Account for any additional issuance from the distribution block itself const pendingDistribution1 = finalBalance1 - initialBalance1 @@ -974,10 +970,10 @@ describe('IssuanceAllocator', () => { expect(pendingDistribution1).to.be.gte(expectedTarget1Pending) expect(pendingDistribution2).to.be.gte(expectedTarget2Pending) - // Verify the ratio is correct: target2 should get 1.5x what target1 gets from pending - // (300000 / 200000 = 1.5) + // Verify the ratio is correct: target2 should get 1.67x what target1 gets from pending + // (250000 / 150000 = 1.67) const ratio = (BigInt(pendingDistribution2) * 1000n) / BigInt(pendingDistribution1) // Multiply by 1000 for precision - expect(ratio).to.be.closeTo(1500n, 50n) // Allow small rounding tolerance + expect(ratio).to.be.closeTo(1667n, 100n) // Allow larger tolerance due to default allocation adjustments // Verify pending was reset expect(await issuanceAllocator.pendingAccumulatedAllocatorIssuance()).to.equal(0) @@ -991,13 +987,13 @@ describe('IssuanceAllocator', () => { await issuanceAllocator.connect(accounts.governor).grantRole(PAUSE_ROLE, accounts.governor.address) await issuanceAllocator.connect(accounts.governor).setIssuancePerBlock(ethers.parseEther('1000'), false) - // Allocator-minting targets: 40% and 60%, plus a small self-minting target initially + // Allocator-minting targets: 30% and 50%, plus a small self-minting target initially (leaving 19% for default) await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await target1.getAddress(), 400000, 0, false) // 40% allocator-minting + ['setTargetAllocation(address,uint256,uint256,bool)'](await target1.getAddress(), 300000, 0, false) // 30% allocator-minting await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await target2.getAddress(), 590000, 10000, false) // 59% allocator-minting, 1% self-minting + ['setTargetAllocation(address,uint256,uint256,bool)'](await target2.getAddress(), 500000, 10000, false) // 50% allocator-minting, 1% self-minting // Initialize and pause await issuanceAllocator.connect(accounts.governor).distributeIssuance() @@ -1012,19 +1008,19 @@ describe('IssuanceAllocator', () => { await ethers.provider.send('evm_mine', []) await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await target2.getAddress(), 600000, 0, true) // Remove self-minting, now 100% allocator-minting + ['setTargetAllocation(address,uint256,uint256,bool)'](await target2.getAddress(), 510000, 0, true) // Remove self-minting (now 51% allocator-minting, leaving 19% for default) // Calculate actual blocks accumulated (from block 0 since lastIssuanceAccumulationBlock starts at 0) const blockAfterAccumulation = await ethers.provider.getBlockNumber() - // Verify accumulation: should use the OLD allocation (99% allocator-minting) that was active during pause - // Accumulation happens BEFORE the allocation change, so uses 40% + 59% = 99% + // Verify accumulation: should use the OLD allocation (80% allocator-minting) that was active during pause + // Accumulation happens BEFORE the allocation change, so uses 30% + 50% + 19% default = 99% allocator-minting, 1% self-minting const pendingAmount = await issuanceAllocator.pendingAccumulatedAllocatorIssuance() const lastDistributionBlock = await issuanceAllocator.lastIssuanceDistributionBlock() // Calculate expected accumulation using the OLD allocation (before the change) const blocksToAccumulate = BigInt(blockAfterAccumulation) - BigInt(lastDistributionBlock) - const oldAllocatorMintingPPM = 400000n + 590000n // 40% + 59% = 99% + const oldAllocatorMintingPPM = 300000n + 500000n + 190000n // 30% + 50% + 19% default = 99% const expectedPending = calculateExpectedAccumulation( parseEther('1000'), blocksToAccumulate, @@ -1041,11 +1037,11 @@ describe('IssuanceAllocator', () => { const finalBalance2 = await (graphToken as any).balanceOf(await target2.getAddress()) // Calculate expected distributions: - // Total allocator-minting allocation: 400000 + 600000 = 1000000 (100%) - // target1 should get: 5000 * (400000 / 1000000) = 2000 tokens from pending - // target2 should get: 5000 * (600000 / 1000000) = 3000 tokens from pending - const expectedTarget1Pending = ethers.parseEther('2000') - const expectedTarget2Pending = ethers.parseEther('3000') + // Total allocator-minting allocation: 300000 + 510000 + 190000 = 1000000 (100%) + // target1 should get: 5000 * (300000 / 1000000) = 1500 tokens from pending + // target2 should get: 5000 * (510000 / 1000000) = 2550 tokens from pending + const expectedTarget1Pending = ethers.parseEther('1500') + const expectedTarget2Pending = ethers.parseEther('2550') // Account for any additional issuance from the distribution block itself const pendingDistribution1 = finalBalance1 - initialBalance1 @@ -1055,10 +1051,10 @@ describe('IssuanceAllocator', () => { expect(pendingDistribution1).to.be.gte(expectedTarget1Pending) expect(pendingDistribution2).to.be.gte(expectedTarget2Pending) - // Verify the ratio is correct: target2 should get 1.5x what target1 gets from pending - // (600000 / 400000 = 1.5) + // Verify the ratio is correct: target2 should get 1.7x what target1 gets from pending + // (510000 / 300000 = 1.7) const ratio = (BigInt(pendingDistribution2) * 1000n) / BigInt(pendingDistribution1) // Multiply by 1000 for precision - expect(ratio).to.be.closeTo(1500n, 50n) // Allow small rounding tolerance + expect(ratio).to.be.closeTo(1700n, 50n) // Allow small rounding tolerance // Verify pending was reset expect(await issuanceAllocator.pendingAccumulatedAllocatorIssuance()).to.equal(0) @@ -1076,13 +1072,13 @@ describe('IssuanceAllocator', () => { const MockTarget = await ethers.getContractFactory('MockSimpleTarget') const target3 = await MockTarget.deploy() - // Mix of targets: 30% + 20% + 10% allocator-minting (60% total), 40% self-minting + // Mix of targets: 25% + 15% + 10% allocator-minting (50% total), 20% self-minting (leaving 30% for default, total 80% allocator) await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await target1.getAddress(), 300000, 0, false) // 30% allocator-minting + ['setTargetAllocation(address,uint256,uint256,bool)'](await target1.getAddress(), 250000, 0, false) // 25% allocator-minting await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await target2.getAddress(), 200000, 0, false) // 20% allocator-minting + ['setTargetAllocation(address,uint256,uint256,bool)'](await target2.getAddress(), 150000, 0, false) // 15% allocator-minting await issuanceAllocator .connect(accounts.governor) ['setTargetAllocation(address,uint256,uint256,bool)'](await target3.getAddress(), 100000, 0, false) // 10% allocator-minting @@ -1091,7 +1087,7 @@ describe('IssuanceAllocator', () => { const selfMintingTarget = await MockTarget.deploy() await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await selfMintingTarget.getAddress(), 0, 400000, false) // 40% self-minting + ['setTargetAllocation(address,uint256,uint256,bool)'](await selfMintingTarget.getAddress(), 0, 200000, false) // 20% self-minting // Initialize and pause await issuanceAllocator.connect(accounts.governor).distributeIssuance() @@ -1107,23 +1103,24 @@ describe('IssuanceAllocator', () => { } await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await selfMintingTarget.getAddress(), 0, 300000, true) // Change self-minting from 40% to 30% + ['setTargetAllocation(address,uint256,uint256,bool)'](await selfMintingTarget.getAddress(), 0, 100000, true) // Change self-minting from 20% to 10% // Calculate actual blocks accumulated (from block 0 since lastIssuanceAccumulationBlock starts at 0) const blockAfterAccumulation = await ethers.provider.getBlockNumber() - // Calculate expected total accumulation: 60% allocator-minting allocation (600000 PPM) + // Calculate expected total accumulation: 80% allocator-minting allocation (25% + 15% + 10% + 30% default = 800000 PPM) // Accumulation should happen from lastIssuanceDistributionBlock to current block const pendingAmount = await issuanceAllocator.pendingAccumulatedAllocatorIssuance() const lastDistributionBlock = await issuanceAllocator.lastIssuanceDistributionBlock() // Calculate expected accumulation from when issuance was last distributed + // During accumulation: 25% (target1) + 15% (target2) + 10% (target3) + 30% (default) = 80% allocator-minting, 20% self-minting const blocksToAccumulate = BigInt(blockAfterAccumulation) - BigInt(lastDistributionBlock) - const allocation = await issuanceAllocator.getTotalAllocation() + const allocatorMintingDuringAccumulation = 800000n // 80% in PPM const expectedPending = calculateExpectedAccumulation( parseEther('1000'), blocksToAccumulate, - allocation.allocatorMintingPPM, + allocatorMintingDuringAccumulation, ) expect(pendingAmount).to.equal(expectedPending) @@ -1142,19 +1139,22 @@ describe('IssuanceAllocator', () => { const totalDistributed = distribution1 + distribution2 + distribution3 // Verify total distributed amount is reasonable - // Should be at least the pending amount (might be more due to additional block issuance) - expect(totalDistributed).to.be.gte(pendingAmount) + // The three explicit targets get 50% of total allocation, default gets 30% + // So they should receive (50/80) = 62.5% of pending allocator-minting issuance + // Plus additional issuance from blocks between accumulation and distribution + const expectedMinimumToThreeTargets = (pendingAmount * 50n) / 80n + expect(totalDistributed).to.be.gte(expectedMinimumToThreeTargets) // Verify proportional distribution within allocator-minting targets - // Total allocator-minting allocation: 300000 + 200000 + 100000 = 600000 - // Expected ratios: target1:target2:target3 = 30:20:10 = 3:2:1 - const ratio12 = (BigInt(distribution1) * 1000n) / BigInt(distribution2) // Should be ~1500 (3/2 * 1000) - const ratio13 = (BigInt(distribution1) * 1000n) / BigInt(distribution3) // Should be ~3000 (3/1 * 1000) - const ratio23 = (BigInt(distribution2) * 1000n) / BigInt(distribution3) // Should be ~2000 (2/1 * 1000) + // Actual allocations: target1=25%, target2=15%, target3=10% + // Expected ratios: target1:target2:target3 = 25:15:10 = 5:3:2 + const ratio12 = (BigInt(distribution1) * 1000n) / BigInt(distribution2) // Should be ~1667 (5/3 * 1000) + const ratio13 = (BigInt(distribution1) * 1000n) / BigInt(distribution3) // Should be ~2500 (5/2 * 1000) + const ratio23 = (BigInt(distribution2) * 1000n) / BigInt(distribution3) // Should be ~1500 (3/2 * 1000) - expect(ratio12).to.be.closeTo(1500n, 100n) // 3:2 ratio with tolerance - expect(ratio13).to.be.closeTo(3000n, 200n) // 3:1 ratio with tolerance - expect(ratio23).to.be.closeTo(2000n, 150n) // 2:1 ratio with tolerance + expect(ratio12).to.be.closeTo(1667n, 100n) // 5:3 ratio with tolerance + expect(ratio13).to.be.closeTo(2500n, 200n) // 5:2 ratio with tolerance + expect(ratio23).to.be.closeTo(1500n, 150n) // 3:2 ratio with tolerance // Verify pending was reset expect(await issuanceAllocator.pendingAccumulatedAllocatorIssuance()).to.equal(0) @@ -2027,11 +2027,11 @@ describe('IssuanceAllocator', () => { .connect(accounts.governor) ['setTargetAllocation(address,uint256,uint256,bool)'](nonExistentTarget, 0, 0, false) - // Verify no targets were added + // Verify no non-default targets were added (only default remains) const targets = await issuanceAllocator.getTargets() - expect(targets.length).to.equal(0) + expect(targets.length).to.equal(1) // Only default allocation - // Verify total allocation remains 0 + // Verify reported total is 0% (all in default, which isn't reported) const totalAlloc = await issuanceAllocator.getTotalAllocation() expect(totalAlloc.totalAllocationPPM).to.equal(0) @@ -2040,9 +2040,9 @@ describe('IssuanceAllocator', () => { .connect(accounts.governor) ['setTargetAllocation(address,uint256,uint256,bool)'](nonExistentTarget, 0, 0, false) - // Verify still no targets + // Verify still only default target const targetsAfter = await issuanceAllocator.getTargets() - expect(targetsAfter.length).to.equal(0) + expect(targetsAfter.length).to.equal(1) // Only default allocation }) }) @@ -2067,24 +2067,24 @@ describe('IssuanceAllocator', () => { it('should manage target count and array correctly', async () => { const { issuanceAllocator, addresses } = sharedContracts - // Test initial state - expect(await issuanceAllocator.getTargetCount()).to.equal(0) - expect((await issuanceAllocator.getTargets()).length).to.equal(0) + // Test initial state (with default allocation) + expect(await issuanceAllocator.getTargetCount()).to.equal(1) // Default allocation exists + expect((await issuanceAllocator.getTargets()).length).to.equal(1) // Test adding targets await issuanceAllocator .connect(accounts.governor) ['setTargetAllocation(address,uint256,uint256,bool)'](addresses.target1, 100000, 0, false) - expect(await issuanceAllocator.getTargetCount()).to.equal(1) + expect(await issuanceAllocator.getTargetCount()).to.equal(2) // Default + target1 await issuanceAllocator .connect(accounts.governor) ['setTargetAllocation(address,uint256,uint256,bool)'](addresses.target2, 200000, 0, false) - expect(await issuanceAllocator.getTargetCount()).to.equal(2) + expect(await issuanceAllocator.getTargetCount()).to.equal(3) // Default + target1 + target2 // Test getTargets array content const targetAddresses = await issuanceAllocator.getTargets() - expect(targetAddresses.length).to.equal(2) + expect(targetAddresses.length).to.equal(3) expect(targetAddresses).to.include(addresses.target1) expect(targetAddresses).to.include(addresses.target2) @@ -2092,13 +2092,13 @@ describe('IssuanceAllocator', () => { await issuanceAllocator .connect(accounts.governor) ['setTargetAllocation(address,uint256,uint256,bool)'](addresses.target1, 0, 0, false) - expect(await issuanceAllocator.getTargetCount()).to.equal(1) + expect(await issuanceAllocator.getTargetCount()).to.equal(2) // Default + target2 await issuanceAllocator .connect(accounts.governor) ['setTargetAllocation(address,uint256,uint256,bool)'](addresses.target2, 0, 0, false) - expect(await issuanceAllocator.getTargetCount()).to.equal(0) - expect((await issuanceAllocator.getTargets()).length).to.equal(0) + expect(await issuanceAllocator.getTargetCount()).to.equal(1) // Only default remains + expect((await issuanceAllocator.getTargets()).length).to.equal(1) }) it('should store targets in the getTargets array in correct order', async () => { @@ -2116,9 +2116,11 @@ describe('IssuanceAllocator', () => { const targetAddresses = await issuanceAllocator.getTargets() // Check that the addresses are in the correct order - expect(targetAddresses[0]).to.equal(addresses.target1) - expect(targetAddresses[1]).to.equal(addresses.target2) - expect(targetAddresses.length).to.equal(2) + // targetAddresses[0] is the default allocation (address(0)) + expect(targetAddresses[0]).to.equal(ethers.ZeroAddress) // Default + expect(targetAddresses[1]).to.equal(addresses.target1) + expect(targetAddresses[2]).to.equal(addresses.target2) + expect(targetAddresses.length).to.equal(3) // Default + target1 + target2 }) it('should return the correct target address by index', async () => { @@ -2140,17 +2142,20 @@ describe('IssuanceAllocator', () => { // Get all target addresses const addresses = await issuanceAllocator.getTargets() - expect(addresses.length).to.equal(3) + expect(addresses.length).to.equal(4) // Default + 3 targets // Check that the addresses are in the correct order - expect(addresses[0]).to.equal(await target1.getAddress()) - expect(addresses[1]).to.equal(await target2.getAddress()) - expect(addresses[2]).to.equal(await target3.getAddress()) + // addresses[0] is the default allocation (address(0)) + expect(addresses[0]).to.equal(ethers.ZeroAddress) // Default + expect(addresses[1]).to.equal(await target1.getAddress()) + expect(addresses[2]).to.equal(await target2.getAddress()) + expect(addresses[3]).to.equal(await target3.getAddress()) // Test getTargetAt method for individual access - expect(await issuanceAllocator.getTargetAt(0)).to.equal(await target1.getAddress()) - expect(await issuanceAllocator.getTargetAt(1)).to.equal(await target2.getAddress()) - expect(await issuanceAllocator.getTargetAt(2)).to.equal(await target3.getAddress()) + expect(await issuanceAllocator.getTargetAt(0)).to.equal(ethers.ZeroAddress) // Default + expect(await issuanceAllocator.getTargetAt(1)).to.equal(await target1.getAddress()) + expect(await issuanceAllocator.getTargetAt(2)).to.equal(await target2.getAddress()) + expect(await issuanceAllocator.getTargetAt(3)).to.equal(await target3.getAddress()) }) it('should return the correct target allocation', async () => { @@ -2958,20 +2963,20 @@ describe('IssuanceAllocator', () => { await issuanceAllocator.connect(accounts.governor).grantRole(PAUSE_ROLE, accounts.governor.address) await issuanceAllocator.connect(accounts.governor).setIssuancePerBlock(ethers.parseEther('1000'), false) - // Test scenario: 25% allocator-minting + 50% self-minting + 25% unallocated + // Test scenario: 20% allocator-minting + 40% self-minting (leaving 40% for default) await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await target1.getAddress(), 250000, 0, false) // 25% allocator-minting + ['setTargetAllocation(address,uint256,uint256,bool)'](await target1.getAddress(), 200000, 0, false) // 20% allocator-minting await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await target2.getAddress(), 0, 500000, false) // 50% self-minting - // 25% remains unallocated + ['setTargetAllocation(address,uint256,uint256,bool)'](await target2.getAddress(), 0, 400000, false) // 40% self-minting + // 40% goes to default allocation // Verify the setup const totalAllocation = await issuanceAllocator.getTotalAllocation() - expect(totalAllocation.totalAllocationPPM).to.equal(750000) // 75% total - expect(totalAllocation.allocatorMintingPPM).to.equal(250000) // 25% allocator - expect(totalAllocation.selfMintingPPM).to.equal(500000) // 50% self + expect(totalAllocation.totalAllocationPPM).to.equal(600000) // 60% reported (excludes default's 40%) + expect(totalAllocation.allocatorMintingPPM).to.equal(200000) // 20% allocator (excludes default's 40%) + expect(totalAllocation.selfMintingPPM).to.equal(400000) // 40% self // Distribute once to initialize await issuanceAllocator.connect(accounts.governor).distributeIssuance() @@ -3004,9 +3009,9 @@ describe('IssuanceAllocator', () => { expect(distributed2).to.equal(0) // Target1 should receive the correct proportional amount - // The calculation is: (pendingAmount * 250000) / (1000000 - 500000) = (pendingAmount * 250000) / 500000 = pendingAmount * 0.5 - // So target1 should get exactly 50% of the pending amount - const expectedDistribution = pendingBefore / 2n // 50% of pending + // The calculation is: (pendingAmount * 200000) / (1000000 - 400000) = (pendingAmount * 200000) / 600000 = pendingAmount * 1/3 + // So target1 should get exactly 33.33% of the pending amount + const expectedDistribution = (pendingBefore * 200000n) / 600000n // 33.33% of pending expect(distributed1).to.be.closeTo(expectedDistribution, ethers.parseEther('1')) // Verify pending issuance was reset @@ -3021,22 +3026,22 @@ describe('IssuanceAllocator', () => { await issuanceAllocator.connect(accounts.governor).grantRole(PAUSE_ROLE, accounts.governor.address) await issuanceAllocator.connect(accounts.governor).setIssuancePerBlock(ethers.parseEther('1000'), false) - // Test scenario: 15% + 10% allocator-minting + 50% self-minting + 25% unallocated + // Test scenario: 12% + 8% allocator-minting + 40% self-minting (leaving 40% for default) await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await target1.getAddress(), 150000, 0, false) // 15% allocator-minting + ['setTargetAllocation(address,uint256,uint256,bool)'](await target1.getAddress(), 120000, 0, false) // 12% allocator-minting await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await target2.getAddress(), 100000, 0, false) // 10% allocator-minting + ['setTargetAllocation(address,uint256,uint256,bool)'](await target2.getAddress(), 80000, 0, false) // 8% allocator-minting await issuanceAllocator .connect(accounts.governor) - ['setTargetAllocation(address,uint256,uint256,bool)'](await target3.getAddress(), 0, 500000, false) // 50% self-minting - // 25% remains unallocated + ['setTargetAllocation(address,uint256,uint256,bool)'](await target3.getAddress(), 0, 400000, false) // 40% self-minting + // 40% goes to default allocation // Verify the setup const totalAllocation = await issuanceAllocator.getTotalAllocation() - expect(totalAllocation.allocatorMintingPPM).to.equal(250000) // 25% total allocator - expect(totalAllocation.selfMintingPPM).to.equal(500000) // 50% self + expect(totalAllocation.allocatorMintingPPM).to.equal(200000) // 12% + 8% = 20% (excludes default's 40%) + expect(totalAllocation.selfMintingPPM).to.equal(400000) // 40% self // Distribute once to initialize await issuanceAllocator.connect(accounts.governor).distributeIssuance() @@ -3072,21 +3077,21 @@ describe('IssuanceAllocator', () => { expect(distributed3).to.equal(0) // Verify proportional distribution between allocator-minting targets - // Target1 should get 15/25 = 60% of the distributed amount - // Target2 should get 10/25 = 40% of the distributed amount + // Target1 should get 12/20 = 60% of the distributed amount + // Target2 should get 8/20 = 40% of the distributed amount if (distributed1 > 0 && distributed2 > 0) { const ratio = (BigInt(distributed1) * 1000n) / BigInt(distributed2) // Multiply by 1000 for precision - expect(ratio).to.be.closeTo(1500n, 50n) // 150000/100000 = 1.5 + expect(ratio).to.be.closeTo(1500n, 50n) // 120000/80000 = 1.5 } // Total distributed should equal the allocator-minting portion of pending - // With 25% total allocator-minting out of 50% allocator-minting space: + // With 20% total allocator-minting (12% + 8%) out of 60% allocator-minting space (20% + 40% default): // Each target gets: (targetPPM / (MILLION - selfMintingPPM)) * pendingAmount - // Target1: (150000 / 500000) * pendingAmount = 30% of pending - // Target2: (100000 / 500000) * pendingAmount = 20% of pending - // Total: 50% of pending + // Target1: (120000 / 600000) * pendingAmount = 20% of pending + // Target2: (80000 / 600000) * pendingAmount = 13.33% of pending + // Total: 33.33% of pending const totalDistributed = distributed1 + distributed2 - const expectedTotal = pendingBefore / 2n // 50% of pending + const expectedTotal = (pendingBefore * 200000n) / 600000n // 33.33% of pending expect(totalDistributed).to.be.closeTo(expectedTotal, ethers.parseEther('1')) }) }) diff --git a/packages/issuance/test/tests/allocate/IssuanceSystem.test.ts b/packages/issuance/test/tests/allocate/IssuanceSystem.test.ts index 5a2de54aa..77645546a 100644 --- a/packages/issuance/test/tests/allocate/IssuanceSystem.test.ts +++ b/packages/issuance/test/tests/allocate/IssuanceSystem.test.ts @@ -84,13 +84,11 @@ describe('Issuance System', () => { // Set up initial allocations using helper await system.helpers.setupStandardAllocations() - // Verify initial total allocation (30% + 40% = 70%) + // Verify initial total allocation (excludes default since it's address(0)) const totalAlloc = await contracts.issuanceAllocator.getTotalAllocation() - expect(totalAlloc.totalAllocationPPM).to.equal( - TestConstants.ALLOCATION_30_PERCENT + TestConstants.ALLOCATION_40_PERCENT, - ) + expect(totalAlloc.totalAllocationPPM).to.equal(700000) // 70% (30% + 40%, excludes default) - // Change allocations: target1 = 50%, target2 = 20% (still 70%) + // Change allocations: target1 = 50%, target2 = 20% (30% goes to default) await contracts.issuanceAllocator .connect(accounts.governor) [ @@ -102,11 +100,9 @@ describe('Issuance System', () => { 'setTargetAllocation(address,uint256,uint256,bool)' ](addresses.target2, TestConstants.ALLOCATION_20_PERCENT, 0, false) - // Verify updated allocations + // Verify updated allocations (excludes default since it's address(0)) const updatedTotalAlloc = await contracts.issuanceAllocator.getTotalAllocation() - expect(updatedTotalAlloc.totalAllocationPPM).to.equal( - TestConstants.ALLOCATION_50_PERCENT + TestConstants.ALLOCATION_20_PERCENT, - ) + expect(updatedTotalAlloc.totalAllocationPPM).to.equal(700000) // 70% (50% + 20%, excludes default) // Verify individual target allocations const target1Info = await contracts.issuanceAllocator.getTargetData(addresses.target1) diff --git a/packages/issuance/test/tests/allocate/optimizedFixtures.ts b/packages/issuance/test/tests/allocate/optimizedFixtures.ts index 22f407f7d..6ded870a1 100644 --- a/packages/issuance/test/tests/allocate/optimizedFixtures.ts +++ b/packages/issuance/test/tests/allocate/optimizedFixtures.ts @@ -73,14 +73,20 @@ export async function setupOptimizedAllocateSystem(customOptions: any = {}) { helpers: { // Helper to reset state without redeploying resetState: async () => { - // Remove all targets + // Remove all targets except the default at index 0 const targets = await issuanceAllocator.getTargets() + const defaultAddress = await issuanceAllocator.getTargetAt(0) for (const targetAddr of targets) { + // Skip the default allocation target + if (targetAddr === defaultAddress) continue await issuanceAllocator .connect(accounts.governor) ['setTargetAllocation(address,uint256,uint256,bool)'](targetAddr, 0, 0, false) } + // Reset default allocation to address(0) with 100% + await issuanceAllocator.connect(accounts.governor).setDefaultAllocationAddress(ethers.ZeroAddress) + // Reset issuance rate await issuanceAllocator.connect(accounts.governor).setIssuancePerBlock(options.issuancePerBlock, false) }, From 13a77e4d92dea53d5fa85a644d052f70213e0d8a Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:44:41 +0000 Subject: [PATCH 2/4] feat: optional issunace reclaim addresses for Rewards Manager Added two configurable reclaim addresses: - indexerEligibilityReclaimAddress - subgraphDeniedReclaimAddress When rewards are denied and the reclaim address is set (non-zero), tokens are minted to that address instead of not being minted at all. Defaults to address(0) (original behavior). Also updated associated documentation and tests. --- .../contracts/rewards/RewardsManager.sol | 147 ++++++++- .../rewards/RewardsManagerStorage.sol | 6 +- .../unit/rewards/rewards-interface.test.ts | 2 +- .../unit/rewards/rewards-reclaim.test.ts | 299 ++++++++++++++++++ .../contracts/rewards/IRewardsManager.sol | 14 + .../test/unit/mocks/MockRewardsManager.sol | 6 + 6 files changed, 455 insertions(+), 19 deletions(-) create mode 100644 packages/contracts/test/tests/unit/rewards/rewards-reclaim.test.ts diff --git a/packages/contracts/contracts/rewards/RewardsManager.sol b/packages/contracts/contracts/rewards/RewardsManager.sol index 458893308..3f45073c4 100644 --- a/packages/contracts/contracts/rewards/RewardsManager.sol +++ b/packages/contracts/contracts/rewards/RewardsManager.sol @@ -108,6 +108,46 @@ contract RewardsManager is RewardsManagerV6Storage, GraphUpgradeable, IERC165, I address indexed newRewardsEligibilityOracle ); + /** + * @notice Emitted when the eligibility reclaim address is set + * @param oldReclaimAddress Previous eligibility reclaim address + * @param newReclaimAddress New eligibility reclaim address + */ + event IndexerEligibilityReclaimAddressSet(address indexed oldReclaimAddress, address indexed newReclaimAddress); + + /** + * @notice Emitted when the subgraph reclaim address is set + * @param oldReclaimAddress Previous subgraph reclaim address + * @param newReclaimAddress New subgraph reclaim address + */ + event SubgraphDeniedReclaimAddressSet(address indexed oldReclaimAddress, address indexed newReclaimAddress); + + /** + * @notice Emitted when denied rewards are reclaimed due to eligibility + * @param indexer Address of the indexer whose rewards were denied + * @param allocationID Address of the allocation + * @param amount Amount of rewards reclaimed + */ + event RewardsReclaimedDueToIndexerEligibility( + address indexed indexer, + address indexed allocationID, + uint256 amount + ); + + /** + * @notice Emitted when denied rewards are reclaimed due to subgraph denylist + * @param indexer Address of the indexer whose rewards were denied + * @param allocationID Address of the allocation + * @param subgraphDeploymentID Subgraph deployment ID that was denied + * @param amount Amount of rewards reclaimed + */ + event RewardsReclaimedDueToSubgraphDenylist( + address indexed indexer, + address indexed allocationID, + bytes32 indexed subgraphDeploymentID, + uint256 amount + ); + // -- Modifiers -- /** @@ -264,6 +304,32 @@ contract RewardsManager is RewardsManagerV6Storage, GraphUpgradeable, IERC165, I } } + /** + * @inheritdoc IRewardsManager + * @dev Set to zero address to disable eligibility reclaim functionality + */ + function setIndexerEligibilityReclaimAddress(address newReclaimAddress) external override onlyGovernor { + address oldReclaimAddress = indexerEligibilityReclaimAddress; + + if (oldReclaimAddress != newReclaimAddress) { + indexerEligibilityReclaimAddress = newReclaimAddress; + emit IndexerEligibilityReclaimAddressSet(oldReclaimAddress, newReclaimAddress); + } + } + + /** + * @inheritdoc IRewardsManager + * @dev Set to zero address to disable subgraph reclaim functionality + */ + function setSubgraphDeniedReclaimAddress(address newReclaimAddress) external override onlyGovernor { + address oldReclaimAddress = subgraphDeniedReclaimAddress; + + if (oldReclaimAddress != newReclaimAddress) { + subgraphDeniedReclaimAddress = newReclaimAddress; + emit SubgraphDeniedReclaimAddressSet(oldReclaimAddress, newReclaimAddress); + } + } + // -- Denylist -- /** @@ -494,6 +560,60 @@ contract RewardsManager is RewardsManagerV6Storage, GraphUpgradeable, IERC165, I return newAccrued.mul(_tokens).div(FIXED_POINT_SCALING_FACTOR); } + /** + * @notice Checks for and handles denial and reclaim of rewards due to subgraph deny list + * @dev If denied, emits RewardsDenied event and mints to reclaim address if configured + * @param indexer Address of the indexer + * @param allocationID Address of the allocation + * @param subgraphDeploymentID Subgraph deployment ID + * @param rewards Amount of rewards that would be distributed + * @return True if rewards are denied, false otherwise + */ + function _rewardsDeniedDueToSubgraphDenyList( + address indexer, + address allocationID, + bytes32 subgraphDeploymentID, + uint256 rewards + ) private returns (bool) { + if (isDenied(subgraphDeploymentID)) { + emit RewardsDenied(indexer, allocationID); + + // If a reclaim address is set, mint the denied rewards there + if (0 < rewards && subgraphDeniedReclaimAddress != address(0)) { + graphToken().mint(subgraphDeniedReclaimAddress, rewards); + emit RewardsReclaimedDueToSubgraphDenylist(indexer, allocationID, subgraphDeploymentID, rewards); + } + return true; + } + return false; + } + + /** + * @notice Checks for and handles denial and reclaim of rewards due to indexer eligibility + * @dev If denied, emits RewardsDeniedDueToEligibility event and mints to reclaim address if configured + * @param indexer Address of the indexer + * @param allocationID Address of the allocation + * @param rewards Amount of rewards that would be distributed + * @return True if rewards are denied, false otherwise + */ + function _rewardsDeniedDueToIndexerEligibility( + address indexer, + address allocationID, + uint256 rewards + ) private returns (bool) { + if (address(rewardsEligibilityOracle) != address(0) && !rewardsEligibilityOracle.isEligible(indexer)) { + emit RewardsDeniedDueToEligibility(indexer, allocationID, rewards); + + // If a reclaim address is set, mint the denied rewards there + if (0 < rewards && indexerEligibilityReclaimAddress != address(0)) { + graphToken().mint(indexerEligibilityReclaimAddress, rewards); + emit RewardsReclaimedDueToIndexerEligibility(indexer, allocationID, rewards); + } + return true; + } + return false; + } + /** * @inheritdoc IRewardsManager * @dev This function can only be called by an authorized rewards issuer which are @@ -518,31 +638,24 @@ contract RewardsManager is RewardsManagerV6Storage, GraphUpgradeable, IERC165, I uint256 updatedAccRewardsPerAllocatedToken = onSubgraphAllocationUpdate(subgraphDeploymentID); - // Do not do rewards on denied subgraph deployments ID - if (isDenied(subgraphDeploymentID)) { - emit RewardsDenied(indexer, _allocationID); - return 0; - } - uint256 rewards = 0; if (isActive) { // Calculate rewards accrued by this allocation rewards = accRewardsPending.add( _calcRewards(tokens, accRewardsPerAllocatedToken, updatedAccRewardsPerAllocatedToken) ); + } - // Do not reward if indexer is not eligible based on rewards eligibility - if (address(rewardsEligibilityOracle) != address(0) && !rewardsEligibilityOracle.isEligible(indexer)) { - emit RewardsDeniedDueToEligibility(indexer, _allocationID, rewards); - return 0; - } + if (_rewardsDeniedDueToSubgraphDenyList(indexer, _allocationID, subgraphDeploymentID, rewards)) return 0; - if (rewards > 0) { - // Mint directly to rewards issuer for the reward amount - // The rewards issuer contract will do bookkeeping of the reward and - // assign in proportion to each stakeholder incentive - graphToken().mint(rewardsIssuer, rewards); - } + if (_rewardsDeniedDueToIndexerEligibility(indexer, _allocationID, rewards)) return 0; + + // Mint rewards to the rewards issuer + if (rewards > 0) { + // Mint directly to rewards issuer for the reward amount + // The rewards issuer contract will do bookkeeping of the reward and + // assign in proportion to each stakeholder incentive + graphToken().mint(rewardsIssuer, rewards); } emit HorizonRewardsAssigned(indexer, _allocationID, rewards); diff --git a/packages/contracts/contracts/rewards/RewardsManagerStorage.sol b/packages/contracts/contracts/rewards/RewardsManagerStorage.sol index 63897f431..c1d9c37dd 100644 --- a/packages/contracts/contracts/rewards/RewardsManagerStorage.sol +++ b/packages/contracts/contracts/rewards/RewardsManagerStorage.sol @@ -83,11 +83,15 @@ contract RewardsManagerV5Storage is RewardsManagerV4Storage { * @title RewardsManagerV6Storage * @author Edge & Node * @notice Storage layout for RewardsManager V6 - * Includes support for Rewards Eligibility Oracle and Issuance Allocator. + * Includes support for Rewards Eligibility Oracle, Issuance Allocator, and reclaim addresses. */ contract RewardsManagerV6Storage is RewardsManagerV5Storage { /// @notice Address of the rewards eligibility oracle contract IRewardsEligibility public rewardsEligibilityOracle; /// @notice Address of the issuance allocator IIssuanceAllocationDistribution public issuanceAllocator; + /// @notice Address to receive tokens denied due to indexer eligibility checks, set to zero to disable + address public indexerEligibilityReclaimAddress; + /// @notice Address to receive tokens denied due to subgraph denylist, set to zero to disable + address public subgraphDeniedReclaimAddress; } diff --git a/packages/contracts/test/tests/unit/rewards/rewards-interface.test.ts b/packages/contracts/test/tests/unit/rewards/rewards-interface.test.ts index 3a9b7c23b..d7db40458 100644 --- a/packages/contracts/test/tests/unit/rewards/rewards-interface.test.ts +++ b/packages/contracts/test/tests/unit/rewards/rewards-interface.test.ts @@ -57,7 +57,7 @@ describe('RewardsManager interfaces', () => { }) it('IRewardsManager should have stable interface ID', () => { - expect(IRewardsManager__factory.interfaceId).to.equal('0xa31d8306') + expect(IRewardsManager__factory.interfaceId).to.equal('0x731e44f0') }) }) diff --git a/packages/contracts/test/tests/unit/rewards/rewards-reclaim.test.ts b/packages/contracts/test/tests/unit/rewards/rewards-reclaim.test.ts new file mode 100644 index 000000000..d7071840f --- /dev/null +++ b/packages/contracts/test/tests/unit/rewards/rewards-reclaim.test.ts @@ -0,0 +1,299 @@ +import { Curation } from '@graphprotocol/contracts' +import { EpochManager } from '@graphprotocol/contracts' +import { GraphToken } from '@graphprotocol/contracts' +import { IStaking } from '@graphprotocol/contracts' +import { RewardsManager } from '@graphprotocol/contracts' +import { deriveChannelKey, GraphNetworkContracts, helpers, randomHexBytes, toGRT } from '@graphprotocol/sdk' +import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { expect } from 'chai' +import { constants } from 'ethers' +import hre from 'hardhat' + +import { NetworkFixture } from '../lib/fixtures' + +const { HashZero } = constants + +describe('Rewards - Reclaim Addresses', () => { + const graph = hre.graph() + let curator1: SignerWithAddress + let governor: SignerWithAddress + let indexer1: SignerWithAddress + let reclaimWallet: SignerWithAddress + + let fixture: NetworkFixture + + let contracts: GraphNetworkContracts + let grt: GraphToken + let curation: Curation + let epochManager: EpochManager + let staking: IStaking + let rewardsManager: RewardsManager + + // Derive channel key for indexer used to sign attestations + const channelKey1 = deriveChannelKey() + + const subgraphDeploymentID1 = randomHexBytes() + + const allocationID1 = channelKey1.address + + const metadata = HashZero + + const ISSUANCE_PER_BLOCK = toGRT('200') // 200 GRT every block + + async function setupIndexerAllocation() { + // Setup + await epochManager.connect(governor).setEpochLength(10) + + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + + // Allocate + const tokensToAllocate = toGRT('12500') + await staking.connect(indexer1).stake(tokensToAllocate) + await staking + .connect(indexer1) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAllocate, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) + } + + before(async function () { + const testAccounts = await graph.getTestAccounts() + curator1 = testAccounts[0] + indexer1 = testAccounts[1] + reclaimWallet = testAccounts[2] + ;({ governor } = await graph.getNamedAccounts()) + + fixture = new NetworkFixture(graph.provider) + contracts = await fixture.load(governor) + grt = contracts.GraphToken as GraphToken + curation = contracts.Curation as Curation + epochManager = contracts.EpochManager + staking = contracts.Staking as IStaking + rewardsManager = contracts.RewardsManager + + // 200 GRT per block + await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) + + // Distribute test funds + for (const wallet of [indexer1, curator1]) { + await grt.connect(governor).mint(wallet.address, toGRT('1000000')) + await grt.connect(wallet).approve(staking.address, toGRT('1000000')) + await grt.connect(wallet).approve(curation.address, toGRT('1000000')) + } + }) + + beforeEach(async function () { + await fixture.setUp() + }) + + afterEach(async function () { + await fixture.tearDown() + }) + + describe('setIndexerEligibilityReclaimAddress', function () { + it('should reject if not governor', async function () { + const tx = rewardsManager.connect(indexer1).setIndexerEligibilityReclaimAddress(reclaimWallet.address) + await expect(tx).revertedWith('Only Controller governor') + }) + + it('should set eligibility reclaim address if governor', async function () { + const tx = rewardsManager.connect(governor).setIndexerEligibilityReclaimAddress(reclaimWallet.address) + await expect(tx) + .emit(rewardsManager, 'IndexerEligibilityReclaimAddressSet') + .withArgs(constants.AddressZero, reclaimWallet.address) + + expect(await rewardsManager.indexerEligibilityReclaimAddress()).eq(reclaimWallet.address) + }) + + it('should allow setting to zero address', async function () { + await rewardsManager.connect(governor).setIndexerEligibilityReclaimAddress(reclaimWallet.address) + + const tx = rewardsManager.connect(governor).setIndexerEligibilityReclaimAddress(constants.AddressZero) + await expect(tx) + .emit(rewardsManager, 'IndexerEligibilityReclaimAddressSet') + .withArgs(reclaimWallet.address, constants.AddressZero) + + expect(await rewardsManager.indexerEligibilityReclaimAddress()).eq(constants.AddressZero) + }) + + it('should not emit event when setting same address', async function () { + await rewardsManager.connect(governor).setIndexerEligibilityReclaimAddress(reclaimWallet.address) + + const tx = rewardsManager.connect(governor).setIndexerEligibilityReclaimAddress(reclaimWallet.address) + await expect(tx).to.not.emit(rewardsManager, 'IndexerEligibilityReclaimAddressSet') + }) + }) + + describe('setSubgraphDeniedReclaimAddress', function () { + it('should reject if not governor', async function () { + const tx = rewardsManager.connect(indexer1).setSubgraphDeniedReclaimAddress(reclaimWallet.address) + await expect(tx).revertedWith('Only Controller governor') + }) + + it('should set subgraph reclaim address if governor', async function () { + const tx = rewardsManager.connect(governor).setSubgraphDeniedReclaimAddress(reclaimWallet.address) + await expect(tx) + .emit(rewardsManager, 'SubgraphDeniedReclaimAddressSet') + .withArgs(constants.AddressZero, reclaimWallet.address) + + expect(await rewardsManager.subgraphDeniedReclaimAddress()).eq(reclaimWallet.address) + }) + + it('should allow setting to zero address', async function () { + await rewardsManager.connect(governor).setSubgraphDeniedReclaimAddress(reclaimWallet.address) + + const tx = rewardsManager.connect(governor).setSubgraphDeniedReclaimAddress(constants.AddressZero) + await expect(tx) + .emit(rewardsManager, 'SubgraphDeniedReclaimAddressSet') + .withArgs(reclaimWallet.address, constants.AddressZero) + + expect(await rewardsManager.subgraphDeniedReclaimAddress()).eq(constants.AddressZero) + }) + + it('should not emit event when setting same address', async function () { + await rewardsManager.connect(governor).setSubgraphDeniedReclaimAddress(reclaimWallet.address) + + const tx = rewardsManager.connect(governor).setSubgraphDeniedReclaimAddress(reclaimWallet.address) + await expect(tx).to.not.emit(rewardsManager, 'SubgraphDeniedReclaimAddressSet') + }) + }) + + describe('reclaim denied rewards - subgraph denylist', function () { + it('should mint to reclaim address when subgraph denied and reclaim address set', async function () { + // Setup reclaim address + await rewardsManager.connect(governor).setSubgraphDeniedReclaimAddress(reclaimWallet.address) + + // Setup denylist + await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) + await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) + + // Align with the epoch boundary + await helpers.mineEpoch(epochManager) + + // Setup allocation + await setupIndexerAllocation() + + // Jump to next epoch + await helpers.mineEpoch(epochManager) + + // Calculate expected rewards + const expectedRewards = toGRT('1400') + + // Check reclaim wallet balance before + const balanceBefore = await grt.balanceOf(reclaimWallet.address) + + // Close allocation - should emit both denial and reclaim events + const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + await expect(tx).emit(rewardsManager, 'RewardsDenied').withArgs(indexer1.address, allocationID1) + await expect(tx) + .emit(rewardsManager, 'RewardsReclaimedDueToSubgraphDenylist') + .withArgs(indexer1.address, allocationID1, subgraphDeploymentID1, expectedRewards) + + // Check reclaim wallet received the rewards + const balanceAfter = await grt.balanceOf(reclaimWallet.address) + expect(balanceAfter.sub(balanceBefore)).eq(expectedRewards) + }) + + it('should not mint to reclaim address when reclaim address not set', async function () { + // Do NOT set reclaim address (defaults to zero address) + + // Setup denylist + await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) + await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) + + // Align with the epoch boundary + await helpers.mineEpoch(epochManager) + + // Setup allocation + await setupIndexerAllocation() + + // Jump to next epoch + await helpers.mineEpoch(epochManager) + + // Close allocation - should only emit denial event, not reclaim + const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + await expect(tx).emit(rewardsManager, 'RewardsDenied').withArgs(indexer1.address, allocationID1) + await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimedDueToSubgraphDenylist') + }) + }) + + describe('reclaim denied rewards - eligibility', function () { + it('should mint to reclaim address when eligibility denied and reclaim address set', async function () { + // Setup reclaim address + await rewardsManager.connect(governor).setIndexerEligibilityReclaimAddress(reclaimWallet.address) + + // Setup eligibility oracle that denies + const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( + 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', + ) + const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny + await mockOracle.deployed() + await rewardsManager.connect(governor).setRewardsEligibilityOracle(mockOracle.address) + + // Align with the epoch boundary + await helpers.mineEpoch(epochManager) + + // Setup allocation + await setupIndexerAllocation() + + // Jump to next epoch + await helpers.mineEpoch(epochManager) + + // Calculate expected rewards + const expectedRewards = toGRT('1400') + + // Check reclaim wallet balance before + const balanceBefore = await grt.balanceOf(reclaimWallet.address) + + // Close allocation - should emit both denial and reclaim events + const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + await expect(tx) + .emit(rewardsManager, 'RewardsDeniedDueToEligibility') + .withArgs(indexer1.address, allocationID1, expectedRewards) + await expect(tx) + .emit(rewardsManager, 'RewardsReclaimedDueToIndexerEligibility') + .withArgs(indexer1.address, allocationID1, expectedRewards) + + // Check reclaim wallet received the rewards + const balanceAfter = await grt.balanceOf(reclaimWallet.address) + expect(balanceAfter.sub(balanceBefore)).eq(expectedRewards) + }) + + it('should not mint to reclaim address when reclaim address not set', async function () { + // Do NOT set reclaim address (defaults to zero address) + + // Setup eligibility oracle that denies + const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( + 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', + ) + const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny + await mockOracle.deployed() + await rewardsManager.connect(governor).setRewardsEligibilityOracle(mockOracle.address) + + // Align with the epoch boundary + await helpers.mineEpoch(epochManager) + + // Setup allocation + await setupIndexerAllocation() + + // Jump to next epoch + await helpers.mineEpoch(epochManager) + + const expectedRewards = toGRT('1400') + + // Close allocation - should only emit denial event, not reclaim + const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + await expect(tx) + .emit(rewardsManager, 'RewardsDeniedDueToEligibility') + .withArgs(indexer1.address, allocationID1, expectedRewards) + await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimedDueToIndexerEligibility') + }) + }) +}) diff --git a/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol b/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol index bd8da3508..dd5346b06 100644 --- a/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol +++ b/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol @@ -49,6 +49,20 @@ interface IRewardsManager { */ function setRewardsEligibilityOracle(address newRewardsEligibilityOracle) external; + /** + * @notice Set the indexer eligibility reclaim address + * @dev Address to mint tokens that would be denied due to indexer eligibility. Set to zero to disable. + * @param newReclaimAddress The address to receive eligibility-denied tokens + */ + function setIndexerEligibilityReclaimAddress(address newReclaimAddress) external; + + /** + * @notice Set the subgraph denied reclaim address + * @dev Address to mint tokens that would be denied due to subgraph denylist. Set to zero to disable. + * @param newReclaimAddress The address to receive subgraph-denied tokens + */ + function setSubgraphDeniedReclaimAddress(address newReclaimAddress) external; + // -- Denylist -- /** diff --git a/packages/subgraph-service/test/unit/mocks/MockRewardsManager.sol b/packages/subgraph-service/test/unit/mocks/MockRewardsManager.sol index 8286f2570..773d676f9 100644 --- a/packages/subgraph-service/test/unit/mocks/MockRewardsManager.sol +++ b/packages/subgraph-service/test/unit/mocks/MockRewardsManager.sol @@ -57,6 +57,12 @@ contract MockRewardsManager is IRewardsManager { function isDenied(bytes32) external view returns (bool) {} + // -- Reclaim -- + + function setSubgraphDeniedReclaimAddress(address) external {} + + function setIndexerEligibilityReclaimAddress(address) external {} + // -- Getters -- function getNewRewardsPerSignal() external view returns (uint256) {} From 70c00e40620fb95ddcb7ac3ca06b9a9a48333143 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 12 Dec 2025 18:16:25 +0000 Subject: [PATCH 3/4] docs: improve code comments in IssuanceAllocator - Add clarity to availablePPM calculation explaining it comprises default allocation's allocator-minting PPM, target's allocator-minting PPM, and target's self-minting PPM to maintain 100% allocation invariant - Refine reentrancy comment to explicitly reference that calculations occur after notifications to prevent reentrancy issues Addresses PR feedback from code review --- packages/issuance/contracts/allocate/IssuanceAllocator.sol | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/issuance/contracts/allocate/IssuanceAllocator.sol b/packages/issuance/contracts/allocate/IssuanceAllocator.sol index 9f6110bea..6b8d004f0 100644 --- a/packages/issuance/contracts/allocate/IssuanceAllocator.sol +++ b/packages/issuance/contracts/allocate/IssuanceAllocator.sol @@ -567,8 +567,12 @@ contract IssuanceAllocator is AllocationTarget storage targetData = $.allocationTargets[target]; AllocationTarget storage defaultTarget = $.allocationTargets[$.targetAddresses[0]]; - // Calculation is done here after notifications to prevent reentrancy issues + // Calculations occur after notifications in the caller to prevent reentrancy issues + // availablePPM comprises the default allocation's current allocator-minting PPM, + // the target's current allocator-minting PPM, and the target's current self-minting PPM. + // This maintains the 100% allocation invariant by calculating how much can be reallocated + // to the target without exceeding total available allocation. uint256 availablePPM = defaultTarget.allocatorMintingPPM + targetData.allocatorMintingPPM + targetData.selfMintingPPM; From cbe2bd1eb018d1490bed669385170ad91ceb1f89 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 12 Dec 2025 19:08:51 +0000 Subject: [PATCH 4/4] fix: resolve fuzz test failure in testGetBalance_WhenCollectedOverThawing (#1268) Replace vm.assume with bounded inputs to fix "vm.assume rejected too many inputs" error. The previous implementation used overly restrictive constraints that caused the fuzzer to reject most random inputs. Now limits amountThawing and amountCollected to half of MAX_STAKING_TOKENS, guaranteeing valid deposit ranges while maintaining test coverage. --- .../horizon/test/unit/escrow/getters.t.sol | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/horizon/test/unit/escrow/getters.t.sol b/packages/horizon/test/unit/escrow/getters.t.sol index 262192125..ded655b39 100644 --- a/packages/horizon/test/unit/escrow/getters.t.sol +++ b/packages/horizon/test/unit/escrow/getters.t.sol @@ -34,12 +34,21 @@ contract GraphEscrowGettersTest is GraphEscrowTest { uint256 amountDeposit, uint256 amountThawing, uint256 amountCollected - ) public useGateway useDeposit(amountDeposit) { - vm.assume(amountThawing > 0); - vm.assume(amountDeposit > 0); - vm.assume(amountDeposit >= amountThawing); - vm.assume(amountDeposit >= amountCollected); - vm.assume(amountDeposit - amountCollected < amountThawing); + ) public useGateway { + // Limit thawing and collected to half of MAX_STAKING_TOKENS to ensure valid deposit range + amountThawing = bound(amountThawing, 1, MAX_STAKING_TOKENS / 2); + amountCollected = bound(amountCollected, 1, MAX_STAKING_TOKENS / 2); + + // amountDeposit must be: + // - >= amountThawing (so we can thaw that amount) + // - >= amountCollected (so we can collect that amount) + // - < amountThawing + amountCollected (so that after collecting, balance < thawing) + // With the above bounds, this range is guaranteed to be valid + uint256 minDeposit = amountThawing > amountCollected ? amountThawing : amountCollected; + uint256 maxDeposit = amountThawing + amountCollected - 1; + amountDeposit = bound(amountDeposit, minDeposit, maxDeposit); + + _depositTokens(users.verifier, users.indexer, amountDeposit); // thaw some funds _thawEscrow(users.verifier, users.indexer, amountThawing);