diff --git a/contracts/l2/reservoir/IL2Reservoir.sol b/contracts/l2/reservoir/IL2Reservoir.sol index 90b089ac9..8144efae2 100644 --- a/contracts/l2/reservoir/IL2Reservoir.sol +++ b/contracts/l2/reservoir/IL2Reservoir.sol @@ -2,7 +2,7 @@ pragma solidity ^0.7.6; -import "../../reservoir/IReservoir.sol"; +import { IReservoir } from "../../reservoir/IReservoir.sol"; /** * @title Interface for the L2 Rewards Reservoir @@ -18,13 +18,23 @@ interface IL2Reservoir is IReservoir { * 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 _nonce, + uint256 _keeperReward, + address _l1Keeper ) external; } diff --git a/contracts/l2/reservoir/L2Reservoir.sol b/contracts/l2/reservoir/L2Reservoir.sol index 1bc93c912..e5cce6fee 100644 --- a/contracts/l2/reservoir/L2Reservoir.sol +++ b/contracts/l2/reservoir/L2Reservoir.sol @@ -3,12 +3,30 @@ pragma solidity ^0.7.6; pragma abicoder v2; -import "@openzeppelin/contracts/math/SafeMath.sol"; +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; +import { ArbRetryableTx } from "arbos-precompiles/arbos/builtin/ArbRetryableTx.sol"; -import "../../reservoir/IReservoir.sol"; -import "../../reservoir/Reservoir.sol"; -import "./IL2Reservoir.sol"; -import "./L2ReservoirStorage.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 @@ -16,11 +34,20 @@ import "./L2ReservoirStorage.sol"; * 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 L2ReservoirV1Storage, Reservoir, IL2Reservoir { +contract L2Reservoir is L2ReservoirV2Storage, Reservoir, IL2Reservoir { using SafeMath for uint256; - event DripReceived(uint256 _issuanceBase); - event NextDripNonceUpdated(uint256 _nonce); + // 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. @@ -36,6 +63,10 @@ contract L2Reservoir is L2ReservoirV1Storage, Reservoir, IL2Reservoir { * 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 { @@ -52,6 +83,30 @@ contract L2Reservoir is L2ReservoirV1Storage, Reservoir, IL2Reservoir { 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: @@ -88,14 +143,21 @@ contract L2Reservoir is L2ReservoirV1Storage, Reservoir, IL2Reservoir { * 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 _nonce, + uint256 _keeperReward, + address _l1Keeper ) external override onlyL2Gateway { require(_nonce == nextDripNonce, "INVALID_NONCE"); nextDripNonce = nextDripNonce.add(1); @@ -108,6 +170,22 @@ contract L2Reservoir is L2ReservoirV1Storage, Reservoir, IL2Reservoir { 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); } diff --git a/contracts/l2/reservoir/L2ReservoirStorage.sol b/contracts/l2/reservoir/L2ReservoirStorage.sol index ee7880343..4d469f889 100644 --- a/contracts/l2/reservoir/L2ReservoirStorage.sol +++ b/contracts/l2/reservoir/L2ReservoirStorage.sol @@ -3,9 +3,20 @@ pragma solidity ^0.7.6; /** - * @dev Storage variables for the L2Reservoir + * @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/L1Reservoir.sol b/contracts/reservoir/L1Reservoir.sol index 674e127ef..1fc1c30ce 100644 --- a/contracts/reservoir/L1Reservoir.sol +++ b/contracts/reservoir/L1Reservoir.sol @@ -3,13 +3,16 @@ pragma solidity ^0.7.6; pragma abicoder v2; -import "@openzeppelin/contracts/math/SafeMath.sol"; +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; -import "../arbitrum/ITokenGateway.sol"; +import { ITokenGateway } from "../arbitrum/ITokenGateway.sol"; -import "../l2/reservoir/IL2Reservoir.sol"; -import "./Reservoir.sol"; -import "./L1ReservoirStorage.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 @@ -17,7 +20,7 @@ import "./L1ReservoirStorage.sol"; * 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 L1ReservoirV1Storage, Reservoir { +contract L1Reservoir is L1ReservoirV2Storage, Reservoir { using SafeMath for uint256; // Emitted when the initial supply snapshot is taken after contract deployment @@ -38,6 +41,34 @@ contract L1Reservoir is L1ReservoirV1Storage, Reservoir { 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. @@ -46,6 +77,8 @@ contract L1Reservoir is L1ReservoirV1Storage, Reservoir { * 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 @@ -105,6 +138,27 @@ contract L1Reservoir is L1ReservoirV1Storage, Reservoir { 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. @@ -116,6 +170,28 @@ contract L1Reservoir is L1ReservoirV1Storage, Reservoir { 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 @@ -140,7 +216,7 @@ contract L1Reservoir is L1ReservoirV1Storage, Reservoir { * 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 equal to l2MaxSubmissionCost + (l2MaxGas * l2GasPriceBid), and must + * 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 @@ -150,19 +226,96 @@ contract L1Reservoir is L1ReservoirV1Storage, Reservoir { * 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 - ) external payable notPaused { + 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 @@ -176,19 +329,18 @@ contract L1Reservoir is L1ReservoirV1Storage, Reservoir { // n = deltaR(t1, t0) uint256 newRewardsToDistribute = getNewGlobalRewards(rewardsMintedUntilBlock); // N = n - eps - uint256 tokensToMint; + uint256 rewardsTokensToMint; { uint256 newRewardsPlusMintedActual = newRewardsToDistribute.add(mintedRewardsActual); require( - newRewardsPlusMintedActual >= mintedRewardsTotal, - "Would mint negative tokens, wait before calling again" + newRewardsPlusMintedActual > mintedRewardsTotal, + "Would mint negative or zero tokens, wait before calling again" ); - tokensToMint = newRewardsPlusMintedActual.sub(mintedRewardsTotal); + rewardsTokensToMint = newRewardsPlusMintedActual.sub(mintedRewardsTotal); } - if (tokensToMint > 0) { - graphToken().mint(address(this), tokensToMint); - } + IGraphToken grt = graphToken(); + grt.mint(address(this), rewardsTokensToMint.add(keeperReward)); uint256 tokensToSendToL2 = 0; if (l2RewardsFraction != nextL2RewardsFraction) { @@ -209,9 +361,9 @@ contract L1Reservoir is L1ReservoirV1Storage, Reservoir { tokensToSendToL2 > l2OffsetAmount, "Negative amount would be sent to L2, wait before calling again" ); - tokensToSendToL2 = tokensToSendToL2.sub(l2OffsetAmount); + tokensToSendToL2 = tokensToSendToL2.add(keeperReward).sub(l2OffsetAmount); } else { - tokensToSendToL2 = tokensToSendToL2.add( + tokensToSendToL2 = tokensToSendToL2.add(keeperReward).add( l2RewardsFraction.mul(mintedRewardsActual.sub(mintedRewardsTotal)).div( FIXED_POINT_SCALING_FACTOR ) @@ -223,22 +375,35 @@ contract L1Reservoir is L1ReservoirV1Storage, Reservoir { tokensToSendToL2, _l2MaxGas, _l2GasPriceBid, - _l2MaxSubmissionCost + _l2MaxSubmissionCost, + keeperReward, + _keeperRewardBeneficiary ); } else if (l2RewardsFraction > 0) { - tokensToSendToL2 = tokensToMint.mul(l2RewardsFraction).div(FIXED_POINT_SCALING_FACTOR); + tokensToSendToL2 = rewardsTokensToMint + .mul(l2RewardsFraction) + .div(FIXED_POINT_SCALING_FACTOR) + .add(keeperReward); _sendNewTokensAndStateToL2( tokensToSendToL2, _l2MaxGas, _l2GasPriceBid, - _l2MaxSubmissionCost + _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(tokensToMint, tokensToSendToL2, rewardsMintedUntilBlock); + emit RewardsDripped( + rewardsTokensToMint.add(keeperReward), + tokensToSendToL2, + rewardsMintedUntilBlock + ); } /** @@ -317,12 +482,16 @@ contract L1Reservoir is L1ReservoirV1Storage, Reservoir { * @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 _maxSubmissionCost, + uint256 _keeperReward, + address _keeper ) internal { uint256 l2IssuanceBase = l2RewardsFraction.mul(issuanceBase).div( FIXED_POINT_SCALING_FACTOR @@ -331,7 +500,9 @@ contract L1Reservoir is L1ReservoirV1Storage, Reservoir { IL2Reservoir.receiveDrip.selector, l2IssuanceBase, issuanceRate, - nextDripNonce + nextDripNonce, + _keeperReward, + _keeper ); nextDripNonce = nextDripNonce.add(1); bytes memory data = abi.encode(_maxSubmissionCost, extraData); @@ -347,4 +518,13 @@ contract L1Reservoir is L1ReservoirV1Storage, Reservoir { 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 index 90821c809..9f60249bd 100644 --- a/contracts/reservoir/L1ReservoirStorage.sol +++ b/contracts/reservoir/L1ReservoirStorage.sol @@ -3,7 +3,7 @@ pragma solidity ^0.7.6; /** - * @dev Storage variables for the L1Reservoir + * @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 @@ -21,3 +21,16 @@ contract L1ReservoirV1Storage { // 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 index d2a0bf6cb..fff7f108a 100644 --- a/contracts/reservoir/Reservoir.sol +++ b/contracts/reservoir/Reservoir.sol @@ -19,7 +19,9 @@ import "./IReservoir.sol"; 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; /** diff --git a/contracts/reservoir/ReservoirStorage.sol b/contracts/reservoir/ReservoirStorage.sol index b46e44d35..b964d87b0 100644 --- a/contracts/reservoir/ReservoirStorage.sol +++ b/contracts/reservoir/ReservoirStorage.sol @@ -5,7 +5,7 @@ pragma solidity ^0.7.6; import "../governance/Managed.sol"; /** - * @dev Base storage variables for the Reservoir on both layers + * @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. diff --git a/test/l2/l2Reservoir.test.ts b/test/l2/l2Reservoir.test.ts index ebfe3f52e..26a9ccc75 100644 --- a/test/l2/l2Reservoir.test.ts +++ b/test/l2/l2Reservoir.test.ts @@ -4,6 +4,7 @@ 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, @@ -15,6 +16,7 @@ import { Account, RewardsTracker, getL2SignerFromL1, + applyL1ToL2Alias, } from '../lib/testHelpers' import { L2Reservoir } from '../../build/types/L2Reservoir' @@ -30,11 +32,13 @@ 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 @@ -106,6 +110,7 @@ describe('L2Reservoir', () => { const validGatewayFinalizeTransfer = async ( callhookData: string, + keeperReward = toGRT('0'), ): Promise => { const tx = await gatewayFinalizeTransfer(callhookData) await expect(tx) @@ -116,12 +121,12 @@ describe('L2Reservoir', () => { // newly minted GRT const receiverBalance = await grt.balanceOf(l2Reservoir.address) - await expect(receiverBalance).eq(dripAmount) + await expect(receiverBalance).eq(dripAmount.sub(keeperReward)) return tx } before(async function () { - ;[governor, testAccount1, mockRouter, mockL1GRT, mockL1Gateway, mockL1Reservoir] = + ;[governor, testAccount1, mockRouter, mockL1GRT, mockL1Gateway, mockL1Reservoir, testAccount2] = await getAccounts() fixture = new NetworkFixture() @@ -135,6 +140,11 @@ describe('L2Reservoir', () => { mockL1Gateway.address, mockL1Reservoir.address, ) + + arbTxMock = await smock.fake('IArbTxWithRedeemer', { + address: '0x000000000000000000000000000000000000006E', + }) + arbTxMock.getCurrentRedeemer.returns(applyL1ToL2Alias(mockL1Reservoir.address)) }) beforeEach(async function () { @@ -156,11 +166,56 @@ describe('L2Reservoir', () => { 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')) + .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 () { @@ -169,6 +224,8 @@ describe('L2Reservoir', () => { dripNormalizedSupply, dripIssuanceRate, toBN('0'), + toBN('0'), + testAccount1.address, ) const tx = await validGatewayFinalizeTransfer(receiveDripTx.data) dripBlock = await latestBlock() @@ -181,6 +238,8 @@ describe('L2Reservoir', () => { dripNormalizedSupply.add(1), dripIssuanceRate.add(1), toBN('2'), + toBN('0'), + testAccount1.address, ) const tx2 = gatewayFinalizeTransfer(receiveDripTx.data) dripBlock = await latestBlock() @@ -192,6 +251,8 @@ describe('L2Reservoir', () => { dripNormalizedSupply, dripIssuanceRate, toBN('0'), + toBN('0'), + testAccount1.address, ) const tx = await validGatewayFinalizeTransfer(receiveDripTx.data) dripBlock = await latestBlock() @@ -199,12 +260,60 @@ describe('L2Reservoir', () => { 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() @@ -216,6 +325,8 @@ describe('L2Reservoir', () => { dripNormalizedSupply.add(1), dripIssuanceRate.add(1), toBN('1'), + toBN('0'), + testAccount1.address, ) tx = await gatewayFinalizeTransfer(receiveDripTx.data) dripBlock = await latestBlock() @@ -230,6 +341,8 @@ describe('L2Reservoir', () => { dripNormalizedSupply, dripIssuanceRate, toBN('0'), + toBN('0'), + testAccount1.address, ) let tx = await validGatewayFinalizeTransfer(receiveDripTx.data) dripBlock = await latestBlock() @@ -241,6 +354,8 @@ describe('L2Reservoir', () => { dripNormalizedSupply.add(1), dripIssuanceRate, toBN('1'), + toBN('0'), + testAccount1.address, ) tx = await gatewayFinalizeTransfer(receiveDripTx.data) dripBlock = await latestBlock() @@ -255,6 +370,8 @@ describe('L2Reservoir', () => { dripNormalizedSupply, dripIssuanceRate, toBN('0'), + toBN('0'), + testAccount1.address, ) let tx = await validGatewayFinalizeTransfer(receiveDripTx.data) dripBlock = await latestBlock() @@ -267,6 +384,8 @@ describe('L2Reservoir', () => { dripNormalizedSupply.add(1), dripIssuanceRate, toBN('2'), + toBN('0'), + testAccount1.address, ) tx = await gatewayFinalizeTransfer(receiveDripTx.data) dripBlock = await latestBlock() @@ -285,6 +404,8 @@ describe('L2Reservoir', () => { dripNormalizedSupply, ISSUANCE_RATE_PER_BLOCK, toBN('0'), + toBN('0'), + testAccount1.address, ) await validGatewayFinalizeTransfer(receiveDripTx.data) dripBlock = await latestBlock() diff --git a/test/lib/testHelpers.ts b/test/lib/testHelpers.ts index 6673349e3..998d0d335 100644 --- a/test/lib/testHelpers.ts +++ b/test/lib/testHelpers.ts @@ -2,7 +2,7 @@ import hre from 'hardhat' import '@nomiclabs/hardhat-ethers' import '@nomiclabs/hardhat-waffle' import { providers, utils, BigNumber, Signer, Wallet } from 'ethers' -import { formatUnits, getAddress } from 'ethers/lib/utils' +import { formatUnits, getAddress, hexValue } from 'ethers/lib/utils' import { BigNumber as BN } from 'bignumber.js' import { EpochManager } from '../../build/types/EpochManager' @@ -66,24 +66,17 @@ export const advanceBlockTo = async (blockNumber: string | number | BigNumber): ? toBN(blockNumber) : blockNumber const currentBlock = await latestBlock() - const start = Date.now() - let notified - if (target.lt(currentBlock)) + if (target.lt(currentBlock)) { throw Error(`Target block #(${target}) is lower than current block #(${currentBlock})`) - while ((await latestBlock()).lt(target)) { - if (!notified && Date.now() - start >= 5000) { - notified = true - console.log(`advanceBlockTo: Advancing too ` + 'many blocks is causing this test to be slow.') - } - await advanceBlock() + } else if (target.eq(currentBlock)) { + return + } else { + await advanceBlocks(target.sub(currentBlock)) } } export const advanceBlocks = async (blocks: string | number | BigNumber): Promise => { - const steps = typeof blocks === 'number' || typeof blocks === 'string' ? toBN(blocks) : blocks - const currentBlock = await latestBlock() - const toBlock = currentBlock.add(steps) - return advanceBlockTo(toBlock) + await provider().send('hardhat_mine', [hexValue(BigNumber.from(blocks))]) } export const advanceToNextEpoch = async (epochManager: EpochManager): Promise => { @@ -185,7 +178,7 @@ export class RewardsTracker { async snapshotPerSignal(totalSignal: BigNumber, atBlock?: BigNumber): Promise { this.accumulatedPerSignal = await this.accRewardsPerSignal(totalSignal, atBlock) this.accumulatedAtLastPerSignalUpdatedBlock = await this.accRewards(atBlock) - this.lastPerSignalUpdatedBlock = atBlock + this.lastPerSignalUpdatedBlock = atBlock || (await latestBlock()) return this.accumulatedPerSignal } diff --git a/test/reservoir/l1Reservoir.test.ts b/test/reservoir/l1Reservoir.test.ts index afc6233ca..261a36c39 100644 --- a/test/reservoir/l1Reservoir.test.ts +++ b/test/reservoir/l1Reservoir.test.ts @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { BigNumber, constants, utils } from 'ethers' +import { BigNumber, constants } from 'ethers' import { defaults, deployContract, deployL1Reservoir } from '../lib/deployment' import { ArbitrumL1Mocks, L1FixtureContracts, NetworkFixture } from '../lib/fixtures' @@ -17,7 +17,6 @@ import { formatGRT, Account, RewardsTracker, - provider, } from '../lib/testHelpers' import { L1Reservoir } from '../../build/types/L1Reservoir' import { BridgeEscrow } from '../../build/types/BridgeEscrow' @@ -26,16 +25,16 @@ import path from 'path' import { Artifacts } from 'hardhat/internal/artifacts' import { Interface } from 'ethers/lib/utils' import { L1GraphTokenGateway } from '../../build/types/L1GraphTokenGateway' -import { SubgraphDeploymentID } from '@graphprotocol/common-ts' 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).split('.')[0] +const toRound = (n: BigNumber) => formatGRT(n.add(toGRT('0.5'))).split('.')[0] const maxGas = toBN('1000000') const maxSubmissionCost = toBN('7') @@ -45,10 +44,13 @@ 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 @@ -58,6 +60,7 @@ describe('L1Reservoir', () => { let l1GraphTokenGateway: L1GraphTokenGateway let controller: Controller let proxyAdmin: GraphProxyAdmin + let staking: Staking let supplyBeforeDrip: BigNumber let dripBlock: BigNumber @@ -120,7 +123,9 @@ describe('L1Reservoir', () => { expect(await tracker.accRewards(dripBlock)).to.eq(0) let expectedNextDeadline = dripBlock.add(dripInterval) let expectedMintedAmount = await tracker.accRewards(expectedNextDeadline) - const tx1 = await l1Reservoir.connect(governor.signer).drip(toBN(0), toBN(0), toBN(0)) + 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)) @@ -133,7 +138,9 @@ describe('L1Reservoir', () => { await advanceBlocks(blocksToAdvance) - const tx2 = await l1Reservoir.connect(governor.signer).drip(toBN(0), toBN(0), toBN(0)) + 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()) @@ -147,12 +154,21 @@ describe('L1Reservoir', () => { } before(async function () { - ;[governor, testAccount1, mockRouter, mockL2GRT, mockL2Gateway, mockL2Reservoir] = - await getAccounts() + ;[ + 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 } = + ;({ grt, l1Reservoir, bridgeEscrow, l1GraphTokenGateway, controller, proxyAdmin, staking } = fixtureContracts) await l1Reservoir.connect(governor.signer).initialSnapshot(toBN(0)) @@ -166,6 +182,7 @@ describe('L1Reservoir', () => { mockL2Gateway.address, mockL2Reservoir.address, ) + await l1Reservoir.connect(governor.signer).grantDripPermission(keeper.address) reservoirMock = (await deployContract( 'ReservoirMock', governor.signer, @@ -248,7 +265,9 @@ describe('L1Reservoir', () => { await expect(tx).emit(l1Reservoir, 'IssuanceRateStaged').withArgs(newIssuanceRate) expect(await l1Reservoir.issuanceRate()).eq(0) expect(await l1Reservoir.nextIssuanceRate()).eq(newIssuanceRate) - tx = l1Reservoir.connect(governor.signer).drip(toBN(0), toBN(0), toBN(0)) + 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) }) @@ -313,17 +332,153 @@ describe('L1Reservoir', () => { await expect(tx).emit(l1Reservoir, 'L2RewardsFractionStaged').withArgs(newValue) expect(await l1Reservoir.nextL2RewardsFraction()).eq(newValue) tx = l1Reservoir - .connect(governor.signer) - .drip(maxGas, gasPriceBid, maxSubmissionCost, { value: defaultEthValue }) + .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()) @@ -337,7 +492,9 @@ describe('L1Reservoir', () => { 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(governor.signer).drip(toBN(0), toBN(0), toBN(0)) + 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) @@ -345,7 +502,7 @@ describe('L1Reservoir', () => { .emit(l1Reservoir, 'RewardsDripped') .withArgs(actualAmount, toBN(0), expectedNextDeadline) }) - it('has no effect if called a second time in the same block', async function () { + 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) @@ -358,14 +515,16 @@ describe('L1Reservoir', () => { expect(await tracker.accRewards(dripBlock)).to.eq(0) const expectedNextDeadline = dripBlock.add(defaults.rewards.dripInterval) const expectedMintedAmount = await tracker.accRewards(expectedNextDeadline) - await provider().send('evm_setAutomine', [false]) - const tx1 = await l1Reservoir.connect(governor.signer).drip(toBN(0), toBN(0), toBN(0)) - const tx2 = await l1Reservoir.connect(governor.signer).drip(toBN(0), toBN(0), toBN(0)) - await provider().send('evm_mine', []) - await provider().send('evm_setAutomine', [true]) + + 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(await latestBlock()).eq(dripBlock) // Just in case disabling automine stops working + expect(toRound(actualAmount)).to.eq(toRound(expectedMintedAmount)) await expect(tx1) .emit(l1Reservoir, 'RewardsDripped') @@ -373,15 +532,36 @@ describe('L1Reservoir', () => { await expect(tx1) .emit(grt, 'Transfer') .withArgs(AddressZero, l1Reservoir.address, actualAmount) - await expect(tx2) - .emit(l1Reservoir, 'RewardsDripped') - .withArgs(toBN(0), toBN(0), expectedNextDeadline) - await expect(tx2).not.emit(grt, 'Transfer') + + 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(governor.signer) - .drip(maxGas, gasPriceBid, maxSubmissionCost, { value: defaultEthValue }) + .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 () { @@ -413,10 +593,84 @@ describe('L1Reservoir', () => { const expectedMintedAmount = await tracker.accRewards(expectedNextDeadline) const expectedSentToL2 = expectedMintedAmount.div(2) const tx = await l1Reservoir - .connect(governor.signer) - .drip(maxGas, gasPriceBid, maxSubmissionCost, { value: defaultEthValue }) + .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), @@ -434,6 +688,8 @@ describe('L1Reservoir', () => { l2IssuanceBase, issuanceRate, toBN('0'), + expectedKeeperReward, + keeper.address, ]) const expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( grt.address, @@ -462,8 +718,14 @@ describe('L1Reservoir', () => { const expectedMintedAmount = await tracker.accRewards(expectedNextDeadline) const expectedSentToL2 = expectedMintedAmount.div(2) const tx = await l1Reservoir - .connect(governor.signer) - .drip(maxGas, gasPriceBid, maxSubmissionCost, { value: defaultEthValue }) + .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))) @@ -483,6 +745,8 @@ describe('L1Reservoir', () => { l2IssuanceBase, issuanceRate, toBN('0'), + toBN('0'), + keeper.address, ]) let expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( grt.address, @@ -511,8 +775,14 @@ describe('L1Reservoir', () => { .add(expectedTotalRewards.sub(rewardsUntilSecondDripBlock).mul(8).div(10)) const tx2 = await l1Reservoir - .connect(governor.signer) - .drip(maxGas, gasPriceBid, maxSubmissionCost, { value: defaultEthValue }) + .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( @@ -529,6 +799,8 @@ describe('L1Reservoir', () => { l2IssuanceBase, issuanceRate, toBN('1'), // Incremented nonce + toBN('0'), + keeper.address, ]) expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( grt.address, @@ -564,8 +836,14 @@ describe('L1Reservoir', () => { const expectedMintedAmount = await tracker.accRewards(expectedNextDeadline) const expectedSentToL2 = expectedMintedAmount.div(2) const tx = await l1Reservoir - .connect(governor.signer) - .drip(maxGas, gasPriceBid, maxSubmissionCost, { value: defaultEthValue }) + .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))) @@ -585,6 +863,8 @@ describe('L1Reservoir', () => { l2IssuanceBase, issuanceRate, toBN('0'), + toBN('0'), + keeper.address, ]) let expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( grt.address, @@ -602,7 +882,6 @@ describe('L1Reservoir', () => { 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, @@ -610,8 +889,14 @@ describe('L1Reservoir', () => { const expectedNewTotalSentToL2 = expectedTotalRewards.div(2) const tx2 = await l1Reservoir - .connect(governor.signer) - .drip(maxGas, gasPriceBid, maxSubmissionCost, { value: defaultEthValue }) + .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( @@ -628,6 +913,8 @@ describe('L1Reservoir', () => { l2IssuanceBase, issuanceRate, toBN('1'), // Incremented nonce + toBN('0'), + keeper.address, ]) expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( grt.address, @@ -647,6 +934,145 @@ describe('L1Reservoir', () => { 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 () { @@ -654,7 +1080,9 @@ describe('L1Reservoir', () => { // 5% minute rate (4 blocks) await l1Reservoir.connect(governor.signer).setIssuanceRate(ISSUANCE_RATE_PER_BLOCK) supplyBeforeDrip = await grt.totalSupply() - await l1Reservoir.drip(toBN(0), toBN(0), toBN(0)) + await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)'](toBN(0), toBN(0), toBN(0), keeper.address) dripBlock = await latestBlock() }) @@ -724,7 +1152,15 @@ describe('L1Reservoir', () => { 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.drip(maxGas, gasPriceBid, maxSubmissionCost, { value: defaultEthValue }) + 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) diff --git a/test/rewards/rewards.test.ts b/test/rewards/rewards.test.ts index b95b0464e..38639acc4 100644 --- a/test/rewards/rewards.test.ts +++ b/test/rewards/rewards.test.ts @@ -42,6 +42,7 @@ describe('Rewards', () => { let indexer1: Account let indexer2: Account let oracle: Account + let keeper: Account let fixture: NetworkFixture @@ -78,7 +79,7 @@ describe('Rewards', () => { ) => { // -- t0 -- const tracker = await RewardsTracker.create(initialSupply, ISSUANCE_RATE_PER_BLOCK, dripBlock) - tracker.snapshotPerSignal(await grt.balanceOf(curation.address)) + await tracker.snapshotPerSignal(await grt.balanceOf(curation.address)) // Jump await advanceBlocks(nBlocks) @@ -107,7 +108,8 @@ describe('Rewards', () => { } 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, l1Reservoir } = await fixture.load( @@ -120,6 +122,7 @@ describe('Rewards', () => { 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() }) @@ -276,7 +279,9 @@ describe('Rewards', () => { beforeEach(async function () { // 5% minute rate (4 blocks) await l1Reservoir.connect(governor.signer).setIssuanceRate(ISSUANCE_RATE_PER_BLOCK) - await l1Reservoir.connect(governor.signer).drip(toBN(0), toBN(0), toBN(0)) + await l1Reservoir + .connect(keeper.signer) + ['drip(uint256,uint256,uint256,address)'](toBN(0), toBN(0), toBN(0), keeper.address) dripBlock = await latestBlock() }) @@ -325,11 +330,11 @@ describe('Rewards', () => { await curation.connect(curator1.signer).mint(subgraphDeploymentID1, toGRT('1000'), 0) // Minting signal triggers onSubgraphSignalUpgrade before pulling the GRT, // so we snapshot using the previous value - tracker.snapshotPerSignal(prevSignal) + await tracker.snapshotPerSignal(prevSignal) // Update await rewardsManager.updateAccRewardsPerSignal() - tracker.snapshotPerSignal(await grt.balanceOf(curation.address)) + await tracker.snapshotPerSignal(await grt.balanceOf(curation.address)) const contractAccrued = await rewardsManager.accRewardsPerSignal() @@ -354,14 +359,14 @@ describe('Rewards', () => { await curation.connect(curator1.signer).mint(subgraphDeploymentID1, toGRT('1000'), 0) // Minting signal triggers onSubgraphSignalUpgrade before pulling the GRT, // so we snapshot using the previous value - tracker.snapshotPerSignal(prevSignal) + await tracker.snapshotPerSignal(prevSignal) // Jump await advanceBlocks(ISSUANCE_RATE_PERIODS) // Update await rewardsManager.updateAccRewardsPerSignal() - tracker.snapshotPerSignal(await grt.balanceOf(curation.address)) + await tracker.snapshotPerSignal(await grt.balanceOf(curation.address)) const contractAccrued = await rewardsManager.accRewardsPerSignal() const blockNum = await latestBlock() @@ -658,18 +663,15 @@ describe('Rewards', () => { describe('takeRewards', function () { it('should distribute rewards on closed allocation and stake', async function () { // Align with the epoch boundary - // dripBlock (81) await epochManager.setEpochLength(10) - // dripBlock + 1 await advanceToNextEpoch(epochManager) - // dripBlock + 4 + // Setup await setupIndexerAllocation() const firstSnapshotBlocks = new BN((await latestBlock()).sub(dripBlock).toString()) - // dripBlock + 7 + // Jump await advanceToNextEpoch(epochManager) - // dripBlock + 14 // Before state const beforeTokenSupply = await grt.totalSupply() @@ -795,7 +797,7 @@ describe('Rewards', () => { // Jump await advanceToNextEpoch(epochManager) - // dripBlock + 14 + // dripBlock + 13 // Before state const beforeTokenSupply = await grt.totalSupply() @@ -837,17 +839,23 @@ describe('Rewards', () => { 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