diff --git a/cli/commands/migrate.ts b/cli/commands/migrate.ts index 335b6fa32..ad8c90148 100644 --- a/cli/commands/migrate.ts +++ b/cli/commands/migrate.ts @@ -36,6 +36,7 @@ let allContracts = [ 'AllocationExchange', 'L1GraphTokenGateway', 'BridgeEscrow', + 'L1Reservoir', ] const l2Contracts = [ @@ -55,6 +56,7 @@ const l2Contracts = [ 'DisputeManager', 'AllocationExchange', 'L2GraphTokenGateway', + 'L2Reservoir', ] export const migrate = async ( diff --git a/cli/contracts.ts b/cli/contracts.ts index b01727c20..6e9e1d28f 100644 --- a/cli/contracts.ts +++ b/cli/contracts.ts @@ -26,6 +26,8 @@ import { L1GraphTokenGateway } from '../build/types/L1GraphTokenGateway' import { L2GraphToken } from '../build/types/L2GraphToken' import { L2GraphTokenGateway } from '../build/types/L2GraphTokenGateway' import { BridgeEscrow } from '../build/types/BridgeEscrow' +import { L1Reservoir } from '../build/types/L1Reservoir' +import { L2Reservoir } from '../build/types/L2Reservoir' export interface NetworkContracts { EpochManager: EpochManager @@ -49,6 +51,8 @@ export interface NetworkContracts { BridgeEscrow: BridgeEscrow L2GraphToken: L2GraphToken L2GraphTokenGateway: L2GraphTokenGateway + L1Reservoir: L1Reservoir + L2Reservoir: L2Reservoir } export const loadAddressBookContract = ( diff --git a/config/graph.arbitrum-goerli.yml b/config/graph.arbitrum-goerli.yml index fa1a66ad3..20e16940e 100644 --- a/config/graph.arbitrum-goerli.yml +++ b/config/graph.arbitrum-goerli.yml @@ -33,6 +33,9 @@ contracts: - fn: "setContractProxy" id: "0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0" # keccak256('GraphTokenGateway') contractAddress: "${{L2GraphTokenGateway.address}}" + - fn: "setContractProxy" + id: "0x96ba401694892957e25e29c7a1e4171ae9945b5ee36339de79b199a530436e9e" # keccak256('Reservoir') + contractAddress: "${{L2Reservoir.address}}" - fn: "setPauseGuardian" pauseGuardian: *pauseGuardian - fn: "transferOwnership" @@ -149,3 +152,10 @@ contracts: - fn: "syncAllContracts" - fn: "setPauseGuardian" pauseGuardian: *pauseGuardian + L2Reservoir: + proxy: true + init: + controller: "${{Controller.address}}" + calls: + - fn: "approveRewardsManager" + - fn: "syncAllContracts" diff --git a/config/graph.arbitrum-localhost.yml b/config/graph.arbitrum-localhost.yml index 1061044be..519b9bfba 100644 --- a/config/graph.arbitrum-localhost.yml +++ b/config/graph.arbitrum-localhost.yml @@ -33,6 +33,9 @@ contracts: - fn: "setContractProxy" id: "0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0" # keccak256('GraphTokenGateway') contractAddress: "${{L2GraphTokenGateway.address}}" + - fn: "setContractProxy" + id: "0x96ba401694892957e25e29c7a1e4171ae9945b5ee36339de79b199a530436e9e" # keccak256('Reservoir') + contractAddress: "${{L2Reservoir.address}}" - fn: "setPauseGuardian" pauseGuardian: *pauseGuardian - fn: "transferOwnership" @@ -149,3 +152,10 @@ contracts: - fn: "syncAllContracts" - fn: "setPauseGuardian" pauseGuardian: *pauseGuardian + L2Reservoir: + proxy: true + init: + controller: "${{Controller.address}}" + calls: + - fn: "approveRewardsManager" + - fn: "syncAllContracts" diff --git a/config/graph.arbitrum-one.yml b/config/graph.arbitrum-one.yml index e68d67521..66127e5a4 100644 --- a/config/graph.arbitrum-one.yml +++ b/config/graph.arbitrum-one.yml @@ -33,6 +33,9 @@ contracts: - fn: "setContractProxy" id: "0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0" # keccak256('GraphTokenGateway') contractAddress: "${{L2GraphTokenGateway.address}}" + - fn: "setContractProxy" + id: "0x96ba401694892957e25e29c7a1e4171ae9945b5ee36339de79b199a530436e9e" # keccak256('Reservoir') + contractAddress: "${{L2Reservoir.address}}" - fn: "setPauseGuardian" pauseGuardian: *pauseGuardian - fn: "transferOwnership" @@ -149,3 +152,10 @@ contracts: - fn: "syncAllContracts" - fn: "setPauseGuardian" pauseGuardian: *pauseGuardian + L2Reservoir: + proxy: true + init: + controller: "${{Controller.address}}" + calls: + - fn: "approveRewardsManager" + - fn: "syncAllContracts" diff --git a/config/graph.goerli.yml b/config/graph.goerli.yml index b4b735b4e..e71b1e98d 100644 --- a/config/graph.goerli.yml +++ b/config/graph.goerli.yml @@ -33,6 +33,9 @@ contracts: - fn: "setContractProxy" id: "0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0" # keccak256('GraphTokenGateway') contractAddress: "${{L1GraphTokenGateway.address}}" + - fn: "setContractProxy" + id: "0x96ba401694892957e25e29c7a1e4171ae9945b5ee36339de79b199a530436e9e" # keccak256('Reservoir') + contractAddress: "${{L1Reservoir.address}}" - fn: "setPauseGuardian" pauseGuardian: *pauseGuardian - fn: "transferOwnership" @@ -57,7 +60,7 @@ contracts: initialSupply: "10000000000000000000000000000" # in wei calls: - fn: "addMinter" - minter: "${{RewardsManager.address}}" + minter: "${{L1Reservoir.address}}" - fn: "renounceMinter" - fn: "transferOwnership" owner: *governor @@ -131,8 +134,6 @@ contracts: init: controller: "${{Controller.address}}" calls: - - fn: "setIssuanceRate" - issuanceRate: "1000000012184945188" # per block increase of total supply, blocks in a year = 365*60*60*24/13 - fn: "setSubgraphAvailabilityOracle" subgraphAvailabilityOracle: *availabilityOracle - fn: "syncAllContracts" @@ -158,3 +159,13 @@ contracts: controller: "${{Controller.address}}" calls: - fn: "syncAllContracts" + L1Reservoir: + proxy: true + init: + controller: "${{Controller.address}}" + dripInterval: 50400 + calls: + - fn: "approveRewardsManager" + - fn: "initialSnapshot" + pendingRewards: "0" + - fn: "syncAllContracts" diff --git a/config/graph.localhost.yml b/config/graph.localhost.yml index 4a90dfd20..3eb3a9161 100644 --- a/config/graph.localhost.yml +++ b/config/graph.localhost.yml @@ -33,6 +33,9 @@ contracts: - fn: "setContractProxy" id: "0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0" # keccak256('GraphTokenGateway') contractAddress: "${{L1GraphTokenGateway.address}}" + - fn: "setContractProxy" + id: "0x96ba401694892957e25e29c7a1e4171ae9945b5ee36339de79b199a530436e9e" # keccak256('Reservoir') + contractAddress: "${{L1Reservoir.address}}" - fn: "setPauseGuardian" pauseGuardian: *pauseGuardian - fn: "transferOwnership" @@ -57,7 +60,7 @@ contracts: initialSupply: "10000000000000000000000000000" # in wei calls: - fn: "addMinter" - minter: "${{RewardsManager.address}}" + minter: "${{L1Reservoir.address}}" - fn: "renounceMinter" - fn: "transferOwnership" owner: *governor @@ -131,8 +134,6 @@ contracts: init: controller: "${{Controller.address}}" calls: - - fn: "setIssuanceRate" - issuanceRate: "1000000012184945188" # per block increase of total supply, blocks in a year = 365*60*60*24/13 - fn: "setSubgraphAvailabilityOracle" subgraphAvailabilityOracle: *availabilityOracle - fn: "syncAllContracts" @@ -158,3 +159,13 @@ contracts: controller: "${{Controller.address}}" calls: - fn: "syncAllContracts" + L1Reservoir: + proxy: true + init: + controller: "${{Controller.address}}" + dripInterval: 50400 + calls: + - fn: "approveRewardsManager" + - fn: "initialSnapshot" + pendingRewards: "0" + - fn: "syncAllContracts" diff --git a/config/graph.mainnet.yml b/config/graph.mainnet.yml index 6eb08b233..529e40ffc 100644 --- a/config/graph.mainnet.yml +++ b/config/graph.mainnet.yml @@ -33,6 +33,9 @@ contracts: - fn: "setContractProxy" id: "0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0" # keccak256('GraphTokenGateway') contractAddress: "${{L1GraphTokenGateway.address}}" + - fn: "setContractProxy" + id: "0x96ba401694892957e25e29c7a1e4171ae9945b5ee36339de79b199a530436e9e" # keccak256('Reservoir') + contractAddress: "${{L1Reservoir.address}}" - fn: "setPauseGuardian" pauseGuardian: *pauseGuardian - fn: "transferOwnership" @@ -57,7 +60,7 @@ contracts: initialSupply: "10000000000000000000000000000" # in wei calls: - fn: "addMinter" - minter: "${{RewardsManager.address}}" + minter: "${{L1Reservoir.address}}" - fn: "renounceMinter" - fn: "transferOwnership" owner: *governor @@ -131,8 +134,6 @@ contracts: init: controller: "${{Controller.address}}" calls: - - fn: "setIssuanceRate" - issuanceRate: "1000000012184945188" # per block increase of total supply, blocks in a year = 365*60*60*24/13 - fn: "setSubgraphAvailabilityOracle" subgraphAvailabilityOracle: *availabilityOracle - fn: "syncAllContracts" @@ -158,3 +159,13 @@ contracts: controller: "${{Controller.address}}" calls: - fn: "syncAllContracts" + L1Reservoir: + proxy: true + init: + controller: "${{Controller.address}}" + dripInterval: 50400 + calls: + - fn: "approveRewardsManager" + - fn: "initialSnapshot" + pendingRewards: "0" + - fn: "syncAllContracts" diff --git a/contracts/governance/Managed.sol b/contracts/governance/Managed.sol index ce18fb009..20a9191e5 100644 --- a/contracts/governance/Managed.sol +++ b/contracts/governance/Managed.sol @@ -10,6 +10,7 @@ import "../rewards/IRewardsManager.sol"; import "../staking/IStaking.sol"; import "../token/IGraphToken.sol"; import "../arbitrum/ITokenGateway.sol"; +import "../reservoir/IReservoir.sol"; /** * @title Graph Managed contract @@ -154,6 +155,14 @@ contract Managed { return ITokenGateway(_resolveContract(keccak256("GraphTokenGateway"))); } + /** + * @dev Return Reservoir (L1 or L2) interface. + * @return Reservoir contract registered with Controller + */ + function reservoir() internal view returns (IReservoir) { + return IReservoir(_resolveContract(keccak256("Reservoir"))); + } + /** * @dev Resolve a contract address from the cache or the Controller if not found. * @return Address of the contract @@ -192,5 +201,6 @@ contract Managed { _syncContract("Staking"); _syncContract("GraphToken"); _syncContract("GraphTokenGateway"); + _syncContract("Reservoir"); } } diff --git a/contracts/l2/reservoir/IL2Reservoir.sol b/contracts/l2/reservoir/IL2Reservoir.sol new file mode 100644 index 000000000..8144efae2 --- /dev/null +++ b/contracts/l2/reservoir/IL2Reservoir.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; + +import { IReservoir } from "../../reservoir/IReservoir.sol"; + +/** + * @title Interface for the L2 Rewards Reservoir + * @dev This exposes a specific function for the L2Reservoir that is called + * as a callhook from L1 to L2, so that state can be updated when dripped rewards + * are bridged between layers. + */ +interface IL2Reservoir is IReservoir { + /** + * @dev Receive dripped tokens from L1. + * This function can only be called by the gateway, as it is + * meant to be a callhook when receiving tokens from L1. It + * updates the issuanceBase and issuanceRate, + * and snapshots the accumulated rewards. If issuanceRate changes, + * it also triggers a snapshot of rewards per signal on the RewardsManager. + * Note that the transaction might revert if it's received out-of-order, + * because it checks an incrementing nonce. If that is the case, the retryable ticket can be redeemed + * again once the ticket for previous drip has been redeemed. + * A keeper reward will be sent to the keeper that dripped on L1, and part of it + * to whoever redeemed the current retryable ticket (as reported by ArbRetryableTx.getCurrentRedeemer) if + * the ticket is not auto-redeemed. + * @param _issuanceBase Base value for token issuance (approximation for token supply times L2 rewards fraction) + * @param _issuanceRate Rewards issuance rate, using fixed point at 1e18, and including a +1 + * @param _nonce Incrementing nonce to ensure messages are received in order + * @param _keeperReward Keeper reward to distribute between keeper that called drip and keeper that redeemed the retryable tx + * @param _l1Keeper Address of the keeper that called drip in L1 + */ + function receiveDrip( + uint256 _issuanceBase, + uint256 _issuanceRate, + uint256 _nonce, + uint256 _keeperReward, + address _l1Keeper + ) external; +} diff --git a/contracts/l2/reservoir/L2Reservoir.sol b/contracts/l2/reservoir/L2Reservoir.sol new file mode 100644 index 000000000..e5cce6fee --- /dev/null +++ b/contracts/l2/reservoir/L2Reservoir.sol @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; +pragma abicoder v2; + +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; +import { ArbRetryableTx } from "arbos-precompiles/arbos/builtin/ArbRetryableTx.sol"; + +import { Managed } from "../../governance/Managed.sol"; +import { IGraphToken } from "../../token/IGraphToken.sol"; +import { AddressAliasHelper } from "../../arbitrum/AddressAliasHelper.sol"; +import { IReservoir } from "../../reservoir/IReservoir.sol"; +import { Reservoir } from "../../reservoir/Reservoir.sol"; +import { IL2Reservoir } from "./IL2Reservoir.sol"; +import { L2ReservoirV2Storage } from "./L2ReservoirStorage.sol"; + +/** + * @dev ArbRetryableTx with additional interface to query the current redeemer. + * This is being added by the Arbitrum team but hasn't made it into the arbos-precompiles + * package yet. + */ +interface IArbTxWithRedeemer is ArbRetryableTx { + /** + * @notice Gets the redeemer of the current retryable redeem attempt. + * Returns the zero address if the current transaction is not a retryable redeem attempt. + * If this is an auto-redeem, returns the fee refund address of the retryable. + */ + function getCurrentRedeemer() external view returns (address); +} + +/** + * @title L2 Rewards Reservoir + * @dev This contract acts as a reservoir/vault for the rewards to be distributed on Layer 2. + * It receives tokens for rewards from L1, and provides functions to compute accumulated and new + * total rewards at a particular block number. + */ +contract L2Reservoir is L2ReservoirV2Storage, Reservoir, IL2Reservoir { + using SafeMath for uint256; + + // Address for the ArbRetryableTx interface provided by Arbitrum + address public constant ARB_TX_ADDRESS = 0x000000000000000000000000000000000000006E; + + // Emitted when a rewards drip is received from L1 + event DripReceived(uint256 issuanceBase); + // Emitted when the next drip nonce is manually updated by governance + event NextDripNonceUpdated(uint256 nonce); + // Emitted when the L1Reservoir's address is updated + event L1ReservoirAddressUpdated(address l1ReservoirAddress); + // Emitted when the L2 keeper reward fraction is updated + event L2KeeperRewardFractionUpdated(uint256 l2KeeperRewardFraction); + + /** + * @dev Checks that the sender is the L2GraphTokenGateway as configured on the Controller. + */ + modifier onlyL2Gateway() { + require(msg.sender == address(graphTokenGateway()), "ONLY_GATEWAY"); + _; + } + + /** + * @dev Initialize this contract. + * The contract will be paused. Note that issuance parameters + * are not set here because they are set from L1 through the drip function. + * The RewardsManager's address might also not be available in the controller at initialization + * time, so approveRewardsManager() must be called separately. + * The l1ReservoirAddress must also be set separately through setL1ReservoirAddress + * for the same reason. + * In the same vein, the l2KeeperRewardFraction is assumed to be zero at initialization, + * so it must be set through setL2KeeperRewardFraction. + * @param _controller Address of the Controller that manages this contract + */ + function initialize(address _controller) external onlyImpl { + Managed._initialize(_controller); + } + + /** + * @dev Update the next drip nonce + * To be used only as a backup option if the two layers get out of sync. + * @param _nonce Expected value for the nonce of the next drip message + */ + function setNextDripNonce(uint256 _nonce) external onlyGovernor { + nextDripNonce = _nonce; + emit NextDripNonceUpdated(_nonce); + } + + /** + * @dev Sets the L1 Reservoir address + * This is the address on L1 that will appear as redeemer when a ticket + * was auto-redeemed. + * @param _l1ReservoirAddress New address for the L1Reservoir on L1 + */ + function setL1ReservoirAddress(address _l1ReservoirAddress) external onlyGovernor { + require(_l1ReservoirAddress != address(0), "INVALID_L1_RESERVOIR"); + l1ReservoirAddress = _l1ReservoirAddress; + emit L1ReservoirAddressUpdated(_l1ReservoirAddress); + } + + /** + * @dev Sets the L2 keeper reward fraction + * This is the fraction of the keeper reward that will be sent to the redeemer on L2 + * if the retryable ticket is not auto-redeemed + * @param _l2KeeperRewardFraction New value for the fraction, with fixed point at 1e18 + */ + function setL2KeeperRewardFraction(uint256 _l2KeeperRewardFraction) external onlyGovernor { + require(_l2KeeperRewardFraction <= FIXED_POINT_SCALING_FACTOR, "INVALID_VALUE"); + l2KeeperRewardFraction = _l2KeeperRewardFraction; + emit L2KeeperRewardFractionUpdated(_l2KeeperRewardFraction); + } + + /** + * @dev Get new total rewards accumulated since the last drip. + * This is deltaR = p * r ^ (blocknum - t0) - p, where: + * - p is the issuance base at t0 (normalized by the L2 rewards fraction) + * - t0 is the last drip block, i.e. lastRewardsUpdateBlock + * - r is the issuanceRate + * @param _blocknum Block number at which to calculate rewards + * @return New total rewards on L2 since the last drip + */ + function getNewRewards(uint256 _blocknum) + public + view + override(Reservoir, IReservoir) + returns (uint256) + { + uint256 t0 = lastRewardsUpdateBlock; + if (issuanceRate <= MIN_ISSUANCE_RATE || _blocknum == t0) { + return 0; + } + return + issuanceBase + .mul(_pow(issuanceRate, _blocknum.sub(t0), FIXED_POINT_SCALING_FACTOR)) + .div(FIXED_POINT_SCALING_FACTOR) + .sub(issuanceBase); + } + + /** + * @dev Receive dripped tokens from L1. + * This function can only be called by the gateway, as it is + * meant to be a callhook when receiving tokens from L1. It + * updates the issuanceBase and issuanceRate, + * and snapshots the accumulated rewards. If issuanceRate changes, + * it also triggers a snapshot of rewards per signal on the RewardsManager. + * Note that the transaction might revert if it's received out-of-order, + * because it checks an incrementing nonce. If that is the case, the retryable ticket can be redeemed + * again once the ticket for previous drip has been redeemed. + * A keeper reward will be sent to the keeper that dripped on L1, and part of it + * to whoever redeemed the current retryable ticket (as reported by ArbRetryableTx.getCurrentRedeemer) if + * the ticket is not auto-redeemed. + * @param _issuanceBase Base value for token issuance (approximation for token supply times L2 rewards fraction) + * @param _issuanceRate Rewards issuance rate, using fixed point at 1e18, and including a +1 + * @param _nonce Incrementing nonce to ensure messages are received in order + * @param _keeperReward Keeper reward to distribute between keeper that called drip and keeper that redeemed the retryable tx + * @param _l1Keeper Address of the keeper that called drip in L1 + */ + function receiveDrip( + uint256 _issuanceBase, + uint256 _issuanceRate, + uint256 _nonce, + uint256 _keeperReward, + address _l1Keeper + ) external override onlyL2Gateway { + require(_nonce == nextDripNonce, "INVALID_NONCE"); + nextDripNonce = nextDripNonce.add(1); + if (_issuanceRate != issuanceRate) { + rewardsManager().updateAccRewardsPerSignal(); + snapshotAccumulatedRewards(); + issuanceRate = _issuanceRate; + emit IssuanceRateUpdated(_issuanceRate); + } else { + snapshotAccumulatedRewards(); + } + issuanceBase = _issuanceBase; + IGraphToken grt = graphToken(); + + // Part of the reward always goes to whoever redeemed the ticket in L2, + // unless this was an autoredeem, in which case the "redeemer" is the sender, i.e. L1Reservoir + address redeemer = IArbTxWithRedeemer(ARB_TX_ADDRESS).getCurrentRedeemer(); + if (redeemer != AddressAliasHelper.applyL1ToL2Alias(l1ReservoirAddress)) { + uint256 _l2KeeperReward = _keeperReward.mul(l2KeeperRewardFraction).div( + FIXED_POINT_SCALING_FACTOR + ); + grt.transfer(redeemer, _l2KeeperReward); + grt.transfer(_l1Keeper, _keeperReward.sub(_l2KeeperReward)); + } else { + // In an auto-redeem, we just send all the rewards to the L1 keeper: + grt.transfer(_l1Keeper, _keeperReward); + } + + emit DripReceived(issuanceBase); + } + + /** + * @dev Snapshot accumulated rewards on this layer + * We compute accumulatedLayerRewards and mark this block as the lastRewardsUpdateBlock. + */ + function snapshotAccumulatedRewards() internal { + accumulatedLayerRewards = getAccumulatedRewards(block.number); + lastRewardsUpdateBlock = block.number; + } +} diff --git a/contracts/l2/reservoir/L2ReservoirStorage.sol b/contracts/l2/reservoir/L2ReservoirStorage.sol new file mode 100644 index 000000000..4d469f889 --- /dev/null +++ b/contracts/l2/reservoir/L2ReservoirStorage.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; + +/** + * @dev Storage variables for the L2Reservoir, version 1 + */ +contract L2ReservoirV1Storage { + // Expected nonce value for the next drip hook + uint256 public nextDripNonce; +} + +/** + * @dev Storage variables for the L2Reservoir, version 2 + * This version adds some variables needed when introducing the keeper reward. + */ +contract L2ReservoirV2Storage is L2ReservoirV1Storage { + // Fraction of the keeper reward to send to the retryable tx redeemer in L2 (fixed point 1e18) + uint256 public l2KeeperRewardFraction; + // Address of the L1Reservoir on L1, used to check if a ticket was auto-redeemed + address public l1ReservoirAddress; +} diff --git a/contracts/reservoir/IReservoir.sol b/contracts/reservoir/IReservoir.sol new file mode 100644 index 000000000..a101952e0 --- /dev/null +++ b/contracts/reservoir/IReservoir.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; + +/** + * @title Interface for the Rewards Reservoir + * @dev This is the shared interface between L1 and L2, for the contracts + * that hold rewards on each layer and provide functions to compute + * accumulated and new total rewards. + */ +interface IReservoir { + // Emitted when the issuance rate is updated + event IssuanceRateUpdated(uint256 newValue); + + /** + * @dev Approve the RewardsManager to manage the reservoir's token funds + */ + function approveRewardsManager() external; + + /** + * @dev Get accumulated total rewards on this layer at a particular block + * @param _blocknum Block number at which to calculate rewards + * @return Accumulated total rewards on this layer + */ + function getAccumulatedRewards(uint256 _blocknum) external view returns (uint256); + + /** + * @dev Get new total rewards on this layer at a particular block, since the last drip event + * @param _blocknum Block number at which to calculate rewards + * @return New total rewards on this layer since the last drip + */ + function getNewRewards(uint256 _blocknum) external view returns (uint256); +} diff --git a/contracts/reservoir/L1Reservoir.sol b/contracts/reservoir/L1Reservoir.sol new file mode 100644 index 000000000..1fc1c30ce --- /dev/null +++ b/contracts/reservoir/L1Reservoir.sol @@ -0,0 +1,530 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; +pragma abicoder v2; + +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; + +import { ITokenGateway } from "../arbitrum/ITokenGateway.sol"; + +import { Managed } from "../governance/Managed.sol"; +import { IGraphToken } from "../token/IGraphToken.sol"; +import { IStaking } from "../staking/IStaking.sol"; +import { IL2Reservoir } from "../l2/reservoir/IL2Reservoir.sol"; +import { Reservoir } from "./Reservoir.sol"; +import { L1ReservoirV2Storage } from "./L1ReservoirStorage.sol"; + +/** + * @title L1 Rewards Reservoir + * @dev This contract acts as a reservoir/vault for the rewards to be distributed on Layer 1. + * It provides a function to periodically drip rewards, and functions to compute accumulated and new + * total rewards at a particular block number. + */ +contract L1Reservoir is L1ReservoirV2Storage, Reservoir { + using SafeMath for uint256; + + // Emitted when the initial supply snapshot is taken after contract deployment + event InitialSnapshotTaken( + uint256 blockNumber, + uint256 issuanceBase, + uint256 mintedPendingRewards + ); + // Emitted when an issuance rate update is staged, to be applied on the next drip + event IssuanceRateStaged(uint256 newValue); + // Emitted when an L2 rewards fraction update is staged, to be applied on the next drip + event L2RewardsFractionStaged(uint256 newValue); + // Emitted when the L2 rewards fraction is updated (during a drip) + event L2RewardsFractionUpdated(uint256 newValue); + // Emitted when the drip interval is updated + event DripIntervalUpdated(uint256 newValue); + // Emitted when new rewards are dripped and potentially sent to L2 + event RewardsDripped(uint256 totalMinted, uint256 sentToL2, uint256 nextDeadline); + // Emitted when the address for the L2Reservoir is updated + event L2ReservoirAddressUpdated(address l2ReservoirAddress); + // Emitted when drip reward per block is updated + event DripRewardPerBlockUpdated(uint256 dripRewardPerBlock); + // Emitted when minDripInterval is updated + event MinDripIntervalUpdated(uint256 minDripInterval); + // Emitted when a new allowedDripper is added + event AllowedDripperAdded(address indexed dripper); + // Emitted when an allowedDripper is revoked + event AllowedDripperRevoked(address indexed dripper); + + /** + * @dev Checks that the sender is an indexer with stake on the Staking contract, + * or that the sender is an address whitelisted by governance to call. + */ + modifier onlyIndexerOrAllowedDripper() { + require(allowedDrippers[msg.sender] || _isIndexer(msg.sender), "UNAUTHORIZED"); + _; + } + + /** + * @dev Checks that the sender is an operator for the specified indexer + * (also checks that the specified indexer is, indeed, an indexer). + * @param _indexer Indexer for which the sender must be an operator + */ + modifier onlyIndexerOperator(address _indexer) { + require(_isIndexer(_indexer), "UNAUTHORIZED_INVALID_INDEXER"); + require(staking().isOperator(msg.sender, _indexer), "UNAUTHORIZED_INVALID_OPERATOR"); + _; + } + + /** + * @dev Initialize this contract. + * The contract will be paused. + * Note that the contract is designed to not accrue rewards until the first call + * to the drip function, that also requires the initial supply snapshot to be taken + * using initialSnapshot. For this reason, issuanceRate and l2RewardsFraction + * are not initialized here and instead need a call to setIssuanceRate and setL2RewardsFraction. + * The same applies to minDripInterval (set through setMinDripInterval) and dripRewardPerBlock + * (set through setDripRewardPerBlock). + * On the other hand, the l2ReservoirAddress is not expected to be known at initialization + * time and must therefore be set using setL2ReservoirAddress. + * The RewardsManager's address might also not be available in the controller at initialization + * time, so approveRewardsManager() must be called separately as well. + * @param _controller Address of the Controller that manages this contract + * @param _dripInterval Drip interval, i.e. time period for which rewards are minted each time we drip + */ + function initialize(address _controller, uint256 _dripInterval) external onlyImpl { + Managed._initialize(_controller); + _setDripInterval(_dripInterval); + } + + /** + * @dev Sets the drip interval. + * This is the time in the future (in blocks) for which drip() will mint rewards. + * Keep in mind that changing this value will require manually re-adjusting + * the reservoir's token balance, because the first call to drip might produce + * more or less tokens than needed. + * @param _dripInterval The new interval in blocks for which drip() will mint rewards + */ + function setDripInterval(uint256 _dripInterval) external onlyGovernor { + _setDripInterval(_dripInterval); + } + + /** + * @dev Sets the issuance rate. + * The issuance rate is defined as a relative increase of the total supply per block, plus 1. + * This means that it needs to be greater than 1.0, any number under 1.0 is not + * allowed and an issuance rate of 1.0 means no issuance. + * To accommodate a high precision the issuance rate is expressed in wei, i.e. fixed point at 1e18. + * Note: It is strongly recommended that the governor triggers a drip immediately after calling this, + * including excess gas to guarantee that the L2 retryable ticket succeeds immediately, to ensure + * good synchronization between L1 and L2. + * @param _issuanceRate Issuance rate expressed in wei / fixed point at 1e18 + */ + function setIssuanceRate(uint256 _issuanceRate) external onlyGovernor { + require(_issuanceRate >= MIN_ISSUANCE_RATE, "Issuance rate under minimum allowed"); + nextIssuanceRate = _issuanceRate; + emit IssuanceRateStaged(_issuanceRate); + } + + /** + * @dev Sets the L2 rewards fraction. + * This is the portion of the indexer rewards that are sent to L2. + * The value is in fixed point at 1e18 and must be less than or equal to 1. + * Note: It is strongly recommended that the governor triggers a drip immediately after calling this, + * including excess gas to guarantee that the L2 retryable ticket succeeds immediately, to ensure + * good synchronization between L1 and L2. + * @param _l2RewardsFraction Fraction of rewards to send to L2, in wei / fixed point at 1e18 + */ + function setL2RewardsFraction(uint256 _l2RewardsFraction) external onlyGovernor { + require( + _l2RewardsFraction <= FIXED_POINT_SCALING_FACTOR, + "L2 Rewards fraction must be <= 1" + ); + nextL2RewardsFraction = _l2RewardsFraction; + emit L2RewardsFractionStaged(_l2RewardsFraction); + } + + /** + * @dev Sets the drip reward per block + * This is the reward in GRT provided to the keeper that calls drip() + * @param _dripRewardPerBlock GRT accrued for each block after the threshold + */ + function setDripRewardPerBlock(uint256 _dripRewardPerBlock) external onlyGovernor { + dripRewardPerBlock = _dripRewardPerBlock; + emit DripRewardPerBlockUpdated(_dripRewardPerBlock); + } + + /** + * @dev Sets the minimum drip interval + * This is the minimum number of blocks between two successful drips + * @param _minDripInterval Minimum number of blocks since last drip for drip to be allowed + */ + function setMinDripInterval(uint256 _minDripInterval) external onlyGovernor { + require(_minDripInterval < dripInterval, "MUST_BE_LT_DRIP_INTERVAL"); + minDripInterval = _minDripInterval; + emit MinDripIntervalUpdated(_minDripInterval); + } + + /** + * @dev Sets the L2 Reservoir address + * This is the address on L2 to which we send tokens for rewards. + * @param _l2ReservoirAddress New address for the L2Reservoir on L2 + */ + function setL2ReservoirAddress(address _l2ReservoirAddress) external onlyGovernor { + require(_l2ReservoirAddress != address(0), "INVALID_L2_RESERVOIR"); + l2ReservoirAddress = _l2ReservoirAddress; + emit L2ReservoirAddressUpdated(_l2ReservoirAddress); + } + + /** + * @dev Grants an address permission to call drip() + * @param _dripper Address that will be an allowed dripper + */ + function grantDripPermission(address _dripper) external onlyGovernor { + require(_dripper != address(0), "INVALID_ADDRESS"); + require(!allowedDrippers[_dripper], "ALREADY_A_DRIPPER"); + allowedDrippers[_dripper] = true; + emit AllowedDripperAdded(_dripper); + } + + /** + * @dev Revokes an address' permission to call drip() + * @param _dripper Address that will not be an allowed dripper anymore + */ + function revokeDripPermission(address _dripper) external onlyGovernor { + require(_dripper != address(0), "INVALID_ADDRESS"); + require(allowedDrippers[_dripper], "NOT_A_DRIPPER"); + allowedDrippers[_dripper] = false; + emit AllowedDripperRevoked(_dripper); + } + + /** + * @dev Computes the initial snapshot for token supply and mints any pending rewards + * This will initialize the issuanceBase to the current GRT supply, after which + * we will keep an internal accounting only using newly minted rewards. This function + * will also mint any pending rewards to cover up to the current block for open allocations, + * to be computed off-chain. Can only be called once as it checks that the issuanceBase is zero. + * @param _pendingRewards Pending rewards up to the current block for open allocations, to be minted by this function + */ + function initialSnapshot(uint256 _pendingRewards) external onlyGovernor { + require(issuanceBase == 0, "Cannot call this function more than once"); + lastRewardsUpdateBlock = block.number; + IGraphToken grt = graphToken(); + grt.mint(address(this), _pendingRewards); + issuanceBase = grt.totalSupply(); + emit InitialSnapshotTaken(block.number, issuanceBase, _pendingRewards); + } + + /** + * @dev Drip indexer rewards for layers 1 and 2 + * This function will mint enough tokens to cover all indexer rewards for the next + * dripInterval number of blocks. If the l2RewardsFraction is > 0, it will also send + * tokens and a callhook to the L2Reservoir, through the GRT Arbitrum bridge. + * Any staged changes to issuanceRate or l2RewardsFraction will be applied when this function + * is called. If issuanceRate changes, it also triggers a snapshot of rewards per signal on the RewardsManager. + * The call value must be greater than or equal to l2MaxSubmissionCost + (l2MaxGas * l2GasPriceBid), and must + * only be nonzero if l2RewardsFraction is nonzero. + * Calling this function can revert if the issuance rate has recently been reduced, and the existing + * tokens are sufficient to cover the full pending period. In this case, it's necessary to wait + * until the drip amount becomes positive before calling the function again. It can also revert + * if the l2RewardsFraction has been updated and the amount already sent to L2 is more than what we + * should send now. + * Note that the transaction on the L2 side might revert if it's received out-of-order by the L2Reservoir, + * because it checks an incrementing nonce. If that is the case, the retryable ticket can be redeemed + * again once the ticket for previous drip has been redeemed. + * This function with an additional parameter is only provided so that indexer operators can call it, + * specifying the indexer for which they are an operator. + * @param _l2MaxGas Max gas for the L2 retryable ticket, only needed if l2RewardsFraction is > 0 + * @param _l2GasPriceBid Gas price for the L2 retryable ticket, only needed if l2RewardsFraction is > 0 + * @param _l2MaxSubmissionCost Max submission price for the L2 retryable ticket, only needed if l2RewardsFraction is > 0 + * @param _keeperRewardBeneficiary Address to which to credit keeper reward (will be redeemed in L2 if l2RewardsFraction is nonzero) + * @param _indexer Indexer for whom the sender must be an authorized Operator + */ + function drip( + uint256 _l2MaxGas, + uint256 _l2GasPriceBid, + uint256 _l2MaxSubmissionCost, + address _keeperRewardBeneficiary, + address _indexer + ) external payable notPaused onlyIndexerOperator(_indexer) { + _drip(_l2MaxGas, _l2GasPriceBid, _l2MaxSubmissionCost, _keeperRewardBeneficiary); + } + + /** + * @dev Drip indexer rewards for layers 1 and 2 + * This function will mint enough tokens to cover all indexer rewards for the next + * dripInterval number of blocks. If the l2RewardsFraction is > 0, it will also send + * tokens and a callhook to the L2Reservoir, through the GRT Arbitrum bridge. + * Any staged changes to issuanceRate or l2RewardsFraction will be applied when this function + * is called. If issuanceRate changes, it also triggers a snapshot of rewards per signal on the RewardsManager. + * The call value must be greater than or equal to l2MaxSubmissionCost + (l2MaxGas * l2GasPriceBid), and must + * only be nonzero if l2RewardsFraction is nonzero. + * Calling this function can revert if the issuance rate has recently been reduced, and the existing + * tokens are sufficient to cover the full pending period. In this case, it's necessary to wait + * until the drip amount becomes positive before calling the function again. It can also revert + * if the l2RewardsFraction has been updated and the amount already sent to L2 is more than what we + * should send now. + * Note that the transaction on the L2 side might revert if it's received out-of-order by the L2Reservoir, + * because it checks an incrementing nonce. If that is the case, the retryable ticket can be redeemed + * again once the ticket for previous drip has been redeemed. + * @param _l2MaxGas Max gas for the L2 retryable ticket, only needed if l2RewardsFraction is > 0 + * @param _l2GasPriceBid Gas price for the L2 retryable ticket, only needed if l2RewardsFraction is > 0 + * @param _l2MaxSubmissionCost Max submission price for the L2 retryable ticket, only needed if l2RewardsFraction is > 0 + * @param _keeperRewardBeneficiary Address to which to credit keeper reward (will be redeemed in L2 if l2RewardsFraction is nonzero) + */ + function drip( + uint256 _l2MaxGas, + uint256 _l2GasPriceBid, + uint256 _l2MaxSubmissionCost, + address _keeperRewardBeneficiary + ) external payable notPaused onlyIndexerOrAllowedDripper { + _drip(_l2MaxGas, _l2GasPriceBid, _l2MaxSubmissionCost, _keeperRewardBeneficiary); + } + + /** + * @dev Drip indexer rewards for layers 1 and 2, private implementation. + * This function will mint enough tokens to cover all indexer rewards for the next + * dripInterval number of blocks. If the l2RewardsFraction is > 0, it will also send + * tokens and a callhook to the L2Reservoir, through the GRT Arbitrum bridge. + * Any staged changes to issuanceRate or l2RewardsFraction will be applied when this function + * is called. If issuanceRate changes, it also triggers a snapshot of rewards per signal on the RewardsManager. + * The call value must be greater than or equal to l2MaxSubmissionCost + (l2MaxGas * l2GasPriceBid), and must + * only be nonzero if l2RewardsFraction is nonzero. + * Calling this function can revert if the issuance rate has recently been reduced, and the existing + * tokens are sufficient to cover the full pending period. In this case, it's necessary to wait + * until the drip amount becomes positive before calling the function again. It can also revert + * if the l2RewardsFraction has been updated and the amount already sent to L2 is more than what we + * should send now. + * Note that the transaction on the L2 side might revert if it's received out-of-order by the L2Reservoir, + * because it checks an incrementing nonce. If that is the case, the retryable ticket can be redeemed + * again once the ticket for previous drip has been redeemed. + * @param _l2MaxGas Max gas for the L2 retryable ticket, only needed if l2RewardsFraction is > 0 + * @param _l2GasPriceBid Gas price for the L2 retryable ticket, only needed if l2RewardsFraction is > 0 + * @param _l2MaxSubmissionCost Max submission price for the L2 retryable ticket, only needed if l2RewardsFraction is > 0 + * @param _keeperRewardBeneficiary Address to which to credit keeper reward (will be redeemed in L2 if l2RewardsFraction is nonzero) + */ + function _drip( + uint256 _l2MaxGas, + uint256 _l2GasPriceBid, + uint256 _l2MaxSubmissionCost, + address _keeperRewardBeneficiary + ) private { + require( + block.number > lastRewardsUpdateBlock.add(minDripInterval), + "WAIT_FOR_MIN_INTERVAL" + ); + // Note we only validate that the beneficiary is nonzero, as the caller might + // want to send the reward to an address that is different to the indexer/dripper's address. + require(_keeperRewardBeneficiary != address(0), "INVALID_BENEFICIARY"); + + uint256 mintedRewardsTotal = getNewGlobalRewards(rewardsMintedUntilBlock); + uint256 mintedRewardsActual = getNewGlobalRewards(block.number); + // eps = (signed int) mintedRewardsTotal - mintedRewardsActual + + uint256 keeperReward = dripRewardPerBlock.mul(block.number.sub(lastRewardsUpdateBlock)); + if (nextIssuanceRate != issuanceRate) { + rewardsManager().updateAccRewardsPerSignal(); + snapshotAccumulatedRewards(mintedRewardsActual); // This updates lastRewardsUpdateBlock + issuanceRate = nextIssuanceRate; + emit IssuanceRateUpdated(issuanceRate); + } else { + snapshotAccumulatedRewards(mintedRewardsActual); + } + + rewardsMintedUntilBlock = block.number.add(dripInterval); + // n = deltaR(t1, t0) + uint256 newRewardsToDistribute = getNewGlobalRewards(rewardsMintedUntilBlock); + // N = n - eps + uint256 rewardsTokensToMint; + { + uint256 newRewardsPlusMintedActual = newRewardsToDistribute.add(mintedRewardsActual); + require( + newRewardsPlusMintedActual > mintedRewardsTotal, + "Would mint negative or zero tokens, wait before calling again" + ); + rewardsTokensToMint = newRewardsPlusMintedActual.sub(mintedRewardsTotal); + } + + IGraphToken grt = graphToken(); + grt.mint(address(this), rewardsTokensToMint.add(keeperReward)); + + uint256 tokensToSendToL2 = 0; + if (l2RewardsFraction != nextL2RewardsFraction) { + tokensToSendToL2 = nextL2RewardsFraction.mul(newRewardsToDistribute).div( + FIXED_POINT_SCALING_FACTOR + ); + if (mintedRewardsTotal > mintedRewardsActual) { + // eps > 0, i.e. t < t1_old + // Note this can fail if the old l2RewardsFraction is larger + // than the new, in which case we just have to wait until enough time has passed + // so that eps is small enough. This also applies to the case where the new + // l2RewardsFraction is zero, since we still need to send one last message + // with the new values to the L2Reservoir. + uint256 l2OffsetAmount = l2RewardsFraction + .mul(mintedRewardsTotal.sub(mintedRewardsActual)) + .div(FIXED_POINT_SCALING_FACTOR); + require( + tokensToSendToL2 > l2OffsetAmount, + "Negative amount would be sent to L2, wait before calling again" + ); + tokensToSendToL2 = tokensToSendToL2.add(keeperReward).sub(l2OffsetAmount); + } else { + tokensToSendToL2 = tokensToSendToL2.add(keeperReward).add( + l2RewardsFraction.mul(mintedRewardsActual.sub(mintedRewardsTotal)).div( + FIXED_POINT_SCALING_FACTOR + ) + ); + } + l2RewardsFraction = nextL2RewardsFraction; + emit L2RewardsFractionUpdated(l2RewardsFraction); + _sendNewTokensAndStateToL2( + tokensToSendToL2, + _l2MaxGas, + _l2GasPriceBid, + _l2MaxSubmissionCost, + keeperReward, + _keeperRewardBeneficiary + ); + } else if (l2RewardsFraction > 0) { + tokensToSendToL2 = rewardsTokensToMint + .mul(l2RewardsFraction) + .div(FIXED_POINT_SCALING_FACTOR) + .add(keeperReward); + _sendNewTokensAndStateToL2( + tokensToSendToL2, + _l2MaxGas, + _l2GasPriceBid, + _l2MaxSubmissionCost, + keeperReward, + _keeperRewardBeneficiary + ); + } else { + // Avoid locking funds in this contract if we don't need to + // send a message to L2. + require(msg.value == 0, "No eth value needed"); + // If we don't send rewards to L2, pay the keeper reward in L1 + grt.transfer(_keeperRewardBeneficiary, keeperReward); + } + emit RewardsDripped( + rewardsTokensToMint.add(keeperReward), + tokensToSendToL2, + rewardsMintedUntilBlock + ); + } + + /** + * @dev Get new total rewards on both layers at a particular block, since the last drip event + * This is deltaR = p * r ^ (blocknum - t0) - p, where: + * - p is the total token supply snapshot at t0 + * - t0 is the last drip block, i.e. lastRewardsUpdateBlock + * - r is the issuanceRate + * @param _blocknum Block number at which to calculate rewards + * @return New total rewards on both layers since the last drip + */ + function getNewGlobalRewards(uint256 _blocknum) public view returns (uint256) { + uint256 t0 = lastRewardsUpdateBlock; + if (issuanceRate <= MIN_ISSUANCE_RATE || _blocknum == t0) { + return 0; + } + return + issuanceBase + .mul(_pow(issuanceRate, _blocknum.sub(t0), FIXED_POINT_SCALING_FACTOR)) + .div(FIXED_POINT_SCALING_FACTOR) + .sub(issuanceBase); + } + + /** + * @dev Get new total rewards on this layer at a particular block, since the last drip event + * This is deltaR_L1 = (1-lambda) * deltaR, where: + * - deltaR is the new global rewards on both layers (see getNewGlobalRewards) + * - lambda is the fraction of rewards sent to L2, i.e. l2RewardsFraction + * @param _blocknum Block number at which to calculate rewards + * @return New total rewards on Layer 1 since the last drip + */ + function getNewRewards(uint256 _blocknum) public view override returns (uint256) { + return + getNewGlobalRewards(_blocknum) + .mul(FIXED_POINT_SCALING_FACTOR.sub(l2RewardsFraction)) + .div(FIXED_POINT_SCALING_FACTOR); + } + + /** + * @dev Sets the drip interval (internal). + * This is the time in the future (in blocks) for which drip() will mint rewards. + * Keep in mind that changing this value will require manually re-adjusting + * the reservoir's token balance, because the first call to drip might produce + * more or less tokens than needed. + * @param _dripInterval The new interval in blocks for which drip() will mint rewards + */ + function _setDripInterval(uint256 _dripInterval) internal { + require(_dripInterval > 0, "Drip interval must be > 0"); + dripInterval = _dripInterval; + emit DripIntervalUpdated(_dripInterval); + } + + /** + * @dev Snapshot accumulated rewards on this layer + * We compute accumulatedLayerRewards and mark this block as the lastRewardsUpdateBlock. + * We also update the issuanceBase by adding the new total rewards on both layers. + * @param _globalDelta New global rewards (i.e. rewards on L1 and L2) since the last update block + */ + function snapshotAccumulatedRewards(uint256 _globalDelta) internal { + issuanceBase = issuanceBase.add(_globalDelta); + // Reimplementation of getAccumulatedRewards but reusing the _globalDelta calculated above, + // to save gas + accumulatedLayerRewards = accumulatedLayerRewards.add( + _globalDelta.mul(FIXED_POINT_SCALING_FACTOR.sub(l2RewardsFraction)).div( + FIXED_POINT_SCALING_FACTOR + ) + ); + lastRewardsUpdateBlock = block.number; + } + + /** + * @dev Send new tokens and a message with state to L2 + * This function will use the L1GraphTokenGateway to send tokens + * to L2, and will also encode a callhook to update state on the L2Reservoir. + * @param _nTokens Number of tokens to send to L2 + * @param _maxGas Max gas for the L2 retryable ticket execution + * @param _gasPriceBid Gas price for the L2 retryable ticket execution + * @param _maxSubmissionCost Max submission price for the L2 retryable ticket + * @param _keeperReward Tokens to assign as keeper reward for calling drip + * @param _keeper Address of the keeper that will be rewarded + */ + function _sendNewTokensAndStateToL2( + uint256 _nTokens, + uint256 _maxGas, + uint256 _gasPriceBid, + uint256 _maxSubmissionCost, + uint256 _keeperReward, + address _keeper + ) internal { + uint256 l2IssuanceBase = l2RewardsFraction.mul(issuanceBase).div( + FIXED_POINT_SCALING_FACTOR + ); + bytes memory extraData = abi.encodeWithSelector( + IL2Reservoir.receiveDrip.selector, + l2IssuanceBase, + issuanceRate, + nextDripNonce, + _keeperReward, + _keeper + ); + nextDripNonce = nextDripNonce.add(1); + bytes memory data = abi.encode(_maxSubmissionCost, extraData); + IGraphToken grt = graphToken(); + ITokenGateway gateway = graphTokenGateway(); + grt.approve(address(gateway), _nTokens); + gateway.outboundTransfer{ value: msg.value }( + address(grt), + l2ReservoirAddress, + _nTokens, + _maxGas, + _gasPriceBid, + data + ); + } + + /** + * @dev Checks if an address is an indexer with stake in the Staking contract + * @param _indexer Address that will be checked + */ + function _isIndexer(address _indexer) internal view returns (bool) { + IStaking staking = staking(); + return staking.hasStake(_indexer); + } +} diff --git a/contracts/reservoir/L1ReservoirStorage.sol b/contracts/reservoir/L1ReservoirStorage.sol new file mode 100644 index 000000000..9f60249bd --- /dev/null +++ b/contracts/reservoir/L1ReservoirStorage.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; + +/** + * @dev Storage variables for the L1Reservoir, version 1 + */ +contract L1ReservoirV1Storage { + // Fraction of total rewards to be sent by L2, expressed in fixed point at 1e18 + uint256 public l2RewardsFraction; + // New fraction of total rewards to be sent by L2, to be applied on the next drip + uint256 public nextL2RewardsFraction; + // Address for the L2Reservoir to which we send rewards + address public l2ReservoirAddress; + // Block until the minted supplies should last before another drip is needed + uint256 public rewardsMintedUntilBlock; + // New issuance rate to be applied on the next drip + uint256 public nextIssuanceRate; + // Interval for rewards drip, in blocks + uint256 public dripInterval; + // Auto-incrementing nonce that will be used when sending rewards to L2, to ensure ordering + uint256 public nextDripNonce; +} + +/** + * @dev Storage variables for the L1Reservoir, version 2 + * This version adds some variables that are needed when introducing keeper rewards. + */ +contract L1ReservoirV2Storage is L1ReservoirV1Storage { + // Minimum number of blocks since last drip for a new drip to be allowed + uint256 public minDripInterval; + // Drip reward in GRT for each block since lastRewardsUpdateBlock + dripRewardThreshold + uint256 public dripRewardPerBlock; + // True for addresses that are allowed to call drip() + mapping(address => bool) public allowedDrippers; +} diff --git a/contracts/reservoir/Reservoir.sol b/contracts/reservoir/Reservoir.sol new file mode 100644 index 000000000..fff7f108a --- /dev/null +++ b/contracts/reservoir/Reservoir.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; +pragma abicoder v2; + +import "@openzeppelin/contracts/math/SafeMath.sol"; + +import "../upgrades/GraphUpgradeable.sol"; + +import "./ReservoirStorage.sol"; +import "./IReservoir.sol"; + +/** + * @title Rewards Reservoir base contract + * @dev This contract acts as a reservoir/vault for the rewards to be distributed on Layer 1 or Layer 2. + * It provides functions to compute accumulated and new total rewards at a particular block number. + * This base contract provides functionality that is common to L1 and L2, to be extended on each layer. + */ +abstract contract Reservoir is GraphUpgradeable, ReservoirV1Storage, IReservoir { + using SafeMath for uint256; + + // Scaling factor for all fixed point arithmetics + uint256 internal constant FIXED_POINT_SCALING_FACTOR = 1e18; + // Minimum issuance rate (expressed in fixed point at 1e18) + uint256 internal constant MIN_ISSUANCE_RATE = 1e18; + + /** + * @dev Approve the RewardsManager to manage the reservoir's token funds + */ + function approveRewardsManager() external override onlyGovernor { + graphToken().approve(address(rewardsManager()), type(uint256).max); + } + + /** + * @dev Get accumulated total rewards on this layer at a particular block + * @param _blocknum Block number at which to calculate rewards + * @return Accumulated total rewards on this layer + */ + function getAccumulatedRewards(uint256 _blocknum) public view override returns (uint256) { + // R(t) = R(t0) + (DeltaR(t, t0)) + return accumulatedLayerRewards.add(getNewRewards(_blocknum)); + } + + /** + * @dev Get new total rewards on this layer at a particular block, since the last drip event. + * Must be implemented by the reservoir on each layer. + * @param _blocknum Block number at which to calculate rewards + * @return New total rewards on this layer since the last drip + */ + function getNewRewards(uint256 _blocknum) public view virtual override returns (uint256); + + /** + * @dev Raises _x to the power of _n with scaling factor of _base. + * Based on: https://github.com/makerdao/dss/blob/master/src/pot.sol#L81 + * @param _x Base of the exponentiation + * @param _n Exponent + * @param _base Scaling factor + * @return Exponential of _n with base _x + */ + function _pow( + uint256 _x, + uint256 _n, + uint256 _base + ) internal pure returns (uint256) { + uint256 z; + // solhint-disable-next-line no-inline-assembly + assembly { + switch _x + case 0 { + switch _n + case 0 { + z := _base + } + default { + z := 0 + } + } + default { + switch mod(_n, 2) + case 0 { + z := _base + } + default { + z := _x + } + let half := div(_base, 2) // for rounding. + for { + _n := div(_n, 2) + } _n { + _n := div(_n, 2) + } { + let xx := mul(_x, _x) + if iszero(eq(div(xx, _x), _x)) { + revert(0, 0) + } + let xxRound := add(xx, half) + if lt(xxRound, xx) { + revert(0, 0) + } + _x := div(xxRound, _base) + if mod(_n, 2) { + let zx := mul(z, _x) + if and(iszero(iszero(_x)), iszero(eq(div(zx, _x), z))) { + revert(0, 0) + } + let zxRound := add(zx, half) + if lt(zxRound, zx) { + revert(0, 0) + } + z := div(zxRound, _base) + } + } + } + } + return z; + } +} diff --git a/contracts/reservoir/ReservoirStorage.sol b/contracts/reservoir/ReservoirStorage.sol new file mode 100644 index 000000000..b964d87b0 --- /dev/null +++ b/contracts/reservoir/ReservoirStorage.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; + +import "../governance/Managed.sol"; + +/** + * @dev Base storage variables for the Reservoir on both layers, version 1 + */ +contract ReservoirV1Storage is Managed { + // Relative increase of the total supply per block, plus 1, expressed in fixed point at 1e18. + uint256 public issuanceRate; + // Accumulated total rewards on the corresponding layer (L1 or L2) + uint256 public accumulatedLayerRewards; + // Last block at which rewards when updated, i.e. block at which the last drip happened or was received + uint256 public lastRewardsUpdateBlock; + // Base value for token issuance, set initially to GRT supply and afterwards using accumulated rewards to update + uint256 public issuanceBase; +} diff --git a/contracts/rewards/IRewardsManager.sol b/contracts/rewards/IRewardsManager.sol index dc17c8ba8..f92ca422a 100644 --- a/contracts/rewards/IRewardsManager.sol +++ b/contracts/rewards/IRewardsManager.sol @@ -15,8 +15,6 @@ interface IRewardsManager { // -- Config -- - function setIssuanceRate(uint256 _issuanceRate) external; - function setMinimumSubgraphSignal(uint256 _minimumSubgraphSignal) external; // -- Denylist -- @@ -54,6 +52,8 @@ interface IRewardsManager { function takeRewards(address _allocationID) external returns (uint256); + function takeAndBurnRewards(address _allocationID) external; + // -- Hooks -- function onSubgraphSignalUpdate(bytes32 _subgraphDeploymentID) external returns (uint256); diff --git a/contracts/rewards/RewardsManager.sol b/contracts/rewards/RewardsManager.sol index 6d2e78965..d97963390 100644 --- a/contracts/rewards/RewardsManager.sol +++ b/contracts/rewards/RewardsManager.sol @@ -11,6 +11,8 @@ import "../staking/libs/MathUtils.sol"; import "./RewardsManagerStorage.sol"; import "./IRewardsManager.sol"; +import "../reservoir/IReservoir.sol"; + /** * @title Rewards Manager Contract * @dev Tracks how inflationary GRT rewards should be handed out. Relies on the Curation contract @@ -28,10 +30,10 @@ import "./IRewardsManager.sol"; * These functions may overestimate the actual rewards due to changes in the total supply * until the actual takeRewards function is called. */ -contract RewardsManager is RewardsManagerV3Storage, GraphUpgradeable, IRewardsManager { +contract RewardsManager is RewardsManagerV4Storage, GraphUpgradeable, IRewardsManager { using SafeMath for uint256; - uint256 private constant TOKEN_DECIMALS = 1e18; + uint256 private constant FIXED_POINT_SCALING_FACTOR = 1e18; uint256 private constant MIN_ISSUANCE_RATE = 1e18; // -- Events -- @@ -47,9 +49,24 @@ contract RewardsManager is RewardsManagerV3Storage, GraphUpgradeable, IRewardsMa ); /** - * @dev Emitted when rewards are denied to an indexer. + * @dev Emitted when rewards are denied to an indexer (and therefore burned). */ - event RewardsDenied(address indexed indexer, address indexed allocationID, uint256 epoch); + event RewardsDenied( + address indexed indexer, + address indexed allocationID, + uint256 epoch, + uint256 amount + ); + + /** + * @dev Emitted when rewards for an indexer are burned . + */ + event RewardsBurned( + address indexed indexer, + address indexed allocationID, + uint256 epoch, + uint256 amount + ); /** * @dev Emitted when a subgraph is denied for claiming rewards. @@ -75,32 +92,6 @@ contract RewardsManager is RewardsManagerV3Storage, GraphUpgradeable, IRewardsMa // -- Config -- - /** - * @dev Sets the issuance rate. - * The issuance rate is defined as a percentage increase of the total supply per block. - * This means that it needs to be greater than 1.0, any number under 1.0 is not - * allowed and an issuance rate of 1.0 means no issuance. - * To accommodate a high precision the issuance rate is expressed in wei. - * @param _issuanceRate Issuance rate expressed in wei - */ - function setIssuanceRate(uint256 _issuanceRate) external override onlyGovernor { - _setIssuanceRate(_issuanceRate); - } - - /** - * @dev Sets the issuance rate. - * @param _issuanceRate Issuance rate - */ - function _setIssuanceRate(uint256 _issuanceRate) private { - require(_issuanceRate >= MIN_ISSUANCE_RATE, "Issuance rate under minimum allowed"); - - // Called since `issuance rate` will change - updateAccRewardsPerSignal(); - - issuanceRate = _issuanceRate; - emit ParameterUpdated("issuanceRate"); - } - /** * @dev Sets the subgraph oracle allowed to denegate distribution of rewards to subgraphs. * @param _subgraphAvailabilityOracle Address of the subgraph availability oracle @@ -188,32 +179,13 @@ contract RewardsManager is RewardsManagerV3Storage, GraphUpgradeable, IRewardsMa /** * @dev Gets the issuance of rewards per signal since last updated. * - * Compound interest formula: `a = p(1 + r/n)^nt` - * The formula is simplified with `n = 1` as we apply the interest once every time step. - * The `r` is passed with +1 included. So for 10% instead of 0.1 it is 1.1 - * The simplified formula is `a = p * r^t` - * - * Notation: - * t: time steps are in blocks since last updated - * p: total supply of GRT tokens - * a: inflated amount of total supply for the period `t` when interest `r` is applied - * x: newly accrued rewards token for the period `t` + * The compound interest formula is applied in the Reservoir contract. + * This function will compare accumulated rewards at the current block + * with the value that was cached at accRewardsPerSignalLastBlockUpdated. * * @return newly accrued rewards per signal since last update */ function getNewRewardsPerSignal() public view override returns (uint256) { - // Calculate time steps - uint256 t = block.number.sub(accRewardsPerSignalLastBlockUpdated); - // Optimization to skip calculations if zero time steps elapsed - if (t == 0) { - return 0; - } - - // Zero issuance under a rate of 1.0 - if (issuanceRate <= MIN_ISSUANCE_RATE) { - return 0; - } - // Zero issuance if no signalled tokens IGraphToken graphToken = graphToken(); uint256 signalledTokens = graphToken.balanceOf(address(curation())); @@ -221,16 +193,14 @@ contract RewardsManager is RewardsManagerV3Storage, GraphUpgradeable, IRewardsMa return 0; } - uint256 r = issuanceRate; - uint256 p = tokenSupplySnapshot; - uint256 a = p.mul(_pow(r, t, TOKEN_DECIMALS)).div(TOKEN_DECIMALS); - - // New issuance of tokens during time steps - uint256 x = a.sub(p); + uint256 accRewardsNow = reservoir().getAccumulatedRewards(block.number); // Get the new issuance per signalled token // We multiply the decimals to keep the precision as fixed-point number - return x.mul(TOKEN_DECIMALS).div(signalledTokens); + return + (accRewardsNow.sub(accRewardsOnLastSignalUpdate)).mul(FIXED_POINT_SCALING_FACTOR).div( + signalledTokens + ); } /** @@ -262,7 +232,7 @@ contract RewardsManager is RewardsManagerV3Storage, GraphUpgradeable, IRewardsMa ? getAccRewardsPerSignal() .sub(subgraph.accRewardsPerSignalSnapshot) .mul(subgraphSignalledTokens) - .div(TOKEN_DECIMALS) + .div(FIXED_POINT_SCALING_FACTOR) : 0; return subgraph.accRewardsForSubgraph.add(newRewards); } @@ -294,9 +264,9 @@ contract RewardsManager is RewardsManagerV3Storage, GraphUpgradeable, IRewardsMa return (0, accRewardsForSubgraph); } - uint256 newRewardsPerAllocatedToken = newRewardsForSubgraph.mul(TOKEN_DECIMALS).div( - subgraphAllocatedTokens - ); + uint256 newRewardsPerAllocatedToken = newRewardsForSubgraph + .mul(FIXED_POINT_SCALING_FACTOR) + .div(subgraphAllocatedTokens); return ( subgraph.accRewardsPerAllocatedToken.add(newRewardsPerAllocatedToken), accRewardsForSubgraph @@ -306,7 +276,8 @@ contract RewardsManager is RewardsManagerV3Storage, GraphUpgradeable, IRewardsMa // -- Updates -- /** - * @dev Updates the accumulated rewards per signal and save checkpoint block number. + * @dev Updates the accumulated rewards per signal and saves the checkpoint block number. + * Also snapshots total accumulated rewards (`accRewardsOnLastSignalUpdate`). * Must be called before `issuanceRate` or `total signalled GRT` changes * Called from the Curation contract on mint() and burn() * @return Accumulated rewards per signal @@ -314,7 +285,7 @@ contract RewardsManager is RewardsManagerV3Storage, GraphUpgradeable, IRewardsMa function updateAccRewardsPerSignal() public override returns (uint256) { accRewardsPerSignal = getAccRewardsPerSignal(); accRewardsPerSignalLastBlockUpdated = block.number; - tokenSupplySnapshot = graphToken().totalSupply(); + accRewardsOnLastSignalUpdate = reservoir().getAccumulatedRewards(block.number); return accRewardsPerSignal; } @@ -395,13 +366,13 @@ contract RewardsManager is RewardsManagerV3Storage, GraphUpgradeable, IRewardsMa uint256 _endAccRewardsPerAllocatedToken ) private pure returns (uint256) { uint256 newAccrued = _endAccRewardsPerAllocatedToken.sub(_startAccRewardsPerAllocatedToken); - return newAccrued.mul(_tokens).div(TOKEN_DECIMALS); + return newAccrued.mul(_tokens).div(FIXED_POINT_SCALING_FACTOR); } /** * @dev Pull rewards from the contract for a particular allocation. * This function can only be called by the Staking contract. - * This function will mint the necessary tokens to reward based on the inflation calculation. + * This function will transfer the necessary tokens to reward based on the inflation calculation. * @param _allocationID Allocation * @return Assigned rewards amount */ @@ -415,90 +386,55 @@ contract RewardsManager is RewardsManagerV3Storage, GraphUpgradeable, IRewardsMa alloc.subgraphDeploymentID ); - // Do not do rewards on denied subgraph deployments ID - if (isDenied(alloc.subgraphDeploymentID)) { - emit RewardsDenied(alloc.indexer, _allocationID, alloc.closedAtEpoch); - return 0; - } - // Calculate rewards accrued by this allocation uint256 rewards = _calcRewards( alloc.tokens, alloc.accRewardsPerAllocatedToken, accRewardsPerAllocatedToken ); - if (rewards > 0) { - // Mint directly to staking contract for the reward amount + if (!isDenied(alloc.subgraphDeploymentID)) { + // Transfer to staking contract for the reward amount // The staking contract will do bookkeeping of the reward and // assign in proportion to each stakeholder incentive - graphToken().mint(address(staking), rewards); + if (rewards > 0) { + graphToken().transferFrom(address(reservoir()), address(staking), rewards); + } + emit RewardsAssigned(alloc.indexer, _allocationID, alloc.closedAtEpoch, rewards); + return rewards; + } else { + if (rewards > 0) { + graphToken().burnFrom(address(reservoir()), rewards); + } + emit RewardsDenied(alloc.indexer, _allocationID, alloc.closedAtEpoch, rewards); + return 0; } - - emit RewardsAssigned(alloc.indexer, _allocationID, alloc.closedAtEpoch, rewards); - - return rewards; } /** - * @dev Raises x to the power of n with scaling factor of base. - * Based on: https://github.com/makerdao/dss/blob/master/src/pot.sol#L81 - * @param x Base of the exponentiation - * @param n Exponent - * @param base Scaling factor - * @return z Exponential of n with base x + * @dev Burn rewards for a particular allocation. + * This function can only be called by the Staking contract. + * This function will burn the necessary tokens to reward based on the inflation calculation. + * @param _allocationID Allocation */ - function _pow( - uint256 x, - uint256 n, - uint256 base - ) private pure returns (uint256 z) { - assembly { - switch x - case 0 { - switch n - case 0 { - z := base - } - default { - z := 0 - } - } - default { - switch mod(n, 2) - case 0 { - z := base - } - default { - z := x - } - let half := div(base, 2) // for rounding. - for { - n := div(n, 2) - } n { - n := div(n, 2) - } { - let xx := mul(x, x) - if iszero(eq(div(xx, x), x)) { - revert(0, 0) - } - let xxRound := add(xx, half) - if lt(xxRound, xx) { - revert(0, 0) - } - x := div(xxRound, base) - if mod(n, 2) { - let zx := mul(z, x) - if and(iszero(iszero(x)), iszero(eq(div(zx, x), z))) { - revert(0, 0) - } - let zxRound := add(zx, half) - if lt(zxRound, zx) { - revert(0, 0) - } - z := div(zxRound, base) - } - } - } + function takeAndBurnRewards(address _allocationID) external override { + // Only Staking contract is authorized as caller + IStaking staking = staking(); + require(msg.sender == address(staking), "Caller must be the staking contract"); + + IStaking.Allocation memory alloc = staking.getAllocation(_allocationID); + uint256 accRewardsPerAllocatedToken = onSubgraphAllocationUpdate( + alloc.subgraphDeploymentID + ); + + // Calculate rewards accrued by this allocation + uint256 rewards = _calcRewards( + alloc.tokens, + alloc.accRewardsPerAllocatedToken, + accRewardsPerAllocatedToken + ); + if (rewards > 0) { + graphToken().burnFrom(address(reservoir()), rewards); + emit RewardsBurned(alloc.indexer, _allocationID, alloc.closedAtEpoch, rewards); } } } diff --git a/contracts/rewards/RewardsManagerStorage.sol b/contracts/rewards/RewardsManagerStorage.sol index 7626992da..d8a6284e5 100644 --- a/contracts/rewards/RewardsManagerStorage.sol +++ b/contracts/rewards/RewardsManagerStorage.sol @@ -8,7 +8,7 @@ import "../governance/Managed.sol"; contract RewardsManagerV1Storage is Managed { // -- State -- - uint256 public issuanceRate; + uint256 public issuanceRateDeprecated; uint256 public accRewardsPerSignal; uint256 public accRewardsPerSignalLastBlockUpdated; @@ -29,5 +29,10 @@ contract RewardsManagerV2Storage is RewardsManagerV1Storage { contract RewardsManagerV3Storage is RewardsManagerV2Storage { // Snapshot of the total supply of GRT when accRewardsPerSignal was last updated - uint256 public tokenSupplySnapshot; + uint256 public tokenSupplySnapshotDeprecated; +} + +contract RewardsManagerV4Storage is RewardsManagerV3Storage { + // Accumulated rewards at accRewardsPerSignalLastBlockUpdated + uint256 public accRewardsOnLastSignalUpdate; } diff --git a/contracts/staking/Staking.sol b/contracts/staking/Staking.sol index 2bcc8d74d..37c972a53 100644 --- a/contracts/staking/Staking.sol +++ b/contracts/staking/Staking.sol @@ -1218,11 +1218,12 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { // Process non-zero-allocation rewards tracking if (alloc.tokens > 0) { - // Distribute rewards if proof of indexing was presented by the indexer or operator + // Distribute rewards if proof of indexing was presented by the indexer or operator, + // otherwise the rewards will be burned from the reservoir. if (isIndexer && _poi != 0) { _distributeRewards(_allocationID, alloc.indexer); } else { - _updateRewards(alloc.subgraphDeploymentID); + _takeAndBurnRewards(_allocationID); } // Free allocated tokens from use @@ -1584,9 +1585,6 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { */ function _updateRewards(bytes32 _subgraphDeploymentID) private returns (uint256) { IRewardsManager rewardsManager = rewardsManager(); - if (address(rewardsManager) == address(0)) { - return 0; - } return rewardsManager.onSubgraphAllocationUpdate(_subgraphDeploymentID); } @@ -1596,12 +1594,9 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { */ function _distributeRewards(address _allocationID, address _indexer) private { IRewardsManager rewardsManager = rewardsManager(); - if (address(rewardsManager) == address(0)) { - return; - } // Automatically triggers update of rewards snapshot as allocation will change - // after this call. Take rewards mint tokens for the Staking contract to distribute + // after this call. Take rewards transfers tokens for the Staking contract to distribute // between indexer and delegators uint256 totalRewards = rewardsManager.takeRewards(_allocationID); if (totalRewards == 0) { @@ -1621,6 +1616,18 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { ); } + /** + * @dev Burn rewards for the closed allocation and update the allocation state. + * @param _allocationID Allocation + */ + function _takeAndBurnRewards(address _allocationID) private { + IRewardsManager rewardsManager = rewardsManager(); + + // Automatically triggers update of rewards snapshot as allocation will change + // after this call. + rewardsManager.takeAndBurnRewards(_allocationID); + } + /** * @dev Send rewards to the appropiate destination. * @param _graphToken Graph token diff --git a/contracts/tests/ReservoirMock.sol b/contracts/tests/ReservoirMock.sol new file mode 100644 index 000000000..2ce40b3f2 --- /dev/null +++ b/contracts/tests/ReservoirMock.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; +pragma abicoder v2; + +import "../reservoir/Reservoir.sol"; + +// Mock contract used for testing rewards +contract ReservoirMock is Reservoir { + function getNewRewards(uint256) public view override returns (uint256 r) {} + + /** + * @dev Raises x to the power of n with scaling factor of base. + * Based on: https://github.com/makerdao/dss/blob/master/src/pot.sol#L81 + * @param x Base of the exponentiation + * @param n Exponent + * @param base Scaling factor + * @return z Exponential of n with base x + */ + function pow( + uint256 x, + uint256 n, + uint256 base + ) public pure returns (uint256 z) { + z = _pow(x, n, base); + } +} diff --git a/contracts/tests/RewardsManagerMock.sol b/contracts/tests/RewardsManagerMock.sol deleted file mode 100644 index cbd57b2d3..000000000 --- a/contracts/tests/RewardsManagerMock.sol +++ /dev/null @@ -1,68 +0,0 @@ -pragma solidity ^0.7.6; -pragma abicoder v2; - -// Mock contract used for testing rewards -contract RewardsManagerMock { - /** - * @dev Raises x to the power of n with scaling factor of base. - * Based on: https://github.com/makerdao/dss/blob/master/src/pot.sol#L81 - * @param x Base of the exponentiation - * @param n Exponent - * @param base Scaling factor - * @return z Exponential of n with base x - */ - function pow( - uint256 x, - uint256 n, - uint256 base - ) public pure returns (uint256 z) { - assembly { - switch x - case 0 { - switch n - case 0 { - z := base - } - default { - z := 0 - } - } - default { - switch mod(n, 2) - case 0 { - z := base - } - default { - z := x - } - let half := div(base, 2) // for rounding. - for { - n := div(n, 2) - } n { - n := div(n, 2) - } { - let xx := mul(x, x) - if iszero(eq(div(xx, x), x)) { - revert(0, 0) - } - let xxRound := add(xx, half) - if lt(xxRound, xx) { - revert(0, 0) - } - x := div(xxRound, base) - if mod(n, 2) { - let zx := mul(z, x) - if and(iszero(iszero(x)), iszero(eq(div(zx, x), z))) { - revert(0, 0) - } - let zxRound := add(zx, half) - if lt(zxRound, zx) { - revert(0, 0) - } - z := div(zxRound, base) - } - } - } - } - } -} diff --git a/e2e/deployment/config/l1/graphToken.test.ts b/e2e/deployment/config/l1/graphToken.test.ts index 41e6322d0..d8d14ff64 100644 --- a/e2e/deployment/config/l1/graphToken.test.ts +++ b/e2e/deployment/config/l1/graphToken.test.ts @@ -5,7 +5,7 @@ import GraphChain from '../../../../gre/helpers/network' describe('[L1] GraphToken', () => { const graph = hre.graph() - const { GraphToken, RewardsManager } = graph.contracts + const { GraphToken, L1Reservoir, RewardsManager } = graph.contracts let unauthorized: SignerWithAddress @@ -23,9 +23,14 @@ describe('[L1] GraphToken', () => { await expect(tx).revertedWith('Only minter can call') }) - it('RewardsManager should be minter', async function () { + it('L1Reservoir should be minter', async function () { + const reservoirIsMinter = await GraphToken.isMinter(L1Reservoir.address) + expect(reservoirIsMinter).eq(true) + }) + + it('RewardsManager should not be minter', async function () { const rewardsMgrIsMinter = await GraphToken.isMinter(RewardsManager.address) - expect(rewardsMgrIsMinter).eq(true) + expect(rewardsMgrIsMinter).eq(false) }) }) }) diff --git a/e2e/deployment/config/l1/l1Reservoir.test.ts b/e2e/deployment/config/l1/l1Reservoir.test.ts new file mode 100644 index 000000000..16531d6a6 --- /dev/null +++ b/e2e/deployment/config/l1/l1Reservoir.test.ts @@ -0,0 +1,39 @@ +import { expect } from 'chai' +import hre from 'hardhat' +import GraphChain from '../../../../gre/helpers/network' +import { getItemValue } from '../../../../cli/config' + +describe('[L1] L1Reservoir configuration', () => { + const graph = hre.graph() + const { graphConfig } = graph + const { L1Reservoir, Controller, GraphToken, RewardsManager } = graph.contracts + + before(async function () { + if (GraphChain.isL2(graph.chainId)) this.skip() + }) + + it('should be controlled by Controller', async function () { + const controller = await L1Reservoir.controller() + expect(controller).eq(Controller.address) + }) + + it('should have a snapshot of the total supply', async function () { + expect(await L1Reservoir.issuanceBase()).eq(await GraphToken.totalSupply()) + }) + + it('should have issuanceRate set to zero', async function () { + expect(await L1Reservoir.issuanceRate()).eq(0) + }) + + it('should have dripInterval set from config', async function () { + const value = await L1Reservoir.dripInterval() + const expected = getItemValue(graphConfig, 'contracts/L1Reservoir/init/dripInterval') + expect(value).eq(expected) + }) + + it('should have RewardsManager approved for the max GRT amount', async function () { + expect(await GraphToken.allowance(L1Reservoir.address, RewardsManager.address)).eq( + hre.ethers.constants.MaxUint256, + ) + }) +}) diff --git a/e2e/deployment/config/l1/rewardsManager.test.ts b/e2e/deployment/config/l1/rewardsManager.test.ts index 4cc990161..d8bd1f553 100644 --- a/e2e/deployment/config/l1/rewardsManager.test.ts +++ b/e2e/deployment/config/l1/rewardsManager.test.ts @@ -9,9 +9,4 @@ describe('[L1] RewardsManager configuration', () => { before(async function () { if (GraphChain.isL2(graph.chainId)) this.skip() }) - - it('issuanceRate should match "issuanceRate" in the config file', async function () { - const value = await RewardsManager.issuanceRate() - expect(value).eq('1000000012184945188') // hardcoded as it's set with a function call rather than init parameter - }) }) diff --git a/e2e/deployment/config/l2/l2GraphToken.test.ts b/e2e/deployment/config/l2/l2GraphToken.test.ts index 7b87253e0..4b5391a01 100644 --- a/e2e/deployment/config/l2/l2GraphToken.test.ts +++ b/e2e/deployment/config/l2/l2GraphToken.test.ts @@ -5,7 +5,7 @@ import GraphChain from '../../../../gre/helpers/network' describe('[L2] L2GraphToken', () => { const graph = hre.graph() - const { L2GraphToken, RewardsManager } = graph.contracts + const { L2GraphToken, RewardsManager, L2Reservoir } = graph.contracts let unauthorized: SignerWithAddress @@ -36,9 +36,14 @@ describe('[L2] L2GraphToken', () => { await expect(tx).revertedWith('Only Governor can call') }) - it('RewardsManager should not be minter (for now)', async function () { + it('RewardsManager should not be minter', async function () { const rewardsMgrIsMinter = await L2GraphToken.isMinter(RewardsManager.address) expect(rewardsMgrIsMinter).eq(false) }) + + it('L2Reservoir should not be minter', async function () { + const reservoirIsMinter = await L2GraphToken.isMinter(L2Reservoir.address) + expect(reservoirIsMinter).eq(false) + }) }) }) diff --git a/e2e/deployment/config/l2/l2Reservoir.test.ts b/e2e/deployment/config/l2/l2Reservoir.test.ts new file mode 100644 index 000000000..12afc4552 --- /dev/null +++ b/e2e/deployment/config/l2/l2Reservoir.test.ts @@ -0,0 +1,33 @@ +import { expect } from 'chai' +import hre from 'hardhat' +import GraphChain from '../../../../gre/helpers/network' +import { getItemValue } from '../../../../cli/config' + +describe('[L2] L2Reservoir configuration', () => { + const graph = hre.graph() + const { graphConfig } = graph + const { L2Reservoir, Controller, GraphToken, RewardsManager } = graph.contracts + + before(async function () { + if (GraphChain.isL1(graph.chainId)) this.skip() + }) + + it('should be controlled by Controller', async function () { + const controller = await L2Reservoir.controller() + expect(controller).eq(Controller.address) + }) + + it('should have issuanceBase set to zero', async function () { + expect(await L2Reservoir.issuanceBase()).eq(0) + }) + + it('should have issuanceRate set to zero', async function () { + expect(await L2Reservoir.issuanceRate()).eq(0) + }) + + it('should have RewardsManager approved for the max GRT amount', async function () { + expect(await GraphToken.allowance(L2Reservoir.address, RewardsManager.address)).eq( + hre.ethers.constants.MaxUint256, + ) + }) +}) diff --git a/e2e/deployment/config/l2/rewardsManager.test.ts b/e2e/deployment/config/l2/rewardsManager.test.ts index 29ad83c5f..7754758de 100644 --- a/e2e/deployment/config/l2/rewardsManager.test.ts +++ b/e2e/deployment/config/l2/rewardsManager.test.ts @@ -9,9 +9,4 @@ describe('[L2] RewardsManager configuration', () => { before(async function () { if (GraphChain.isL1(graph.chainId)) this.skip() }) - - it('issuanceRate should be zero', async function () { - const value = await RewardsManager.issuanceRate() - expect(value).eq('0') - }) }) diff --git a/test/gateway/l1GraphTokenGateway.test.ts b/test/gateway/l1GraphTokenGateway.test.ts index a5a473d86..f14204816 100644 --- a/test/gateway/l1GraphTokenGateway.test.ts +++ b/test/gateway/l1GraphTokenGateway.test.ts @@ -29,6 +29,7 @@ describe('L1GraphTokenGateway', () => { let mockL2GRT: Account let mockL2Gateway: Account let pauseGuardian: Account + let mockL2Reservoir: Account let fixture: NetworkFixture let grt: GraphToken @@ -58,8 +59,16 @@ describe('L1GraphTokenGateway', () => { ) before(async function () { - ;[governor, tokenSender, l2Receiver, mockRouter, mockL2GRT, mockL2Gateway, pauseGuardian] = - await getAccounts() + ;[ + governor, + tokenSender, + l2Receiver, + mockRouter, + mockL2GRT, + mockL2Gateway, + pauseGuardian, + mockL2Reservoir, + ] = await getAccounts() fixture = new NetworkFixture() fixtureContracts = await fixture.load(governor.signer) @@ -371,6 +380,7 @@ describe('L1GraphTokenGateway', () => { mockRouter.address, mockL2GRT.address, mockL2Gateway.address, + mockL2Reservoir.address, ) }) diff --git a/test/l2/l2GraphTokenGateway.test.ts b/test/l2/l2GraphTokenGateway.test.ts index f283aabe1..5cec09962 100644 --- a/test/l2/l2GraphTokenGateway.test.ts +++ b/test/l2/l2GraphTokenGateway.test.ts @@ -28,6 +28,7 @@ describe('L2GraphTokenGateway', () => { let mockL1GRT: Account let mockL1Gateway: Account let pauseGuardian: Account + let mockL1Reservoir: Account let fixture: NetworkFixture let arbSysMock: FakeContract @@ -59,6 +60,7 @@ describe('L2GraphTokenGateway', () => { mockL1Gateway, l2Receiver, pauseGuardian, + mockL1Reservoir, ] = await getAccounts() fixture = new NetworkFixture() diff --git a/test/l2/l2Reservoir.test.ts b/test/l2/l2Reservoir.test.ts new file mode 100644 index 000000000..39bba0209 --- /dev/null +++ b/test/l2/l2Reservoir.test.ts @@ -0,0 +1,478 @@ +import { expect } from 'chai' +import { BigNumber, constants, ContractTransaction, utils } from 'ethers' + +import { L2FixtureContracts, NetworkFixture } from '../lib/fixtures' + +import { BigNumber as BN } from 'bignumber.js' +import { FakeContract, smock } from '@defi-wonderland/smock' + +import { + advanceBlocks, + getAccounts, + latestBlock, + toBN, + toGRT, + formatGRT, + Account, + RewardsTracker, + getL2SignerFromL1, + applyL1ToL2Alias, +} from '../lib/testHelpers' +import { L2Reservoir } from '../../build/types/L2Reservoir' + +import { L2GraphTokenGateway } from '../../build/types/L2GraphTokenGateway' +import { L2GraphToken } from '../../build/types/L2GraphToken' + +const toRound = (n: BigNumber) => formatGRT(n).split('.')[0] + +const dripAmount = toBN('5851557519569225000000000') +const dripNormalizedSupply = toGRT('10004000000') +const dripIssuanceRate = toBN('1000000023206889619') + +describe('L2Reservoir', () => { + let governor: Account + let testAccount1: Account + let testAccount2: Account + let mockRouter: Account + let mockL1GRT: Account + let mockL1Gateway: Account + let mockL1Reservoir: Account + let fixture: NetworkFixture + let arbTxMock: FakeContract + + let grt: L2GraphToken + let l2Reservoir: L2Reservoir + let l2GraphTokenGateway: L2GraphTokenGateway + + let fixtureContracts: L2FixtureContracts + + let normalizedSupply: BigNumber + let dripBlock: BigNumber + + const ISSUANCE_RATE_PERIODS = toBN(4) // blocks required to issue 0.05% rewards + const ISSUANCE_RATE_PER_BLOCK = toBN('1000122722344290393') // % increase every block + + // Test accumulated rewards after nBlocksToAdvance, + // asking for the value at blockToQuery + const shouldGetNewRewards = async ( + initialSupply: BigNumber, + nBlocksToAdvance: BigNumber = ISSUANCE_RATE_PERIODS, + blockToQuery?: BigNumber, + expectedValue?: BigNumber, + round = true, + ) => { + // -- t0 -- + const tracker = await RewardsTracker.create(initialSupply, ISSUANCE_RATE_PER_BLOCK) + const startAccrued = await l2Reservoir.getAccumulatedRewards(await latestBlock()) + // Jump + await advanceBlocks(nBlocksToAdvance) + + // -- t1 -- + + // Contract calculation + if (!blockToQuery) { + blockToQuery = await latestBlock() + } + const contractAccrued = await l2Reservoir.getAccumulatedRewards(blockToQuery) + // Local calculation + if (expectedValue == null) { + expectedValue = await tracker.newRewards(blockToQuery) + } + + // Check + if (round) { + expect(toRound(contractAccrued.sub(startAccrued))).eq(toRound(expectedValue)) + } else { + expect(contractAccrued.sub(startAccrued)).eq(expectedValue) + } + + return expectedValue + } + + const gatewayFinalizeTransfer = async (callhookData: string): Promise => { + const mockL1GatewayL2Alias = await getL2SignerFromL1(mockL1Gateway.address) + await testAccount1.signer.sendTransaction({ + to: await mockL1GatewayL2Alias.getAddress(), + value: utils.parseUnits('1', 'ether'), + }) + const data = utils.defaultAbiCoder.encode(['bytes', 'bytes'], ['0x', callhookData]) + const tx = l2GraphTokenGateway + .connect(mockL1GatewayL2Alias) + .finalizeInboundTransfer( + mockL1GRT.address, + mockL1Reservoir.address, + l2Reservoir.address, + dripAmount, + data, + ) + return tx + } + + const validGatewayFinalizeTransfer = async ( + callhookData: string, + keeperReward = toGRT('0'), + ): Promise => { + const tx = await gatewayFinalizeTransfer(callhookData) + await expect(tx) + .emit(l2GraphTokenGateway, 'DepositFinalized') + .withArgs(mockL1GRT.address, mockL1Reservoir.address, l2Reservoir.address, dripAmount) + + await expect(tx).emit(grt, 'BridgeMinted').withArgs(l2Reservoir.address, dripAmount) + + // newly minted GRT + const receiverBalance = await grt.balanceOf(l2Reservoir.address) + await expect(receiverBalance).eq(dripAmount.sub(keeperReward)) + return tx + } + + before(async function () { + ;[governor, testAccount1, mockRouter, mockL1GRT, mockL1Gateway, mockL1Reservoir, testAccount2] = + await getAccounts() + + fixture = new NetworkFixture() + fixtureContracts = await fixture.loadL2(governor.signer) + ;({ grt, l2Reservoir, l2GraphTokenGateway } = fixtureContracts) + await fixture.configureL2Bridge( + governor.signer, + fixtureContracts, + mockRouter.address, + mockL1GRT.address, + mockL1Gateway.address, + ) + + arbTxMock = await smock.fake('IArbTxWithRedeemer', { + address: '0x000000000000000000000000000000000000006E', + }) + arbTxMock.getCurrentRedeemer.returns(applyL1ToL2Alias(mockL1Reservoir.address)) + }) + + beforeEach(async function () { + await fixture.setUp() + }) + + afterEach(async function () { + await fixture.tearDown() + }) + + describe('setNextDripNonce', async function () { + it('rejects unauthorized calls', async function () { + const tx = l2Reservoir.connect(testAccount1.signer).setNextDripNonce(toBN('10')) + await expect(tx).revertedWith('Caller must be Controller governor') + }) + it('sets the next expected drip nonce', async function () { + const tx = l2Reservoir.connect(governor.signer).setNextDripNonce(toBN('10')) + await expect(tx).emit(l2Reservoir, 'NextDripNonceUpdated').withArgs(toBN('10')) + await expect(await l2Reservoir.nextDripNonce()).to.eq(toBN('10')) + }) + }) + + describe('setL1ReservoirAddress', async function () { + it('rejects unauthorized calls', async function () { + const tx = l2Reservoir + .connect(testAccount1.signer) + .setL1ReservoirAddress(testAccount1.address) + await expect(tx).revertedWith('Caller must be Controller governor') + }) + it('rejects setting a zero address', async function () { + const tx = l2Reservoir.connect(governor.signer).setL1ReservoirAddress(constants.AddressZero) + await expect(tx).revertedWith('INVALID_L1_RESERVOIR') + }) + it('sets the L1Reservoir address', async function () { + const tx = l2Reservoir.connect(governor.signer).setL1ReservoirAddress(testAccount1.address) + await expect(tx).emit(l2Reservoir, 'L1ReservoirAddressUpdated').withArgs(testAccount1.address) + await expect(await l2Reservoir.l1ReservoirAddress()).to.eq(testAccount1.address) + }) + }) + + describe('setL2KeeperRewardFraction', async function () { + it('rejects unauthorized calls', async function () { + const tx = l2Reservoir.connect(testAccount1.signer).setL2KeeperRewardFraction(toBN(1)) + await expect(tx).revertedWith('Caller must be Controller governor') + }) + it('rejects invalid values (> 1)', async function () { + const tx = l2Reservoir.connect(governor.signer).setL2KeeperRewardFraction(toGRT('1.000001')) + await expect(tx).revertedWith('INVALID_VALUE') + }) + it('sets the L1Reservoir address', async function () { + const tx = l2Reservoir.connect(governor.signer).setL2KeeperRewardFraction(toGRT('0.999')) + await expect(tx).emit(l2Reservoir, 'L2KeeperRewardFractionUpdated').withArgs(toGRT('0.999')) + await expect(await l2Reservoir.l2KeeperRewardFraction()).to.eq(toGRT('0.999')) + }) + }) + + describe('receiveDrip', async function () { + beforeEach(async function () { + await l2Reservoir.connect(governor.signer).setL2KeeperRewardFraction(toGRT('0.2')) + await l2Reservoir.connect(governor.signer).setL1ReservoirAddress(mockL1Reservoir.address) + }) + it('rejects the call when not called by the gateway', async function () { + const tx = l2Reservoir + .connect(governor.signer) + .receiveDrip( + dripNormalizedSupply, + dripIssuanceRate, + toBN('0'), + toBN('0'), + testAccount1.address, + ) + await expect(tx).revertedWith('ONLY_GATEWAY') + }) + it('rejects the call when received out of order', async function () { + normalizedSupply = dripNormalizedSupply + let receiveDripTx = await l2Reservoir.populateTransaction.receiveDrip( + dripNormalizedSupply, + dripIssuanceRate, + toBN('0'), + toBN('0'), + testAccount1.address, + ) + const tx = await validGatewayFinalizeTransfer(receiveDripTx.data) + dripBlock = await latestBlock() + await expect(await l2Reservoir.issuanceBase()).to.eq(dripNormalizedSupply) + await expect(await l2Reservoir.issuanceRate()).to.eq(dripIssuanceRate) + await expect(tx).emit(l2Reservoir, 'DripReceived').withArgs(dripNormalizedSupply) + + // Incorrect nonce + receiveDripTx = await l2Reservoir.populateTransaction.receiveDrip( + dripNormalizedSupply.add(1), + dripIssuanceRate.add(1), + toBN('2'), + toBN('0'), + testAccount1.address, + ) + const tx2 = gatewayFinalizeTransfer(receiveDripTx.data) + dripBlock = await latestBlock() + await expect(tx2).revertedWith('CALLHOOK_FAILED') // Gateway overrides revert message + }) + it('updates the normalized supply cache', async function () { + normalizedSupply = dripNormalizedSupply + const receiveDripTx = await l2Reservoir.populateTransaction.receiveDrip( + dripNormalizedSupply, + dripIssuanceRate, + toBN('0'), + toBN('0'), + testAccount1.address, + ) + const tx = await validGatewayFinalizeTransfer(receiveDripTx.data) + dripBlock = await latestBlock() + await expect(await l2Reservoir.issuanceBase()).to.eq(dripNormalizedSupply) + await expect(await l2Reservoir.issuanceRate()).to.eq(dripIssuanceRate) + await expect(tx).emit(l2Reservoir, 'DripReceived').withArgs(dripNormalizedSupply) + }) + it('delivers the keeper reward to the beneficiary address', async function () { + normalizedSupply = dripNormalizedSupply + const reward = toBN('15') + const receiveDripTx = await l2Reservoir.populateTransaction.receiveDrip( + dripNormalizedSupply, + dripIssuanceRate, + toBN('0'), + reward, + testAccount1.address, + ) + const tx = await validGatewayFinalizeTransfer(receiveDripTx.data, reward) + dripBlock = await latestBlock() + await expect(await l2Reservoir.issuanceBase()).to.eq(dripNormalizedSupply) + await expect(await l2Reservoir.issuanceRate()).to.eq(dripIssuanceRate) + await expect(tx).emit(l2Reservoir, 'DripReceived').withArgs(dripNormalizedSupply) + await expect(tx) + .emit(grt, 'Transfer') + .withArgs(l2Reservoir.address, testAccount1.address, reward) + await expect(await grt.balanceOf(testAccount1.address)).to.eq(reward) + }) + it('delivers part of the keeper reward to the L2 redeemer', async function () { + arbTxMock.getCurrentRedeemer.returns(testAccount2.address) + await l2Reservoir.connect(governor.signer).setL2KeeperRewardFraction(toGRT('0.25')) + normalizedSupply = dripNormalizedSupply + const reward = toGRT('16') + const receiveDripTx = await l2Reservoir.populateTransaction.receiveDrip( + dripNormalizedSupply, + dripIssuanceRate, + toBN('0'), + reward, + testAccount1.address, + ) + const tx = await validGatewayFinalizeTransfer(receiveDripTx.data, reward) + dripBlock = await latestBlock() + await expect(await l2Reservoir.issuanceBase()).to.eq(dripNormalizedSupply) + await expect(await l2Reservoir.issuanceRate()).to.eq(dripIssuanceRate) + await expect(tx).emit(l2Reservoir, 'DripReceived').withArgs(dripNormalizedSupply) + await expect(tx) + .emit(grt, 'Transfer') + .withArgs(l2Reservoir.address, testAccount1.address, toGRT('12')) + await expect(tx) + .emit(grt, 'Transfer') + .withArgs(l2Reservoir.address, testAccount2.address, toGRT('4')) + await expect(await grt.balanceOf(testAccount1.address)).to.eq(toGRT('12')) + await expect(await grt.balanceOf(testAccount2.address)).to.eq(toGRT('4')) + }) + it('updates the normalized supply cache and issuance rate', async function () { + normalizedSupply = dripNormalizedSupply + let receiveDripTx = await l2Reservoir.populateTransaction.receiveDrip( + dripNormalizedSupply, + dripIssuanceRate, + toBN('0'), + toBN('0'), + testAccount1.address, + ) + let tx = await validGatewayFinalizeTransfer(receiveDripTx.data) + dripBlock = await latestBlock() + await expect(await l2Reservoir.issuanceBase()).to.eq(dripNormalizedSupply) + await expect(await l2Reservoir.issuanceRate()).to.eq(dripIssuanceRate) + await expect(tx).emit(l2Reservoir, 'DripReceived').withArgs(dripNormalizedSupply) + + receiveDripTx = await l2Reservoir.populateTransaction.receiveDrip( + dripNormalizedSupply.add(1), + dripIssuanceRate.add(1), + toBN('1'), + toBN('0'), + testAccount1.address, + ) + tx = await gatewayFinalizeTransfer(receiveDripTx.data) + dripBlock = await latestBlock() + await expect(await l2Reservoir.issuanceBase()).to.eq(dripNormalizedSupply.add(1)) + await expect(await l2Reservoir.issuanceRate()).to.eq(dripIssuanceRate.add(1)) + await expect(tx).emit(l2Reservoir, 'DripReceived').withArgs(dripNormalizedSupply.add(1)) + await expect(await grt.balanceOf(l2Reservoir.address)).to.eq(dripAmount.mul(2)) + }) + it('accepts subsequent calls without changing issuance rate', async function () { + normalizedSupply = dripNormalizedSupply + let receiveDripTx = await l2Reservoir.populateTransaction.receiveDrip( + dripNormalizedSupply, + dripIssuanceRate, + toBN('0'), + toBN('0'), + testAccount1.address, + ) + let tx = await validGatewayFinalizeTransfer(receiveDripTx.data) + dripBlock = await latestBlock() + await expect(await l2Reservoir.issuanceBase()).to.eq(dripNormalizedSupply) + await expect(await l2Reservoir.issuanceRate()).to.eq(dripIssuanceRate) + await expect(tx).emit(l2Reservoir, 'DripReceived').withArgs(dripNormalizedSupply) + + receiveDripTx = await l2Reservoir.populateTransaction.receiveDrip( + dripNormalizedSupply.add(1), + dripIssuanceRate, + toBN('1'), + toBN('0'), + testAccount1.address, + ) + tx = await gatewayFinalizeTransfer(receiveDripTx.data) + dripBlock = await latestBlock() + await expect(await l2Reservoir.issuanceBase()).to.eq(dripNormalizedSupply.add(1)) + await expect(await l2Reservoir.issuanceRate()).to.eq(dripIssuanceRate) + await expect(tx).emit(l2Reservoir, 'DripReceived').withArgs(dripNormalizedSupply.add(1)) + await expect(await grt.balanceOf(l2Reservoir.address)).to.eq(dripAmount.mul(2)) + }) + it('accepts a different nonce set through setNextDripNonce', async function () { + normalizedSupply = dripNormalizedSupply + let receiveDripTx = await l2Reservoir.populateTransaction.receiveDrip( + dripNormalizedSupply, + dripIssuanceRate, + toBN('0'), + toBN('0'), + testAccount1.address, + ) + let tx = await validGatewayFinalizeTransfer(receiveDripTx.data) + dripBlock = await latestBlock() + await expect(await l2Reservoir.issuanceBase()).to.eq(dripNormalizedSupply) + await expect(await l2Reservoir.issuanceRate()).to.eq(dripIssuanceRate) + await expect(tx).emit(l2Reservoir, 'DripReceived').withArgs(dripNormalizedSupply) + + await l2Reservoir.connect(governor.signer).setNextDripNonce(toBN('2')) + receiveDripTx = await l2Reservoir.populateTransaction.receiveDrip( + dripNormalizedSupply.add(1), + dripIssuanceRate, + toBN('2'), + toBN('0'), + testAccount1.address, + ) + tx = await gatewayFinalizeTransfer(receiveDripTx.data) + dripBlock = await latestBlock() + await expect(await l2Reservoir.issuanceBase()).to.eq(dripNormalizedSupply.add(1)) + await expect(await l2Reservoir.issuanceRate()).to.eq(dripIssuanceRate) + await expect(tx).emit(l2Reservoir, 'DripReceived').withArgs(dripNormalizedSupply.add(1)) + await expect(await grt.balanceOf(l2Reservoir.address)).to.eq(dripAmount.mul(2)) + }) + }) + + context('calculating rewards', async function () { + beforeEach(async function () { + // 5% minute rate (4 blocks) + normalizedSupply = dripNormalizedSupply + const receiveDripTx = await l2Reservoir.populateTransaction.receiveDrip( + dripNormalizedSupply, + ISSUANCE_RATE_PER_BLOCK, + toBN('0'), + toBN('0'), + testAccount1.address, + ) + await validGatewayFinalizeTransfer(receiveDripTx.data) + dripBlock = await latestBlock() + }) + + describe('getAccumulatedRewards', function () { + it('returns rewards accrued after some blocks', async function () { + await shouldGetNewRewards(normalizedSupply) + }) + it('returns zero if evaluated at the block where reservoir had the first drip', async function () { + await shouldGetNewRewards( + normalizedSupply, + ISSUANCE_RATE_PERIODS, + dripBlock, + toBN(0), + false, + ) + }) + it('returns the supply times issuance rate one block after the first drip', async function () { + const expectedVal = normalizedSupply + .mul(ISSUANCE_RATE_PER_BLOCK.sub(toGRT(1))) + .div(toGRT(1)) + await shouldGetNewRewards( + normalizedSupply, + ISSUANCE_RATE_PERIODS, + dripBlock.add(1), + expectedVal, + false, + ) + }) + it('returns the rewards for a block some time in the future', async function () { + await shouldGetNewRewards(normalizedSupply, toBN(1), dripBlock.add(10000)) + }) + }) + describe('getNewRewards', function () { + const computeDelta = function (t1: BigNumber, t0: BigNumber, lambda = toBN(0)): BigNumber { + const deltaT = new BN(t1.toString()).minus(new BN(t0.toString())) + const rate = new BN(ISSUANCE_RATE_PER_BLOCK.toString()).div(1e18) + const supply = new BN(normalizedSupply.toString()) + return toBN(supply.times(rate.pow(deltaT)).minus(supply).precision(18).toString(10)) + .mul(toGRT('1').sub(lambda)) + .div(toGRT('1')) + } + it('computes the rewards delta between the last drip block and the current block', async function () { + const t0 = dripBlock + const t1 = t0.add(200) + const expectedVal = computeDelta(t1, t0) + expect(toRound(await l2Reservoir.getNewRewards(t1))).to.eq(toRound(expectedVal)) + }) + it('returns zero rewards if the time delta is zero', async function () { + const t0 = dripBlock + const expectedVal = toBN('0') + expect(await l2Reservoir.getNewRewards(t0)).to.eq(expectedVal) + }) + it('computes the rewards delta between a past drip block and a future block', async function () { + await advanceBlocks(20) + const t0 = dripBlock + const t1 = t0.add(100) + const expectedVal = computeDelta(t1, t0) + expect(toRound(await l2Reservoir.getNewRewards(t1))).to.eq(toRound(expectedVal)) + }) + it('computes the rewards delta between a past drip block and the current block', async function () { + await advanceBlocks(20) + const t0 = dripBlock + const t1 = await latestBlock() + const expectedVal = computeDelta(t1, t0) + expect(toRound(await l2Reservoir.getNewRewards(t1))).to.eq(toRound(expectedVal)) + }) + }) + }) +}) diff --git a/test/lib/deployment.ts b/test/lib/deployment.ts index a6afe9dbb..4bdcbe629 100644 --- a/test/lib/deployment.ts +++ b/test/lib/deployment.ts @@ -22,6 +22,8 @@ import { L1GraphTokenGateway } from '../../build/types/L1GraphTokenGateway' import { L2GraphTokenGateway } from '../../build/types/L2GraphTokenGateway' import { L2GraphToken } from '../../build/types/L2GraphToken' import { BridgeEscrow } from '../../build/types/BridgeEscrow' +import { L1Reservoir } from '../../build/types/L1Reservoir' +import { L2Reservoir } from '../../build/types/L2Reservoir' // Disable logging for tests logger.pause() @@ -299,3 +301,29 @@ export async function deployL2GRT( deployer, ) as unknown as L2GraphToken } + +export async function deployL1Reservoir( + deployer: Signer, + controller: string, + proxyAdmin: GraphProxyAdmin, +): Promise { + return network.deployContractWithProxy( + proxyAdmin, + 'L1Reservoir', + [controller, defaults.rewards.dripInterval], + deployer, + ) as unknown as L1Reservoir +} + +export async function deployL2Reservoir( + deployer: Signer, + controller: string, + proxyAdmin: GraphProxyAdmin, +): Promise { + return network.deployContractWithProxy( + proxyAdmin, + 'L2Reservoir', + [controller], + deployer, + ) as unknown as L2Reservoir +} diff --git a/test/lib/fixtures.ts b/test/lib/fixtures.ts index 8375a86a4..e00e7698b 100644 --- a/test/lib/fixtures.ts +++ b/test/lib/fixtures.ts @@ -19,8 +19,10 @@ import { ServiceRegistry } from '../../build/types/ServiceRegistry' import { GraphProxyAdmin } from '../../build/types/GraphProxyAdmin' import { L1GraphTokenGateway } from '../../build/types/L1GraphTokenGateway' import { BridgeEscrow } from '../../build/types/BridgeEscrow' +import { L1Reservoir } from '../../build/types/L1Reservoir' import { L2GraphTokenGateway } from '../../build/types/L2GraphTokenGateway' import { L2GraphToken } from '../../build/types/L2GraphToken' +import { L2Reservoir } from '../../build/types/L2Reservoir' export interface L1FixtureContracts { controller: Controller @@ -35,6 +37,7 @@ export interface L1FixtureContracts { proxyAdmin: GraphProxyAdmin l1GraphTokenGateway: L1GraphTokenGateway bridgeEscrow: BridgeEscrow + l1Reservoir: L1Reservoir } export interface L2FixtureContracts { @@ -49,6 +52,7 @@ export interface L2FixtureContracts { serviceRegistry: ServiceRegistry proxyAdmin: GraphProxyAdmin l2GraphTokenGateway: L2GraphTokenGateway + l2Reservoir: L2Reservoir } export interface ArbitrumL1Mocks { @@ -114,12 +118,15 @@ export class NetworkFixture { let l1GraphTokenGateway: L1GraphTokenGateway let l2GraphTokenGateway: L2GraphTokenGateway let bridgeEscrow: BridgeEscrow + let l1Reservoir: L1Reservoir + let l2Reservoir: L2Reservoir if (isL2) { l2GraphTokenGateway = await deployment.deployL2GraphTokenGateway( deployer, controller.address, proxyAdmin, ) + l2Reservoir = await deployment.deployL2Reservoir(deployer, controller.address, proxyAdmin) } else { l1GraphTokenGateway = await deployment.deployL1GraphTokenGateway( deployer, @@ -127,6 +134,7 @@ export class NetworkFixture { proxyAdmin, ) bridgeEscrow = await deployment.deployBridgeEscrow(deployer, controller.address, proxyAdmin) + l1Reservoir = await deployment.deployL1Reservoir(deployer, controller.address, proxyAdmin) } // Setup controller @@ -139,8 +147,10 @@ export class NetworkFixture { await controller.setContractProxy(utils.id('ServiceRegistry'), serviceRegistry.address) if (isL2) { await controller.setContractProxy(utils.id('GraphTokenGateway'), l2GraphTokenGateway.address) + await controller.setContractProxy(utils.id('Reservoir'), l2Reservoir.address) } else { await controller.setContractProxy(utils.id('GraphTokenGateway'), l1GraphTokenGateway.address) + await controller.setContractProxy(utils.id('Reservoir'), l1Reservoir.address) } // Setup contracts @@ -152,15 +162,21 @@ export class NetworkFixture { await staking.connect(deployer).syncAllContracts() if (isL2) { await l2GraphTokenGateway.connect(deployer).syncAllContracts() + await l2Reservoir.connect(deployer).syncAllContracts() } else { await l1GraphTokenGateway.connect(deployer).syncAllContracts() await bridgeEscrow.connect(deployer).syncAllContracts() + await l1Reservoir.connect(deployer).syncAllContracts() } await staking.connect(deployer).setSlasher(slasherAddress, true) await gns.connect(deployer).approveAll() - if (!isL2) { - await grt.connect(deployer).addMinter(rewardsManager.address) + if (isL2) { + await l2Reservoir.connect(deployer).approveRewardsManager() + } else { + await grt.connect(deployer).addMinter(l1Reservoir.address) + await l1Reservoir.connect(deployer).setIssuanceRate(deployment.defaults.rewards.issuanceRate) + await l1Reservoir.connect(deployer).approveRewardsManager() } // Unpause the protocol @@ -179,6 +195,7 @@ export class NetworkFixture { serviceRegistry, proxyAdmin, l2GraphTokenGateway, + l2Reservoir, } as L2FixtureContracts } else { return { @@ -194,6 +211,7 @@ export class NetworkFixture { proxyAdmin, l1GraphTokenGateway, bridgeEscrow, + l1Reservoir, } as L1FixtureContracts } } @@ -232,6 +250,7 @@ export class NetworkFixture { mockRouterAddress: string, mockL2GRTAddress: string, mockL2GatewayAddress: string, + mockL2ReservoirAddress: string, ): Promise { // First configure the Arbitrum bridge mocks await arbitrumMocks.bridgeMock.connect(deployer).setInbox(arbitrumMocks.inboxMock.address, true) @@ -254,9 +273,15 @@ export class NetworkFixture { await l1FixtureContracts.l1GraphTokenGateway .connect(deployer) .setEscrowAddress(l1FixtureContracts.bridgeEscrow.address) + await l1FixtureContracts.l1GraphTokenGateway + .connect(deployer) + .addToCallhookWhitelist(l1FixtureContracts.l1Reservoir.address) await l1FixtureContracts.bridgeEscrow .connect(deployer) .approveAll(l1FixtureContracts.l1GraphTokenGateway.address) + await l1FixtureContracts.l1Reservoir + .connect(deployer) + .setL2ReservoirAddress(mockL2ReservoirAddress) await l1FixtureContracts.l1GraphTokenGateway.connect(deployer).setPaused(false) } diff --git a/test/lib/testHelpers.ts b/test/lib/testHelpers.ts index 87e04beb1..998d0d335 100644 --- a/test/lib/testHelpers.ts +++ b/test/lib/testHelpers.ts @@ -133,6 +133,98 @@ export const applyL1ToL2Alias = (l1Address: string): string => { return l2AddressAsNumber.mod(mask).toHexString() } +// Core formula that gets accumulated rewards for a period of time +const getRewards = (p: BN, r: BN, t: BN): string => { + BN.config({ POW_PRECISION: 100 }) + return p.times(r.pow(t)).minus(p).precision(18).toString(10) +} + +// Tracks the accumulated rewards as supply changes across snapshots +// both at a global level (like the Reservoir) and per signal (like RewardsManager) +export class RewardsTracker { + totalSupply = BigNumber.from(0) + lastUpdatedBlock = BigNumber.from(0) + lastPerSignalUpdatedBlock = BigNumber.from(0) + accumulated = BigNumber.from(0) + accumulatedPerSignal = BigNumber.from(0) + accumulatedAtLastPerSignalUpdatedBlock = BigNumber.from(0) + issuanceRate = BigNumber.from(0) + + static async create( + initialSupply: BigNumber, + issuanceRate: BigNumber, + updatedBlock?: BigNumber, + ): Promise { + const lastUpdatedBlock = updatedBlock || (await latestBlock()) + const tracker = new RewardsTracker(initialSupply, issuanceRate, lastUpdatedBlock) + return tracker + } + + constructor(initialSupply: BigNumber, issuanceRate: BigNumber, updatedBlock: BigNumber) { + this.issuanceRate = issuanceRate + this.totalSupply = initialSupply + this.lastUpdatedBlock = updatedBlock + this.lastPerSignalUpdatedBlock = updatedBlock + } + + async snapshotRewards(initialSupply?: BigNumber, atBlock?: BigNumber): Promise { + const newRewards = await this.newRewards(atBlock) + this.accumulated = this.accumulated.add(newRewards) + this.totalSupply = initialSupply || this.totalSupply.add(newRewards) + this.lastUpdatedBlock = atBlock || (await latestBlock()) + return this.accumulated + } + + async snapshotPerSignal(totalSignal: BigNumber, atBlock?: BigNumber): Promise { + this.accumulatedPerSignal = await this.accRewardsPerSignal(totalSignal, atBlock) + this.accumulatedAtLastPerSignalUpdatedBlock = await this.accRewards(atBlock) + this.lastPerSignalUpdatedBlock = atBlock || (await latestBlock()) + return this.accumulatedPerSignal + } + + async elapsedBlocks(): Promise { + const currentBlock = await latestBlock() + return currentBlock.sub(this.lastUpdatedBlock) + } + + async newRewardsPerSignal(totalSignal: BigNumber, atBlock?: BigNumber): Promise { + const accRewards = await this.accRewards(atBlock) + const diff = accRewards.sub(this.accumulatedAtLastPerSignalUpdatedBlock) + if (totalSignal.eq(0)) { + return BigNumber.from(0) + } + return diff.mul(toGRT(1)).div(totalSignal) + } + + async accRewardsPerSignal(totalSignal: BigNumber, atBlock?: BigNumber): Promise { + return this.accumulatedPerSignal.add(await this.newRewardsPerSignal(totalSignal, atBlock)) + } + + async newRewards(atBlock?: BigNumber): Promise { + if (!atBlock) { + atBlock = await latestBlock() + } + const nBlocks = atBlock.sub(this.lastUpdatedBlock) + return this.accruedByElapsed(nBlocks) + } + + async accRewards(atBlock?: BigNumber): Promise { + if (!atBlock) { + atBlock = await latestBlock() + } + return this.accumulated.add(await this.newRewards(atBlock)) + } + + async accruedByElapsed(nBlocks: BigNumber | number): Promise { + const n = getRewards( + new BN(this.totalSupply.toString()), + new BN(this.issuanceRate.toString()).div(1e18), + new BN(nBlocks.toString()), + ) + return BigNumber.from(n) + } +} + // Adapted from: // https://github.com/livepeer/arbitrum-lpt-bridge/blob/e1a81edda3594e434dbcaa4f1ebc95b7e67ecf2a/test/utils/messaging.ts#L5 export async function getL2SignerFromL1(l1Address: string): Promise { diff --git a/test/reservoir/l1Reservoir.test.ts b/test/reservoir/l1Reservoir.test.ts new file mode 100644 index 000000000..261a36c39 --- /dev/null +++ b/test/reservoir/l1Reservoir.test.ts @@ -0,0 +1,1197 @@ +import { expect } from 'chai' +import { BigNumber, constants } from 'ethers' + +import { defaults, deployContract, deployL1Reservoir } from '../lib/deployment' +import { ArbitrumL1Mocks, L1FixtureContracts, NetworkFixture } from '../lib/fixtures' + +import { GraphToken } from '../../build/types/GraphToken' +import { ReservoirMock } from '../../build/types/ReservoirMock' +import { BigNumber as BN } from 'bignumber.js' + +import { + advanceBlocks, + getAccounts, + latestBlock, + toBN, + toGRT, + formatGRT, + Account, + RewardsTracker, +} from '../lib/testHelpers' +import { L1Reservoir } from '../../build/types/L1Reservoir' +import { BridgeEscrow } from '../../build/types/BridgeEscrow' + +import path from 'path' +import { Artifacts } from 'hardhat/internal/artifacts' +import { Interface } from 'ethers/lib/utils' +import { L1GraphTokenGateway } from '../../build/types/L1GraphTokenGateway' +import { Controller } from '../../build/types/Controller' +import { GraphProxyAdmin } from '../../build/types/GraphProxyAdmin' +import { Staking } from '../../build/types/Staking' +const ARTIFACTS_PATH = path.resolve('build/contracts') +const artifacts = new Artifacts(ARTIFACTS_PATH) +const l2ReservoirAbi = artifacts.readArtifactSync('L2Reservoir').abi +const l2ReservoirIface = new Interface(l2ReservoirAbi) + +const { AddressZero } = constants +const toRound = (n: BigNumber) => formatGRT(n.add(toGRT('0.5'))).split('.')[0] + +const maxGas = toBN('1000000') +const maxSubmissionCost = toBN('7') +const gasPriceBid = toBN('2') +const defaultEthValue = maxSubmissionCost.add(maxGas.mul(gasPriceBid)) + +describe('L1Reservoir', () => { + let governor: Account + let testAccount1: Account + let testAccount2: Account + let testAccount3: Account + let mockRouter: Account + let mockL2GRT: Account + let mockL2Gateway: Account + let mockL2Reservoir: Account + let keeper: Account + let fixture: NetworkFixture + + let grt: GraphToken + let reservoirMock: ReservoirMock + let l1Reservoir: L1Reservoir + let bridgeEscrow: BridgeEscrow + let l1GraphTokenGateway: L1GraphTokenGateway + let controller: Controller + let proxyAdmin: GraphProxyAdmin + let staking: Staking + + let supplyBeforeDrip: BigNumber + let dripBlock: BigNumber + let fixtureContracts: L1FixtureContracts + let arbitrumMocks: ArbitrumL1Mocks + + const ISSUANCE_RATE_PERIODS = toBN(4) // blocks required to issue 0.05% rewards + const ISSUANCE_RATE_PER_BLOCK = toBN('1000122722344290393') // % increase every block + + // Test accumulated rewards after nBlocksToAdvance, + // asking for the value at blockToQuery + const shouldGetNewRewards = async ( + initialSupply: BigNumber, + nBlocksToAdvance: BigNumber = ISSUANCE_RATE_PERIODS, + blockToQuery?: BigNumber, + expectedValue?: BigNumber, + round = true, + ) => { + // -- t0 -- + const tracker = await RewardsTracker.create(initialSupply, ISSUANCE_RATE_PER_BLOCK) + const startAccrued = await l1Reservoir.getAccumulatedRewards(await latestBlock()) + // Jump + await advanceBlocks(nBlocksToAdvance) + + // -- t1 -- + + // Contract calculation + if (!blockToQuery) { + blockToQuery = await latestBlock() + } + const contractAccrued = await l1Reservoir.getAccumulatedRewards(blockToQuery) + // Local calculation + if (expectedValue == null) { + expectedValue = await tracker.newRewards(blockToQuery) + } + + // Check + if (round) { + expect(toRound(contractAccrued.sub(startAccrued))).eq(toRound(expectedValue)) + } else { + expect(contractAccrued.sub(startAccrued)).eq(expectedValue) + } + + return expectedValue + } + + const sequentialDoubleDrip = async ( + blocksToAdvance: BigNumber, + dripInterval = defaults.rewards.dripInterval, + ) => { + const supplyBeforeDrip = await grt.totalSupply() + const startAccrued = await l1Reservoir.getAccumulatedRewards(await latestBlock()) + expect(startAccrued).to.eq(0) + const dripBlock = (await latestBlock()).add(1) // We're gonna drip in the next transaction + const tracker = await RewardsTracker.create( + supplyBeforeDrip, + defaults.rewards.issuanceRate, + dripBlock, + ) + expect(await tracker.accRewards(dripBlock)).to.eq(0) + let expectedNextDeadline = dripBlock.add(dripInterval) + let expectedMintedAmount = await tracker.accRewards(expectedNextDeadline) + const tx1 = await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)'](toBN(0), toBN(0), toBN(0), keeper.address) + const actualAmount = await grt.balanceOf(l1Reservoir.address) + expect(await latestBlock()).eq(dripBlock) + expect(toRound(actualAmount)).to.eq(toRound(expectedMintedAmount)) + expect(await l1Reservoir.issuanceBase()).to.eq(supplyBeforeDrip) + await expect(tx1) + .emit(l1Reservoir, 'RewardsDripped') + .withArgs(actualAmount, toBN(0), expectedNextDeadline) + await expect(tx1).emit(grt, 'Transfer').withArgs(AddressZero, l1Reservoir.address, actualAmount) + await tracker.snapshotRewards() + + await advanceBlocks(blocksToAdvance) + + const tx2 = await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)'](toBN(0), toBN(0), toBN(0), keeper.address) + const newAmount = (await grt.balanceOf(l1Reservoir.address)).sub(actualAmount) + expectedNextDeadline = (await latestBlock()).add(dripInterval) + const expectedSnapshottedSupply = supplyBeforeDrip.add(await tracker.accRewards()) + expectedMintedAmount = (await tracker.accRewards(expectedNextDeadline)).sub(actualAmount) + expect(toRound(newAmount)).to.eq(toRound(expectedMintedAmount)) + expect(toRound(await l1Reservoir.issuanceBase())).to.eq(toRound(expectedSnapshottedSupply)) + await expect(tx2) + .emit(l1Reservoir, 'RewardsDripped') + .withArgs(newAmount, toBN(0), expectedNextDeadline) + await expect(tx2).emit(grt, 'Transfer').withArgs(AddressZero, l1Reservoir.address, newAmount) + } + + before(async function () { + ;[ + governor, + testAccount1, + mockRouter, + mockL2GRT, + mockL2Gateway, + mockL2Reservoir, + keeper, + testAccount2, + testAccount3, + ] = await getAccounts() + + fixture = new NetworkFixture() + fixtureContracts = await fixture.load(governor.signer) + ;({ grt, l1Reservoir, bridgeEscrow, l1GraphTokenGateway, controller, proxyAdmin, staking } = + fixtureContracts) + + await l1Reservoir.connect(governor.signer).initialSnapshot(toBN(0)) + arbitrumMocks = await fixture.loadArbitrumL1Mocks(governor.signer) + await fixture.configureL1Bridge( + governor.signer, + arbitrumMocks, + fixtureContracts, + mockRouter.address, + mockL2GRT.address, + mockL2Gateway.address, + mockL2Reservoir.address, + ) + await l1Reservoir.connect(governor.signer).grantDripPermission(keeper.address) + reservoirMock = (await deployContract( + 'ReservoirMock', + governor.signer, + )) as unknown as ReservoirMock + }) + + beforeEach(async function () { + await fixture.setUp() + }) + + afterEach(async function () { + await fixture.tearDown() + }) + + describe('configuration', function () { + describe('initial snapshot', function () { + let reservoir: L1Reservoir + beforeEach(async function () { + // Deploy a new reservoir to avoid issues with initialSnapshot being called twice + reservoir = await deployL1Reservoir(governor.signer, controller.address, proxyAdmin) + await grt.connect(governor.signer).addMinter(reservoir.address) + }) + + it('rejects call if unauthorized', async function () { + const tx = reservoir.connect(testAccount1.signer).initialSnapshot(toGRT('1.025')) + await expect(tx).revertedWith('Caller must be Controller governor') + }) + + it('snapshots the total GRT supply', async function () { + const tx = reservoir.connect(governor.signer).initialSnapshot(toGRT('0')) + const supply = await grt.totalSupply() + await expect(tx) + .emit(reservoir, 'InitialSnapshotTaken') + .withArgs(await latestBlock(), supply, toGRT('0')) + expect(await grt.balanceOf(reservoir.address)).to.eq(toGRT('0')) + expect(await reservoir.issuanceBase()).to.eq(supply) + expect(await reservoir.lastRewardsUpdateBlock()).to.eq(await latestBlock()) + }) + it('mints pending rewards and includes them in the snapshot', async function () { + const pending = toGRT('10000000') + const tx = reservoir.connect(governor.signer).initialSnapshot(pending) + const supply = await grt.totalSupply() + const expectedSupply = supply.add(pending) + await expect(tx) + .emit(reservoir, 'InitialSnapshotTaken') + .withArgs(await latestBlock(), expectedSupply, pending) + expect(await grt.balanceOf(reservoir.address)).to.eq(pending) + expect(await reservoir.issuanceBase()).to.eq(expectedSupply) + expect(await reservoir.lastRewardsUpdateBlock()).to.eq(await latestBlock()) + }) + it('cannot be called more than once', async function () { + let tx = reservoir.connect(governor.signer).initialSnapshot(toGRT('0')) + await expect(tx).emit(reservoir, 'InitialSnapshotTaken') + tx = reservoir.connect(governor.signer).initialSnapshot(toGRT('0')) + await expect(tx).revertedWith('Cannot call this function more than once') + }) + }) + describe('issuance rate update', function () { + it('rejects setting issuance rate if unauthorized', async function () { + const tx = l1Reservoir.connect(testAccount1.signer).setIssuanceRate(toGRT('1.025')) + await expect(tx).revertedWith('Caller must be Controller governor') + }) + + it('rejects setting issuance rate to less than minimum allowed', async function () { + const newIssuanceRate = toGRT('0.1') // this get a bignumber with 1e17 + const tx = l1Reservoir.connect(governor.signer).setIssuanceRate(newIssuanceRate) + await expect(tx).revertedWith('Issuance rate under minimum allowed') + }) + + it('should set issuance rate to minimum allowed', async function () { + const newIssuanceRate = toGRT('1') // this get a bignumber with 1e18 + const tx = l1Reservoir.connect(governor.signer).setIssuanceRate(newIssuanceRate) + await expect(tx).emit(l1Reservoir, 'IssuanceRateStaged').withArgs(newIssuanceRate) + expect(await l1Reservoir.nextIssuanceRate()).eq(newIssuanceRate) + }) + + it('should set issuance rate to apply on next drip', async function () { + const newIssuanceRate = toGRT('1.00025') + let tx = l1Reservoir.connect(governor.signer).setIssuanceRate(newIssuanceRate) + await expect(tx).emit(l1Reservoir, 'IssuanceRateStaged').withArgs(newIssuanceRate) + expect(await l1Reservoir.issuanceRate()).eq(0) + expect(await l1Reservoir.nextIssuanceRate()).eq(newIssuanceRate) + tx = l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)'](toBN(0), toBN(0), toBN(0), keeper.address) + await expect(tx).emit(l1Reservoir, 'IssuanceRateUpdated').withArgs(newIssuanceRate) + expect(await l1Reservoir.issuanceRate()).eq(newIssuanceRate) + }) + }) + describe('drip interval update', function () { + it('rejects setting drip interval if unauthorized', async function () { + const tx = l1Reservoir.connect(testAccount1.signer).setDripInterval(toBN(40800)) + await expect(tx).revertedWith('Caller must be Controller governor') + }) + + it('rejects setting drip interval to zero', async function () { + const tx = l1Reservoir.connect(governor.signer).setDripInterval(toBN(0)) + await expect(tx).revertedWith('Drip interval must be > 0') + }) + + it('updates the drip interval', async function () { + const newInterval = toBN(40800) + const tx = l1Reservoir.connect(governor.signer).setDripInterval(newInterval) + await expect(tx).emit(l1Reservoir, 'DripIntervalUpdated').withArgs(newInterval) + expect(await l1Reservoir.dripInterval()).eq(newInterval) + }) + }) + describe('L2 reservoir address update', function () { + it('rejects setting L2 reservoir address if unauthorized', async function () { + const tx = l1Reservoir + .connect(testAccount1.signer) + .setL2ReservoirAddress(testAccount1.address) + await expect(tx).revertedWith('Caller must be Controller governor') + }) + + it('updates the L2 reservoir address', async function () { + const tx = l1Reservoir.connect(governor.signer).setL2ReservoirAddress(testAccount1.address) + await expect(tx) + .emit(l1Reservoir, 'L2ReservoirAddressUpdated') + .withArgs(testAccount1.address) + expect(await l1Reservoir.l2ReservoirAddress()).eq(testAccount1.address) + }) + }) + describe('L2 rewards fraction update', function () { + it('rejects setting L2 rewards fraction if unauthorized', async function () { + const tx = l1Reservoir.connect(testAccount1.signer).setL2RewardsFraction(toGRT('1.025')) + await expect(tx).revertedWith('Caller must be Controller governor') + }) + + it('rejects setting L2 rewards fraction to more than 1', async function () { + const newValue = toGRT('1').add(1) + const tx = l1Reservoir.connect(governor.signer).setL2RewardsFraction(newValue) + await expect(tx).revertedWith('L2 Rewards fraction must be <= 1') + }) + + it('should set L2 rewards fraction to maximum allowed', async function () { + const newValue = toGRT('1') // this gets a bignumber with 1e18 + const tx = l1Reservoir.connect(governor.signer).setL2RewardsFraction(newValue) + await expect(tx).emit(l1Reservoir, 'L2RewardsFractionStaged').withArgs(newValue) + expect(await l1Reservoir.l2RewardsFraction()).eq(0) + expect(await l1Reservoir.nextL2RewardsFraction()).eq(newValue) + }) + + it('should set L2 rewards fraction to apply on next drip', async function () { + const newValue = toGRT('0.25') + let tx = l1Reservoir.connect(governor.signer).setL2RewardsFraction(newValue) + await expect(tx).emit(l1Reservoir, 'L2RewardsFractionStaged').withArgs(newValue) + expect(await l1Reservoir.nextL2RewardsFraction()).eq(newValue) + tx = l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)']( + maxGas, + gasPriceBid, + maxSubmissionCost, + keeper.address, + { value: defaultEthValue }, + ) + await expect(tx).emit(l1Reservoir, 'L2RewardsFractionUpdated').withArgs(newValue) + expect(await l1Reservoir.l2RewardsFraction()).eq(newValue) + }) + }) + describe('minimum drip interval update', function () { + it('rejects setting minimum drip interval if unauthorized', async function () { + const tx = l1Reservoir.connect(testAccount1.signer).setMinDripInterval(toBN('200')) + await expect(tx).revertedWith('Caller must be Controller governor') + }) + it('rejects setting minimum drip interval if equal to dripInterval', async function () { + const tx = l1Reservoir + .connect(governor.signer) + .setMinDripInterval(await l1Reservoir.dripInterval()) + await expect(tx).revertedWith('MUST_BE_LT_DRIP_INTERVAL') + }) + it('rejects setting minimum drip interval if larger than dripInterval', async function () { + const tx = l1Reservoir + .connect(governor.signer) + .setMinDripInterval((await l1Reservoir.dripInterval()).add(1)) + await expect(tx).revertedWith('MUST_BE_LT_DRIP_INTERVAL') + }) + it('sets the minimum drip interval', async function () { + const newValue = toBN('200') + const tx = l1Reservoir.connect(governor.signer).setMinDripInterval(newValue) + await expect(tx).emit(l1Reservoir, 'MinDripIntervalUpdated').withArgs(newValue) + expect(await l1Reservoir.minDripInterval()).eq(newValue) + }) + }) + describe('allowed drippers whitelist', function () { + it('only allows the governor to add a dripper', async function () { + const tx = l1Reservoir + .connect(testAccount1.signer) + .grantDripPermission(testAccount1.address) + await expect(tx).revertedWith('Caller must be Controller governor') + }) + it('only allows the governor to revoke a dripper', async function () { + const tx = l1Reservoir.connect(testAccount1.signer).revokeDripPermission(keeper.address) + await expect(tx).revertedWith('Caller must be Controller governor') + }) + it('allows adding an address to the allowed drippers', async function () { + const tx = l1Reservoir.connect(governor.signer).grantDripPermission(testAccount1.address) + await expect(tx).emit(l1Reservoir, 'AllowedDripperAdded').withArgs(testAccount1.address) + expect(await l1Reservoir.allowedDrippers(testAccount1.address)).eq(true) + }) + it('allows removing an address from the allowed drippers', async function () { + await l1Reservoir.connect(governor.signer).grantDripPermission(testAccount1.address) + const tx = l1Reservoir.connect(governor.signer).revokeDripPermission(testAccount1.address) + await expect(tx).emit(l1Reservoir, 'AllowedDripperRevoked').withArgs(testAccount1.address) + expect(await l1Reservoir.allowedDrippers(testAccount1.address)).eq(false) + }) + }) + }) + + // TODO test that rewardsManager.updateAccRewardsPerSignal is called when + // issuanceRate or l2RewardsFraction is updated + describe('drip', function () { + it('cannot be called by an unauthorized address', async function () { + const tx = l1Reservoir + .connect(testAccount1.signer) + ['drip(uint256,uint256,uint256,address)'](toBN(0), toBN(0), toBN(0), testAccount1.address) + await expect(tx).revertedWith('UNAUTHORIZED') + }) + it('can be called by an indexer', async function () { + const stakedAmount = toGRT('100000') + await grt.connect(governor.signer).mint(testAccount1.address, stakedAmount) + await grt.connect(testAccount1.signer).approve(staking.address, stakedAmount) + await staking.connect(testAccount1.signer).stake(stakedAmount) + const tx = l1Reservoir + .connect(testAccount1.signer) + ['drip(uint256,uint256,uint256,address)'](toBN(0), toBN(0), toBN(0), testAccount1.address) + await expect(tx).emit(l1Reservoir, 'RewardsDripped') + }) + it('can be called by a whitelisted address', async function () { + await l1Reservoir.connect(governor.signer).grantDripPermission(testAccount1.address) + const tx = l1Reservoir + .connect(testAccount1.signer) + ['drip(uint256,uint256,uint256,address)'](toBN(0), toBN(0), toBN(0), testAccount1.address) + await expect(tx).emit(l1Reservoir, 'RewardsDripped') + }) + it('cannot be called with a zero address for the keeper reward beneficiary', async function () { + await l1Reservoir.connect(governor.signer).grantDripPermission(testAccount1.address) + const tx = l1Reservoir + .connect(testAccount1.signer) + ['drip(uint256,uint256,uint256,address)'](toBN(0), toBN(0), toBN(0), constants.AddressZero) + await expect(tx).revertedWith('INVALID_BENEFICIARY') + }) + it('(operator variant) cannot be called with an invalid indexer', async function () { + const tx = l1Reservoir + .connect(testAccount2.signer) + ['drip(uint256,uint256,uint256,address,address)']( + toBN(0), + toBN(0), + toBN(0), + testAccount1.address, + testAccount1.address, + ) + await expect(tx).revertedWith('UNAUTHORIZED_INVALID_INDEXER') + }) + it('(operator variant) cannot be called by someone who is not an operator for the right indexer', async function () { + const stakedAmount = toGRT('100000') + // testAccount1 is a valid indexer + await grt.connect(governor.signer).mint(testAccount1.address, stakedAmount) + await grt.connect(testAccount1.signer).approve(staking.address, stakedAmount) + await staking.connect(testAccount1.signer).stake(stakedAmount) + // testAccount2 is an operator for testAccount1's indexer + await staking.connect(testAccount1.signer).setOperator(testAccount2.address, true) + // testAccount3 is another valid indexer + await grt.connect(governor.signer).mint(testAccount3.address, stakedAmount) + await grt.connect(testAccount3.signer).approve(staking.address, stakedAmount) + await staking.connect(testAccount3.signer).stake(stakedAmount) + // But testAccount2 is not an operator for testAccount3's indexer + const tx = l1Reservoir + .connect(testAccount2.signer) + ['drip(uint256,uint256,uint256,address,address)']( + toBN(0), + toBN(0), + toBN(0), + testAccount1.address, + testAccount3.address, + ) + await expect(tx).revertedWith('UNAUTHORIZED_INVALID_OPERATOR') + }) + it('(operator variant) can be called by an indexer operator using an extra parameter', async function () { + const stakedAmount = toGRT('100000') + await grt.connect(governor.signer).mint(testAccount1.address, stakedAmount) + await grt.connect(testAccount1.signer).approve(staking.address, stakedAmount) + await staking.connect(testAccount1.signer).stake(stakedAmount) + await staking.connect(testAccount1.signer).setOperator(testAccount2.address, true) + const tx = l1Reservoir + .connect(testAccount2.signer) + ['drip(uint256,uint256,uint256,address,address)']( + toBN(0), + toBN(0), + toBN(0), + testAccount1.address, + testAccount1.address, + ) + await expect(tx).emit(l1Reservoir, 'RewardsDripped') + }) + it('mints rewards for the next week', async function () { + supplyBeforeDrip = await grt.totalSupply() + const startAccrued = await l1Reservoir.getAccumulatedRewards(await latestBlock()) + expect(startAccrued).to.eq(0) + const dripBlock = (await latestBlock()).add(1) // We're gonna drip in the next transaction + const tracker = await RewardsTracker.create( + supplyBeforeDrip, + defaults.rewards.issuanceRate, + dripBlock, + ) + expect(await tracker.accRewards(dripBlock)).to.eq(0) + const expectedNextDeadline = dripBlock.add(defaults.rewards.dripInterval) + const expectedMintedAmount = await tracker.accRewards(expectedNextDeadline) + const tx = await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)'](toBN(0), toBN(0), toBN(0), keeper.address) + const actualAmount = await grt.balanceOf(l1Reservoir.address) + expect(toRound(actualAmount)).to.eq(toRound(expectedMintedAmount)) + expect(await l1Reservoir.issuanceBase()).to.eq(supplyBeforeDrip) + await expect(tx) + .emit(l1Reservoir, 'RewardsDripped') + .withArgs(actualAmount, toBN(0), expectedNextDeadline) + }) + it('cannot be called more than once per minDripInterval', async function () { + supplyBeforeDrip = await grt.totalSupply() + const startAccrued = await l1Reservoir.getAccumulatedRewards(await latestBlock()) + expect(startAccrued).to.eq(0) + const dripBlock = (await latestBlock()).add(1) // We're gonna drip in the next transaction + const tracker = await RewardsTracker.create( + supplyBeforeDrip, + defaults.rewards.issuanceRate, + dripBlock, + ) + expect(await tracker.accRewards(dripBlock)).to.eq(0) + const expectedNextDeadline = dripBlock.add(defaults.rewards.dripInterval) + const expectedMintedAmount = await tracker.accRewards(expectedNextDeadline) + + const tx1 = await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)'](toBN(0), toBN(0), toBN(0), keeper.address) + + const minInterval = toBN('200') + await l1Reservoir.connect(governor.signer).setMinDripInterval(minInterval) + + const actualAmount = await grt.balanceOf(l1Reservoir.address) + + expect(toRound(actualAmount)).to.eq(toRound(expectedMintedAmount)) + await expect(tx1) + .emit(l1Reservoir, 'RewardsDripped') + .withArgs(actualAmount, toBN(0), expectedNextDeadline) + await expect(tx1) + .emit(grt, 'Transfer') + .withArgs(AddressZero, l1Reservoir.address, actualAmount) + + const tx2 = l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)'](toBN(0), toBN(0), toBN(0), keeper.address) + await expect(tx2).revertedWith('WAIT_FOR_MIN_INTERVAL') + + // We've had 1 block since the last drip so far, so we jump to one block before the interval is done + await advanceBlocks(minInterval.sub(2)) + const tx3 = l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)'](toBN(0), toBN(0), toBN(0), keeper.address) + await expect(tx3).revertedWith('WAIT_FOR_MIN_INTERVAL') + + await advanceBlocks(1) + // Now we're over the interval so we can drip again + const tx4 = l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)'](toBN(0), toBN(0), toBN(0), keeper.address) + await expect(tx4).emit(l1Reservoir, 'RewardsDripped') + }) + it('prevents locking eth in the contract if l2RewardsFraction is 0', async function () { + const tx = l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)']( + maxGas, + gasPriceBid, + maxSubmissionCost, + keeper.address, + { value: defaultEthValue }, + ) + await expect(tx).revertedWith('No eth value needed') + }) + it('mints only a few more tokens if called on the next block', async function () { + await sequentialDoubleDrip(toBN(0)) + }) + it('mints the right amount of tokens if called before the drip period is over', async function () { + const dripInterval = toBN('100') + await l1Reservoir.connect(governor.signer).setDripInterval(dripInterval) + await sequentialDoubleDrip(toBN('50'), dripInterval) + }) + it('mints the right amount of tokens filling the gap if called after the drip period is over', async function () { + const dripInterval = toBN('100') + await l1Reservoir.connect(governor.signer).setDripInterval(dripInterval) + await sequentialDoubleDrip(toBN('150'), dripInterval) + }) + it('sends the specified fraction of the rewards with a callhook to L2', async function () { + await l1Reservoir.connect(governor.signer).setL2RewardsFraction(toGRT('0.5')) + supplyBeforeDrip = await grt.totalSupply() + const startAccrued = await l1Reservoir.getAccumulatedRewards(await latestBlock()) + expect(startAccrued).to.eq(0) + const dripBlock = (await latestBlock()).add(1) // We're gonna drip in the next transaction + const tracker = await RewardsTracker.create( + supplyBeforeDrip, + defaults.rewards.issuanceRate, + dripBlock, + ) + expect(await tracker.accRewards(dripBlock)).to.eq(0) + const expectedNextDeadline = dripBlock.add(defaults.rewards.dripInterval) + const expectedMintedAmount = await tracker.accRewards(expectedNextDeadline) + const expectedSentToL2 = expectedMintedAmount.div(2) + const tx = await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)']( + maxGas, + gasPriceBid, + maxSubmissionCost, + keeper.address, + { value: defaultEthValue }, + ) + const actualAmount = await grt.balanceOf(l1Reservoir.address) + const escrowedAmount = await grt.balanceOf(bridgeEscrow.address) + expect(toRound(actualAmount)).to.eq(toRound(expectedMintedAmount.sub(expectedSentToL2))) + expect(toRound((await grt.totalSupply()).sub(supplyBeforeDrip))).to.eq( + toRound(expectedMintedAmount), + ) + expect(toRound(escrowedAmount)).to.eq(toRound(expectedSentToL2)) + await expect(tx) + .emit(l1Reservoir, 'RewardsDripped') + .withArgs(actualAmount.add(escrowedAmount), escrowedAmount, expectedNextDeadline) + + const l2IssuanceBase = (await l1Reservoir.issuanceBase()) + .mul(await l1Reservoir.l2RewardsFraction()) + .div(toGRT('1')) + const issuanceRate = await l1Reservoir.issuanceRate() + const expectedCallhookData = l2ReservoirIface.encodeFunctionData('receiveDrip', [ + l2IssuanceBase, + issuanceRate, + toBN('0'), + toBN('0'), + keeper.address, + ]) + const expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( + grt.address, + l1Reservoir.address, + mockL2Reservoir.address, + escrowedAmount, + expectedCallhookData, + ) + await expect(tx) + .emit(l1GraphTokenGateway, 'TxToL2') + .withArgs(l1Reservoir.address, mockL2Gateway.address, toBN(1), expectedL2Data) + }) + it('sends the specified fraction of the rewards with a keeper reward to L2', async function () { + await l1Reservoir.connect(governor.signer).setL2RewardsFraction(toGRT('0.5')) + await l1Reservoir.connect(governor.signer).setDripRewardPerBlock(toGRT('3')) + await l1Reservoir.connect(governor.signer).setMinDripInterval(toBN('2')) + + await advanceBlocks(toBN('4')) + + supplyBeforeDrip = await grt.totalSupply() + const issuanceBase = await l1Reservoir.issuanceBase() + const startAccrued = await l1Reservoir.getAccumulatedRewards(await latestBlock()) + expect(startAccrued).to.eq(0) + const dripBlock = (await latestBlock()).add(1) // We're gonna drip in the next transaction + const expectedKeeperReward = dripBlock + .sub(await l1Reservoir.lastRewardsUpdateBlock()) + .mul(toGRT('3')) + const tracker = await RewardsTracker.create( + issuanceBase, + defaults.rewards.issuanceRate, + dripBlock, + ) + expect(await tracker.accRewards(dripBlock)).to.eq(0) + const expectedNextDeadline = dripBlock.add(defaults.rewards.dripInterval) + const expectedMintedRewards = await tracker.accRewards(expectedNextDeadline) + const expectedMintedAmount = expectedMintedRewards.add(expectedKeeperReward) + const expectedSentToL2 = expectedMintedRewards.div(2).add(expectedKeeperReward) + const tx = await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)']( + maxGas, + gasPriceBid, + maxSubmissionCost, + keeper.address, + { value: defaultEthValue }, + ) + const actualAmount = await grt.balanceOf(l1Reservoir.address) + const escrowedAmount = await grt.balanceOf(bridgeEscrow.address) + + expect(toRound(actualAmount)).to.eq(toRound(expectedMintedAmount.sub(expectedSentToL2))) + expect(toRound((await grt.totalSupply()).sub(supplyBeforeDrip))).to.eq( + toRound(expectedMintedAmount), + ) + expect(toRound(escrowedAmount)).to.eq(toRound(expectedSentToL2)) + await expect(tx) + .emit(l1Reservoir, 'RewardsDripped') + .withArgs(actualAmount.add(escrowedAmount), escrowedAmount, expectedNextDeadline) + + const l2IssuanceBase = (await l1Reservoir.issuanceBase()) + .mul(await l1Reservoir.l2RewardsFraction()) + .div(toGRT('1')) + const issuanceRate = await l1Reservoir.issuanceRate() + const expectedCallhookData = l2ReservoirIface.encodeFunctionData('receiveDrip', [ + l2IssuanceBase, + issuanceRate, + toBN('0'), + expectedKeeperReward, + keeper.address, + ]) + const expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( + grt.address, + l1Reservoir.address, + mockL2Reservoir.address, + escrowedAmount, + expectedCallhookData, + ) + await expect(tx) + .emit(l1GraphTokenGateway, 'TxToL2') + .withArgs(l1Reservoir.address, mockL2Gateway.address, toBN(1), expectedL2Data) + }) + it('sends the outstanding amount if the L2 rewards fraction changes', async function () { + await l1Reservoir.connect(governor.signer).setL2RewardsFraction(toGRT('0.5')) + supplyBeforeDrip = await grt.totalSupply() + const startAccrued = await l1Reservoir.getAccumulatedRewards(await latestBlock()) + expect(startAccrued).to.eq(0) + const dripBlock = (await latestBlock()).add(1) // We're gonna drip in the next transaction + const tracker = await RewardsTracker.create( + supplyBeforeDrip, + defaults.rewards.issuanceRate, + dripBlock, + ) + expect(await tracker.accRewards(dripBlock)).to.eq(0) + const expectedNextDeadline = dripBlock.add(defaults.rewards.dripInterval) + const expectedMintedAmount = await tracker.accRewards(expectedNextDeadline) + const expectedSentToL2 = expectedMintedAmount.div(2) + const tx = await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)']( + maxGas, + gasPriceBid, + maxSubmissionCost, + keeper.address, + { value: defaultEthValue }, + ) + const actualAmount = await grt.balanceOf(l1Reservoir.address) + const escrowedAmount = await grt.balanceOf(bridgeEscrow.address) + expect(toRound(actualAmount)).to.eq(toRound(expectedMintedAmount.sub(expectedSentToL2))) + expect(toRound((await grt.totalSupply()).sub(supplyBeforeDrip))).to.eq( + toRound(expectedMintedAmount), + ) + expect(toRound(escrowedAmount)).to.eq(toRound(expectedSentToL2)) + await expect(tx) + .emit(l1Reservoir, 'RewardsDripped') + .withArgs(actualAmount.add(escrowedAmount), escrowedAmount, expectedNextDeadline) + + let l2IssuanceBase = (await l1Reservoir.issuanceBase()) + .mul(await l1Reservoir.l2RewardsFraction()) + .div(toGRT('1')) + const issuanceRate = await l1Reservoir.issuanceRate() + let expectedCallhookData = l2ReservoirIface.encodeFunctionData('receiveDrip', [ + l2IssuanceBase, + issuanceRate, + toBN('0'), + toBN('0'), + keeper.address, + ]) + let expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( + grt.address, + l1Reservoir.address, + mockL2Reservoir.address, + escrowedAmount, + expectedCallhookData, + ) + await expect(tx) + .emit(l1GraphTokenGateway, 'TxToL2') + .withArgs(l1Reservoir.address, mockL2Gateway.address, toBN(1), expectedL2Data) + + await tracker.snapshotRewards() + + await l1Reservoir.connect(governor.signer).setL2RewardsFraction(toGRT('0.8')) + supplyBeforeDrip = await grt.totalSupply() + const secondDripBlock = (await latestBlock()).add(1) + const expectedNewNextDeadline = secondDripBlock.add(defaults.rewards.dripInterval) + const rewardsUntilSecondDripBlock = await tracker.accRewards(secondDripBlock) + const expectedTotalRewards = await tracker.accRewards(expectedNewNextDeadline) + const expectedNewMintedAmount = expectedTotalRewards.sub(expectedMintedAmount) + // The amount sent to L2 should cover up to the new drip block with the old fraction, + // and from then onwards with the new fraction + const expectedNewTotalSentToL2 = rewardsUntilSecondDripBlock + .div(2) + .add(expectedTotalRewards.sub(rewardsUntilSecondDripBlock).mul(8).div(10)) + + const tx2 = await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)']( + maxGas, + gasPriceBid, + maxSubmissionCost, + keeper.address, + { value: defaultEthValue }, + ) + const newActualAmount = await grt.balanceOf(l1Reservoir.address) + const newEscrowedAmount = await grt.balanceOf(bridgeEscrow.address) + expect(toRound(newActualAmount)).to.eq( + toRound(expectedTotalRewards.sub(expectedNewTotalSentToL2)), + ) + expect(toRound((await grt.totalSupply()).sub(supplyBeforeDrip))).to.eq( + toRound(expectedNewMintedAmount), + ) + expect(toRound(newEscrowedAmount)).to.eq(toRound(expectedNewTotalSentToL2)) + l2IssuanceBase = (await l1Reservoir.issuanceBase()) + .mul(await l1Reservoir.l2RewardsFraction()) + .div(toGRT('1')) + expectedCallhookData = l2ReservoirIface.encodeFunctionData('receiveDrip', [ + l2IssuanceBase, + issuanceRate, + toBN('1'), // Incremented nonce + toBN('0'), + keeper.address, + ]) + expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( + grt.address, + l1Reservoir.address, + mockL2Reservoir.address, + newEscrowedAmount.sub(escrowedAmount), + expectedCallhookData, + ) + await expect(tx2) + .emit(l1GraphTokenGateway, 'TxToL2') + .withArgs(l1Reservoir.address, mockL2Gateway.address, toBN(2), expectedL2Data) + await expect(tx2) + .emit(l1Reservoir, 'RewardsDripped') + .withArgs( + newActualAmount.add(newEscrowedAmount).sub(actualAmount.add(escrowedAmount)), + newEscrowedAmount.sub(escrowedAmount), + expectedNewNextDeadline, + ) + }) + it('sends the outstanding amount if the L2 rewards fraction stays constant', async function () { + await l1Reservoir.connect(governor.signer).setL2RewardsFraction(toGRT('0.5')) + supplyBeforeDrip = await grt.totalSupply() + const startAccrued = await l1Reservoir.getAccumulatedRewards(await latestBlock()) + expect(startAccrued).to.eq(0) + const dripBlock = (await latestBlock()).add(1) // We're gonna drip in the next transaction + const tracker = await RewardsTracker.create( + supplyBeforeDrip, + defaults.rewards.issuanceRate, + dripBlock, + ) + expect(await tracker.accRewards(dripBlock)).to.eq(0) + const expectedNextDeadline = dripBlock.add(defaults.rewards.dripInterval) + const expectedMintedAmount = await tracker.accRewards(expectedNextDeadline) + const expectedSentToL2 = expectedMintedAmount.div(2) + const tx = await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)']( + maxGas, + gasPriceBid, + maxSubmissionCost, + keeper.address, + { value: defaultEthValue }, + ) + const actualAmount = await grt.balanceOf(l1Reservoir.address) + const escrowedAmount = await grt.balanceOf(bridgeEscrow.address) + expect(toRound(actualAmount)).to.eq(toRound(expectedMintedAmount.sub(expectedSentToL2))) + expect(toRound((await grt.totalSupply()).sub(supplyBeforeDrip))).to.eq( + toRound(expectedMintedAmount), + ) + expect(toRound(escrowedAmount)).to.eq(toRound(expectedSentToL2)) + await expect(tx) + .emit(l1Reservoir, 'RewardsDripped') + .withArgs(actualAmount.add(escrowedAmount), escrowedAmount, expectedNextDeadline) + + let l2IssuanceBase = (await l1Reservoir.issuanceBase()) + .mul(await l1Reservoir.l2RewardsFraction()) + .div(toGRT('1')) + const issuanceRate = await l1Reservoir.issuanceRate() + let expectedCallhookData = l2ReservoirIface.encodeFunctionData('receiveDrip', [ + l2IssuanceBase, + issuanceRate, + toBN('0'), + toBN('0'), + keeper.address, + ]) + let expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( + grt.address, + l1Reservoir.address, + mockL2Reservoir.address, + escrowedAmount, + expectedCallhookData, + ) + await expect(tx) + .emit(l1GraphTokenGateway, 'TxToL2') + .withArgs(l1Reservoir.address, mockL2Gateway.address, toBN(1), expectedL2Data) + + await tracker.snapshotRewards() + + supplyBeforeDrip = await grt.totalSupply() + const secondDripBlock = (await latestBlock()).add(1) + const expectedNewNextDeadline = secondDripBlock.add(defaults.rewards.dripInterval) + const expectedTotalRewards = await tracker.accRewards(expectedNewNextDeadline) + const expectedNewMintedAmount = expectedTotalRewards.sub(expectedMintedAmount) + // The amount sent to L2 should cover up to the new drip block with the old fraction, + // and from then onwards with the new fraction + const expectedNewTotalSentToL2 = expectedTotalRewards.div(2) + + const tx2 = await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)']( + maxGas, + gasPriceBid, + maxSubmissionCost, + keeper.address, + { value: defaultEthValue }, + ) + const newActualAmount = await grt.balanceOf(l1Reservoir.address) + const newEscrowedAmount = await grt.balanceOf(bridgeEscrow.address) + expect(toRound(newActualAmount)).to.eq( + toRound(expectedTotalRewards.sub(expectedNewTotalSentToL2)), + ) + expect(toRound((await grt.totalSupply()).sub(supplyBeforeDrip))).to.eq( + toRound(expectedNewMintedAmount), + ) + expect(toRound(newEscrowedAmount)).to.eq(toRound(expectedNewTotalSentToL2)) + l2IssuanceBase = (await l1Reservoir.issuanceBase()) + .mul(await l1Reservoir.l2RewardsFraction()) + .div(toGRT('1')) + expectedCallhookData = l2ReservoirIface.encodeFunctionData('receiveDrip', [ + l2IssuanceBase, + issuanceRate, + toBN('1'), // Incremented nonce + toBN('0'), + keeper.address, + ]) + expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( + grt.address, + l1Reservoir.address, + mockL2Reservoir.address, + newEscrowedAmount.sub(escrowedAmount), + expectedCallhookData, + ) + await expect(tx2) + .emit(l1GraphTokenGateway, 'TxToL2') + .withArgs(l1Reservoir.address, mockL2Gateway.address, toBN(2), expectedL2Data) + await expect(tx2) + .emit(l1Reservoir, 'RewardsDripped') + .withArgs( + newActualAmount.add(newEscrowedAmount).sub(actualAmount.add(escrowedAmount)), + newEscrowedAmount.sub(escrowedAmount), + expectedNewNextDeadline, + ) + }) + + it('reverts for a while but can be called again later if L2 fraction goes to zero', async function () { + await l1Reservoir.connect(governor.signer).setL2RewardsFraction(toGRT('0.5')) + + // First drip call, sending half the rewards to L2 + supplyBeforeDrip = await grt.totalSupply() + const startAccrued = await l1Reservoir.getAccumulatedRewards(await latestBlock()) + expect(startAccrued).to.eq(0) + const dripBlock = (await latestBlock()).add(1) // We're gonna drip in the next transaction + const tracker = await RewardsTracker.create( + supplyBeforeDrip, + defaults.rewards.issuanceRate, + dripBlock, + ) + expect(await tracker.accRewards(dripBlock)).to.eq(0) + const expectedNextDeadline = dripBlock.add(defaults.rewards.dripInterval) + const expectedMintedAmount = await tracker.accRewards(expectedNextDeadline) + const expectedSentToL2 = expectedMintedAmount.div(2) + const tx = await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)']( + maxGas, + gasPriceBid, + maxSubmissionCost, + keeper.address, + { value: defaultEthValue }, + ) + const actualAmount = await grt.balanceOf(l1Reservoir.address) + const escrowedAmount = await grt.balanceOf(bridgeEscrow.address) + expect(toRound(actualAmount)).to.eq(toRound(expectedMintedAmount.sub(expectedSentToL2))) + expect(toRound((await grt.totalSupply()).sub(supplyBeforeDrip))).to.eq( + toRound(expectedMintedAmount), + ) + expect(toRound(escrowedAmount)).to.eq(toRound(expectedSentToL2)) + await expect(tx) + .emit(l1Reservoir, 'RewardsDripped') + .withArgs(actualAmount.add(escrowedAmount), escrowedAmount, expectedNextDeadline) + + let l2IssuanceBase = (await l1Reservoir.issuanceBase()) + .mul(await l1Reservoir.l2RewardsFraction()) + .div(toGRT('1')) + const issuanceRate = await l1Reservoir.issuanceRate() + let expectedCallhookData = l2ReservoirIface.encodeFunctionData('receiveDrip', [ + l2IssuanceBase, + issuanceRate, + toBN('0'), + toBN('0'), + keeper.address, + ]) + let expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( + grt.address, + l1Reservoir.address, + mockL2Reservoir.address, + escrowedAmount, + expectedCallhookData, + ) + await expect(tx) + .emit(l1GraphTokenGateway, 'TxToL2') + .withArgs(l1Reservoir.address, mockL2Gateway.address, toBN(1), expectedL2Data) + + await tracker.snapshotRewards() + + await l1Reservoir.connect(governor.signer).setL2RewardsFraction(toGRT('0')) + + // Second attempt to drip immediately afterwards will revert, because we + // would have to send negative tokens to L2 to compensate + const tx2 = l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)']( + maxGas, + gasPriceBid, + maxSubmissionCost, + keeper.address, + { value: defaultEthValue }, + ) + await expect(tx2).revertedWith( + 'Negative amount would be sent to L2, wait before calling again', + ) + + await advanceBlocks(await l1Reservoir.dripInterval()) + + // Now we should be able to drip again, and a small amount will be sent to L2 + // to cover the few blocks since the drip interval was over + supplyBeforeDrip = await grt.totalSupply() + const secondDripBlock = (await latestBlock()).add(1) + const expectedNewNextDeadline = secondDripBlock.add(defaults.rewards.dripInterval) + const rewardsUntilSecondDripBlock = await tracker.accRewards(secondDripBlock) + const expectedTotalRewards = await tracker.accRewards(expectedNewNextDeadline) + const expectedNewMintedAmount = expectedTotalRewards.sub(expectedMintedAmount) + // The amount sent to L2 should cover up to the new drip block with the old fraction, + // and from then onwards with the new fraction, that is zero + const expectedNewTotalSentToL2 = rewardsUntilSecondDripBlock.div(2) + + const tx3 = await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)']( + maxGas, + gasPriceBid, + maxSubmissionCost, + keeper.address, + { value: defaultEthValue }, + ) + const newActualAmount = await grt.balanceOf(l1Reservoir.address) + const newEscrowedAmount = await grt.balanceOf(bridgeEscrow.address) + expect(toRound(newActualAmount)).to.eq( + toRound(expectedTotalRewards.sub(expectedNewTotalSentToL2)), + ) + expect(toRound((await grt.totalSupply()).sub(supplyBeforeDrip))).to.eq( + toRound(expectedNewMintedAmount), + ) + expect(toRound(newEscrowedAmount)).to.eq(toRound(expectedNewTotalSentToL2)) + l2IssuanceBase = (await l1Reservoir.issuanceBase()) + .mul(await l1Reservoir.l2RewardsFraction()) + .div(toGRT('1')) + expectedCallhookData = l2ReservoirIface.encodeFunctionData('receiveDrip', [ + l2IssuanceBase, + issuanceRate, + toBN('1'), // Incremented nonce + toBN('0'), + keeper.address, + ]) + expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( + grt.address, + l1Reservoir.address, + mockL2Reservoir.address, + newEscrowedAmount.sub(escrowedAmount), + expectedCallhookData, + ) + await expect(tx3) + .emit(l1GraphTokenGateway, 'TxToL2') + .withArgs(l1Reservoir.address, mockL2Gateway.address, toBN(2), expectedL2Data) + await expect(tx3) + .emit(l1Reservoir, 'RewardsDripped') + .withArgs( + newActualAmount.add(newEscrowedAmount).sub(actualAmount.add(escrowedAmount)), + newEscrowedAmount.sub(escrowedAmount), + expectedNewNextDeadline, + ) + }) + }) + + context('calculating rewards', async function () { + beforeEach(async function () { + // 5% minute rate (4 blocks) + await l1Reservoir.connect(governor.signer).setIssuanceRate(ISSUANCE_RATE_PER_BLOCK) + supplyBeforeDrip = await grt.totalSupply() + await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)'](toBN(0), toBN(0), toBN(0), keeper.address) + dripBlock = await latestBlock() + }) + + describe('getAccumulatedRewards', function () { + it('returns rewards accrued after some blocks', async function () { + await shouldGetNewRewards(supplyBeforeDrip) + }) + it('returns zero if evaluated at the block where reservoir had the first drip', async function () { + await shouldGetNewRewards( + supplyBeforeDrip, + ISSUANCE_RATE_PERIODS, + dripBlock, + toBN(0), + false, + ) + }) + it('returns the supply times issuance rate one block after the first drip', async function () { + const expectedVal = supplyBeforeDrip + .mul(ISSUANCE_RATE_PER_BLOCK.sub(toGRT(1))) + .div(toGRT(1)) + await shouldGetNewRewards( + supplyBeforeDrip, + ISSUANCE_RATE_PERIODS, + dripBlock.add(1), + expectedVal, + false, + ) + }) + it('returns the rewards for a block some time in the future', async function () { + await shouldGetNewRewards(supplyBeforeDrip, toBN(1), dripBlock.add(10000)) + }) + }) + describe('getNewRewards', function () { + const computeDelta = function (t1: BigNumber, t0: BigNumber, lambda = toBN(0)): BigNumber { + const deltaT = new BN(t1.toString()).minus(new BN(t0.toString())) + const rate = new BN(ISSUANCE_RATE_PER_BLOCK.toString()).div(1e18) + const supply = new BN(supplyBeforeDrip.toString()) + return toBN(supply.times(rate.pow(deltaT)).minus(supply).precision(18).toString(10)) + .mul(toGRT('1').sub(lambda)) + .div(toGRT('1')) + } + it('computes the rewards delta between the last drip block and the current block', async function () { + const t0 = dripBlock + const t1 = t0.add(200) + const expectedVal = computeDelta(t1, t0) + expect(toRound(await l1Reservoir.getNewRewards(t1))).to.eq(toRound(expectedVal)) + }) + it('returns zero rewards if the time delta is zero', async function () { + const t0 = dripBlock + const expectedVal = toBN('0') + expect(await l1Reservoir.getNewRewards(t0)).to.eq(expectedVal) + }) + it('computes the rewards delta between a past drip block and a future block', async function () { + await advanceBlocks(20) + const t0 = dripBlock + const t1 = t0.add(100) + const expectedVal = computeDelta(t1, t0) + expect(toRound(await l1Reservoir.getNewRewards(t1))).to.eq(toRound(expectedVal)) + }) + it('computes the rewards delta between a past drip block and the current block', async function () { + await advanceBlocks(20) + const t0 = dripBlock + const t1 = await latestBlock() + const expectedVal = computeDelta(t1, t0) + expect(toRound(await l1Reservoir.getNewRewards(t1))).to.eq(toRound(expectedVal)) + }) + it('computes the rewards delta considering the L2 rewards fraction', async function () { + const lambda = toGRT('0.32') + await l1Reservoir.connect(governor.signer).setL2RewardsFraction(lambda) + await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)']( + maxGas, + gasPriceBid, + maxSubmissionCost, + keeper.address, + { value: defaultEthValue }, + ) + supplyBeforeDrip = await l1Reservoir.issuanceBase() // Has been updated accordingly + dripBlock = await latestBlock() + await advanceBlocks(20) + const t0 = dripBlock + const t1 = await latestBlock() + + const expectedVal = computeDelta(t1, t0, lambda) + expect(toRound(await l1Reservoir.getNewRewards(t1))).to.eq(toRound(expectedVal)) + }) + }) + }) + + describe('pow', function () { + it('exponentiation works under normal boundaries (annual rate from 1% to 700%, 90 days period)', async function () { + const baseRatio = toGRT('0.000000004641377923') // 1% annual rate + const timePeriods = (60 * 60 * 24 * 10) / 15 // 90 days in blocks + const powPrecision = 14 // Compare up to this amount of significant digits + BN.config({ POW_PRECISION: 100 }) + for (let i = 0; i < 50; i = i + 4) { + const r = baseRatio.mul(i * 4).add(toGRT('1')) + const h = await reservoirMock.pow(r, timePeriods, toGRT('1')) + console.log('\tr:', formatGRT(r), '=> c:', formatGRT(h)) + expect(new BN(h.toString()).precision(powPrecision).toString(10)).to.eq( + new BN(r.toString()) + .div(1e18) + .pow(timePeriods) + .times(1e18) + .precision(powPrecision) + .toString(10), + ) + } + }) + }) +}) diff --git a/test/rewards/rewards.test.ts b/test/rewards/rewards.test.ts index 23aee0dfa..40ed21bf5 100644 --- a/test/rewards/rewards.test.ts +++ b/test/rewards/rewards.test.ts @@ -1,17 +1,16 @@ import { expect } from 'chai' -import { constants, BigNumber } from 'ethers' -import { BigNumber as BN } from 'bignumber.js' +import { constants, BigNumber, ContractReceipt } from 'ethers' -import { deployContract } from '../lib/deployment' import { NetworkFixture } from '../lib/fixtures' import { Curation } from '../../build/types/Curation' import { EpochManager } from '../../build/types/EpochManager' import { GraphToken } from '../../build/types/GraphToken' import { RewardsManager } from '../../build/types/RewardsManager' -import { RewardsManagerMock } from '../../build/types/RewardsManagerMock' import { Staking } from '../../build/types/Staking' +import { BigNumber as BN } from 'bignumber.js' + import { advanceBlocks, deriveChannelKey, @@ -24,7 +23,10 @@ import { Account, advanceToNextEpoch, provider, + RewardsTracker, } from '../lib/testHelpers' +import { L1Reservoir } from '../../build/types/L1Reservoir' +import { LogDescription } from 'ethers/lib/utils' const MAX_PPM = 1000000 @@ -40,6 +42,7 @@ describe('Rewards', () => { let indexer1: Account let indexer2: Account let oracle: Account + let keeper: Account let fixture: NetworkFixture @@ -48,7 +51,10 @@ describe('Rewards', () => { let epochManager: EpochManager let staking: Staking let rewardsManager: RewardsManager - let rewardsManagerMock: RewardsManagerMock + let l1Reservoir: L1Reservoir + + let supplyBeforeDrip: BigNumber + let dripBlock: BigNumber // Derive some channel keys for each indexer used to sign attestations const channelKey1 = deriveChannelKey() @@ -62,64 +68,18 @@ describe('Rewards', () => { const metadata = HashZero - const ISSUANCE_RATE_PERIODS = 4 // blocks required to issue 5% rewards - const ISSUANCE_RATE_PER_BLOCK = toBN('1012272234429039270') // % increase every block - - // Core formula that gets accumulated rewards per signal for a period of time - const getRewardsPerSignal = (p: BN, r: BN, t: BN, s: BN): string => { - if (s.eq(0)) { - return '0' - } - return p.times(r.pow(t)).minus(p).div(s).toPrecision(18).toString() - } - - // Tracks the accumulated rewards as totalSignalled or supply changes across snapshots - class RewardsTracker { - totalSupply = BigNumber.from(0) - totalSignalled = BigNumber.from(0) - lastUpdatedBlock = BigNumber.from(0) - accumulated = BigNumber.from(0) - - static async create() { - const tracker = new RewardsTracker() - await tracker.snapshot() - return tracker - } - - async snapshot() { - this.accumulated = this.accumulated.add(await this.accrued()) - this.totalSupply = await grt.totalSupply() - this.totalSignalled = await grt.balanceOf(curation.address) - this.lastUpdatedBlock = await latestBlock() - return this - } - - async elapsedBlocks() { - const currentBlock = await latestBlock() - return currentBlock.sub(this.lastUpdatedBlock) - } - - async accrued() { - const nBlocks = await this.elapsedBlocks() - return this.accruedByElapsed(nBlocks) - } - - async accruedByElapsed(nBlocks: BigNumber | number) { - const n = getRewardsPerSignal( - new BN(this.totalSupply.toString()), - new BN(ISSUANCE_RATE_PER_BLOCK.toString()).div(1e18), - new BN(nBlocks.toString()), - new BN(this.totalSignalled.toString()), - ) - return toGRT(n) - } - } + const ISSUANCE_RATE_PERIODS = 4 // blocks required to issue 0.05% rewards + const ISSUANCE_RATE_PER_BLOCK = toBN('1000122722344290393') // % increase every block // Test accumulated rewards per signal - const shouldGetNewRewardsPerSignal = async (nBlocks = ISSUANCE_RATE_PERIODS) => { + const shouldGetNewRewardsPerSignal = async ( + initialSupply: BigNumber, + nBlocks = ISSUANCE_RATE_PERIODS, + dripBlock?: BigNumber, + ) => { // -- t0 -- - const tracker = await RewardsTracker.create() - + const tracker = await RewardsTracker.create(initialSupply, ISSUANCE_RATE_PER_BLOCK, dripBlock) + await tracker.snapshotPerSignal(await grt.balanceOf(curation.address)) // Jump await advanceBlocks(nBlocks) @@ -128,35 +88,43 @@ describe('Rewards', () => { // Contract calculation const contractAccrued = await rewardsManager.getNewRewardsPerSignal() // Local calculation - const expectedAccrued = await tracker.accrued() + const expectedAccrued = await tracker.newRewardsPerSignal(await grt.balanceOf(curation.address)) // Check - expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) + expect(toRound(contractAccrued)).eq(toRound(expectedAccrued)) return expectedAccrued } + const findRewardsManagerEvents = (receipt: ContractReceipt): Array => { + return receipt.logs + .map((l) => { + try { + return rewardsManager.interface.parseLog(l) + } catch { + return null + } + }) + .filter((l) => !!l) + } + before(async function () { - ;[delegator, governor, curator1, curator2, indexer1, indexer2, oracle] = await getAccounts() + ;[delegator, governor, curator1, curator2, indexer1, indexer2, oracle, keeper] = + await getAccounts() fixture = new NetworkFixture() - ;({ grt, curation, epochManager, staking, rewardsManager } = await fixture.load( + ;({ grt, curation, epochManager, staking, rewardsManager, l1Reservoir } = await fixture.load( governor.signer, )) - rewardsManagerMock = (await deployContract( - 'RewardsManagerMock', - governor.signer, - )) as unknown as RewardsManagerMock - - // 5% minute rate (4 blocks) - await rewardsManager.connect(governor.signer).setIssuanceRate(ISSUANCE_RATE_PER_BLOCK) - // Distribute test funds for (const wallet of [indexer1, indexer2, curator1, curator2]) { await grt.connect(governor.signer).mint(wallet.address, toGRT('1000000')) await grt.connect(wallet.signer).approve(staking.address, toGRT('1000000')) await grt.connect(wallet.signer).approve(curation.address, toGRT('1000000')) } + await l1Reservoir.connect(governor.signer).grantDripPermission(keeper.address) + await l1Reservoir.connect(governor.signer).initialSnapshot(toBN(0)) + supplyBeforeDrip = await grt.totalSupply() }) beforeEach(async function () { @@ -168,32 +136,6 @@ describe('Rewards', () => { }) describe('configuration', function () { - describe('issuance rate update', function () { - it('reject set issuance rate if unauthorized', async function () { - const tx = rewardsManager.connect(indexer1.signer).setIssuanceRate(toGRT('1.025')) - await expect(tx).revertedWith('Caller must be Controller governor') - }) - - it('reject set issuance rate to less than minimum allowed', async function () { - const newIssuanceRate = toGRT('0.1') // this get a bignumber with 1e17 - const tx = rewardsManager.connect(governor.signer).setIssuanceRate(newIssuanceRate) - await expect(tx).revertedWith('Issuance rate under minimum allowed') - }) - - it('should set issuance rate to minimum allowed', async function () { - const newIssuanceRate = toGRT('1') // this get a bignumber with 1e18 - await rewardsManager.connect(governor.signer).setIssuanceRate(newIssuanceRate) - expect(await rewardsManager.issuanceRate()).eq(newIssuanceRate) - }) - - it('should set issuance rate', async function () { - const newIssuanceRate = toGRT('1.025') - await rewardsManager.connect(governor.signer).setIssuanceRate(newIssuanceRate) - expect(await rewardsManager.issuanceRate()).eq(newIssuanceRate) - expect(await rewardsManager.accRewardsPerSignalLastBlockUpdated()).eq(await latestBlock()) - }) - }) - describe('subgraph availability service', function () { it('reject set subgraph oracle if unauthorized', async function () { const tx = rewardsManager @@ -243,9 +185,127 @@ describe('Rewards', () => { }) context('issuing rewards', async function () { + interface DelegationParameters { + indexingRewardCut: BigNumber + queryFeeCut: BigNumber + cooldownBlocks: number + } + + async function setupIndexerAllocation() { + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1.signer).mint(subgraphDeploymentID1, signalled1, 0) + + // Allocate + const tokensToAllocate = toGRT('12500') + await staking.connect(indexer1.signer).stake(tokensToAllocate) + await staking + .connect(indexer1.signer) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAllocate, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) + } + + async function setupIndexerAllocationSignalingAfter() { + // Setup + await epochManager.setEpochLength(10) + + // Allocate + const tokensToAllocate = toGRT('12500') + await staking.connect(indexer1.signer).stake(tokensToAllocate) + await staking + .connect(indexer1.signer) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAllocate, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) + + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1.signer).mint(subgraphDeploymentID1, signalled1, 0) + } + + async function setupIndexerAllocationWithDelegation( + tokensToDelegate: BigNumber, + delegationParams: DelegationParameters, + ) { + const tokensToAllocate = toGRT('12500') + + // Transfer some funds from the curator, I don't want to mint new tokens + await grt.connect(curator1.signer).transfer(delegator.address, tokensToDelegate) + await grt.connect(delegator.signer).approve(staking.address, tokensToDelegate) + + // Stake and set delegation parameters + await staking.connect(indexer1.signer).stake(tokensToAllocate) + await staking + .connect(indexer1.signer) + .setDelegationParameters( + delegationParams.indexingRewardCut, + delegationParams.queryFeeCut, + delegationParams.cooldownBlocks, + ) + + // Delegate + await staking.connect(delegator.signer).delegate(indexer1.address, tokensToDelegate) + + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1.signer).mint(subgraphDeploymentID1, signalled1, 0) + + // Allocate + await staking + .connect(indexer1.signer) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAllocate, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) + } + + function calculatedExpectedRewards( + firstSnapshotBlocks: BN, + lastSnapshotBlocks: BN, + allocatedTokens: BN, + ): BigNumber { + const issuanceBase = new BN(10004000000) + const issuanceRate = new BN(ISSUANCE_RATE_PER_BLOCK.toString()).div(1e18) + // All the rewards in this subgraph go to this allocation. + // Rewards per token will be (issuanceBase * issuanceRate^nBlocks - issuanceBase) / allocatedTokens + // The first snapshot is after allocating, that is lastSnapshotBlocks blocks after dripBlock: + const startRewardsPerToken = issuanceBase + .times(issuanceRate.pow(firstSnapshotBlocks)) + .minus(issuanceBase) + .div(allocatedTokens) + // The final snapshot is when we close the allocation, that happens 8 blocks later: + const endRewardsPerToken = issuanceBase + .times(issuanceRate.pow(lastSnapshotBlocks)) + .minus(issuanceBase) + .div(allocatedTokens) + // Then our expected rewards are (endRewardsPerToken - startRewardsPerToken) * allocatedTokens. + return toGRT( + endRewardsPerToken.minus(startRewardsPerToken).times(allocatedTokens).toPrecision(18), + ) + } + beforeEach(async function () { // 5% minute rate (4 blocks) - await rewardsManager.connect(governor.signer).setIssuanceRate(ISSUANCE_RATE_PER_BLOCK) + await l1Reservoir.connect(governor.signer).setIssuanceRate(ISSUANCE_RATE_PER_BLOCK) + await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)'](toBN(0), toBN(0), toBN(0), keeper.address) + dripBlock = await latestBlock() }) describe('getNewRewardsPerSignal', function () { @@ -262,7 +322,7 @@ describe('Rewards', () => { await curation.connect(curator1.signer).mint(subgraphDeploymentID1, tokensToSignal, 0) // Check - await shouldGetNewRewardsPerSignal() + await shouldGetNewRewardsPerSignal(supplyBeforeDrip, ISSUANCE_RATE_PERIODS, dripBlock) }) it('accrued per signal when signalled tokens w/ many subgraphs', async function () { @@ -270,78 +330,112 @@ describe('Rewards', () => { await curation.connect(curator1.signer).mint(subgraphDeploymentID1, toGRT('1000'), 0) // Check - await shouldGetNewRewardsPerSignal() + await shouldGetNewRewardsPerSignal(supplyBeforeDrip, ISSUANCE_RATE_PERIODS, dripBlock) // Update total signalled await curation.connect(curator2.signer).mint(subgraphDeploymentID2, toGRT('250'), 0) // Check - await shouldGetNewRewardsPerSignal() + await shouldGetNewRewardsPerSignal(supplyBeforeDrip, ISSUANCE_RATE_PERIODS, dripBlock) }) }) describe('updateAccRewardsPerSignal', function () { it('update the accumulated rewards per signal state', async function () { + const tracker = await RewardsTracker.create( + supplyBeforeDrip, + ISSUANCE_RATE_PER_BLOCK, + dripBlock, + ) + // Snapshot + const prevSignal = await grt.balanceOf(curation.address) // Update total signalled await curation.connect(curator1.signer).mint(subgraphDeploymentID1, toGRT('1000'), 0) - // Snapshot - const tracker = await RewardsTracker.create() + // Minting signal triggers onSubgraphSignalUpgrade before pulling the GRT, + // so we snapshot using the previous value + await tracker.snapshotPerSignal(prevSignal) // Update await rewardsManager.updateAccRewardsPerSignal() + await tracker.snapshotPerSignal(await grt.balanceOf(curation.address)) + const contractAccrued = await rewardsManager.accRewardsPerSignal() // Check - const expectedAccrued = await tracker.accrued() - expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) + const blockNum = await latestBlock() + const expectedAccrued = await tracker.accRewardsPerSignal( + await grt.balanceOf(curation.address), + blockNum, + ) + expect(toRound(contractAccrued)).eq(toRound(expectedAccrued)) }) it('update the accumulated rewards per signal state after many blocks', async function () { + const tracker = await RewardsTracker.create( + supplyBeforeDrip, + ISSUANCE_RATE_PER_BLOCK, + dripBlock, + ) + // Snapshot + const prevSignal = await grt.balanceOf(curation.address) // Update total signalled await curation.connect(curator1.signer).mint(subgraphDeploymentID1, toGRT('1000'), 0) - // Snapshot - const tracker = await RewardsTracker.create() + // Minting signal triggers onSubgraphSignalUpgrade before pulling the GRT, + // so we snapshot using the previous value + await tracker.snapshotPerSignal(prevSignal) // Jump await advanceBlocks(ISSUANCE_RATE_PERIODS) // Update await rewardsManager.updateAccRewardsPerSignal() + await tracker.snapshotPerSignal(await grt.balanceOf(curation.address)) const contractAccrued = await rewardsManager.accRewardsPerSignal() - // Check - const expectedAccrued = await tracker.accrued() - expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) + const blockNum = await latestBlock() + const expectedAccrued = await tracker.accRewardsPerSignal( + await grt.balanceOf(curation.address), + blockNum.add(0), + ) + expect(toRound(contractAccrued)).eq(toRound(expectedAccrued)) }) }) describe('getAccRewardsForSubgraph', function () { it('accrued for each subgraph', async function () { + const tracker = await RewardsTracker.create( + supplyBeforeDrip, + ISSUANCE_RATE_PER_BLOCK, + dripBlock, + ) + // Snapshot + let prevSignal = await grt.balanceOf(curation.address) // Curator1 - Update total signalled const signalled1 = toGRT('1500') await curation.connect(curator1.signer).mint(subgraphDeploymentID1, signalled1, 0) - const tracker1 = await RewardsTracker.create() + const sg1Snapshot = await tracker.snapshotPerSignal(prevSignal) // Curator2 - Update total signalled const signalled2 = toGRT('500') + prevSignal = await grt.balanceOf(curation.address) await curation.connect(curator2.signer).mint(subgraphDeploymentID2, signalled2, 0) - - // Snapshot - const tracker2 = await RewardsTracker.create() - await tracker1.snapshot() + const sg2Snapshot = await tracker.snapshotPerSignal(prevSignal) // Jump await advanceBlocks(ISSUANCE_RATE_PERIODS) - // Snapshot - await tracker1.snapshot() - await tracker2.snapshot() - // Calculate rewards - const rewardsPerSignal1 = await tracker1.accumulated - const rewardsPerSignal2 = await tracker2.accumulated - const expectedRewardsSG1 = rewardsPerSignal1.mul(signalled1).div(WeiPerEther) - const expectedRewardsSG2 = rewardsPerSignal2.mul(signalled2).div(WeiPerEther) + const rewardsPerSignal = await tracker.accRewardsPerSignal( + await grt.balanceOf(curation.address), + ) + const expectedRewardsSG1 = rewardsPerSignal + .sub(sg1Snapshot) + .mul(signalled1) + .div(WeiPerEther) + const expectedRewardsSG2 = rewardsPerSignal + .sub(sg2Snapshot) + .mul(signalled2) + .div(WeiPerEther) // Get rewards from contract const contractRewardsSG1 = await rewardsManager.getAccRewardsForSubgraph( @@ -359,27 +453,35 @@ describe('Rewards', () => { describe('onSubgraphSignalUpdate', function () { it('update the accumulated rewards for subgraph state', async function () { + const tracker = await RewardsTracker.create( + supplyBeforeDrip, + ISSUANCE_RATE_PER_BLOCK, + dripBlock, + ) + // Snapshot + const prevSignal = await grt.balanceOf(curation.address) // Update total signalled const signalled1 = toGRT('1500') await curation.connect(curator1.signer).mint(subgraphDeploymentID1, signalled1, 0) // Snapshot - const tracker1 = await RewardsTracker.create() + await tracker.snapshotPerSignal(prevSignal) // Jump await advanceBlocks(ISSUANCE_RATE_PERIODS) // Update await rewardsManager.onSubgraphSignalUpdate(subgraphDeploymentID1) - + const snapshot = await tracker.snapshotPerSignal(await grt.balanceOf(curation.address)) // Check const contractRewardsSG1 = (await rewardsManager.subgraphs(subgraphDeploymentID1)) .accRewardsForSubgraph - const rewardsPerSignal1 = await tracker1.accrued() - const expectedRewardsSG1 = rewardsPerSignal1.mul(signalled1).div(WeiPerEther) + const expectedRewardsSG1 = snapshot.mul(signalled1).div(WeiPerEther) expect(toRound(expectedRewardsSG1)).eq(toRound(contractRewardsSG1)) const contractAccrued = await rewardsManager.accRewardsPerSignal() - const expectedAccrued = await tracker1.accrued() + const expectedAccrued = await tracker.accRewardsPerSignal( + await grt.balanceOf(curation.address), + ) expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) const contractBlockUpdated = await rewardsManager.accRewardsPerSignalLastBlockUpdated() @@ -430,11 +532,14 @@ describe('Rewards', () => { it('update the accumulated rewards for allocated tokens state', async function () { // Update total signalled const signalled1 = toGRT('1500') + // block = dripBlock await curation.connect(curator1.signer).mint(subgraphDeploymentID1, signalled1, 0) + // block = dripBlock + 1 // Allocate const tokensToAllocate = toGRT('12500') await staking.connect(indexer1.signer).stake(tokensToAllocate) + // block = dripBlock + 2 await staking .connect(indexer1.signer) .allocateFrom( @@ -445,19 +550,28 @@ describe('Rewards', () => { metadata, await channelKey1.generateProof(indexer1.address), ) - + // block = dripBlock + 3 // Jump await advanceBlocks(ISSUANCE_RATE_PERIODS) - - // Prepare expected results - // NOTE: calculated the expected result manually as the above code has 1 off block difference - // replace with a RewardsManagerMock - const expectedSubgraphRewards = toGRT('891695470') - const expectedRewardsAT = toGRT('51571') + // block = dripBlock + 7 // Update await rewardsManager.onSubgraphAllocationUpdate(subgraphDeploymentID1) + // block = dripBlock + 8 + // Prepare expected results + // Expected total rewards: + // DeltaR_end = supplyBeforeDrip * r ^ 8 - supplyBeforeDrip + // DeltaR_end = 10004000000 GRT * (1000122722344290393 / 1e18)^8 - 10004000000 GRT = 9825934.397 + // The signal was minted at dripBlock + 1, so: + // DeltaR_start = supplyBeforeDrip * r ^ 1 - supplyBeforeDrip = 1227714.332 + + // And they all go to this subgraph, so subgraph rewards = DeltaR_end - DeltaR_start = 8598220.065 + const expectedSubgraphRewards = toGRT('8598220') + + // The allocation happened at dripBlock + 3, so rewards per allocated token are: + // ((supplyBeforeDrip * r ^ 8 - supplyBeforeDrip) - (supplyBeforeDrip * r ^ 3 - supplyBeforeDrip)) / 12500 = 491.387 + const expectedRewardsAT = toGRT('491') // Check on demand results saved const subgraph = await rewardsManager.subgraphs(subgraphDeploymentID1) const contractSubgraphRewards = await rewardsManager.getAccRewardsForSubgraph( @@ -506,108 +620,77 @@ describe('Rewards', () => { expect(expectedRewards).eq(contractRewards) }) }) - - describe('takeRewards', function () { - interface DelegationParameters { - indexingRewardCut: BigNumber - queryFeeCut: BigNumber - cooldownBlocks: number - } - - async function setupIndexerAllocation() { - // Setup + describe('takeAndBurnRewards', function () { + it('should burn rewards on closed allocation with POI zero', async function () { + // Align with the epoch boundary await epochManager.setEpochLength(10) + await advanceToNextEpoch(epochManager) - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1.signer).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1.signer).stake(tokensToAllocate) - await staking - .connect(indexer1.signer) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - } - - async function setupIndexerAllocationSignalingAfter() { // Setup - await epochManager.setEpochLength(10) + await setupIndexerAllocation() + const firstSnapshotBlocks = new BN((await latestBlock()).sub(dripBlock).toString()) - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1.signer).stake(tokensToAllocate) - await staking - .connect(indexer1.signer) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) + // Jump + await advanceToNextEpoch(epochManager) - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1.signer).mint(subgraphDeploymentID1, signalled1, 0) - } + // Before state + const beforeTokenSupply = await grt.totalSupply() + const beforeIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) + const beforeIndexer1Balance = await grt.balanceOf(indexer1.address) + const beforeStakingBalance = await grt.balanceOf(staking.address) - async function setupIndexerAllocationWithDelegation( - tokensToDelegate: BigNumber, - delegationParams: DelegationParameters, - ) { - const tokensToAllocate = toGRT('12500') + // Close allocation with POI zero, which should burn the rewards + const tx = await staking.connect(indexer1.signer).closeAllocation(allocationID1, HashZero) + const receipt = await tx.wait() - // Setup - await epochManager.setEpochLength(10) + const lastSnapshotBlocks = new BN((await latestBlock()).sub(dripBlock).toString()) - // Transfer some funds from the curator, I don't want to mint new tokens - await grt.connect(curator1.signer).transfer(delegator.address, tokensToDelegate) - await grt.connect(delegator.signer).approve(staking.address, tokensToDelegate) + const expectedIndexingRewards = calculatedExpectedRewards( + firstSnapshotBlocks, + lastSnapshotBlocks, + new BN(12500), + ) - // Stake and set delegation parameters - await staking.connect(indexer1.signer).stake(tokensToAllocate) - await staking - .connect(indexer1.signer) - .setDelegationParameters( - delegationParams.indexingRewardCut, - delegationParams.queryFeeCut, - delegationParams.cooldownBlocks, - ) + const log = findRewardsManagerEvents(receipt)[0] + const event = log.args + expect(log.name).eq('RewardsBurned') + expect(event.indexer).eq(indexer1.address) + expect(event.allocationID).eq(allocationID1) + expect(event.epoch).eq(await epochManager.currentEpoch()) + expect(toRound(event.amount)).eq(toRound(expectedIndexingRewards)) - // Delegate - await staking.connect(delegator.signer).delegate(indexer1.address, tokensToDelegate) + // After state + const afterTokenSupply = await grt.totalSupply() + const afterIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) + const afterIndexer1Balance = await grt.balanceOf(indexer1.address) + const afterStakingBalance = await grt.balanceOf(staking.address) - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1.signer).mint(subgraphDeploymentID1, signalled1, 0) + // Check that rewards are NOT put into indexer stake + const expectedIndexerStake = beforeIndexer1Stake - // Allocate - await staking - .connect(indexer1.signer) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - } + // Check stake should NOT have increased with the rewards staked + expect(toRound(afterIndexer1Stake)).eq(toRound(expectedIndexerStake)) + // Check indexer balance remains the same + expect(afterIndexer1Balance).eq(beforeIndexer1Balance) + // Check indexing rewards are kept in the staking contract + expect(toRound(afterStakingBalance)).eq(toRound(beforeStakingBalance)) + // Check that tokens have been burned + // We divide by 10 to accept numeric errors up to 10 GRT + expect(toRound(afterTokenSupply.div(10))).eq( + toRound(beforeTokenSupply.sub(expectedIndexingRewards).div(10)), + ) + }) + }) + describe('takeRewards', function () { it('should distribute rewards on closed allocation and stake', async function () { // Align with the epoch boundary + await epochManager.setEpochLength(10) await advanceToNextEpoch(epochManager) + // Setup await setupIndexerAllocation() + const firstSnapshotBlocks = new BN((await latestBlock()).sub(dripBlock).toString()) // Jump await advanceToNextEpoch(epochManager) @@ -618,21 +701,20 @@ describe('Rewards', () => { const beforeIndexer1Balance = await grt.balanceOf(indexer1.address) const beforeStakingBalance = await grt.balanceOf(staking.address) - // All the rewards in this subgraph go to this allocation. - // Rewards per token will be (totalSupply * issuanceRate^nBlocks - totalSupply) / allocatedTokens - // The first snapshot is after allocating, that is 2 blocks after the signal is minted: - // startRewardsPerToken = (10004000000 * 1.01227 ^ 2 - 10004000000) / 12500 = 122945.16 - // The final snapshot is when we close the allocation, that happens 9 blocks later: - // endRewardsPerToken = (10004000000 * 1.01227 ^ 9 - 10004000000) / 12500 = 92861.24 - // Then our expected rewards are (endRewardsPerToken - startRewardsPerToken) * 12500. - const expectedIndexingRewards = toGRT('913715958') - // Close allocation. At this point rewards should be collected for that indexer const tx = await staking .connect(indexer1.signer) .closeAllocation(allocationID1, randomHexBytes()) const receipt = await tx.wait() - const event = rewardsManager.interface.parseLog(receipt.logs[1]).args + + const lastSnapshotBlocks = new BN((await latestBlock()).sub(dripBlock).toString()) + const expectedIndexingRewards = calculatedExpectedRewards( + firstSnapshotBlocks, + lastSnapshotBlocks, + new BN(12500), + ) + + const event = findRewardsManagerEvents(receipt)[0].args expect(event.indexer).eq(indexer1.address) expect(event.allocationID).eq(allocationID1) expect(event.epoch).eq(await epochManager.currentEpoch()) @@ -646,7 +728,7 @@ describe('Rewards', () => { // Check that rewards are put into indexer stake const expectedIndexerStake = beforeIndexer1Stake.add(expectedIndexingRewards) - const expectedTokenSupply = beforeTokenSupply.add(expectedIndexingRewards) + // Check stake should have increased with the rewards staked expect(toRound(afterIndexer1Stake)).eq(toRound(expectedIndexerStake)) // Check indexer balance remains the same @@ -655,8 +737,8 @@ describe('Rewards', () => { expect(toRound(afterStakingBalance)).eq( toRound(beforeStakingBalance.add(expectedIndexingRewards)), ) - // Check that tokens have been minted - expect(toRound(afterTokenSupply)).eq(toRound(expectedTokenSupply)) + // Check that tokens have NOT been minted + expect(toRound(afterTokenSupply)).eq(toRound(beforeTokenSupply)) }) it('does not revert with an underflow if the minimum signal changes', async function () { @@ -716,10 +798,12 @@ describe('Rewards', () => { const destinationAddress = randomHexBytes(20) await staking.connect(indexer1.signer).setRewardsDestination(destinationAddress) + await epochManager.setEpochLength(10) // Align with the epoch boundary await advanceToNextEpoch(epochManager) // Setup await setupIndexerAllocation() + const firstSnapshotBlocks = new BN((await latestBlock()).sub(dripBlock).toString()) // Jump await advanceToNextEpoch(epochManager) @@ -730,24 +814,22 @@ describe('Rewards', () => { const beforeDestinationBalance = await grt.balanceOf(destinationAddress) const beforeStakingBalance = await grt.balanceOf(staking.address) - // All the rewards in this subgraph go to this allocation. - // Rewards per token will be (totalSupply * issuanceRate^nBlocks - totalSupply) / allocatedTokens - // The first snapshot is after allocating, that is 2 blocks after the signal is minted: - // startRewardsPerToken = (10004000000 * 1.01227 ^ 2 - 10004000000) / 12500 = 122945.16 - // The final snapshot is when we close the allocation, that happens 9 blocks later: - // endRewardsPerToken = (10004000000 * 1.01227 ^ 9 - 10004000000) / 12500 = 92861.24 - // Then our expected rewards are (endRewardsPerToken - startRewardsPerToken) * 12500. - const expectedIndexingRewards = toGRT('913715958') - // Close allocation. At this point rewards should be collected for that indexer const tx = await staking .connect(indexer1.signer) .closeAllocation(allocationID1, randomHexBytes()) const receipt = await tx.wait() - const event = rewardsManager.interface.parseLog(receipt.logs[1]).args + const lastSnapshotBlocks = new BN((await latestBlock()).sub(dripBlock).toString()) + const event = findRewardsManagerEvents(receipt)[0].args expect(event.indexer).eq(indexer1.address) expect(event.allocationID).eq(allocationID1) expect(event.epoch).eq(await epochManager.currentEpoch()) + + const expectedIndexingRewards = calculatedExpectedRewards( + firstSnapshotBlocks, + lastSnapshotBlocks, + new BN(12500), + ) expect(toRound(event.amount)).eq(toRound(expectedIndexingRewards)) // After state @@ -758,7 +840,7 @@ describe('Rewards', () => { // Check that rewards are properly assigned const expectedIndexerStake = beforeIndexer1Stake - const expectedTokenSupply = beforeTokenSupply.add(expectedIndexingRewards) + // Check stake should not have changed expect(toRound(afterIndexer1Stake)).eq(toRound(expectedIndexerStake)) // Check indexing rewards are received by the rewards destination @@ -767,8 +849,8 @@ describe('Rewards', () => { ) // Check indexing rewards were not sent to the staking contract expect(afterStakingBalance).eq(beforeStakingBalance) - // Check that tokens have been minted - expect(toRound(afterTokenSupply)).eq(toRound(expectedTokenSupply)) + // Check that tokens have NOT been minted + expect(toRound(afterTokenSupply)).eq(toRound(beforeTokenSupply)) }) it('should distribute rewards on closed allocation w/delegators', async function () { @@ -779,14 +861,18 @@ describe('Rewards', () => { cooldownBlocks: 5, } const tokensToDelegate = toGRT('2000') + await epochManager.setEpochLength(10) // Align with the epoch boundary await advanceToNextEpoch(epochManager) + // Setup the allocation and delegators await setupIndexerAllocationWithDelegation(tokensToDelegate, delegationParams) + const firstSnapshotBlocks = new BN((await latestBlock()).sub(dripBlock).toString()) // Jump await advanceToNextEpoch(epochManager) + // dripBlock + 13 // Before state const beforeTokenSupply = await grt.totalSupply() @@ -795,6 +881,7 @@ describe('Rewards', () => { // Close allocation. At this point rewards should be collected for that indexer await staking.connect(indexer1.signer).closeAllocation(allocationID1, randomHexBytes()) + const lastSnapshotBlocks = new BN((await latestBlock()).sub(dripBlock).toString()) // After state const afterTokenSupply = await grt.totalSupply() @@ -804,14 +891,12 @@ describe('Rewards', () => { // Check that rewards are put into indexer stake (only indexer cut) // Check that rewards are put into delegators pool accordingly - // All the rewards in this subgraph go to this allocation. - // Rewards per token will be (totalSupply * issuanceRate^nBlocks - totalSupply) / allocatedTokens - // The first snapshot is after allocating, that is 2 blocks after the signal is minted: - // startRewardsPerToken = (10004000000 * 1.01227 ^ 2 - 10004000000) / 14500 = 8466.995 - // The final snapshot is when we close the allocation, that happens 4 blocks later: - // endRewardsPerToken = (10004000000 * 1.01227 ^ 4 - 10004000000) / 14500 = 34496.55 - // Then our expected rewards are (endRewardsPerToken - startRewardsPerToken) * 14500. - const expectedIndexingRewards = toGRT('377428566.77') + const expectedIndexingRewards = calculatedExpectedRewards( + firstSnapshotBlocks, + lastSnapshotBlocks, + new BN(14500), + ) + // Calculate delegators cut const indexerRewards = delegationParams.indexingRewardCut .mul(expectedIndexingRewards) @@ -821,101 +906,162 @@ describe('Rewards', () => { // Check const expectedIndexerStake = beforeIndexer1Stake.add(indexerRewards) const expectedDelegatorsPoolTokens = beforeDelegationPool.tokens.add(delegatorsRewards) - const expectedTokenSupply = beforeTokenSupply.add(expectedIndexingRewards) expect(toRound(afterIndexer1Stake)).eq(toRound(expectedIndexerStake)) expect(toRound(afterDelegationPool.tokens)).eq(toRound(expectedDelegatorsPoolTokens)) - // Check that tokens have been minted - expect(toRound(afterTokenSupply)).eq(toRound(expectedTokenSupply)) + // Check that tokens have NOT been minted + expect(toRound(afterTokenSupply)).eq(toRound(beforeTokenSupply)) }) - it('should deny rewards if subgraph on denylist', async function () { + it('should deny and burn rewards if subgraph on denylist', async function () { // Setup + // dripBlock (82) + await epochManager.setEpochLength(10) + // dripBlock + 1 await rewardsManager .connect(governor.signer) .setSubgraphAvailabilityOracle(governor.address) + // dripBlock + 2 await rewardsManager.connect(governor.signer).setDenied(subgraphDeploymentID1, true) + // dripBlock + 3 (epoch boundary!) + await advanceToNextEpoch(epochManager) + // dripBlock + 13 await setupIndexerAllocation() + const firstSnapshotBlocks = new BN((await latestBlock()).sub(dripBlock).toString()) // Jump await advanceToNextEpoch(epochManager) + // dripBlock + 23 + const supplyBefore = await grt.totalSupply() // Close allocation. At this point rewards should be collected for that indexer const tx = staking.connect(indexer1.signer).closeAllocation(allocationID1, randomHexBytes()) - await expect(tx) - .emit(rewardsManager, 'RewardsDenied') - .withArgs(indexer1.address, allocationID1, await epochManager.currentEpoch()) + await expect(tx).emit(rewardsManager, 'RewardsDenied') + const lastSnapshotBlocks = new BN((await latestBlock()).sub(dripBlock).toString()) + const receipt = await (await tx).wait() + const logs = findRewardsManagerEvents(receipt) + expect(logs.length).to.eq(1) + expect(logs[0].name).to.eq('RewardsDenied') + const ev = logs[0].args + expect(ev.indexer).to.eq(indexer1.address) + expect(ev.allocationID).to.eq(allocationID1) + expect(ev.epoch).to.eq(await epochManager.currentEpoch()) + + const expectedIndexingRewards = calculatedExpectedRewards( + firstSnapshotBlocks, + lastSnapshotBlocks, + new BN(12500), + ) + expect(toRound(ev.amount)).to.eq(toRound(expectedIndexingRewards)) + // Check that the rewards were burned + // We divide by 10 to accept numeric errors up to 10 GRT + expect(toRound((await grt.totalSupply()).div(10))).to.eq( + toRound(supplyBefore.sub(expectedIndexingRewards).div(10)), + ) }) }) - }) - describe('pow', function () { - it('exponentiation works under normal boundaries (annual rate from 1% to 700%, 90 days period)', async function () { - const baseRatio = toGRT('0.000000004641377923') // 1% annual rate - const timePeriods = (60 * 60 * 24 * 10) / 15 // 90 days in blocks - for (let i = 0; i < 50; i = i + 4) { - const r = baseRatio.mul(i * 4).add(toGRT('1')) - const h = await rewardsManagerMock.pow(r, timePeriods, toGRT('1')) - console.log('\tr:', formatGRT(r), '=> c:', formatGRT(h)) - } - }) - }) + describe('edge scenarios', function () { + it('close allocation on a subgraph that no longer have signal', async function () { + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1.signer).mint(subgraphDeploymentID1, signalled1, 0) - describe('edge scenarios', function () { - it('close allocation on a subgraph that no longer have signal', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1.signer).mint(subgraphDeploymentID1, signalled1, 0) + // Allocate + const tokensToAllocate = toGRT('12500') + await staking.connect(indexer1.signer).stake(tokensToAllocate) + await staking + .connect(indexer1.signer) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAllocate, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1.signer).stake(tokensToAllocate) - await staking - .connect(indexer1.signer) - .allocateFrom( - indexer1.address, + // Jump + await advanceToNextEpoch(epochManager) + + // Remove all signal from the subgraph + const curatorShares = await curation.getCuratorSignal( + curator1.address, subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), ) + await curation.connect(curator1.signer).burn(subgraphDeploymentID1, curatorShares, 0) - // Jump - await advanceToNextEpoch(epochManager) + // Close allocation. At this point rewards should be collected for that indexer + await staking.connect(indexer1.signer).closeAllocation(allocationID1, randomHexBytes()) + }) + }) - // Remove all signal from the subgraph - const curatorShares = await curation.getCuratorSignal(curator1.address, subgraphDeploymentID1) - await curation.connect(curator1.signer).burn(subgraphDeploymentID1, curatorShares, 0) + describe('multiple allocations', function () { + it('two allocations in the same block with a GRT burn in the middle should succeed', async function () { + // If rewards are not monotonically increasing, this can trigger + // a subtraction overflow error as seen in mainnet tx: + // 0xb6bf7bbc446720a7409c482d714aebac239dd62e671c3c94f7e93dd3a61835ab + await advanceToNextEpoch(epochManager) - // Close allocation. At this point rewards should be collected for that indexer - await staking.connect(indexer1.signer).closeAllocation(allocationID1, randomHexBytes()) - }) - }) + // Setup + await epochManager.setEpochLength(10) - describe('multiple allocations', function () { - it('two allocations in the same block with a GRT burn in the middle should succeed', async function () { - // If rewards are not monotonically increasing, this can trigger - // a subtraction overflow error as seen in mainnet tx: - // 0xb6bf7bbc446720a7409c482d714aebac239dd62e671c3c94f7e93dd3a61835ab - await advanceToNextEpoch(epochManager) + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1.signer).mint(subgraphDeploymentID1, signalled1, 0) - // Setup - await epochManager.setEpochLength(10) + // Stake + const tokensToStake = toGRT('12500') + await staking.connect(indexer1.signer).stake(tokensToStake) - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1.signer).mint(subgraphDeploymentID1, signalled1, 0) + // Allocate simultaneously, burning in the middle + const tokensToAlloc = toGRT('5000') + await provider().send('evm_setAutomine', [false]) + const tx1 = await staking + .connect(indexer1.signer) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAlloc, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) + const tx2 = await grt.connect(indexer1.signer).burn(toGRT(1)) + const tx3 = await staking + .connect(indexer1.signer) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAlloc, + allocationID2, + metadata, + await channelKey2.generateProof(indexer1.address), + ) - // Stake - const tokensToStake = toGRT('12500') - await staking.connect(indexer1.signer).stake(tokensToStake) + await provider().send('evm_mine', []) + await provider().send('evm_setAutomine', [true]) - // Allocate simultaneously, burning in the middle - const tokensToAlloc = toGRT('5000') - await provider().send('evm_setAutomine', [false]) - const tx1 = await staking - .connect(indexer1.signer) - .allocateFrom( + await expect(tx1).emit(staking, 'AllocationCreated') + await expect(tx2).emit(grt, 'Transfer') + await expect(tx3).emit(staking, 'AllocationCreated') + }) + it('two simultanous-similar allocations should get same amount of rewards', async function () { + await advanceToNextEpoch(epochManager) + + // Setup + await epochManager.setEpochLength(10) + + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1.signer).mint(subgraphDeploymentID1, signalled1, 0) + + // Stake + const tokensToStake = toGRT('12500') + await staking.connect(indexer1.signer).stake(tokensToStake) + + // Allocate simultaneously + const tokensToAlloc = toGRT('5000') + const tx1 = await staking.populateTransaction.allocateFrom( indexer1.address, subgraphDeploymentID1, tokensToAlloc, @@ -923,10 +1069,7 @@ describe('Rewards', () => { metadata, await channelKey1.generateProof(indexer1.address), ) - const tx2 = await grt.connect(indexer1.signer).burn(toGRT(1)) - const tx3 = await staking - .connect(indexer1.signer) - .allocateFrom( + const tx2 = await staking.populateTransaction.allocateFrom( indexer1.address, subgraphDeploymentID1, tokensToAlloc, @@ -934,61 +1077,31 @@ describe('Rewards', () => { metadata, await channelKey2.generateProof(indexer1.address), ) + await staking.connect(indexer1.signer).multicall([tx1.data, tx2.data]) - await provider().send('evm_mine', []) - await provider().send('evm_setAutomine', [true]) - - await expect(tx1).emit(staking, 'AllocationCreated') - await expect(tx2).emit(grt, 'Transfer') - await expect(tx3).emit(staking, 'AllocationCreated') - }) - it('two simultanous-similar allocations should get same amount of rewards', async function () { - await advanceToNextEpoch(epochManager) - - // Setup - await epochManager.setEpochLength(10) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1.signer).mint(subgraphDeploymentID1, signalled1, 0) - - // Stake - const tokensToStake = toGRT('12500') - await staking.connect(indexer1.signer).stake(tokensToStake) - - // Allocate simultaneously - const tokensToAlloc = toGRT('5000') - const tx1 = await staking.populateTransaction.allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAlloc, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - const tx2 = await staking.populateTransaction.allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAlloc, - allocationID2, - metadata, - await channelKey2.generateProof(indexer1.address), - ) - await staking.connect(indexer1.signer).multicall([tx1.data, tx2.data]) - - // Jump - await advanceToNextEpoch(epochManager) - - // Close allocations simultaneously - const tx3 = await staking.populateTransaction.closeAllocation(allocationID1, randomHexBytes()) - const tx4 = await staking.populateTransaction.closeAllocation(allocationID2, randomHexBytes()) - const tx5 = await staking.connect(indexer1.signer).multicall([tx3.data, tx4.data]) + // Jump + await advanceToNextEpoch(epochManager) - // Both allocations should receive the same amount of rewards - const receipt = await tx5.wait() - const event1 = rewardsManager.interface.parseLog(receipt.logs[1]).args - const event2 = rewardsManager.interface.parseLog(receipt.logs[5]).args - expect(event1.amount).eq(event2.amount) + // Close allocations simultaneously + const tx3 = await staking.populateTransaction.closeAllocation( + allocationID1, + randomHexBytes(), + ) + const tx4 = await staking.populateTransaction.closeAllocation( + allocationID2, + randomHexBytes(), + ) + const tx5 = await staking.connect(indexer1.signer).multicall([tx3.data, tx4.data]) + + // Both allocations should receive the same amount of rewards + const receipt = await tx5.wait() + const rewardsMgrEvents = findRewardsManagerEvents(receipt) + expect(rewardsMgrEvents.length).to.eq(2) + const event1 = rewardsMgrEvents[0].args + const event2 = rewardsMgrEvents[1].args + expect(event1.amount).to.not.eq(toBN(0)) + expect(event1.amount).to.eq(event2.amount) + }) }) }) })