diff --git a/packages/contracts/contracts/rewards/RewardsManager.sol b/packages/contracts/contracts/rewards/RewardsManager.sol index 85143530a..9a85b0274 100644 --- a/packages/contracts/contracts/rewards/RewardsManager.sol +++ b/packages/contracts/contracts/rewards/RewardsManager.sol @@ -295,6 +295,10 @@ contract RewardsManager is RewardsManagerV6Storage, GraphUpgradeable, IERC165, I * @inheritdoc IRewardsManager * @dev bytes32(0) is reserved as an invalid reason to prevent accidental misconfiguration * and catch uninitialized reason identifiers. + * + * IMPORTANT: Changes take effect immediately and retroactively. All unclaimed rewards from + * previous periods will be sent to the new reclaim address when they are eventually reclaimed, + * regardless of which address was configured when the rewards were originally accrued. */ function setReclaimAddress(bytes32 reason, address newAddress) external override onlyGovernor { require(reason != bytes32(0), "Cannot set reclaim address for (bytes32(0))"); diff --git a/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol b/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol index 87adda601..9c297203a 100644 --- a/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol +++ b/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol @@ -52,6 +52,11 @@ interface IRewardsManager { /** * @notice Set the reclaim address for a specific reason * @dev Address to mint tokens for denied/reclaimed rewards. Set to zero to disable. + * + * IMPORTANT: Changes take effect immediately and retroactively. All unclaimed rewards from + * previous periods will be sent to the new reclaim address when they are eventually reclaimed, + * regardless of which address was configured when the rewards were originally accrued. + * * @param reason The reclaim reason identifier (see RewardsReclaim library for canonical reasons) * @param newReclaimAddress The address to receive tokens */ diff --git a/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocationAdministration.sol b/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocationAdministration.sol index d225c80c9..31c7779dd 100644 --- a/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocationAdministration.sol +++ b/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocationAdministration.sol @@ -3,6 +3,7 @@ pragma solidity ^0.7.6 || ^0.8.0; import { IIssuanceTarget } from "./IIssuanceTarget.sol"; +import { SelfMintingEventMode } from "./IIssuanceAllocatorTypes.sol"; /** * @title IIssuanceAllocationAdministration @@ -134,4 +135,21 @@ interface IIssuanceAllocationAdministration { * @return distributedBlock Block number that issuance was distributed up to */ function distributePendingIssuance(uint256 toBlockNumber) external returns (uint256 distributedBlock); + + /** + * @notice Set the self-minting event emission mode + * @param newMode The new emission mode (None, Aggregate, or PerTarget) + * @return applied True if the mode was set (including if already set to that mode) + * @dev None: Skip event emission entirely (lowest gas) + * @dev Aggregate: Emit single aggregated event for all self-minting (medium gas) + * @dev PerTarget: Emit events for each target with self-minting (highest gas) + * @dev Self-minting targets should call getTargetIssuancePerBlock() rather than relying on events + */ + function setSelfMintingEventMode(SelfMintingEventMode newMode) external returns (bool applied); + + /** + * @notice Get the current self-minting event emission mode + * @return mode The current emission mode + */ + function getSelfMintingEventMode() external view returns (SelfMintingEventMode mode); } diff --git a/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocatorTypes.sol b/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocatorTypes.sol index f1eb58ca5..2d24dba1d 100644 --- a/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocatorTypes.sol +++ b/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocatorTypes.sol @@ -3,6 +3,18 @@ pragma solidity ^0.7.6 || ^0.8.0; pragma abicoder v2; +/** + * @notice Controls self-minting event emission behavior to manage gas costs + * @dev None skips event emission entirely (lowest gas) + * @dev Aggregate emits a single aggregated event for all self-minting + * @dev PerTarget emits events for each target with self-minting (highest gas) + */ +enum SelfMintingEventMode { + None, + Aggregate, + PerTarget +} + /** * @notice Target issuance per block information * @param allocatorIssuanceRate Issuance rate for allocator-minting (tokens per block) diff --git a/packages/issuance/contracts/allocate/IssuanceAllocator.md b/packages/issuance/contracts/allocate/IssuanceAllocator.md index 6a00d92d1..47ff7233d 100644 --- a/packages/issuance/contracts/allocate/IssuanceAllocator.md +++ b/packages/issuance/contracts/allocate/IssuanceAllocator.md @@ -68,261 +68,6 @@ This system enables: - **Gradual recovery**: Distribute accumulated issuance manually or automatically when ready - **Target changes**: Modify allocations during pause periods, with accumulated issuance distributed according to updated allocations -### Storage - -The contract uses ERC-7201 namespaced storage to prevent storage collisions in upgradeable contracts: - -- `issuancePerBlock`: Total token issuance rate per block across all targets (tokens per block) -- `lastDistributionBlock`: Last block when allocator-minting issuance was distributed -- `lastSelfMintingBlock`: Last block when self-minting allowances were calculated and tracked -- `selfMintingOffset`: Accumulated self-minting that offsets allocator-minting budget (starts during pause, clears on distribution) -- `allocationTargets`: Maps target addresses to their allocation data (allocatorMintingRate, selfMintingRate, lastChangeNotifiedBlock) -- `targetAddresses`: Array of all target addresses (index 0 is always the default target, indices 1+ are explicitly allocated targets) -- `totalSelfMintingRate`: Sum of self-minting rates across all targets (tokens per block) - -**Allocation Invariant:** The contract maintains a 100% allocation invariant: - -- A default target exists at `targetAddresses[0]` (initially `address(0)`) -- Total allocator-minting rate + total self-minting rate always equals `issuancePerBlock` -- The default target automatically receives any unallocated portion -- When the default address is `address(0)`, the unallocated portion is not minted - -## Core Functions - -### Distribution Management - -#### `distributeIssuance() → uint256` - -- **Access**: Public (no restrictions) -- **Purpose**: Distribute pending issuance to all allocator-minting targets -- **Returns**: Block number that issuance was distributed to (normally current block) -- **Behavior**: - - First distributes any pending accumulated issuance from pause periods - - Calculates blocks since last distribution - - Mints tokens proportionally to allocator-minting targets only - - Updates `lastDistributionBlock` to current block when not paused - - Returns `lastDistributionBlock` when paused (no distribution occurs, block number frozen) - - Returns early if no blocks have passed since last distribution - - Can be called by anyone to trigger distribution - -#### `setIssuancePerBlock(uint256 newIssuancePerBlock) → bool` - -- **Access**: GOVERNOR_ROLE only -- **Purpose**: Set the total token issuance rate per block -- **Parameters**: - - `newIssuancePerBlock` - New issuance rate in tokens per block -- **Returns**: True if applied -- **Events**: Emits `IssuancePerBlockUpdated` -- **Notes**: - - Requires distribution to have reached `block.number` - - Automatically distributes pending issuance before changing rate - - Notifies the default target of the upcoming change - - Only the default target's rate changes; other targets' rates remain fixed - - L1GraphTokenGateway must be updated when this changes to maintain bridge functionality - - No-op if new rate equals current rate (returns true immediately) - -#### `setIssuancePerBlock(uint256 newIssuancePerBlock, uint256 minDistributedBlock) → bool` - -- **Access**: GOVERNOR_ROLE only -- **Purpose**: Set the total token issuance rate per block, requiring distribution has reached at least the specified block -- **Parameters**: - - `newIssuancePerBlock` - New issuance rate in tokens per block - - `minDistributedBlock` - Minimum block number that distribution must have reached -- **Returns**: True if applied, false if distribution hasn't reached `minDistributedBlock` -- **Events**: Emits `IssuancePerBlockUpdated` -- **Notes**: - - Allows configuration changes while paused: first call `distributePendingIssuance(blockNumber)`, then this function with same or lower blockNumber - - Rate changes apply immediately and are used retroactively when distribution resumes - -### Target Management - -The contract provides multiple overloaded functions for setting target allocations: - -#### `setTargetAllocation(IIssuanceTarget target, uint256 allocatorMintingRate) → bool` - -- **Access**: GOVERNOR_ROLE only -- **Purpose**: Set allocator-minting rate only (selfMintingRate=0) -- **Parameters**: - - `target` - Target contract address (must support IIssuanceTarget interface) - - `allocatorMintingRate` - Allocator-minting rate in tokens per block (0 removes target if no self-minting rate) -- **Returns**: True if applied -- **Events**: Emits `TargetAllocationUpdated` -- **Notes**: - - Requires distribution to have reached `block.number` - - Cannot be used for the default target (use `setDefaultTarget()` instead) - -#### `setTargetAllocation(IIssuanceTarget target, uint256 allocatorMintingRate, uint256 selfMintingRate) → bool` - -- **Access**: GOVERNOR_ROLE only -- **Purpose**: Set both allocator-minting and self-minting rates -- **Parameters**: - - `target` - Target contract address (must support IIssuanceTarget interface) - - `allocatorMintingRate` - Allocator-minting rate in tokens per block - - `selfMintingRate` - Self-minting rate in tokens per block -- **Returns**: True if applied -- **Events**: Emits `TargetAllocationUpdated` -- **Notes**: - - Requires distribution to have reached `block.number` - - Cannot be used for the default target (use `setDefaultTarget()` instead) - -#### `setTargetAllocation(IIssuanceTarget target, uint256 allocatorMintingRate, uint256 selfMintingRate, uint256 minDistributedBlock) → bool` - -- **Access**: GOVERNOR_ROLE only -- **Purpose**: Set both rates, requiring distribution has reached at least the specified block -- **Parameters**: - - `target` - Target contract address (must support IIssuanceTarget interface) - - `allocatorMintingRate` - Allocator-minting rate in tokens per block - - `selfMintingRate` - Self-minting rate in tokens per block - - `minDistributedBlock` - Minimum block number that distribution must have reached -- **Returns**: True if applied, false if distribution hasn't reached `minDistributedBlock` -- **Events**: Emits `TargetAllocationUpdated` -- **Behavior**: - - Validates target supports IIssuanceTarget interface (for non-zero total rates) - - No-op if new rates equal current rates (returns true immediately) - - Distributes pending issuance before changing allocation - - Notifies target of upcoming change (always occurs unless overridden by `forceTargetNoChangeNotificationBlock()`) - - Reverts if notification fails - - Validates requested rates don't exceed available budget (prevents exceeding 100% invariant) - - Adds target to registry if total rate > 0 and not already present - - Removes target from registry if total rate = 0 (uses swap-and-pop for gas efficiency) - - Deletes allocation data when removing target from registry - - Default target automatically adjusted to maintain 100% invariant - - Allows configuration changes while paused: first call `distributePendingIssuance(blockNumber)`, then this function - -#### `setDefaultTarget(address newAddress) → bool` - -- **Access**: GOVERNOR_ROLE only -- **Purpose**: Set the address that receives the default portion of issuance (unallocated to other targets) -- **Parameters**: - - `newAddress` - The new default target address (can be `address(0)`) -- **Returns**: True if applied -- **Events**: Emits `DefaultTargetUpdated` -- **Notes**: - - Requires distribution to have reached `block.number` - - The default target automatically receives any unallocated portion to maintain 100% invariant - - When set to `address(0)`, the unallocated portion is not minted - - Cannot set default to an address that already has an explicit allocation - - Notifies both old and new addresses - -#### `setDefaultTarget(address newAddress, uint256 minDistributedBlock) → bool` - -- **Access**: GOVERNOR_ROLE only -- **Purpose**: Set the default target address, requiring distribution has reached at least the specified block -- **Parameters**: - - `newAddress` - The new default target address (can be `address(0)`) - - `minDistributedBlock` - Minimum block number that distribution must have reached -- **Returns**: True if applied, false if distribution hasn't reached `minDistributedBlock` -- **Events**: Emits `DefaultTargetUpdated` -- **Notes**: - - Allows configuration changes while paused: first call `distributePendingIssuance(blockNumber)`, then this function - -#### `notifyTarget(address target) → bool` - -- **Access**: GOVERNOR_ROLE only -- **Purpose**: Manually notify a specific target about allocation changes -- **Returns**: True if notification sent or already sent this block -- **Notes**: Used for gas limit recovery scenarios. Will revert if target notification fails. - -#### `forceTargetNoChangeNotificationBlock(address target, uint256 blockNumber) → uint256` - -- **Access**: GOVERNOR_ROLE only -- **Purpose**: Override the last notification block for a target -- **Parameters**: - - `target` - Target address to update - - `blockNumber` - Block number to set (past = allow re-notification, future = prevent notification) -- **Returns**: The block number that was set -- **Notes**: Used for gas limit recovery scenarios - -#### `distributePendingIssuance() → uint256` - -- **Access**: GOVERNOR_ROLE only -- **Purpose**: Distribute pending accumulated allocator-minting issuance using current rates -- **Returns**: Block number up to which issuance has been distributed -- **Notes**: - - Distributes retroactively using current rates for the entire undistributed period - - Can be called even when the contract is paused - - Prioritizes non-default targets getting full rates; default gets remainder - - Finalizes self-minting accumulation for the distributed period - -#### `distributePendingIssuance(uint256 toBlockNumber) → uint256` - -- **Access**: GOVERNOR_ROLE only -- **Purpose**: Distribute pending accumulated allocator-minting issuance up to a specific block -- **Parameters**: - - `toBlockNumber` - Block number to distribute up to (must be >= lastDistributionBlock and <= current block) -- **Returns**: Block number up to which issuance has been distributed -- **Notes**: - - Distributes retroactively using current rates from lastDistributionBlock to toBlockNumber - - Can be called even when the contract is paused - - Will revert with `ToBlockOutOfRange()` if toBlockNumber is invalid - - Useful for gradual catch-up during pause or for setting up configuration changes - -### View Functions - -#### `getTargetAllocation(address target) → Allocation` - -- **Purpose**: Get current allocation for a target -- **Returns**: Allocation struct containing: - - `totalAllocationRate`: Total allocation rate (allocatorMintingRate + selfMintingRate) in tokens per block - - `allocatorMintingRate`: Allocator-minting rate in tokens per block - - `selfMintingRate`: Self-minting rate in tokens per block -- **Notes**: Returns assigned allocation regardless of whether target is `address(0)` or the default target - -#### `getTotalAllocation() → Allocation` - -- **Purpose**: Get current global allocation totals -- **Returns**: Allocation struct with totals across all targets -- **Notes**: When default target is `address(0)`, its allocation is excluded from reported totals (treated as unallocated since `address(0)` cannot receive minting) - -#### `getTargets() → address[]` - -- **Purpose**: Get all target addresses (including default target at index 0) -- **Returns**: Array of target addresses - -#### `getTargetAt(uint256 index) → address` - -- **Purpose**: Get a specific target address by index -- **Returns**: Target address at the specified index -- **Notes**: Index 0 is always the default target - -#### `getTargetCount() → uint256` - -- **Purpose**: Get the number of targets (including default target) -- **Returns**: Total number of targets (always >= 1) - -#### `getTargetIssuancePerBlock(address target) → TargetIssuancePerBlock` - -- **Purpose**: Get issuance rate information for a target -- **Returns**: TargetIssuancePerBlock struct containing: - - `allocatorIssuanceRate`: Allocator-minting rate in tokens per block - - `allocatorIssuanceBlockAppliedTo`: Block up to which allocator issuance has been distributed (`lastDistributionBlock`) - - `selfIssuanceRate`: Self-minting rate in tokens per block - - `selfIssuanceBlockAppliedTo`: Block up to which self-minting allowances have been calculated (`lastSelfMintingBlock`) -- **Notes**: - - Does not revert when paused - callers should check blockAppliedTo fields - - If `allocatorIssuanceBlockAppliedTo < block.number`, allocator distribution is behind (likely paused) - - Self-minting targets should use this to determine their issuance rate - - Returns assigned rates regardless of whether target is `address(0)` or the default - -#### `getIssuancePerBlock() → uint256` - -- **Purpose**: Get the current total issuance rate per block -- **Returns**: Current issuance rate in tokens per block across all targets - -#### `getDistributionState() → DistributionState` - -- **Purpose**: Get pending issuance distribution state -- **Returns**: DistributionState struct containing: - - `lastDistributionBlock`: Last block where allocator-minting issuance was distributed - - `lastSelfMintingBlock`: Last block where self-minting allowances were calculated - - `selfMintingOffset`: Accumulated self-minting that will reduce allocator-minting budget - -#### `getTargetData(address target) → AllocationTarget` - -- **Purpose**: Get internal target data (implementation-specific) -- **Returns**: AllocationTarget struct containing allocatorMintingRate, selfMintingRate, and lastChangeNotifiedBlock -- **Notes**: Primarily for operator use and debugging - ## Allocation Logic ### Rate-Based System @@ -369,7 +114,7 @@ Before any allocation changes, targets are notified via the `IIssuanceTarget.bef - Each target is notified at most once per block (unless overridden via `forceTargetNoChangeNotificationBlock()`) - Notifications are tracked per target using `lastChangeNotifiedBlock` - Failed notifications cause the entire transaction to revert -- Use `forceTargetNoChangeNotificationBlock()` to skip notification for broken targets before removing them +- Use `forceTargetNoChangeNotificationBlock()` to skip notification for malfunctioning targets before removing them - Notifications always occur when allocations change (even when paused) - Manual notification is available for gas limit recovery via `notifyTarget()` @@ -392,60 +137,90 @@ The contract includes several mechanisms to handle potential gas limit issues: 5. **Target removal**: Use `forceTargetNoChangeNotificationBlock()` to skip notification, then remove malfunctioning targets by setting both rates to 0 6. **Pending issuance distribution**: `distributePendingIssuance()` can be called manually to distribute accumulated issuance -## Events - -```solidity -event IssuanceDistributed(address indexed target, uint256 amount, uint256 indexed fromBlock, uint256 indexed toBlock); - -event TargetAllocationUpdated(address indexed target, uint256 newAllocatorMintingRate, uint256 newSelfMintingRate); - -event IssuancePerBlockUpdated(uint256 oldIssuancePerBlock, uint256 newIssuancePerBlock); - -event DefaultTargetUpdated(address indexed oldAddress, address indexed newAddress); - -event IssuanceSelfMintAllowance( - address indexed target, - uint256 amount, - uint256 indexed fromBlock, - uint256 indexed toBlock -); -``` - -## Error Conditions - -```solidity -error TargetAddressCannotBeZero(); -error InsufficientAllocationAvailable(uint256 requested, uint256 available); -error InsufficientUnallocatedForRateDecrease(uint256 oldRate, uint256 newRate, uint256 unallocated); -error TargetDoesNotSupportIIssuanceTarget(address target); -error ToBlockOutOfRange(uint256 toBlock, uint256 minBlock, uint256 maxBlock); -error CannotSetAllocationForDefaultTarget(address defaultTarget); -error CannotSetDefaultToAllocatedTarget(address target); -``` - -### Error Descriptions - -- **TargetAddressCannotBeZero**: Thrown when attempting to set allocation for the zero address (note: zero address can be the default target) -- **InsufficientAllocationAvailable**: Thrown when the requested allocation exceeds available budget (default target allocation + current target allocation) -- **InsufficientUnallocatedForRateDecrease**: Thrown when attempting to decrease issuance rate without sufficient unallocated budget in the default target -- **TargetDoesNotSupportIIssuanceTarget**: Thrown when a target contract does not implement the required IIssuanceTarget interface -- **ToBlockOutOfRange**: Thrown when the `toBlockNumber` parameter in `distributePendingIssuance(uint256)` is outside the valid range (must be >= lastDistributionBlock and <= current block) -- **CannotSetAllocationForDefaultTarget**: Thrown when attempting to use `setTargetAllocation()` on the default target address -- **CannotSetDefaultToAllocatedTarget**: Thrown when attempting to set the default target to an address that already has an explicit allocation - ## Usage Patterns ### Initial Setup -1. Deploy contract with Graph Token address -2. Initialize with governor address - - `lastDistributionBlock` is set to `block.number` at initialization as a safety guard against pausing before configuration - - This should be updated during initial configuration when `setIssuancePerBlock()` is called -3. Set initial issuance per block rate - - Updates `lastDistributionBlock` to current block via distribution call - - This establishes the correct starting point for issuance tracking -4. Add targets with their allocations -5. Grant minter role to IssuanceAllocator on Graph Token +**Note: This section is a work-in-progress discussion document for planning deployment, not finalized implementation documentation.** + +**The verification steps documented here are minimal deployment verification checks. These should be complemented by appropriate functional testing and verification as needed for production deployment.** + +**Prerequisites:** + +- GraphToken contract deployed +- RewardsManager upgraded with `setIssuanceAllocator()` function +- GraphIssuanceProxyAdmin deployed with protocol governance as owner + +To safely replicate existing issuance configuration during RewardsManager migration: + +- Default target starts as `address(0)` (that will not be minted to), allowing initial configuration without minting to any targets +- Deployment uses atomic initialization via proxy constructor (prevents front-running) +- Deployment account performs initial configuration, then transfers control to governance +- Granting of minter role can be delayed until replication of initial configuration with upgraded RewardsManager is verified to allow seamless transition to use of IssuanceAllocator +- **Governance control**: This contract uses OpenZeppelin's TransparentUpgradeableProxy pattern (not custom GraphProxy). GraphIssuanceProxyAdmin (owned by protocol governance) controls upgrades, while GOVERNOR_ROLE controls operations. The same governance address should have both roles. + +**Deployment sequence:** + +1. **Deploy and initialize** (deployment account) + - Deploy IssuanceAllocator implementation with GraphToken address + - Deploy TransparentUpgradeableProxy with implementation, GraphIssuanceProxyAdmin, and initialization data + - **Atomic initialization**: `initialize(deploymentAccountAddress)` called via proxy constructor + - Deployment account receives GOVERNOR_ROLE (temporary, for configuration) + - Automatically creates default target at `targetAddresses[0] = address(0)` + - Sets `lastDistributionBlock = block.number` + - **Security**: Front-running prevented by atomic deployment + initialization +2. **Set issuance rate** (deployment account) + - Query current rate from RewardsManager: `rate = rewardsManager.issuancePerBlock()` + - Call `setIssuancePerBlock(rate)` to replicate existing rate + - All issuance allocated to default target (`address(0)`) + - No tokens minted (default target cannot receive mints) +3. **Assign RewardsManager allocation** (deployment account) + - Call `setTargetAllocation(rewardsManagerAddress, 0, issuancePerBlock)` + - `allocatorMintingRate = 0` (RewardsManager will self-mint) + - `selfMintingRate = issuancePerBlock` (RewardsManager receives 100% allocation) + - Default target automatically adjusts to zero allocation +4. **Verify configuration before transfer** (deployment account) + - Verify contract is not paused (`paused()` returns false) + - Verify `getIssuancePerBlock()` returns expected rate (matches RewardsManager) + - Verify `getTargetAllocation(rewardsManager)` shows correct self-minting configuration + - Verify only two targets exist: `targetAddresses[0] = address(0)` and `targetAddresses[1] = rewardsManager` + - Verify default target is `address(0)` with zero allocation + - Contract is ready to transfer control to governance +5. **Distribute issuance** (anyone - no role required) + - Call `distributeIssuance()` to bring contract to fully current state + - Updates `lastDistributionBlock` to current block + - Verifies distribution mechanism is functioning correctly + - No tokens minted (no minter role yet, all allocation to self-minting RM) +6. **Set pause controls and transfer governance** (deployment account) + - Grant PAUSE_ROLE to pause guardian (same account as used for RewardsManager pause control) + - Grant GOVERNOR_ROLE to actual governor address (protocol governance multisig) + - Revoke GOVERNOR_ROLE from deployment account (MUST grant to governance first, then revoke) + - **Note**: Upgrade control (via GraphIssuanceProxyAdmin) is separate from GOVERNOR_ROLE +7. **Verify deployment and configuration** (governor) + - **Bytecode verification**: Verify deployed implementation bytecode matches expected contract + - **Access control**: + - Verify governance address has GOVERNOR_ROLE + - Verify deployment account does NOT have GOVERNOR_ROLE + - Verify pause guardian has PAUSE_ROLE + - **Off-chain**: Review all RoleGranted events since deployment to verify no other addresses have GOVERNOR_ROLE or PAUSE_ROLE + - **Pause state**: Verify contract is not paused (`paused()` returns false) + - **Issuance rate**: Verify `getIssuancePerBlock()` matches RewardsManager rate exactly + - **Target configuration**: + - Verify only two targets exist: `targetAddresses[0] = address(0)` and `targetAddresses[1] = rewardsManager` + - Verify default target is `address(0)` with zero allocation + - Verify `getTargetAllocation(rewardsManager)` shows correct self-minting allocation (100%) + - **Proxy configuration**: + - Verify GraphIssuanceProxyAdmin controls the proxy + - Verify GraphIssuanceProxyAdmin owner is protocol governance +8. **Configure RewardsManager** (governor) + - Call `rewardsManager.setIssuanceAllocator(issuanceAllocatorAddress)` + - RewardsManager will now query IssuanceAllocator for its issuance rate + - RewardsManager continues to mint tokens itself (self-minting) +9. **Grant minter role** (governor, only when configuration verified) + - Grant minter role to IssuanceAllocator on Graph Token +10. **Set default target** (governor, optional, recommended) + +- Call `setDefaultTarget()` to receive future unallocated issuance ### Normal Operation diff --git a/packages/issuance/contracts/allocate/IssuanceAllocator.sol b/packages/issuance/contracts/allocate/IssuanceAllocator.sol index 66b7c94c1..8e5fbeeb4 100644 --- a/packages/issuance/contracts/allocate/IssuanceAllocator.sol +++ b/packages/issuance/contracts/allocate/IssuanceAllocator.sol @@ -6,7 +6,8 @@ import { TargetIssuancePerBlock, Allocation, AllocationTarget, - DistributionState + DistributionState, + SelfMintingEventMode } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceAllocatorTypes.sol"; import { IIssuanceAllocationDistribution } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceAllocationDistribution.sol"; import { IIssuanceAllocationAdministration } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceAllocationAdministration.sol"; @@ -14,7 +15,7 @@ import { IIssuanceAllocationStatus } from "@graphprotocol/interfaces/contracts/i import { IIssuanceAllocationData } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceAllocationData.sol"; import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; import { BaseUpgradeable } from "../common/BaseUpgradeable.sol"; -import { ReentrancyGuardTransientUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardTransientUpgradeable.sol"; +import { ReentrancyGuardTransient } from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; // solhint-disable-next-line no-unused-import @@ -28,10 +29,10 @@ import { ERC165Upgradeable } from "@openzeppelin/contracts-upgradeable/utils/int * (tokens per block) and handles minting for allocator-minting targets. * * @dev The contract maintains a 100% allocation invariant through a default target mechanism: - * - A default target target exists at targetAddresses[0] (initialized to address(0)) + * - A default target exists at targetAddresses[0] (initialized to address(0)) * - The default target automatically receives any unallocated portion of issuance * - Total allocation across all targets always equals issuancePerBlock (tracked as absolute rates) - * - The default target address can be changed via setDefaultAllocationAddress() + * - The default target address can be changed via setDefaultTarget() * - When the default address is address(0), this 'unallocated' portion is not minted * - Regular targets cannot be set as the default target address * @@ -59,6 +60,50 @@ import { ERC165Upgradeable } from "@openzeppelin/contracts-upgradeable/utils/int * - Tracking divergence: lastSelfMintingBlock advances during pause (for allowance tracking) while * lastDistributionBlock stays frozen (no allocator-minting). This is intentional and correct. * + * @dev Issuance Accounting Invariants: + * The contract maintains strict accounting to ensure total token issuance never exceeds the configured + * issuancePerBlock rate over any time period. This section provides the mathematical foundation for + * understanding the relationship between self-minting and allocator-minting. + * + * Key Invariants: + * 1. Allocation Completeness: For all blocks b, totalAllocatorRate_b + totalSelfMintingRate_b = issuancePerBlock_b + * This ensures 100% of issuance is always allocated across all targets. + * + * 2. Self-Minting Accumulation: For any undistributed block range [fromBlock, toBlock]: + * selfMintingOffset = Σ(totalSelfMintingRate_b) for all b in range + * where totalSelfMintingRate_b is the end-state rate for block b. + * + * 3. Rate Constraint: For all blocks b, totalSelfMintingRate_b ≤ issuancePerBlock_b + * This follows from invariant (1) since 0 ≤ totalAllocatorRate_b. + * + * 4. Issuance Upper Bound: For any distribution period with blocks = toBlock - fromBlock + 1: + * Let issuancePerBlock_final = current issuancePerBlock at distribution time + * + * From invariants (2) and (3): + * selfMintingOffset ≤ Σ(issuancePerBlock_b) + * + * Allocator-minting budget for period: + * available = max(0, issuancePerBlock_final * blocks - selfMintingOffset) + * + * Total minted (self + allocator) for period: + * ≤ max(selfMintingOffset, issuancePerBlock_final * blocks) + * ≤ Σ(issuancePerBlock_b) + * + * Therefore, total issuance never exceeds the sum of configured rates during the period. + * + * 5. Offset Reconciliation: During pending distribution, selfMintingOffset is adjusted to account for + * the period's issuance budget. When distribution catches up to current block, the offset is cleared. + * Any remaining offset when cleared represents self-minting that occurred beyond what the final + * issuancePerBlock rate would allow for the period. This is acceptable because: + * a) Self-minting targets were operating under rates that were valid at the time + * b) The total minted still respects the Σ(issuancePerBlock_b) bound (invariant 4) + * c) Clearing the offset prevents it from affecting future distributions + * d) The SelfMintingOffsetReconciled event provides visibility into all offset adjustments + * + * This design ensures that even when issuancePerBlock or allocation rates change over time, and even + * when self-minting targets mint independently, the total tokens minted never exceeds the sum of + * configured issuance rates during the period. + * * @dev There are a number of scenarios where the IssuanceAllocator could run into issues, including: * 1. The targetAddresses array could grow large enough that it exceeds the gas limit when calling distributeIssuance. * 2. When notifying targets of allocation changes the calls to `beforeIssuanceAllocationChange` could exceed the gas limit. @@ -91,7 +136,7 @@ import { ERC165Upgradeable } from "@openzeppelin/contracts-upgradeable/utils/int */ contract IssuanceAllocator is BaseUpgradeable, - ReentrancyGuardTransientUpgradeable, + ReentrancyGuardTransient, IIssuanceAllocationDistribution, IIssuanceAllocationAdministration, IIssuanceAllocationStatus, @@ -113,6 +158,7 @@ contract IssuanceAllocator is /// @param allocationTargets Mapping of target addresses to their allocation data /// @param targetAddresses Array of all target addresses (including default target at index 0) /// @param totalSelfMintingRate Total self-minting rate (tokens per block) across all targets + /// @param selfMintingEventMode Controls self-minting event emission behavior (PerTarget, Aggregate, or None) /// @dev Design invariant: totalAllocatorRate + totalSelfMintingRate == issuancePerBlock (always 100% allocated) /// @dev Design invariant: targetAddresses[0] is always the default target address /// @dev Design invariant: 1 <= targetAddresses.length (default target always exists) @@ -126,6 +172,7 @@ contract IssuanceAllocator is mapping(address => AllocationTarget) allocationTargets; address[] targetAddresses; uint256 totalSelfMintingRate; + SelfMintingEventMode selfMintingEventMode; } /** @@ -169,7 +216,7 @@ contract IssuanceAllocator is /// @param maxBlock The maximum valid block number (current block) error ToBlockOutOfRange(uint256 toBlock, uint256 minBlock, uint256 maxBlock); - /// @notice Thrown when attempting to set allocation for the default target target + /// @notice Thrown when attempting to set allocation for the default target /// @param defaultTarget The address of the default target error CannotSetAllocationForDefaultTarget(address defaultTarget); @@ -221,6 +268,56 @@ contract IssuanceAllocator is uint256 indexed toBlock ); // solhint-disable-line gas-indexed-events + /* solhint-disable gas-indexed-events */ + /// @notice Emitted when self-minting offset is reconciled during pending distribution + /// @param offsetBefore The self-minting offset before reconciliation + /// @param offsetAfter The self-minting offset after reconciliation (0 when caught up to current block) + /// @param totalForPeriod The total issuance budget for the distributed period + /// @param fromBlock First block in the distribution period (inclusive) + /// @param toBlock Last block in the distribution period (inclusive) + /// @dev This event provides visibility into the accounting reconciliation between self-minting + /// and allocator-minting budgets during pending distribution. When offsetAfter is 0, the contract + /// has fully caught up with distribution. When offsetAfter > 0, there remains accumulated offset + /// that will be applied to future distributions. + event SelfMintingOffsetReconciled( + uint256 offsetBefore, + uint256 offsetAfter, + uint256 totalForPeriod, + uint256 indexed fromBlock, + uint256 indexed toBlock + ); + /* solhint-enable gas-indexed-events */ + + /* solhint-disable gas-indexed-events */ + /// @notice Emitted when self-minting offset accumulates during pause or catch-up + /// @param offsetBefore The self-minting offset before accumulation + /// @param offsetAfter The self-minting offset after accumulation + /// @param fromBlock First block in the accumulation period (inclusive) + /// @param toBlock Last block in the accumulation period (inclusive) + /// @dev This event provides visibility into offset growth during pause periods or while catching up + /// after unpause. Together with SelfMintingOffsetReconciled, provides complete accounting of all + /// offset changes. + event SelfMintingOffsetAccumulated( + uint256 offsetBefore, + uint256 offsetAfter, + uint256 indexed fromBlock, + uint256 indexed toBlock + ); + /* solhint-enable gas-indexed-events */ + + /// @notice Emitted when self-minting allowance is calculated in aggregate mode + /// @param totalAmount The total amount of tokens available for self-minting across all targets + /// @param fromBlock First block included in this allowance period (inclusive) + /// @param toBlock Last block included in this allowance period (inclusive) + /// @dev This event is emitted when selfMintingEventMode is Aggregate, providing a single event + /// instead of per-target events to reduce gas costs + event IssuanceSelfMintAllowanceAggregate(uint256 totalAmount, uint256 indexed fromBlock, uint256 indexed toBlock); // solhint-disable-line gas-indexed-events + + /// @notice Emitted when self-minting event mode is changed + /// @param oldMode The previous event emission mode + /// @param newMode The new event emission mode + event SelfMintingEventModeUpdated(SelfMintingEventMode oldMode, SelfMintingEventMode newMode); + // -- Constructor -- /** @@ -238,21 +335,15 @@ contract IssuanceAllocator is * @notice Initialize the IssuanceAllocator contract * @param _governor Address that will have the GOVERNOR_ROLE * @dev Initializes with a default target at index 0 set to address(0) - * @dev default target will receive all unallocated issuance (initially 0 until rate is set) - * @dev Initialization: lastDistributionBlock is set to block.number as a safety guard against - * pausing before configuration. lastSelfMintingBlock defaults to 0. issuancePerBlock is 0. - * Once setIssuancePerBlock() is called, it triggers _distributeIssuance() which updates + * @dev Default target will receive all unallocated issuance (initially 0 until rate is set) + * @dev lastDistributionBlock is set to block.number as a safety guard against pausing before + * configuration. lastSelfMintingBlock defaults to 0. issuancePerBlock is 0. Once + * setIssuancePerBlock() is called, it triggers _distributeIssuance() which updates * lastDistributionBlock to current block, establishing the starting point for issuance tracking. - * @dev Rate changes while paused: Rate changes are stored but distributeIssuance() will NOT - * apply them while paused - it returns immediately with frozen lastDistributionBlock. When - * distribution eventually resumes (via unpause or manual distributePendingIssuance()), the - * CURRENT rates at that time are applied retroactively to the entire undistributed period. - * Governance must exercise caution when changing rates while paused to ensure they are applied - * to the correct block range. See setIssuancePerBlock() documentation for details. + * @dev selfMintingEventMode is initialized to PerTarget */ function initialize(address _governor) external virtual initializer { __BaseUpgradeable_init(_governor); - __ReentrancyGuardTransient_init(); IssuanceAllocatorData storage $ = _getIssuanceAllocatorStorage(); @@ -260,6 +351,8 @@ contract IssuanceAllocator is // Rates are 0 initially; default gets remainder when issuancePerBlock is set $.targetAddresses.push(address(0)); + $.selfMintingEventMode = SelfMintingEventMode.PerTarget; + // To guard against extreme edge case of pausing before setting issuancePerBlock, we initialize // lastDistributionBlock to block.number. This should be updated to the correct starting block // during configuration by governance. @@ -309,7 +402,7 @@ contract IssuanceAllocator is * @notice Advances self-minting block and emits allowance events * @dev When paused, accumulates self-minting amounts. This accumulation reduces the allocator-minting * budget when distribution resumes, ensuring total issuance stays within bounds. - * When not paused, just emits self-minting allowance events. + * When not paused, emits self-minting allowance events based on selfMintingEventMode. * Called by _distributeIssuance() which anyone can call. * Optimized for no-op cases: very cheap when already at current block. */ @@ -320,26 +413,42 @@ contract IssuanceAllocator is if (previousBlock == block.number) return; uint256 blocks = block.number - previousBlock; + uint256 fromBlock = previousBlock + 1; // Accumulate if currently paused OR if there's existing accumulated balance. // Once accumulation starts (during pause), continue through any unpaused periods // until distribution clears the accumulation. This is conservative and allows // better recovery when distribution is delayed through pause/unpause cycles. - if (paused() || 0 < $.selfMintingOffset) $.selfMintingOffset += $.totalSelfMintingRate * blocks; + uint256 offsetBefore = $.selfMintingOffset; + if (paused() || 0 < offsetBefore) { + $.selfMintingOffset += $.totalSelfMintingRate * blocks; + + // Emit accumulation event whenever offset changes + if (offsetBefore != $.selfMintingOffset) { + emit SelfMintingOffsetAccumulated(offsetBefore, $.selfMintingOffset, fromBlock, block.number); + } + } $.lastSelfMintingBlock = block.number; - uint256 fromBlock = previousBlock + 1; - // Emit self-minting allowance events + // Emit self-minting allowance events based on mode if (0 < $.totalSelfMintingRate) { - for (uint256 i = 0; i < $.targetAddresses.length; ++i) { - address target = $.targetAddresses[i]; - AllocationTarget storage targetData = $.allocationTargets[target]; - - if (0 < targetData.selfMintingRate) { - uint256 amount = targetData.selfMintingRate * blocks; - emit IssuanceSelfMintAllowance(target, amount, fromBlock, block.number); + if ($.selfMintingEventMode == SelfMintingEventMode.PerTarget) { + // Emit per-target events (highest gas cost) + for (uint256 i = 0; i < $.targetAddresses.length; ++i) { + address target = $.targetAddresses[i]; + AllocationTarget storage targetData = $.allocationTargets[target]; + + if (0 < targetData.selfMintingRate) { + uint256 amount = targetData.selfMintingRate * blocks; + emit IssuanceSelfMintAllowance(target, amount, fromBlock, block.number); + } } + } else if ($.selfMintingEventMode == SelfMintingEventMode.Aggregate) { + // Emit single aggregated event (lower gas cost) + uint256 totalAmount = $.totalSelfMintingRate * blocks; + emit IssuanceSelfMintAllowanceAggregate(totalAmount, fromBlock, block.number); } + // else None: skip event emission entirely (lowest gas cost) } } @@ -411,23 +520,14 @@ contract IssuanceAllocator is /** * @notice Internal implementation for distributing pending accumulated allocator-minting issuance * @param toBlockNumber Block number to distribute up to - * @dev Distributes allocator-minting issuance for undistributed period using current rates - * (retroactively applied to from lastDistributionBlock to toBlockNumber, inclusive of both endpoints). - * @dev Called when 0 < self-minting offset, which occurs after pause periods or when - * distribution is delayed across pause/unpause cycles. Conservative accumulation strategy - * continues accumulating through unpaused periods until distribution clears it. - * The undistributed period (lastDistributionBlock to toBlockNumber) could theoretically span multiple pause/unpause cycles. In practice this is unlikely if there are active targets that call distributeIssuance(). - * @dev Current rate is always applied retroactively to undistributed period, to the extent possible given the accumulated self-minting offset. - * If any interim rate was higher than current rate, there might be insufficient allocation - * to satisfy required allocations. In this case, we make the best match to honour the current rate. - * There will never more issuance relative to what the max interim issuance rate was, but in some circumstances the current rate is insufficient to satisfy the accumulated self-minting. In other cases, to satisfy the current rate, we distribute proportionally less to non-default targets than their current allocation rate. - * @dev Constraint: cannot distribute more than total issuance for the period. - * @dev Shortfall: When accumulated self-minting exceeds what current rate allows for the period, - * the total issuance already exceeded current rate expectations. No allocator-minting distributed. - * @dev When allocator-minting is available, there are two distribution cases: - * (1) available < allowance: proportional distribution among non-default, default gets zero - * (2) allowance <= available: full rates to non-default, remainder to default - * Where allowance is allocator rate (for non-default targets) * blocks, and available is total issuance for period minus accumulated self-minting. + * @dev Distributes allocator-minting issuance for undistributed period using current rates, + * retroactively applied from lastDistributionBlock to toBlockNumber (inclusive). + * Called when 0 < selfMintingOffset, which occurs after pause periods or delayed distribution. + * @dev Available budget = max(0, issuancePerBlock * blocks - selfMintingOffset). + * Distribution cases: + * (1) available < allocatedTotal: proportional distribution to non-default, default gets zero + * (2) allocatedTotal <= available: full rates to non-default, remainder to default + * Where allocatedTotal is sum of non-default allocator rates * blocks. * @return Block number that issuance was distributed up to */ function _distributePendingIssuance(uint256 toBlockNumber) private returns (uint256) { @@ -467,14 +567,44 @@ contract IssuanceAllocator is } $.lastDistributionBlock = toBlockNumber; + _reconcileSelfMintingOffset(toBlockNumber, blocks, totalForPeriod, selfMintingOffset); + return toBlockNumber; + } + + /** + * @notice Reconciles self-minting offset after distribution and emits event if changed + * @param toBlockNumber Block number distributed to + * @param blocks Number of blocks in the distribution period + * @param totalForPeriod Total issuance budget for the period + * @param selfMintingOffset Self-minting offset before reconciliation + * @dev Updates accumulated self-minting after distribution. + * Subtracts the period budget used (min of accumulated and totalForPeriod). + * When caught up to current block, clears all since nothing remains to distribute. + */ + function _reconcileSelfMintingOffset( + uint256 toBlockNumber, + uint256 blocks, + uint256 totalForPeriod, + uint256 selfMintingOffset + ) private { + IssuanceAllocatorData storage $ = _getIssuanceAllocatorStorage(); - // Update accumulated self-minting after distribution. - // Subtract the period budget used (min of accumulated and totalForPeriod). - // When caught up to current block, clear all since nothing remains to distribute. - if (toBlockNumber == block.number) $.selfMintingOffset = 0; - else $.selfMintingOffset = totalForPeriod < selfMintingOffset ? selfMintingOffset - totalForPeriod : 0; + uint256 newOffset = toBlockNumber == block.number + ? 0 + : (totalForPeriod < selfMintingOffset ? selfMintingOffset - totalForPeriod : 0); + + // Emit reconciliation event whenever offset changes during pending distribution + if (selfMintingOffset != newOffset) { + emit SelfMintingOffsetReconciled( + selfMintingOffset, + newOffset, + totalForPeriod, + toBlockNumber - blocks + 1, + toBlockNumber + ); + } - return toBlockNumber; + $.selfMintingOffset = newOffset; } /** @@ -577,6 +707,10 @@ contract IssuanceAllocator is * - Only the default target is notified (target rates don't change, only default target changes) * - Target rates stay fixed; default target absorbs the change * - Whenever the rate is changed, the updateL2MintAllowance function _must_ be called on the L1GraphTokenGateway in L1, to ensure the bridge can mint the right amount of tokens + * @dev Rate changes while paused: The new rate applies retroactively to the entire undistributed + * period when distribution resumes. Governance must exercise caution to ensure rates are applied + * to the correct block range. Use distributePendingIssuance(blockNumber) to control precisely + * which block the new rate applies from. */ function setIssuancePerBlock( uint256 newIssuancePerBlock, @@ -616,6 +750,28 @@ contract IssuanceAllocator is return true; } + /** + * @inheritdoc IIssuanceAllocationAdministration + */ + function setSelfMintingEventMode(SelfMintingEventMode newMode) external onlyRole(GOVERNOR_ROLE) returns (bool) { + IssuanceAllocatorData storage $ = _getIssuanceAllocatorStorage(); + SelfMintingEventMode oldMode = $.selfMintingEventMode; + + if (newMode == oldMode) return true; + + $.selfMintingEventMode = newMode; + emit SelfMintingEventModeUpdated(oldMode, newMode); + + return true; + } + + /** + * @inheritdoc IIssuanceAllocationAdministration + */ + function getSelfMintingEventMode() external view override returns (SelfMintingEventMode) { + return _getIssuanceAllocatorStorage().selfMintingEventMode; + } + // -- Target Management -- /** @@ -710,15 +866,8 @@ contract IssuanceAllocator is * - If any allocation is non-zero and the target doesn't exist, the target will be added * - Will revert if the total allocation would exceed available capacity (default target + current target allocation) * - Will revert if attempting to add a target that doesn't support IIssuanceTarget - * - * Self-minting allocation is a special case for backwards compatibility with - * existing contracts like the RewardsManager. The IssuanceAllocator calculates - * issuance for self-minting targets but does not mint tokens directly for them. Self-minting targets - * should call getTargetIssuancePerBlock to determine their issuance amount and mint - * tokens accordingly. For example, the RewardsManager contract is expected to call - * getTargetIssuancePerBlock in its takeRewards function to calculate the correct - * amount of tokens to mint. Self-minting targets are responsible for adhering to - * the issuance schedule and should not mint more tokens than allocated. + * @dev Self-minting targets must call getTargetIssuancePerBlock to determine their issuance and mint + * accordingly. See contract header for details on self-minting vs allocator-minting allocation. */ function setTargetAllocation( IIssuanceTarget target, @@ -1047,7 +1196,7 @@ contract IssuanceAllocator is /** * @inheritdoc IIssuanceAllocationStatus - * @dev For reporting purposes, if the default target target is address(0), its allocation + * @dev For reporting purposes, if the default 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 issuancePerBlock) * @dev When default is a real address: returns issuancePerBlock @@ -1057,7 +1206,7 @@ contract IssuanceAllocator is IssuanceAllocatorData storage $ = _getIssuanceAllocatorStorage(); // If default is address(0), exclude its allocation from reported totals - // since it doe not receive minting (so it is considered unallocated). + // since it does not receive minting (so it is considered unallocated). // Address(0) will only have non-zero allocation when it is the default target, // so we can directly subtract zero address allocation. allocation.totalAllocationRate = $.issuancePerBlock - $.allocationTargets[address(0)].allocatorMintingRate; diff --git a/packages/issuance/contracts/test/allocate/IssuanceAllocatorTestHarness.sol b/packages/issuance/contracts/test/allocate/IssuanceAllocatorTestHarness.sol index a0362449a..15d589c6c 100644 --- a/packages/issuance/contracts/test/allocate/IssuanceAllocatorTestHarness.sol +++ b/packages/issuance/contracts/test/allocate/IssuanceAllocatorTestHarness.sol @@ -14,6 +14,7 @@ contract IssuanceAllocatorTestHarness is IssuanceAllocator { /** * @notice Constructor for the test harness * @param _graphToken Address of the Graph Token contract + * @custom:oz-upgrades-unsafe-allow constructor */ constructor(address _graphToken) IssuanceAllocator(_graphToken) {} diff --git a/packages/issuance/test/tests/allocate/InterfaceIdStability.test.ts b/packages/issuance/test/tests/allocate/InterfaceIdStability.test.ts index f2e86437c..fc5f27349 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('0xd0b6c0e8') + expect(IIssuanceAllocationAdministration__factory.interfaceId).to.equal('0x50d8541d') }) 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 cca35ac0b..feb0cb0d8 100644 --- a/packages/issuance/test/tests/allocate/IssuanceAllocator.test.ts +++ b/packages/issuance/test/tests/allocate/IssuanceAllocator.test.ts @@ -1866,6 +1866,37 @@ describe('IssuanceAllocator', () => { expect(result).to.equal(currentBlock) }) + it('should not emit SelfMintingOffsetReconciled when offset unchanged', async () => { + const { issuanceAllocator, graphToken, target1 } = await setupIssuanceAllocator() + + // Setup with only allocator-minting (no self-minting) + await (graphToken as any).addMinter(await issuanceAllocator.getAddress()) + await issuanceAllocator.connect(accounts.governor).setIssuancePerBlock(ethers.parseEther('100')) + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256)'](await target1.getAddress(), ethers.parseEther('50'), 0) + + // Distribute to current block (no accumulated offset) + await issuanceAllocator.connect(accounts.governor).distributeIssuance() + + // Verify no offset accumulated + const stateBefore = await issuanceAllocator.getDistributionState() + expect(stateBefore.selfMintingOffset).to.equal(0) + + // Mine blocks and distribute again + await ethers.provider.send('evm_mine', []) + await ethers.provider.send('evm_mine', []) + + // distributePendingIssuance with no accumulated offset should not emit reconciliation event + const tx = await issuanceAllocator.connect(accounts.governor)['distributePendingIssuance()']() + + await expect(tx).to.not.emit(issuanceAllocator, 'SelfMintingOffsetReconciled') + + // Verify offset is still 0 + const stateAfter = await issuanceAllocator.getDistributionState() + expect(stateAfter.selfMintingOffset).to.equal(0) + }) + it('should handle proportional distribution when available < allocatedTotal', async () => { const { issuanceAllocator, graphToken, target1, target2 } = await setupIssuanceAllocator() diff --git a/packages/issuance/test/tests/allocate/SelfMintingEventMode.test.ts b/packages/issuance/test/tests/allocate/SelfMintingEventMode.test.ts new file mode 100644 index 000000000..bcf6be726 --- /dev/null +++ b/packages/issuance/test/tests/allocate/SelfMintingEventMode.test.ts @@ -0,0 +1,372 @@ +import { expect } from 'chai' +import hre from 'hardhat' +const { ethers } = hre + +import { deployTestGraphToken, getTestAccounts } from '../common/fixtures' +import { deployDirectAllocation, deployIssuanceAllocator } from './fixtures' + +describe('SelfMintingEventMode', () => { + let accounts + let graphToken + let issuanceAllocator + let selfMintingTarget + let addresses + + const issuancePerBlock = ethers.parseEther('100') + + // SelfMintingEventMode enum values + const EventMode = { + None: 0, + Aggregate: 1, + PerTarget: 2, + } + + beforeEach(async () => { + accounts = await getTestAccounts() + + // Deploy contracts + graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + + issuanceAllocator = await deployIssuanceAllocator(graphTokenAddress, accounts.governor, issuancePerBlock) + + selfMintingTarget = await deployDirectAllocation(graphTokenAddress, accounts.governor) + + // Cache addresses + addresses = { + issuanceAllocator: await issuanceAllocator.getAddress(), + selfMintingTarget: await selfMintingTarget.getAddress(), + graphToken: graphTokenAddress, + } + + // Grant minter role + await (graphToken as any).addMinter(addresses.issuanceAllocator) + }) + + describe('Initialization', () => { + it('should initialize to PerTarget mode', async () => { + const mode = await issuanceAllocator.getSelfMintingEventMode() + expect(mode).to.equal(EventMode.PerTarget) + }) + }) + + describe('setSelfMintingEventMode', () => { + it('should allow governor to set event mode', async () => { + await expect(issuanceAllocator.connect(accounts.governor).setSelfMintingEventMode(EventMode.None)) + .to.emit(issuanceAllocator, 'SelfMintingEventModeUpdated') + .withArgs(EventMode.PerTarget, EventMode.None) + + expect(await issuanceAllocator.getSelfMintingEventMode()).to.equal(EventMode.None) + }) + + it('should return true when setting to same mode', async () => { + const currentMode = await issuanceAllocator.getSelfMintingEventMode() + const result = await issuanceAllocator.connect(accounts.governor).setSelfMintingEventMode(currentMode) + expect(result).to.not.be.reverted + }) + + it('should not emit event when setting to same mode', async () => { + const currentMode = await issuanceAllocator.getSelfMintingEventMode() + await expect(issuanceAllocator.connect(accounts.governor).setSelfMintingEventMode(currentMode)).to.not.emit( + issuanceAllocator, + 'SelfMintingEventModeUpdated', + ) + }) + + it('should allow switching between all modes', async () => { + // PerTarget -> None + await issuanceAllocator.connect(accounts.governor).setSelfMintingEventMode(EventMode.None) + expect(await issuanceAllocator.getSelfMintingEventMode()).to.equal(EventMode.None) + + // None -> Aggregate + await issuanceAllocator.connect(accounts.governor).setSelfMintingEventMode(EventMode.Aggregate) + expect(await issuanceAllocator.getSelfMintingEventMode()).to.equal(EventMode.Aggregate) + + // Aggregate -> PerTarget + await issuanceAllocator.connect(accounts.governor).setSelfMintingEventMode(EventMode.PerTarget) + expect(await issuanceAllocator.getSelfMintingEventMode()).to.equal(EventMode.PerTarget) + }) + + it('should revert when non-governor tries to set mode', async () => { + await expect( + issuanceAllocator.connect(accounts.nonGovernor).setSelfMintingEventMode(EventMode.None), + ).to.be.revertedWithCustomError(issuanceAllocator, 'AccessControlUnauthorizedAccount') + }) + }) + + describe('Event Emission - None Mode', () => { + beforeEach(async () => { + // Set to None mode + await issuanceAllocator.connect(accounts.governor).setSelfMintingEventMode(EventMode.None) + + // Set up self-minting target + const selfMintingRate = ethers.parseEther('30') + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256)'](addresses.selfMintingTarget, 0, selfMintingRate) + }) + + it('should not emit IssuanceSelfMintAllowance events in None mode', async () => { + // Advance blocks by calling distributeIssuance + await ethers.provider.send('evm_mine', []) + const tx = await issuanceAllocator.distributeIssuance() + + // Should not emit per-target events + await expect(tx).to.not.emit(issuanceAllocator, 'IssuanceSelfMintAllowance') + }) + + it('should not emit IssuanceSelfMintAllowanceAggregate events in None mode', async () => { + await ethers.provider.send('evm_mine', []) + const tx = await issuanceAllocator.distributeIssuance() + + await expect(tx).to.not.emit(issuanceAllocator, 'IssuanceSelfMintAllowanceAggregate') + }) + }) + + describe('Event Emission - Aggregate Mode', () => { + beforeEach(async () => { + // Set to Aggregate mode + await issuanceAllocator.connect(accounts.governor).setSelfMintingEventMode(EventMode.Aggregate) + + // Set up self-minting target + const selfMintingRate = ethers.parseEther('30') + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256)'](addresses.selfMintingTarget, 0, selfMintingRate) + }) + + it('should emit IssuanceSelfMintAllowanceAggregate event', async () => { + await ethers.provider.send('evm_mine', []) + const tx = await issuanceAllocator.distributeIssuance() + + await expect(tx).to.emit(issuanceAllocator, 'IssuanceSelfMintAllowanceAggregate') + }) + + it('should emit aggregate event with correct total amount', async () => { + const selfMintingRate = ethers.parseEther('30') + + // Distribute to get to current state + await issuanceAllocator.distributeIssuance() + const startBlock = await ethers.provider.getBlockNumber() + + // Mine a block then distribute + await ethers.provider.send('evm_mine', []) + const tx = await issuanceAllocator.distributeIssuance() + const endBlock = await ethers.provider.getBlockNumber() + + // Expected amount is for the block we just mined + const blocks = endBlock - startBlock + const expectedAmount = selfMintingRate * BigInt(blocks) + + await expect(tx) + .to.emit(issuanceAllocator, 'IssuanceSelfMintAllowanceAggregate') + .withArgs(expectedAmount, startBlock + 1, endBlock) + }) + + it('should not emit per-target events in Aggregate mode', async () => { + await ethers.provider.send('evm_mine', []) + const tx = await issuanceAllocator.distributeIssuance() + + await expect(tx).to.not.emit(issuanceAllocator, 'IssuanceSelfMintAllowance') + }) + }) + + describe('Event Emission - PerTarget Mode', () => { + beforeEach(async () => { + // Already in PerTarget mode by default + const selfMintingRate = ethers.parseEther('30') + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256)'](addresses.selfMintingTarget, 0, selfMintingRate) + }) + + it('should emit IssuanceSelfMintAllowance event for each target', async () => { + await ethers.provider.send('evm_mine', []) + const tx = await issuanceAllocator.distributeIssuance() + + await expect(tx).to.emit(issuanceAllocator, 'IssuanceSelfMintAllowance') + }) + + it('should emit per-target event with correct amount', async () => { + const selfMintingRate = ethers.parseEther('30') + + // Distribute to get to current state + await issuanceAllocator.distributeIssuance() + const startBlock = await ethers.provider.getBlockNumber() + + // Mine a block then distribute + await ethers.provider.send('evm_mine', []) + const tx = await issuanceAllocator.distributeIssuance() + const endBlock = await ethers.provider.getBlockNumber() + + // Expected amount is for the block we just mined + const blocks = endBlock - startBlock + const expectedAmount = selfMintingRate * BigInt(blocks) + + await expect(tx) + .to.emit(issuanceAllocator, 'IssuanceSelfMintAllowance') + .withArgs(addresses.selfMintingTarget, expectedAmount, startBlock + 1, endBlock) + }) + + it('should not emit aggregate events in PerTarget mode', async () => { + await ethers.provider.send('evm_mine', []) + const tx = await issuanceAllocator.distributeIssuance() + + await expect(tx).to.not.emit(issuanceAllocator, 'IssuanceSelfMintAllowanceAggregate') + }) + }) + + describe('Mode Switching During Operation', () => { + it('should apply new mode immediately on next distribution', async () => { + // Set up self-minting target + const selfMintingRate = ethers.parseEther('30') + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256)'](addresses.selfMintingTarget, 0, selfMintingRate) + + // PerTarget mode initially + await ethers.provider.send('evm_mine', []) + await expect(issuanceAllocator.distributeIssuance()).to.emit(issuanceAllocator, 'IssuanceSelfMintAllowance') + + // Switch to None mode + await issuanceAllocator.connect(accounts.governor).setSelfMintingEventMode(EventMode.None) + + // Next distribution should not emit events + await ethers.provider.send('evm_mine', []) + await expect(issuanceAllocator.distributeIssuance()).to.not.emit(issuanceAllocator, 'IssuanceSelfMintAllowance') + }) + + it('should handle rapid mode switching correctly', async () => { + const selfMintingRate = ethers.parseEther('30') + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256)'](addresses.selfMintingTarget, 0, selfMintingRate) + + // Switch through all modes + await issuanceAllocator.connect(accounts.governor).setSelfMintingEventMode(EventMode.None) + await issuanceAllocator.connect(accounts.governor).setSelfMintingEventMode(EventMode.Aggregate) + await issuanceAllocator.connect(accounts.governor).setSelfMintingEventMode(EventMode.PerTarget) + + // Should end up in PerTarget mode + expect(await issuanceAllocator.getSelfMintingEventMode()).to.equal(EventMode.PerTarget) + + await ethers.provider.send('evm_mine', []) + await expect(issuanceAllocator.distributeIssuance()).to.emit(issuanceAllocator, 'IssuanceSelfMintAllowance') + }) + }) + + describe('Gas Optimization', () => { + it('should use less gas in None mode than PerTarget mode', async () => { + const selfMintingRate = ethers.parseEther('30') + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256)'](addresses.selfMintingTarget, 0, selfMintingRate) + + // Measure gas in PerTarget mode + await ethers.provider.send('evm_mine', []) + const perTargetTx = await issuanceAllocator.distributeIssuance() + const perTargetReceipt = await perTargetTx.wait() + const perTargetGas = perTargetReceipt.gasUsed + + // Switch to None mode + await issuanceAllocator.connect(accounts.governor).setSelfMintingEventMode(EventMode.None) + + // Measure gas in None mode + await ethers.provider.send('evm_mine', []) + const noneTx = await issuanceAllocator.distributeIssuance() + const noneReceipt = await noneTx.wait() + const noneGas = noneReceipt.gasUsed + + // None mode should use less gas + expect(noneGas).to.be.lessThan(perTargetGas) + }) + + it('should use less gas in Aggregate mode than PerTarget mode with multiple targets', async () => { + // Add multiple self-minting targets + const target2 = await deployDirectAllocation(await graphToken.getAddress(), accounts.governor) + const target3 = await deployDirectAllocation(await graphToken.getAddress(), accounts.governor) + + const selfMintingRate = ethers.parseEther('10') + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256)'](addresses.selfMintingTarget, 0, selfMintingRate) + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256)'](await target2.getAddress(), 0, selfMintingRate) + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256)'](await target3.getAddress(), 0, selfMintingRate) + + // Measure gas in PerTarget mode + await ethers.provider.send('evm_mine', []) + const perTargetTx = await issuanceAllocator.distributeIssuance() + const perTargetReceipt = await perTargetTx.wait() + const perTargetGas = perTargetReceipt.gasUsed + + // Switch to Aggregate mode + await issuanceAllocator.connect(accounts.governor).setSelfMintingEventMode(EventMode.Aggregate) + + // Measure gas in Aggregate mode + await ethers.provider.send('evm_mine', []) + const aggregateTx = await issuanceAllocator.distributeIssuance() + const aggregateReceipt = await aggregateTx.wait() + const aggregateGas = aggregateReceipt.gasUsed + + // Aggregate mode should use less gas + expect(aggregateGas).to.be.lessThan(perTargetGas) + }) + }) + + describe('Edge Cases', () => { + it('should handle mode changes when no self-minting targets exist', async () => { + // No self-minting targets added + await issuanceAllocator.connect(accounts.governor).setSelfMintingEventMode(EventMode.None) + + await ethers.provider.send('evm_mine', []) + const tx = await issuanceAllocator.distributeIssuance() + + // Should not emit any self-minting events + await expect(tx).to.not.emit(issuanceAllocator, 'IssuanceSelfMintAllowance') + await expect(tx).to.not.emit(issuanceAllocator, 'IssuanceSelfMintAllowanceAggregate') + }) + + it('should handle mode when totalSelfMintingRate is zero', async () => { + // Add target with only allocator-minting (no self-minting) + const allocatorMintingRate = ethers.parseEther('50') + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256)'](addresses.selfMintingTarget, allocatorMintingRate, 0) + + await issuanceAllocator.connect(accounts.governor).setSelfMintingEventMode(EventMode.Aggregate) + + await ethers.provider.send('evm_mine', []) + const tx = await issuanceAllocator.distributeIssuance() + + // Should not emit self-minting events when totalSelfMintingRate is 0 + await expect(tx).to.not.emit(issuanceAllocator, 'IssuanceSelfMintAllowanceAggregate') + }) + + it('should work correctly after removing and re-adding self-minting target', async () => { + const selfMintingRate = ethers.parseEther('30') + + // Add target + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256)'](addresses.selfMintingTarget, 0, selfMintingRate) + + // Remove target + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256)'](addresses.selfMintingTarget, 0, 0) + + // Re-add target + await issuanceAllocator + .connect(accounts.governor) + ['setTargetAllocation(address,uint256,uint256)'](addresses.selfMintingTarget, 0, selfMintingRate) + + // Should emit events normally + await ethers.provider.send('evm_mine', []) + await expect(issuanceAllocator.distributeIssuance()).to.emit(issuanceAllocator, 'IssuanceSelfMintAllowance') + }) + }) +}) diff --git a/packages/subgraph-service/contracts/utilities/AllocationManager.sol b/packages/subgraph-service/contracts/utilities/AllocationManager.sol index 8ca711758..c58336e35 100644 --- a/packages/subgraph-service/contracts/utilities/AllocationManager.sol +++ b/packages/subgraph-service/contracts/utilities/AllocationManager.sol @@ -260,6 +260,21 @@ abstract contract AllocationManager is EIP712Upgradeable, GraphDirectory, Alloca * which to calculate POIs. EBO posts once per epoch typically at each epoch change, so we restrict rewards to allocations * that have gone through at least one epoch change. * + * Reclaim target hierarchy: + * When rewards cannot be minted, they are reclaimed with a specific reason. The following conditions are checked + * in order, and the first matching condition determines which reclaim reason is used: + * 1. STALE_POI - if allocation is stale (lastPOI older than maxPOIStaleness) + * 2. ALTRUISTIC_ALLOCATION - if allocation has zero tokens + * 3. ZERO_POI - if POI is bytes32(0) + * 4. ALLOCATION_TOO_YOUNG - if allocation was created in the current epoch + * Each reason may have a different reclaim address configured in the RewardsManager. If multiple conditions + * apply simultaneously, only the first matching condition's reclaim address receives the rewards. + * + * Retroactive reclaim address changes: + * Any change to a reclaim address in the RewardsManager takes effect immediately and retroactively. + * All unclaimed rewards from previous periods will be sent to the new reclaim address when they are + * eventually reclaimed, regardless of which address was configured when the rewards were originally accrued. + * * Emits a {IndexingRewardsCollected} event. * * @param _allocationId The id of the allocation to collect rewards for @@ -442,6 +457,8 @@ abstract contract AllocationManager is EIP712Upgradeable, GraphDirectory, Alloca // Clear pending rewards only if rewards were reclaimed. This marks them as consumed, // which could be useful for future logic that searches for unconsumed rewards. + // Known limitation: This capture is incomplete due to other code paths (e.g., _presentPOI) + // that clear pending even when rewards are not consumed. if (0 < reclaimedRewards) _allocations.clearPendingRewards(_allocationId); _allocations.close(_allocationId);