diff --git a/cli/commands/migrate.ts b/cli/commands/migrate.ts index 9a88f0b57..d129f931c 100644 --- a/cli/commands/migrate.ts +++ b/cli/commands/migrate.ts @@ -35,30 +35,28 @@ let allContracts = [ 'AllocationExchange', 'L1GraphTokenGateway', 'BridgeEscrow', + 'L1Reservoir', ] -// This is all we'll want to deploy to L2 eventually: -// const l2Contracts = [ -// 'GraphProxyAdmin', -// 'BancorFormula', -// 'Controller', -// 'EpochManager', -// 'L2GraphToken', -// 'GraphCurationToken', -// 'ServiceRegistry', -// 'Curation', -// 'SubgraphNFTDescriptor', -// 'SubgraphNFT', -// 'GNS', -// 'Staking', -// 'RewardsManager', -// 'DisputeManager', -// 'AllocationExchange', -// 'L2GraphTokenGateway', -// ] -// -// But for now we'll only include a subset: -const l2Contracts = ['GraphProxyAdmin', 'Controller', 'L2GraphToken', 'L2GraphTokenGateway'] +const l2Contracts = [ + 'GraphProxyAdmin', + 'BancorFormula', + 'Controller', + 'EpochManager', + 'L2GraphToken', + 'GraphCurationToken', + 'ServiceRegistry', + 'Curation', + 'SubgraphNFTDescriptor', + 'SubgraphNFT', + 'GNS', + 'Staking', + 'RewardsManager', + 'DisputeManager', + 'AllocationExchange', + 'L2GraphTokenGateway', + 'L2Reservoir', +] export const migrate = async (cli: CLIEnvironment, cliArgs: CLIArgs): Promise => { const graphConfigPath = cliArgs.graphConfig diff --git a/config/graph.arbitrum-one.yml b/config/graph.arbitrum-one.yml index 80d7cceeb..9bcbeeed1 100644 --- a/config/graph.arbitrum-one.yml +++ b/config/graph.arbitrum-one.yml @@ -6,18 +6,122 @@ general: contracts: Controller: calls: + - fn: "setContractProxy" + id: "0xe6876326c1291dfcbbd3864a6816d698cd591defc7aa2153d7f9c4c04016c89f" # keccak256('Curation') + contractAddress: "${{Curation.address}}" + - fn: "setContractProxy" + id: "0x39605a6c26a173774ca666c67ef70cf491880e5d3d6d0ca66ec0a31034f15ea3" # keccak256('GNS') + contractAddress: "${{GNS.address}}" + - fn: "setContractProxy" + id: "0xf942813d07d17b56de9a9afc8de0ced6e8c053bbfdcc87b7badea4ddcf27c307" # keccak256('DisputeManager') + contractAddress: "${{DisputeManager.address}}" + - fn: "setContractProxy" + id: "0xc713c3df6d14cdf946460395d09af88993ee2b948b1a808161494e32c5f67063" # keccak256('EpochManager') + contractAddress: "${{EpochManager.address}}" + - fn: "setContractProxy" + id: "0x966f1e8d8d8014e05f6ec4a57138da9be1f7c5a7f802928a18072f7c53180761" # keccak256('RewardsManager') + contractAddress: "${{RewardsManager.address}}" + - fn: "setContractProxy" + id: "0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034" # keccak256('Staking') + contractAddress: "${{Staking.address}}" - fn: "setContractProxy" id: "0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247" # keccak256('GraphToken') contractAddress: "${{L2GraphToken.address}}" - fn: "setContractProxy" id: "0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0" # keccak256('GraphTokenGateway') contractAddress: "${{L2GraphTokenGateway.address}}" + - fn: "setContractProxy" + id: "0x96ba401694892957e25e29c7a1e4171ae9945b5ee36339de79b199a530436e9e" # keccak256('Reservoir') + contractAddress: "${{L2Reservoir.address}}" + ServiceRegistry: + proxy: true + init: + controller: "${{Controller.address}}" + EpochManager: + proxy: true + init: + controller: "${{Controller.address}}" + lengthInBlocks: 1108 # 4 hours (in 13 second blocks) L2GraphToken: proxy: true init: owner: *governor - initialSupply: "0" + Curation: + proxy: true + init: + controller: "${{Controller.address}}" + bondingCurve: "${{BancorFormula.address}}" + curationTokenMaster: "${{GraphCurationToken.address}}" + reserveRatio: 500000 # 50% (parts per million) + curationTaxPercentage: 10000 # 1% (parts per million) + minimumCurationDeposit: "1000000000000000000" # 1 GRT + DisputeManager: + proxy: true + init: + controller: "${{Controller.address}}" + arbitrator: *arbitrator + minimumDeposit: "10000000000000000000000" # 10,000 GRT (in wei) + fishermanRewardPercentage: 500000 # 50% (parts per million) + idxSlashingPercentage: 25000 # 2.5% (parts per million) + qrySlashingPercentage: 5000 # 0.5% (parts per million) + GNS: + proxy: true + init: + controller: "${{Controller.address}}" + bondingCurve: "${{BancorFormula.address}}" + subgraphNFT: "${{SubgraphNFT.address}}" + calls: + - fn: "approveAll" + SubgraphNFT: + init: + governor: "${{Env.deployer}}" + calls: + - fn: "setTokenDescriptor" + tokenDescriptor: "${{SubgraphNFTDescriptor.address}}" + - fn: "setMinter" + minter: "${{GNS.address}}" + Staking: + proxy: true + init: + controller: "${{Controller.address}}" + minimumIndexerStake: "100000000000000000000000" # 100,000 GRT (in wei) + thawingPeriod: 6646 # 10 days (in blocks) + protocolPercentage: 10000 # 1% (parts per million) + curationPercentage: 100000 # 10% (parts per million) + channelDisputeEpochs: 2 # (in epochs) + maxAllocationEpochs: 6 # Based on epoch length this is 28 days (in epochs) + delegationUnbondingPeriod: 6 # Based on epoch length this is 28 days (in epochs) + delegationRatio: 16 # 16x (delegated stake to indexer stake multiplier) + rebateAlphaNumerator: 77 # rebateAlphaNumerator / rebateAlphaDenominator + rebateAlphaDenominator: 100 # rebateAlphaNumerator / rebateAlphaDenominator + calls: + - fn: "setDelegationTaxPercentage" + delegationTaxPercentage: 5000 # 0.5% (parts per million) + - fn: "setSlasher" + slasher: "${{DisputeManager.address}}" + allowed: true + - fn: "setAssetHolder" + assetHolder: "${{AllocationExchange.address}}" + allowed: true + RewardsManager: + proxy: true + init: + controller: "${{Controller.address}}" + AllocationExchange: + init: + graphToken: "${{GraphToken.address}}" + staking: "${{Staking.address}}" + governor: *governor + authority: *authority + calls: + - fn: "approveAll" L2GraphTokenGateway: proxy: true init: controller: "${{Controller.address}}" + L2Reservoir: + proxy: true + init: + controller: "${{Controller.address}}" + calls: + - fn: "approveRewardsManager" diff --git a/config/graph.mainnet.yml b/config/graph.mainnet.yml index 9ba114ec5..4a6908a27 100644 --- a/config/graph.mainnet.yml +++ b/config/graph.mainnet.yml @@ -30,6 +30,9 @@ contracts: - fn: "setContractProxy" id: "0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0" # keccak256('GraphTokenGateway') contractAddress: "${{L1GraphTokenGateway.address}}" + - fn: "setContractProxy" + id: "0x96ba401694892957e25e29c7a1e4171ae9945b5ee36339de79b199a530436e9e" # keccak256('Reservoir') + contractAddress: "${{L1Reservoir.address}}" ServiceRegistry: proxy: true init: @@ -44,7 +47,7 @@ contracts: initialSupply: "10000000000000000000000000000" # in wei calls: - fn: "addMinter" - minter: "${{RewardsManager.address}}" + minter: "${{L1Reservoir.address}}" Curation: proxy: true init: @@ -106,7 +109,6 @@ contracts: proxy: true init: controller: "${{Controller.address}}" - issuanceRate: "1000000012184945188" # per block increase of total supply, blocks in a year = 365*60*60*24/13 AllocationExchange: init: graphToken: "${{GraphToken.address}}" @@ -123,3 +125,11 @@ contracts: proxy: true init: controller: "${{Controller.address}}" + L1Reservoir: + proxy: true + init: + controller: "${{Controller.address}}" + dripInterval: 50400 + calls: + - fn: "approveRewardsManager" + - fn: "initialSnapshot" diff --git a/contracts/arbitrum/AddressAliasHelper.sol b/contracts/arbitrum/AddressAliasHelper.sol new file mode 100644 index 000000000..740b70361 --- /dev/null +++ b/contracts/arbitrum/AddressAliasHelper.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 + +/* + * Copyright 2019-2021, Offchain Labs, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Originally copied from: + * https://github.com/OffchainLabs/arbitrum/tree/84e64dee6ee82adbf8ec34fd4b86c207a61d9007/packages/arb-bridge-eth + * + * MODIFIED from Offchain Labs' implementation: + * - Changed solidity version to 0.7.6 (pablo@edgeandnode.com) + * + */ + +pragma solidity ^0.7.6; + +library AddressAliasHelper { + uint160 constant offset = uint160(0x1111000000000000000000000000000000001111); + + /// @notice Utility function that converts the address in the L1 that submitted a tx to + /// the inbox to the msg.sender viewed in the L2 + /// @param l1Address the address in the L1 that triggered the tx to L2 + /// @return l2Address L2 address as viewed in msg.sender + function applyL1ToL2Alias(address l1Address) internal pure returns (address l2Address) { + l2Address = address(uint160(l1Address) + offset); + } + + /// @notice Utility function that converts the msg.sender viewed in the L2 to the + /// address in the L1 that submitted a tx to the inbox + /// @param l2Address L2 address as viewed in msg.sender + /// @return l1Address the address in the L1 that triggered the tx to L2 + function undoL1ToL2Alias(address l2Address) internal pure returns (address l1Address) { + l1Address = address(uint160(l2Address) - offset); + } +} diff --git a/contracts/arbitrum/ITokenGateway.sol b/contracts/arbitrum/ITokenGateway.sol index c59af47a6..977fe07f2 100644 --- a/contracts/arbitrum/ITokenGateway.sol +++ b/contracts/arbitrum/ITokenGateway.sol @@ -66,7 +66,7 @@ interface ITokenGateway { /** * @notice Calculate the address used when bridging an ERC20 token * @dev the L1 and L2 address oracles may not always be in sync. - * For example, a custom token may have been registered but not deploy or the contract self destructed. + * For example, a custom token may have been registered but not deployed or the contract self destructed. * @param l1ERC20 address of L1 token * @return L2 address of a bridged ERC20 token */ diff --git a/contracts/gateway/BridgeEscrow.sol b/contracts/gateway/BridgeEscrow.sol index 42cea78b8..605f13a50 100644 --- a/contracts/gateway/BridgeEscrow.sol +++ b/contracts/gateway/BridgeEscrow.sol @@ -13,8 +13,6 @@ import "../token/IGraphToken.sol"; * approved as a spender. */ contract BridgeEscrow is GraphUpgradeable, Managed { - uint256 private constant MAX_UINT256 = 2**256 - 1; - /** * @dev Initialize this contract. * @param _controller Address of the Controller that manages this contract @@ -25,18 +23,18 @@ contract BridgeEscrow is GraphUpgradeable, Managed { /** * @dev Approve a spender (i.e. a bridge that manages the GRT funds held by the escrow) - * @param spender Address of the spender that will be approved + * @param _spender Address of the spender that will be approved */ - function approveAll(address spender) external onlyGovernor { - graphToken().approve(spender, MAX_UINT256); + function approveAll(address _spender) external onlyGovernor { + graphToken().approve(_spender, type(uint256).max); } /** * @dev Revoke a spender (i.e. a bridge that will no longer manage the GRT funds held by the escrow) - * @param spender Address of the spender that will be revoked + * @param _spender Address of the spender that will be revoked */ - function revokeAll(address spender) external onlyGovernor { + function revokeAll(address _spender) external onlyGovernor { IGraphToken grt = graphToken(); - grt.decreaseAllowance(spender, grt.allowance(address(this), spender)); + grt.decreaseAllowance(_spender, grt.allowance(address(this), _spender)); } } diff --git a/contracts/gateway/GraphTokenGateway.sol b/contracts/gateway/GraphTokenGateway.sol index 6cd33a432..00e8441f5 100644 --- a/contracts/gateway/GraphTokenGateway.sol +++ b/contracts/gateway/GraphTokenGateway.sol @@ -42,10 +42,10 @@ abstract contract GraphTokenGateway is GraphUpgradeable, Pausable, Managed, ITok /** * @notice Change the paused state of the contract - * @param newPaused New value for the pause state (true means the transfers will be paused) + * @param _newPaused New value for the pause state (true means the transfers will be paused) */ - function setPaused(bool newPaused) external onlyGovernorOrGuardian { - _setPaused(newPaused); + function setPaused(bool _newPaused) external onlyGovernorOrGuardian { + _setPaused(_newPaused); } /** diff --git a/contracts/gateway/L1GraphTokenGateway.sol b/contracts/gateway/L1GraphTokenGateway.sol index 5e1304b08..19fd73be5 100644 --- a/contracts/gateway/L1GraphTokenGateway.sol +++ b/contracts/gateway/L1GraphTokenGateway.sol @@ -3,6 +3,7 @@ pragma solidity ^0.7.6; pragma abicoder v2; +import "@openzeppelin/contracts/utils/Address.sol"; import "@openzeppelin/contracts/math/SafeMath.sol"; import "../arbitrum/L1ArbitrumMessenger.sol"; @@ -30,35 +31,35 @@ contract L1GraphTokenGateway is GraphTokenGateway, L1ArbitrumMessenger { address public l2Counterpart; // Address of the BridgeEscrow contract that holds the GRT in the bridge address public escrow; - // Address of the L1 Reservoir that is the only sender allowed to send extra data + // Addresses for which this mapping is true are allowed to send callhooks in outbound transfers mapping(address => bool) public callhookWhitelist; // Emitted when an outbound transfer is initiated, i.e. tokens are deposited from L1 to L2 event DepositInitiated( - address _l1Token, - address indexed _from, - address indexed _to, - uint256 indexed _sequenceNumber, - uint256 _amount + address l1Token, + address indexed from, + address indexed to, + uint256 indexed sequenceNumber, + uint256 amount ); // Emitted when an incoming transfer is finalized, i.e tokens are withdrawn from L2 to L1 event WithdrawalFinalized( - address _l1Token, - address indexed _from, - address indexed _to, - uint256 indexed _exitNum, - uint256 _amount + address l1Token, + address indexed from, + address indexed to, + uint256 indexed exitNum, + uint256 amount ); // Emitted when the Arbitrum Inbox and Gateway Router addresses have been updated - event ArbitrumAddressesSet(address _inbox, address _l1Router); + event ArbitrumAddressesSet(address inbox, address l1Router); // Emitted when the L2 GRT address has been updated - event L2TokenAddressSet(address _l2GRT); + event L2TokenAddressSet(address l2GRT); // Emitted when the counterpart L2GraphTokenGateway address has been updated - event L2CounterpartAddressSet(address _l2Counterpart); + event L2CounterpartAddressSet(address l2Counterpart); // Emitted when the escrow address has been updated - event EscrowAddressSet(address _escrow); + event EscrowAddressSet(address escrow); // Emitted when an address is added to the callhook whitelist event AddedToCallhookWhitelist(address newWhitelisted); // Emitted when an address is removed from the callhook whitelist @@ -83,6 +84,14 @@ contract L1GraphTokenGateway is GraphTokenGateway, L1ArbitrumMessenger { /** * @dev Initialize this contract. * The contract will be paused. + * Note some parameters have to be set separately as they are generally + * not expected to be available at initialization time: + * - inbox and l1Router using setArbitrumAddresses + * - l2GRT using setL2TokenAddress + * - l2Counterpart using setL2CounterpartAddress + * - escrow using setEscrowAddress + * - whitelisted callhook callers using addToCallhookWhitelist + * - pauseGuardian using setPauseGuardian * @param _controller Address of the Controller that manages this contract */ function initialize(address _controller) external onlyImpl { @@ -91,11 +100,13 @@ contract L1GraphTokenGateway is GraphTokenGateway, L1ArbitrumMessenger { } /** - * @dev sets the addresses for L1 contracts provided by Arbitrum + * @dev Sets the addresses for L1 contracts provided by Arbitrum * @param _inbox Address of the Inbox that is part of the Arbitrum Bridge * @param _l1Router Address of the Gateway Router */ function setArbitrumAddresses(address _inbox, address _l1Router) external onlyGovernor { + require(_inbox != address(0), "INVALID_INBOX"); + require(_l1Router != address(0), "INVALID_L1_ROUTER"); inbox = _inbox; l1Router = _l1Router; emit ArbitrumAddressesSet(_inbox, _l1Router); @@ -106,6 +117,7 @@ contract L1GraphTokenGateway is GraphTokenGateway, L1ArbitrumMessenger { * @param _l2GRT Address of the GRT contract on L2 */ function setL2TokenAddress(address _l2GRT) external onlyGovernor { + require(_l2GRT != address(0), "INVALID_L2_GRT"); l2GRT = _l2GRT; emit L2TokenAddressSet(_l2GRT); } @@ -115,6 +127,7 @@ contract L1GraphTokenGateway is GraphTokenGateway, L1ArbitrumMessenger { * @param _l2Counterpart Address of the corresponding L2GraphTokenGateway on Arbitrum */ function setL2CounterpartAddress(address _l2Counterpart) external onlyGovernor { + require(_l2Counterpart != address(0), "INVALID_L2_COUNTERPART"); l2Counterpart = _l2Counterpart; emit L2CounterpartAddressSet(_l2Counterpart); } @@ -124,6 +137,7 @@ contract L1GraphTokenGateway is GraphTokenGateway, L1ArbitrumMessenger { * @param _escrow Address of the BridgeEscrow */ function setEscrowAddress(address _escrow) external onlyGovernor { + require(_escrow != address(0) && Address.isContract(_escrow), "INVALID_ESCROW"); escrow = _escrow; emit EscrowAddressSet(_escrow); } @@ -131,28 +145,39 @@ contract L1GraphTokenGateway is GraphTokenGateway, L1ArbitrumMessenger { /** * @dev Adds an address to the callhook whitelist. * This address will be allowed to include callhooks when transferring tokens. - * @param newWhitelisted Address to add to the whitelist + * @param _newWhitelisted Address to add to the whitelist */ - function addToCallhookWhitelist(address newWhitelisted) external onlyGovernor { - callhookWhitelist[newWhitelisted] = true; - emit AddedToCallhookWhitelist(newWhitelisted); + function addToCallhookWhitelist(address _newWhitelisted) external onlyGovernor { + require(_newWhitelisted != address(0), "INVALID_ADDRESS"); + require(!callhookWhitelist[_newWhitelisted], "ALREADY_WHITELISTED"); + callhookWhitelist[_newWhitelisted] = true; + emit AddedToCallhookWhitelist(_newWhitelisted); } /** * @dev Removes an address from the callhook whitelist. * This address will no longer be allowed to include callhooks when transferring tokens. - * @param notWhitelisted Address to remove from the whitelist + * @param _notWhitelisted Address to remove from the whitelist */ - function removeFromCallhookWhitelist(address notWhitelisted) external onlyGovernor { - callhookWhitelist[notWhitelisted] = false; - emit RemovedFromCallhookWhitelist(notWhitelisted); + function removeFromCallhookWhitelist(address _notWhitelisted) external onlyGovernor { + require(_notWhitelisted != address(0), "INVALID_ADDRESS"); + require(callhookWhitelist[_notWhitelisted], "NOT_WHITELISTED"); + callhookWhitelist[_notWhitelisted] = false; + emit RemovedFromCallhookWhitelist(_notWhitelisted); } /** * @notice Creates and sends a retryable ticket to transfer GRT to L2 using the Arbitrum Inbox. * The tokens are escrowed by the gateway until they are withdrawn back to L1. * The ticket must be redeemed on L2 to receive tokens at the specified address. + * Note that the caller must previously allow the gateway to spend the specified amount of GRT. * @dev maxGas and gasPriceBid must be set using Arbitrum's NodeInterface.estimateRetryableTicket method. + * Also note that whitelisted senders (some protocol contracts) can include additional calldata + * for a callhook to be executed on the L2 side when the tokens are received. In this case, the L2 transaction + * can revert if the callhook reverts, potentially locking the tokens on the bridge if the callhook + * never succeeds. This requires extra care when adding contracts to the whitelist, but is necessary to ensure that + * the tickets can be retried in the case of a temporary failure, and to ensure the atomicity of callhooks + * with token transfers. * @param _l1Token L1 Address of the GRT contract (needed for compatibility with Arbitrum Gateway Router) * @param _to Recipient address on L2 * @param _amount Amount of tokens to tranfer @@ -172,6 +197,7 @@ contract L1GraphTokenGateway is GraphTokenGateway, L1ArbitrumMessenger { IGraphToken token = graphToken(); require(_l1Token == address(token), "TOKEN_NOT_GRT"); require(_amount > 0, "INVALID_ZERO_AMOUNT"); + require(_to != address(0), "INVALID_DESTINATION"); // nested scopes to avoid stack too deep errors address from; @@ -189,11 +215,11 @@ contract L1GraphTokenGateway is GraphTokenGateway, L1ArbitrumMessenger { require(maxSubmissionCost > 0, "NO_SUBMISSION_COST"); { - // makes sure only sufficient ETH is supplied required for successful redemption on L2 + // makes sure only sufficient ETH is supplied as required for successful redemption on L2 // if a user does not desire immediate redemption they should provide // a msg.value of AT LEAST maxSubmissionCost - uint256 expectedEth = maxSubmissionCost + (_maxGas * _gasPriceBid); - require(msg.value == expectedEth, "WRONG_ETH_VALUE"); + uint256 expectedEth = maxSubmissionCost.add(_maxGas.mul(_gasPriceBid)); + require(msg.value >= expectedEth, "WRONG_ETH_VALUE"); } outboundCalldata = getOutboundCalldata(_l1Token, from, _to, _amount, extraData); } @@ -224,101 +250,106 @@ contract L1GraphTokenGateway is GraphTokenGateway, L1ArbitrumMessenger { /** * @notice Receives withdrawn tokens from L2 * The equivalent tokens are released from escrow and sent to the destination. - * @dev can only accept transactions coming from the L2 GRT Gateway + * @dev can only accept transactions coming from the L2 GRT Gateway. + * The last parameter is unused but kept for compatibility with Arbitrum gateways, + * and the encoded exitNum is assumed to be 0. * @param _l1Token L1 Address of the GRT contract (needed for compatibility with Arbitrum Gateway Router) * @param _from Address of the sender * @param _to Recepient address on L1 * @param _amount Amount of tokens transferred - * @param _data Contains exitNum which is always set to 0 */ function finalizeInboundTransfer( address _l1Token, address _from, address _to, uint256 _amount, - bytes calldata _data + bytes calldata // _data, contains exitNum, unused by this contract ) external payable override notPaused onlyL2Counterpart { IGraphToken token = graphToken(); require(_l1Token == address(token), "TOKEN_NOT_GRT"); - (uint256 exitNum, ) = abi.decode(_data, (uint256, bytes)); uint256 escrowBalance = token.balanceOf(escrow); // If the bridge doesn't have enough tokens, something's very wrong! require(_amount <= escrowBalance, "BRIDGE_OUT_OF_FUNDS"); token.transferFrom(escrow, _to, _amount); - emit WithdrawalFinalized(_l1Token, _from, _to, exitNum, _amount); + emit WithdrawalFinalized(_l1Token, _from, _to, 0, _amount); } /** - * @notice decodes calldata required for migration of tokens - * @dev data must include maxSubmissionCost, extraData can be left empty. When the router + * @notice Decodes calldata required for migration of tokens + * @dev Data must include maxSubmissionCost, extraData can be left empty. When the router * sends an outbound message, data also contains the from address. - * @param data encoded callhook data - * @return from sender of the tx - * @return maxSubmissionCost base ether value required to keep retyrable ticket alive - * @return extraData any other data sent to L2 + * @param _data encoded callhook data + * @return Sender of the tx + * @return Base ether value required to keep retryable ticket alive + * @return Additional data sent to L2 */ - function parseOutboundData(bytes memory data) + function parseOutboundData(bytes memory _data) private view returns ( - address from, - uint256 maxSubmissionCost, - bytes memory extraData + address, + uint256, + bytes memory ) { + address from; + uint256 maxSubmissionCost; + bytes memory extraData; if (msg.sender == l1Router) { // Data encoded by the Gateway Router includes the sender address - (from, extraData) = abi.decode(data, (address, bytes)); + (from, extraData) = abi.decode(_data, (address, bytes)); } else { from = msg.sender; - extraData = data; + extraData = _data; } // User-encoded data contains the max retryable ticket submission cost // and additional L2 calldata (maxSubmissionCost, extraData) = abi.decode(extraData, (uint256, bytes)); + return (from, maxSubmissionCost, extraData); } /** * @notice Creates calldata required to create a retryable ticket * @dev encodes the target function with its params which * will be called on L2 when the retryable ticket is redeemed - * @param l1Token Address of the Graph token contract on L1 - * @param from Address on L1 from which we're transferring tokens - * @param to Address on L2 to which we're transferring tokens - * @param amount Amount of GRT to transfer - * @param data Additional call data for the L2 transaction, which must be empty - * @return outboundCalldata Encoded calldata (including function selector) for the L2 transaction + * @param _l1Token Address of the Graph token contract on L1 + * @param _from Address on L1 from which we're transferring tokens + * @param _to Address on L2 to which we're transferring tokens + * @param _amount Amount of GRT to transfer + * @param _data Additional call data for the L2 transaction, which must be empty unless the caller is whitelisted + * @return Encoded calldata (including function selector) for the L2 transaction */ function getOutboundCalldata( - address l1Token, - address from, - address to, - uint256 amount, - bytes memory data - ) public pure returns (bytes memory outboundCalldata) { + address _l1Token, + address _from, + address _to, + uint256 _amount, + bytes memory _data + ) public pure returns (bytes memory) { bytes memory emptyBytes; - outboundCalldata = abi.encodeWithSelector( - ITokenGateway.finalizeInboundTransfer.selector, - l1Token, - from, - to, - amount, - abi.encode(emptyBytes, data) - ); + return + abi.encodeWithSelector( + ITokenGateway.finalizeInboundTransfer.selector, + _l1Token, + _from, + _to, + _amount, + abi.encode(emptyBytes, _data) + ); } /** * @notice Calculate the L2 address of a bridged token * @dev In our case, this would only work for GRT. - * @param l1ERC20 address of L1 GRT contract + * @param _l1ERC20 address of L1 GRT contract * @return L2 address of the bridged GRT token */ - function calculateL2TokenAddress(address l1ERC20) external view override returns (address) { + function calculateL2TokenAddress(address _l1ERC20) external view override returns (address) { IGraphToken token = graphToken(); - if (l1ERC20 != address(token)) { + if (_l1ERC20 != address(token)) { return address(0); } return l2GRT; diff --git a/contracts/governance/Managed.sol b/contracts/governance/Managed.sol index 561d5244d..20a9191e5 100644 --- a/contracts/governance/Managed.sol +++ b/contracts/governance/Managed.sol @@ -9,6 +9,8 @@ import "../epochs/IEpochManager.sol"; import "../rewards/IRewardsManager.sol"; import "../staking/IStaking.sol"; import "../token/IGraphToken.sol"; +import "../arbitrum/ITokenGateway.sol"; +import "../reservoir/IReservoir.sol"; /** * @title Graph Managed contract @@ -145,6 +147,22 @@ contract Managed { return IGraphToken(_resolveContract(keccak256("GraphToken"))); } + /** + * @dev Return GraphTokenGateway (L1 or L2) interface. + * @return Graph token gateway contract registered with Controller + */ + function graphTokenGateway() internal view returns (ITokenGateway) { + 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 @@ -183,5 +201,6 @@ contract Managed { _syncContract("Staking"); _syncContract("GraphToken"); _syncContract("GraphTokenGateway"); + _syncContract("Reservoir"); } } diff --git a/contracts/l2/gateway/L2GraphTokenGateway.sol b/contracts/l2/gateway/L2GraphTokenGateway.sol index b639e2374..593e0e228 100644 --- a/contracts/l2/gateway/L2GraphTokenGateway.sol +++ b/contracts/l2/gateway/L2GraphTokenGateway.sol @@ -6,13 +6,14 @@ pragma abicoder v2; import "@openzeppelin/contracts/math/SafeMath.sol"; import "../../arbitrum/L2ArbitrumMessenger.sol"; +import "../../arbitrum/AddressAliasHelper.sol"; import "../../gateway/GraphTokenGateway.sol"; import "../token/L2GraphToken.sol"; /** * @title L2 Graph Token Gateway Contract * @dev Provides the L2 side of the Ethereum-Arbitrum GRT bridge. Receives GRT from the L1 chain - * and mints them on the L2 side. Sending GRT back to L1 by burning them on the L2 side. + * and mints them on the L2 side. Sends GRT back to L1 by burning them on the L2 side. * Based on Offchain Labs' reference implementation and Livepeer's arbitrum-lpt-bridge * (See: https://github.com/OffchainLabs/arbitrum/tree/master/packages/arb-bridge-peripherals/contracts/tokenbridge * and https://github.com/livepeer/arbitrum-lpt-bridge) @@ -20,18 +21,13 @@ import "../token/L2GraphToken.sol"; contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger { using SafeMath for uint256; - // Offset applied by the bridge to L1 addresses sending messages to L2 - uint160 internal constant L2_ADDRESS_OFFSET = - uint160(0x1111000000000000000000000000000000001111); - // Address of the Graph Token contract on L1 address public l1GRT; // Address of the L1GraphTokenGateway that is the counterpart of this gateway on L1 address public l1Counterpart; // Address of the Arbitrum Gateway Router on L2 address public l2Router; - // Addresses in L1 that are whitelisted to have callhooks on transfers - mapping(address => bool) public callhookWhitelist; + // Calldata included in an outbound transfer, stored as a structure for convenience and stack depth struct OutboundCalldata { address from; @@ -41,46 +37,49 @@ contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger { // Emitted when an incoming transfer is finalized, i.e. tokens were deposited from L1 to L2 event DepositFinalized( address indexed l1Token, - address indexed _from, - address indexed _to, - uint256 _amount + address indexed from, + address indexed to, + uint256 amount ); // Emitted when an outbound transfer is initiated, i.e. tokens are being withdrawn from L2 back to L1 event WithdrawalInitiated( address l1Token, - address indexed _from, - address indexed _to, - uint256 indexed _l2ToL1Id, - uint256 _exitNum, - uint256 _amount + address indexed from, + address indexed to, + uint256 indexed l2ToL1Id, + uint256 exitNum, + uint256 amount ); // Emitted when the Arbitrum Gateway Router address on L2 has been updated - event L2RouterSet(address _l2Router); + event L2RouterSet(address l2Router); // Emitted when the L1 Graph Token address has been updated - event L1TokenAddressSet(address _l1GRT); + event L1TokenAddressSet(address l1GRT); // Emitted when the address of the counterpart gateway on L1 has been updated - event L1CounterpartAddressSet(address _l1Counterpart); - // Emitted when an address is added to the callhook whitelist - event AddedToCallhookWhitelist(address newWhitelisted); - // Emitted when an address is removed from the callhook whitelist - event RemovedFromCallhookWhitelist(address notWhitelisted); - // Emitted when a callhook call failed - event CallhookFailed(address destination); + event L1CounterpartAddressSet(address l1Counterpart); /** * @dev Checks that the sender is the L2 alias of the counterpart * gateway on L1. */ modifier onlyL1Counterpart() { - require(msg.sender == l1ToL2Alias(l1Counterpart), "ONLY_COUNTERPART_GATEWAY"); + require( + msg.sender == AddressAliasHelper.applyL1ToL2Alias(l1Counterpart), + "ONLY_COUNTERPART_GATEWAY" + ); _; } /** * @dev Initialize this contract. * The contract will be paused. + * Note some parameters have to be set separately as they are generally + * not expected to be available at initialization time: + * - l2Router using setL2Router + * - l1GRT using setL1TokenAddress + * - l1Counterpart using setL1CounterpartAddress + * - pauseGuardian using setPauseGuardian * @param _controller Address of the Controller that manages this contract */ function initialize(address _controller) external onlyImpl { @@ -93,6 +92,7 @@ contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger { * @param _l2Router Address of the L2 Router (provided by Arbitrum) */ function setL2Router(address _l2Router) external onlyGovernor { + require(_l2Router != address(0), "INVALID_L2_ROUTER"); l2Router = _l2Router; emit L2RouterSet(_l2Router); } @@ -102,6 +102,7 @@ contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger { * @param _l1GRT L1 address of the Graph Token contract */ function setL1TokenAddress(address _l1GRT) external onlyGovernor { + require(_l1GRT != address(0), "INVALID_L1_GRT"); l1GRT = _l1GRT; emit L1TokenAddressSet(_l1GRT); } @@ -111,41 +112,23 @@ contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger { * @param _l1Counterpart Address of the L1GraphTokenGateway on L1 */ function setL1CounterpartAddress(address _l1Counterpart) external onlyGovernor { + require(_l1Counterpart != address(0), "INVALID_L1_COUNTERPART"); l1Counterpart = _l1Counterpart; emit L1CounterpartAddressSet(_l1Counterpart); } - /** - * @dev Adds an L1 address to the callhook whitelist. - * This address will be allowed to include callhooks when transferring tokens. - * @param newWhitelisted Address to add to the whitelist - */ - function addToCallhookWhitelist(address newWhitelisted) external onlyGovernor { - callhookWhitelist[newWhitelisted] = true; - emit AddedToCallhookWhitelist(newWhitelisted); - } - - /** - * @dev Removes an L1 address from the callhook whitelist. - * This address will no longer be allowed to include callhooks when transferring tokens. - * @param notWhitelisted Address to remove from the whitelist - */ - function removeFromCallhookWhitelist(address notWhitelisted) external onlyGovernor { - callhookWhitelist[notWhitelisted] = false; - emit RemovedFromCallhookWhitelist(notWhitelisted); - } - /** * @notice Burns L2 tokens and initiates a transfer to L1. * The tokens will be available on L1 only after the wait period (7 days) is over, * and will require an Outbox.executeTransaction to finalize. + * Note that the caller must previously allow the gateway to spend the specified amount of GRT. * @dev no additional callhook data is allowed. The two unused params are needed * for compatibility with Arbitrum's gateway router. * The function is payable for ITokenGateway compatibility, but msg.value must be zero. * @param _l1Token L1 Address of GRT (needed for compatibility with Arbitrum Gateway Router) * @param _to Recipient address on L1 * @param _amount Amount of tokens to burn - * @param _data Contains sender and additional data (always zero) to send to L1 + * @param _data Contains sender and additional data (always empty) to send to L1 * @return ID of the withdraw transaction */ function outboundTransfer( @@ -159,24 +142,31 @@ contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger { require(_l1Token == l1GRT, "TOKEN_NOT_GRT"); require(_amount > 0, "INVALID_ZERO_AMOUNT"); require(msg.value == 0, "INVALID_NONZERO_VALUE"); + require(_to != address(0), "INVALID_DESTINATION"); - OutboundCalldata memory s; + OutboundCalldata memory outboundCalldata; - (s.from, s.extraData) = parseOutboundData(_data); - require(s.extraData.length == 0, "CALL_HOOK_DATA_NOT_ALLOWED"); + (outboundCalldata.from, outboundCalldata.extraData) = parseOutboundData(_data); + require(outboundCalldata.extraData.length == 0, "CALL_HOOK_DATA_NOT_ALLOWED"); // from needs to approve this contract to burn the amount first - L2GraphToken(this.calculateL2TokenAddress(l1GRT)).bridgeBurn(s.from, _amount); + L2GraphToken(calculateL2TokenAddress(l1GRT)).bridgeBurn(outboundCalldata.from, _amount); uint256 id = sendTxToL1( 0, - s.from, + outboundCalldata.from, l1Counterpart, - getOutboundCalldata(_l1Token, s.from, _to, _amount, s.extraData) + getOutboundCalldata( + _l1Token, + outboundCalldata.from, + _to, + _amount, + outboundCalldata.extraData + ) ); // we don't need to track exitNums (b/c we have no fast exits) so we always use 0 - emit WithdrawalInitiated(_l1Token, s.from, _to, id, 0, _amount); + emit WithdrawalInitiated(_l1Token, outboundCalldata.from, _to, id, 0, _amount); return abi.encode(id); } @@ -198,7 +188,7 @@ contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger { uint256 _amount, bytes calldata _data ) external returns (bytes memory) { - return outboundTransfer(_l1Token, _to, _amount, uint256(0), uint256(0), _data); + return outboundTransfer(_l1Token, _to, _amount, 0, 0, _data); } /** @@ -211,14 +201,19 @@ contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger { if (l1ERC20 != l1GRT) { return address(0); } - return Managed._resolveContract(keccak256("GraphToken")); + return address(graphToken()); } /** * @notice Receives token amount from L1 and mints the equivalent tokens to the receiving address - * @dev Only accepts transactions from the L1 GRT Gateway - * data param is unused because no additional data is allowed from L1. + * @dev Only accepts transactions from the L1 GRT Gateway. * The function is payable for ITokenGateway compatibility, but msg.value must be zero. + * Note that whitelisted senders (some protocol contracts) can include additional calldata + * for a callhook to be executed on the L2 side when the tokens are received. In this case, the L2 transaction + * can revert if the callhook reverts, potentially locking the tokens on the bridge if the callhook + * never succeeds. This requires extra care when adding contracts to the whitelist, but is necessary to ensure that + * the tickets can be retried in the case of a temporary failure, and to ensure the atomicity of callhooks + * with token transfers. * @param _l1Token L1 Address of GRT * @param _from Address of the sender on L1 * @param _to Recipient address on L2 @@ -237,7 +232,7 @@ contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger { L2GraphToken(calculateL2TokenAddress(l1GRT)).bridgeMint(_to, _amount); - if (_data.length > 0 && callhookWhitelist[_from] == true) { + if (_data.length > 0) { bytes memory callhookData; { bytes memory gatewayData; @@ -261,57 +256,47 @@ contract L2GraphTokenGateway is GraphTokenGateway, L2ArbitrumMessenger { * @notice Creates calldata required to send tx to L1 * @dev encodes the target function with its params which * will be called on L1 when the message is received on L1 - * @param token Address of the token on L1 - * @param from Address of the token sender on L2 - * @param to Address to which we're sending tokens on L1 - * @param amount Amount of GRT to transfer - * @param data Additional calldata for the transaction + * @param _token Address of the token on L1 + * @param _from Address of the token sender on L2 + * @param _to Address to which we're sending tokens on L1 + * @param _amount Amount of GRT to transfer + * @param _data Additional calldata for the transaction + * @return Calldata for a transaction sent to L1 */ function getOutboundCalldata( - address token, - address from, - address to, - uint256 amount, - bytes memory data - ) public pure returns (bytes memory outboundCalldata) { - outboundCalldata = abi.encodeWithSelector( - ITokenGateway.finalizeInboundTransfer.selector, - token, - from, - to, - amount, - abi.encode(0, data) // we don't need to track exitNums (b/c we have no fast exits) so we always use 0 - ); + address _token, + address _from, + address _to, + uint256 _amount, + bytes memory _data + ) public pure returns (bytes memory) { + return + abi.encodeWithSelector( + ITokenGateway.finalizeInboundTransfer.selector, + _token, + _from, + _to, + _amount, + abi.encode(0, _data) // we don't need to track exitNums (b/c we have no fast exits) so we always use 0 + ); } /** * @notice Decodes calldata required for migration of tokens * @dev extraData can be left empty - * @param data Encoded callhook data - * @return from Sender of the tx - * @return extraData Any other data sent to L1 + * @param _data Encoded callhook data + * @return Sender of the tx + * @return Any other data sent to L1 */ - function parseOutboundData(bytes memory data) - private - view - returns (address from, bytes memory extraData) - { + function parseOutboundData(bytes memory _data) private view returns (address, bytes memory) { + address from; + bytes memory extraData; if (msg.sender == l2Router) { - (from, extraData) = abi.decode(data, (address, bytes)); + (from, extraData) = abi.decode(_data, (address, bytes)); } else { from = msg.sender; - extraData = data; + extraData = _data; } - } - - /** - * @notice Converts L1 address to its L2 alias used when sending messages - * @dev The Arbitrum bridge adds an offset to addresses when sending messages, - * so we need to apply it to check any L1 address from a message in L2 - * @param _l1Address The L1 address - * @return _l2Address the L2 alias of _l1Address - */ - function l1ToL2Alias(address _l1Address) internal pure returns (address _l2Address) { - _l2Address = address(uint160(_l1Address) + L2_ADDRESS_OFFSET); + return (from, extraData); } } diff --git a/contracts/l2/reservoir/IL2Reservoir.sol b/contracts/l2/reservoir/IL2Reservoir.sol new file mode 100644 index 000000000..90b089ac9 --- /dev/null +++ b/contracts/l2/reservoir/IL2Reservoir.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; + +import "../../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. + * @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 + */ + function receiveDrip( + uint256 _issuanceBase, + uint256 _issuanceRate, + uint256 _nonce + ) external; +} diff --git a/contracts/l2/reservoir/L2Reservoir.sol b/contracts/l2/reservoir/L2Reservoir.sol new file mode 100644 index 000000000..1bc93c912 --- /dev/null +++ b/contracts/l2/reservoir/L2Reservoir.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; +pragma abicoder v2; + +import "@openzeppelin/contracts/math/SafeMath.sol"; + +import "../../reservoir/IReservoir.sol"; +import "../../reservoir/Reservoir.sol"; +import "./IL2Reservoir.sol"; +import "./L2ReservoirStorage.sol"; + +/** + * @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 L2ReservoirV1Storage, Reservoir, IL2Reservoir { + using SafeMath for uint256; + + event DripReceived(uint256 _issuanceBase); + event NextDripNonceUpdated(uint256 _nonce); + + /** + * @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. + * @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 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. + * @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 + */ + function receiveDrip( + uint256 _issuanceBase, + uint256 _issuanceRate, + uint256 _nonce + ) 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; + 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..ee7880343 --- /dev/null +++ b/contracts/l2/reservoir/L2ReservoirStorage.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; + +/** + * @dev Storage variables for the L2Reservoir + */ +contract L2ReservoirV1Storage { + // Expected nonce value for the next drip hook + uint256 public nextDripNonce; +} diff --git a/contracts/l2/token/GraphTokenUpgradeable.sol b/contracts/l2/token/GraphTokenUpgradeable.sol new file mode 100644 index 000000000..0df9b8e06 --- /dev/null +++ b/contracts/l2/token/GraphTokenUpgradeable.sol @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; + +import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20BurnableUpgradeable.sol"; +import "@openzeppelin/contracts/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/math/SafeMath.sol"; + +import "../../upgrades/GraphUpgradeable.sol"; +import "../../token/GraphToken.sol"; +import "../../governance/Governed.sol"; + +/** + * @title GraphTokenUpgradeable contract + * @dev This is the implementation of the ERC20 Graph Token. + * The implementation exposes a permit() function to allow for a spender to send a signed message + * and approve funds to a spender following EIP2612 to make integration with other contracts easier. + * + * The token is initially owned by the deployer address that can mint tokens to create the initial + * distribution. For convenience, an initial supply can be passed in the constructor that will be + * assigned to the deployer. + * + * The governor can add contracts allowed to mint indexing rewards. + * + * Note this is an exact copy of the original GraphToken contract, but using + * initializer functions and upgradeable OpenZeppelin contracts instead of + * the original's constructor + non-upgradeable approach. + */ +contract GraphTokenUpgradeable is GraphUpgradeable, Governed, ERC20BurnableUpgradeable { + using SafeMath for uint256; + + // -- EIP712 -- + // https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md#definition-of-domainseparator + + bytes32 private constant DOMAIN_TYPE_HASH = + keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)" + ); + bytes32 private constant DOMAIN_NAME_HASH = keccak256("Graph Token"); + bytes32 private constant DOMAIN_VERSION_HASH = keccak256("0"); + bytes32 private constant DOMAIN_SALT = + 0xe33842a7acd1d5a1d28f25a931703e5605152dc48d64dc4716efdae1f5659591; // Randomly generated salt + bytes32 private constant PERMIT_TYPEHASH = + keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ); + + // -- State -- + + // solhint-disable-next-line var-name-mixedcase + bytes32 private DOMAIN_SEPARATOR; + mapping(address => bool) private _minters; + mapping(address => uint256) public nonces; + + // -- Events -- + + event MinterAdded(address indexed account); + event MinterRemoved(address indexed account); + + modifier onlyMinter() { + require(isMinter(msg.sender), "Only minter can call"); + _; + } + + /** + * @dev Approve token allowance by validating a message signed by the holder. + * @param _owner Address of the token holder + * @param _spender Address of the approved spender + * @param _value Amount of tokens to approve the spender + * @param _deadline Expiration time of the signed permit (if zero, the permit will never expire, so use with caution) + * @param _v Signature recovery id + * @param _r Signature r value + * @param _s Signature s value + */ + function permit( + address _owner, + address _spender, + uint256 _value, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external { + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + DOMAIN_SEPARATOR, + keccak256( + abi.encode(PERMIT_TYPEHASH, _owner, _spender, _value, nonces[_owner], _deadline) + ) + ) + ); + + address recoveredAddress = ECDSA.recover(digest, _v, _r, _s); + require(_owner == recoveredAddress, "GRT: invalid permit"); + require(_deadline == 0 || block.timestamp <= _deadline, "GRT: expired permit"); + + nonces[_owner] = nonces[_owner].add(1); + _approve(_owner, _spender, _value); + } + + /** + * @dev Add a new minter. + * @param _account Address of the minter + */ + function addMinter(address _account) external onlyGovernor { + require(_account != address(0), "INVALID_MINTER"); + _addMinter(_account); + } + + /** + * @dev Remove a minter. + * @param _account Address of the minter + */ + function removeMinter(address _account) external onlyGovernor { + require(_minters[_account], "NOT_A_MINTER"); + _removeMinter(_account); + } + + /** + * @dev Renounce to be a minter. + */ + function renounceMinter() external { + require(_minters[msg.sender], "NOT_A_MINTER"); + _removeMinter(msg.sender); + } + + /** + * @dev Mint new tokens. + * @param _to Address to send the newly minted tokens + * @param _amount Amount of tokens to mint + */ + function mint(address _to, uint256 _amount) external onlyMinter { + _mint(_to, _amount); + } + + /** + * @dev Return if the `_account` is a minter or not. + * @param _account Address to check + * @return True if the `_account` is minter + */ + function isMinter(address _account) public view returns (bool) { + return _minters[_account]; + } + + /** + * @dev Graph Token Contract initializer. + * @param _owner Owner of this contract, who will hold the initial supply and will be a minter + * @param _initialSupply Initial supply of GRT + */ + function _initialize(address _owner, uint256 _initialSupply) internal { + __ERC20_init("Graph Token", "GRT"); + Governed._initialize(_owner); + + // The Governor has the initial supply of tokens + _mint(_owner, _initialSupply); + + // The Governor is the default minter + _addMinter(_owner); + + // EIP-712 domain separator + DOMAIN_SEPARATOR = keccak256( + abi.encode( + DOMAIN_TYPE_HASH, + DOMAIN_NAME_HASH, + DOMAIN_VERSION_HASH, + _getChainID(), + address(this), + DOMAIN_SALT + ) + ); + } + + /** + * @dev Add a new minter. + * @param _account Address of the minter + */ + function _addMinter(address _account) private { + _minters[_account] = true; + emit MinterAdded(_account); + } + + /** + * @dev Remove a minter. + * @param _account Address of the minter + */ + function _removeMinter(address _account) private { + _minters[_account] = false; + emit MinterRemoved(_account); + } + + /** + * @dev Get the running network chain ID. + * @return The chain ID + */ + function _getChainID() private pure returns (uint256) { + uint256 id; + // solhint-disable-next-line no-inline-assembly + assembly { + id := chainid() + } + return id; + } +} diff --git a/contracts/l2/token/L2GraphToken.sol b/contracts/l2/token/L2GraphToken.sol index 2307385c5..ec6ca4eb8 100644 --- a/contracts/l2/token/L2GraphToken.sol +++ b/contracts/l2/token/L2GraphToken.sol @@ -2,209 +2,10 @@ pragma solidity ^0.7.6; -import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20BurnableUpgradeable.sol"; -import "@openzeppelin/contracts/cryptography/ECDSA.sol"; import "@openzeppelin/contracts/math/SafeMath.sol"; -import "../../upgrades/GraphUpgradeable.sol"; -import "../../token/GraphToken.sol"; +import "./GraphTokenUpgradeable.sol"; import "../../arbitrum/IArbToken.sol"; -import "../../governance/Governed.sol"; - -/** - * @title GraphTokenUpgradeable contract - * @dev This is the implementation of the ERC20 Graph Token. - * The implementation exposes a Permit() function to allow for a spender to send a signed message - * and approve funds to a spender following EIP2612 to make integration with other contracts easier. - * - * The token is initially owned by the deployer address that can mint tokens to create the initial - * distribution. For convenience, an initial supply can be passed in the constructor that will be - * assigned to the deployer. - * - * The governor can add contracts allowed to mint indexing rewards. - * - * Note this is an exact copy of the original GraphToken contract, but using - * initializer functions and upgradeable OpenZeppelin contracts instead of - * the original's constructor + non-upgradeable approach. - */ -contract GraphTokenUpgradeable is - GraphUpgradeable, - Governed, - ERC20Upgradeable, - ERC20BurnableUpgradeable -{ - using SafeMath for uint256; - - // -- EIP712 -- - // https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md#definition-of-domainseparator - - bytes32 private constant DOMAIN_TYPE_HASH = - keccak256( - "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)" - ); - bytes32 private constant DOMAIN_NAME_HASH = keccak256("Graph Token"); - bytes32 private constant DOMAIN_VERSION_HASH = keccak256("0"); - bytes32 private constant DOMAIN_SALT = - 0xe33842a7acd1d5a1d28f25a931703e5605152dc48d64dc4716efdae1f5659591; // Randomly generated salt - bytes32 private constant PERMIT_TYPEHASH = - keccak256( - "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" - ); - - // -- State -- - - // solhint-disable-next-line var-name-mixedcase - bytes32 private DOMAIN_SEPARATOR; - mapping(address => bool) private _minters; - mapping(address => uint256) public nonces; - - // -- Events -- - - event MinterAdded(address indexed account); - event MinterRemoved(address indexed account); - - modifier onlyMinter() { - require(isMinter(msg.sender), "Only minter can call"); - _; - } - - /** - * @dev Graph Token Contract initializer. - * @param _initialSupply Initial supply of GRT - */ - function _initialize(address owner, uint256 _initialSupply) internal { - __ERC20_init("Graph Token", "GRT"); - Governed._initialize(owner); - - // The Governor has the initial supply of tokens - _mint(owner, _initialSupply); - - // The Governor is the default minter - _addMinter(owner); - - // EIP-712 domain separator - DOMAIN_SEPARATOR = keccak256( - abi.encode( - DOMAIN_TYPE_HASH, - DOMAIN_NAME_HASH, - DOMAIN_VERSION_HASH, - _getChainID(), - address(this), - DOMAIN_SALT - ) - ); - } - - /** - * @dev Approve token allowance by validating a message signed by the holder. - * @param _owner Address of the token holder - * @param _spender Address of the approved spender - * @param _value Amount of tokens to approve the spender - * @param _deadline Expiration time of the signed permit - * @param _v Signature version - * @param _r Signature r value - * @param _s Signature s value - */ - function permit( - address _owner, - address _spender, - uint256 _value, - uint256 _deadline, - uint8 _v, - bytes32 _r, - bytes32 _s - ) external { - bytes32 digest = keccak256( - abi.encodePacked( - "\x19\x01", - DOMAIN_SEPARATOR, - keccak256( - abi.encode(PERMIT_TYPEHASH, _owner, _spender, _value, nonces[_owner], _deadline) - ) - ) - ); - nonces[_owner] = nonces[_owner].add(1); - - address recoveredAddress = ECDSA.recover(digest, abi.encodePacked(_r, _s, _v)); - require(_owner == recoveredAddress, "GRT: invalid permit"); - require(_deadline == 0 || block.timestamp <= _deadline, "GRT: expired permit"); - - _approve(_owner, _spender, _value); - } - - /** - * @dev Add a new minter. - * @param _account Address of the minter - */ - function addMinter(address _account) external onlyGovernor { - _addMinter(_account); - } - - /** - * @dev Remove a minter. - * @param _account Address of the minter - */ - function removeMinter(address _account) external onlyGovernor { - _removeMinter(_account); - } - - /** - * @dev Renounce to be a minter. - */ - function renounceMinter() external { - _removeMinter(msg.sender); - } - - /** - * @dev Mint new tokens. - * @param _to Address to send the newly minted tokens - * @param _amount Amount of tokens to mint - */ - function mint(address _to, uint256 _amount) external onlyMinter { - _mint(_to, _amount); - } - - /** - * @dev Return if the `_account` is a minter or not. - * @param _account Address to check - * @return True if the `_account` is minter - */ - function isMinter(address _account) public view returns (bool) { - return _minters[_account]; - } - - /** - * @dev Add a new minter. - * @param _account Address of the minter - */ - function _addMinter(address _account) private { - _minters[_account] = true; - emit MinterAdded(_account); - } - - /** - * @dev Remove a minter. - * @param _account Address of the minter - */ - function _removeMinter(address _account) private { - _minters[_account] = false; - emit MinterRemoved(_account); - } - - /** - * @dev Get the running network chain ID. - * @return The chain ID - */ - function _getChainID() private pure returns (uint256) { - uint256 id; - // solhint-disable-next-line no-inline-assembly - assembly { - id := chainid() - } - return id; - } -} /** * @title L2 Graph Token Contract @@ -238,47 +39,56 @@ contract L2GraphToken is GraphTokenUpgradeable, IArbToken { /** * @dev L2 Graph Token Contract initializer. - * @param owner Governance address that owns this contract - * @param _initialSupply Initial supply of GRT + * Note some parameters have to be set separately as they are generally + * not expected to be available at initialization time: + * - gateway using setGateway + * - l1Address using setL1Address + * @param _owner Governance address that owns this contract */ - function initialize(address owner, uint256 _initialSupply) external onlyImpl { - require(owner != address(0), "Owner must be set"); - GraphTokenUpgradeable._initialize(owner, _initialSupply); + function initialize(address _owner) external onlyImpl { + require(_owner != address(0), "Owner must be set"); + // Initial supply hard coded to 0 as tokens are only supposed + // to be minted through the bridge. + GraphTokenUpgradeable._initialize(_owner, 0); } /** * @dev Sets the address of the L2 gateway allowed to mint tokens + * @param _gw Address for the L2GraphTokenGateway that will be allowed to mint tokens */ - function setGateway(address gw) external onlyGovernor { - gateway = gw; + function setGateway(address _gw) external onlyGovernor { + require(_gw != address(0), "INVALID_GATEWAY"); + gateway = _gw; emit GatewaySet(gateway); } /** * @dev Sets the address of the counterpart token on L1 + * @param _addr Address for the GraphToken contract on L1 */ - function setL1Address(address addr) external onlyGovernor { - l1Address = addr; - emit L1AddressSet(addr); + function setL1Address(address _addr) external onlyGovernor { + require(_addr != address(0), "INVALID_L1_ADDRESS"); + l1Address = _addr; + emit L1AddressSet(_addr); } /** * @dev Increases token supply, only callable by the L1/L2 bridge (when tokens are transferred to L2) - * @param account Address to credit with the new tokens - * @param amount Number of tokens to mint + * @param _account Address to credit with the new tokens + * @param _amount Number of tokens to mint */ - function bridgeMint(address account, uint256 amount) external override onlyGateway { - _mint(account, amount); - emit BridgeMinted(account, amount); + function bridgeMint(address _account, uint256 _amount) external override onlyGateway { + _mint(_account, _amount); + emit BridgeMinted(_account, _amount); } /** * @dev Decreases token supply, only callable by the L1/L2 bridge (when tokens are transferred to L1). - * @param account Address from which to extract the tokens - * @param amount Number of tokens to burn + * @param _account Address from which to extract the tokens + * @param _amount Number of tokens to burn */ - function bridgeBurn(address account, uint256 amount) external override onlyGateway { - burnFrom(account, amount); - emit BridgeBurned(account, amount); + function bridgeBurn(address _account, uint256 _amount) external override onlyGateway { + burnFrom(_account, _amount); + emit BridgeBurned(_account, _amount); } } 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..674e127ef --- /dev/null +++ b/contracts/reservoir/L1Reservoir.sol @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; +pragma abicoder v2; + +import "@openzeppelin/contracts/math/SafeMath.sol"; + +import "../arbitrum/ITokenGateway.sol"; + +import "../l2/reservoir/IL2Reservoir.sol"; +import "./Reservoir.sol"; +import "./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 L1ReservoirV1Storage, 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); + + /** + * @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. + * 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 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 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 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 + */ + function drip( + uint256 _l2MaxGas, + uint256 _l2GasPriceBid, + uint256 _l2MaxSubmissionCost + ) external payable notPaused { + uint256 mintedRewardsTotal = getNewGlobalRewards(rewardsMintedUntilBlock); + uint256 mintedRewardsActual = getNewGlobalRewards(block.number); + // eps = (signed int) mintedRewardsTotal - mintedRewardsActual + + 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 tokensToMint; + { + uint256 newRewardsPlusMintedActual = newRewardsToDistribute.add(mintedRewardsActual); + require( + newRewardsPlusMintedActual >= mintedRewardsTotal, + "Would mint negative tokens, wait before calling again" + ); + tokensToMint = newRewardsPlusMintedActual.sub(mintedRewardsTotal); + } + + if (tokensToMint > 0) { + graphToken().mint(address(this), tokensToMint); + } + + 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.sub(l2OffsetAmount); + } else { + tokensToSendToL2 = tokensToSendToL2.add( + l2RewardsFraction.mul(mintedRewardsActual.sub(mintedRewardsTotal)).div( + FIXED_POINT_SCALING_FACTOR + ) + ); + } + l2RewardsFraction = nextL2RewardsFraction; + emit L2RewardsFractionUpdated(l2RewardsFraction); + _sendNewTokensAndStateToL2( + tokensToSendToL2, + _l2MaxGas, + _l2GasPriceBid, + _l2MaxSubmissionCost + ); + } else if (l2RewardsFraction > 0) { + tokensToSendToL2 = tokensToMint.mul(l2RewardsFraction).div(FIXED_POINT_SCALING_FACTOR); + _sendNewTokensAndStateToL2( + tokensToSendToL2, + _l2MaxGas, + _l2GasPriceBid, + _l2MaxSubmissionCost + ); + } 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"); + } + emit RewardsDripped(tokensToMint, 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 + */ + function _sendNewTokensAndStateToL2( + uint256 _nTokens, + uint256 _maxGas, + uint256 _gasPriceBid, + uint256 _maxSubmissionCost + ) internal { + uint256 l2IssuanceBase = l2RewardsFraction.mul(issuanceBase).div( + FIXED_POINT_SCALING_FACTOR + ); + bytes memory extraData = abi.encodeWithSelector( + IL2Reservoir.receiveDrip.selector, + l2IssuanceBase, + issuanceRate, + nextDripNonce + ); + 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 + ); + } +} diff --git a/contracts/reservoir/L1ReservoirStorage.sol b/contracts/reservoir/L1ReservoirStorage.sol new file mode 100644 index 000000000..90821c809 --- /dev/null +++ b/contracts/reservoir/L1ReservoirStorage.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; + +/** + * @dev Storage variables for the L1Reservoir + */ +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; +} diff --git a/contracts/reservoir/Reservoir.sol b/contracts/reservoir/Reservoir.sol new file mode 100644 index 000000000..d2a0bf6cb --- /dev/null +++ b/contracts/reservoir/Reservoir.sol @@ -0,0 +1,115 @@ +// 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; + + uint256 internal constant FIXED_POINT_SCALING_FACTOR = 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..b46e44d35 --- /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 + */ +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 9a0e24d2a..c9dcf103d 100644 --- a/contracts/rewards/RewardsManager.sol +++ b/contracts/rewards/RewardsManager.sol @@ -10,6 +10,8 @@ import "../upgrades/GraphUpgradeable.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 @@ -27,10 +29,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 RewardsManagerV2Storage, 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 -- @@ -46,9 +48,24 @@ contract RewardsManager is RewardsManagerV2Storage, 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. @@ -68,41 +85,12 @@ contract RewardsManager is RewardsManagerV2Storage, GraphUpgradeable, IRewardsMa /** * @dev Initialize this contract. */ - function initialize(address _controller, uint256 _issuanceRate) external onlyImpl { + function initialize(address _controller) external onlyImpl { Managed._initialize(_controller); - - // Settings - _setIssuanceRate(_issuanceRate); } // -- 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 @@ -190,32 +178,13 @@ contract RewardsManager is RewardsManagerV2Storage, 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())); @@ -223,16 +192,14 @@ contract RewardsManager is RewardsManagerV2Storage, GraphUpgradeable, IRewardsMa return 0; } - uint256 r = issuanceRate; - uint256 p = graphToken.totalSupply(); - 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 + ); } /** @@ -264,7 +231,7 @@ contract RewardsManager is RewardsManagerV2Storage, GraphUpgradeable, IRewardsMa ? getAccRewardsPerSignal() .sub(subgraph.accRewardsPerSignalSnapshot) .mul(subgraphSignalledTokens) - .div(TOKEN_DECIMALS) + .div(FIXED_POINT_SCALING_FACTOR) : 0; return subgraph.accRewardsForSubgraph.add(newRewards); } @@ -295,9 +262,9 @@ contract RewardsManager is RewardsManagerV2Storage, 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 @@ -307,7 +274,8 @@ contract RewardsManager is RewardsManagerV2Storage, 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 @@ -315,6 +283,7 @@ contract RewardsManager is RewardsManagerV2Storage, GraphUpgradeable, IRewardsMa function updateAccRewardsPerSignal() public override returns (uint256) { accRewardsPerSignal = getAccRewardsPerSignal(); accRewardsPerSignalLastBlockUpdated = block.number; + accRewardsOnLastSignalUpdate = reservoir().getAccumulatedRewards(block.number); return accRewardsPerSignal; } @@ -395,13 +364,13 @@ contract RewardsManager is RewardsManagerV2Storage, 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 +384,55 @@ contract RewardsManager is RewardsManagerV2Storage, 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 2bb0c2978..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; @@ -26,3 +26,13 @@ contract RewardsManagerV2Storage is RewardsManagerV1Storage { // Minimum amount of signaled tokens on a subgraph required to accrue rewards uint256 public minimumSubgraphSignal; } + +contract RewardsManagerV3Storage is RewardsManagerV2Storage { + // Snapshot of the total supply of GRT when accRewardsPerSignal was last updated + 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 0e582fc83..f7052dcb1 100644 --- a/contracts/staking/Staking.sol +++ b/contracts/staking/Staking.sol @@ -1202,11 +1202,12 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { } rebatePool.addToPool(alloc.collectedFees, alloc.effectiveAllocation); - // 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 @@ -1567,9 +1568,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); } @@ -1579,12 +1577,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) { @@ -1604,6 +1599,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/contracts/tests/arbitrum/BridgeMock.sol b/contracts/tests/arbitrum/BridgeMock.sol index 7f18084d6..4f2848288 100644 --- a/contracts/tests/arbitrum/BridgeMock.sol +++ b/contracts/tests/arbitrum/BridgeMock.sol @@ -21,25 +21,25 @@ contract BridgeMock is IBridge { /** * @dev Deliver a message to the inbox. The encoded message will be * added to the inbox array, and messageIndex will be incremented. - * @param kind Type of the message - * @param sender Address that is sending the message - * @param messageDataHash keccak256 hash of the message data + * @param _kind Type of the message + * @param _sender Address that is sending the message + * @param _messageDataHash keccak256 hash of the message data * @return The next index for the inbox array */ function deliverMessageToInbox( - uint8 kind, - address sender, - bytes32 messageDataHash + uint8 _kind, + address _sender, + bytes32 _messageDataHash ) external payable override returns (uint256) { messageIndex = messageIndex + 1; - inboxAccs.push(keccak256(abi.encodePacked(inbox, kind, sender, messageDataHash))); + inboxAccs.push(keccak256(abi.encodePacked(inbox, _kind, _sender, _messageDataHash))); emit MessageDelivered( messageIndex, inboxAccs[messageIndex - 1], msg.sender, - kind, - sender, - messageDataHash + _kind, + _sender, + _messageDataHash ); return messageIndex; } @@ -47,40 +47,45 @@ contract BridgeMock is IBridge { /** * @dev Executes an L1 function call incoing from L2. This can only be called * by the Outbox. - * @param destAddr Contract to call - * @param amount ETH value to send - * @param data Calldata for the function call + * @param _destAddr Contract to call + * @param _amount ETH value to send + * @param _data Calldata for the function call + * @return True if the call was successful, false otherwise + * @return Return data from the call */ function executeCall( - address destAddr, - uint256 amount, - bytes calldata data - ) external override returns (bool success, bytes memory returnData) { + address _destAddr, + uint256 _amount, + bytes calldata _data + ) external override returns (bool, bytes memory) { require(outbox == msg.sender, "NOT_FROM_OUTBOX"); + bool success; + bytes memory returnData; // solhint-disable-next-line avoid-low-level-calls - (success, returnData) = destAddr.call{ value: amount }(data); - emit BridgeCallTriggered(msg.sender, destAddr, amount, data); + (success, returnData) = _destAddr.call{ value: _amount }(_data); + emit BridgeCallTriggered(msg.sender, _destAddr, _amount, _data); + return (success, returnData); } /** * @dev Set the address of the inbox. Anyone can call this, because it's a mock. * @param _inbox Address of the inbox - * @param enabled Enable the inbox (ignored) + * @param _enabled Enable the inbox (ignored) */ - function setInbox(address _inbox, bool enabled) external override { + function setInbox(address _inbox, bool _enabled) external override { inbox = _inbox; - emit InboxToggle(inbox, enabled); + emit InboxToggle(inbox, _enabled); } /** * @dev Set the address of the outbox. Anyone can call this, because it's a mock. * @param _outbox Address of the outbox - * @param enabled Enable the outbox (ignored) + * @param _enabled Enable the outbox (ignored) */ - function setOutbox(address _outbox, bool enabled) external override { + function setOutbox(address _outbox, bool _enabled) external override { outbox = _outbox; - emit OutboxToggle(outbox, enabled); + emit OutboxToggle(outbox, _enabled); } // View functions diff --git a/contracts/tests/arbitrum/InboxMock.sol b/contracts/tests/arbitrum/InboxMock.sol index 940e33bfd..b600ec3ac 100644 --- a/contracts/tests/arbitrum/InboxMock.sol +++ b/contracts/tests/arbitrum/InboxMock.sol @@ -3,14 +3,13 @@ pragma solidity ^0.7.6; import "../../arbitrum/IInbox.sol"; +import "../../arbitrum/AddressAliasHelper.sol"; /** * @title Arbitrum Inbox mock contract * @dev This contract implements (a subset of) Arbitrum's IInbox interface for testing purposes */ contract InboxMock is IInbox { - // Offset used when calculating the L2 alias of an L1 address - uint160 internal constant OFFSET = uint160(0x1111000000000000000000000000000000001111); // Type indicator for a standard L2 message uint8 internal constant L2_MSG = 3; // Type indicator for a retryable ticket message @@ -21,12 +20,12 @@ contract InboxMock is IInbox { /** * @dev Send a message to L2 (by delivering it to the Bridge) - * @param messageData Encoded data to send in the message + * @param _messageData Encoded data to send in the message * @return message number returned by the inbox */ - function sendL2Message(bytes calldata messageData) external override returns (uint256) { - uint256 msgNum = deliverToBridge(L2_MSG, msg.sender, keccak256(messageData)); - emit InboxMessageDelivered(msgNum, messageData); + function sendL2Message(bytes calldata _messageData) external override returns (uint256) { + uint256 msgNum = deliverToBridge(L2_MSG, msg.sender, keccak256(_messageData)); + emit InboxMessageDelivered(msgNum, _messageData); return msgNum; } @@ -90,55 +89,45 @@ contract InboxMock is IInbox { revert("Unimplemented"); } - /** - * @dev Utility function that converts the address in the L1 that submitted a tx to - * the inbox to the msg.sender viewed in the L2 - * @param l1Address the address in the L1 that triggered the tx to L2 - * @return l2Address L2 address as viewed in msg.sender - */ - function applyL1ToL2Alias(address l1Address) internal pure returns (address l2Address) { - l2Address = address(uint160(l1Address) + OFFSET); - } - /** * @dev Creates a retryable ticket for an L2 transaction - * @param destAddr Address of the contract to call in L2 - * @param arbTxCallValue Callvalue to use in the L2 transaction - * @param maxSubmissionCost Max cost of submitting the ticket, in Wei - * @param submissionRefundAddress L2 address to refund for any remaining value from the submission cost - * @param valueRefundAddress L2 address to refund if the ticket times out or gets cancelled - * @param maxGas Max gas for the L2 transcation - * @param gasPriceBid Gas price bid on L2 - * @param data Encoded calldata for the L2 transaction (including function selector) + * @param _destAddr Address of the contract to call in L2 + * @param _arbTxCallValue Callvalue to use in the L2 transaction + * @param _maxSubmissionCost Max cost of submitting the ticket, in Wei + * @param _submissionRefundAddress L2 address to refund for any remaining value from the submission cost + * @param _valueRefundAddress L2 address to refund if the ticket times out or gets cancelled + * @param _maxGas Max gas for the L2 transcation + * @param _gasPriceBid Gas price bid on L2 + * @param _data Encoded calldata for the L2 transaction (including function selector) * @return message number returned by the bridge */ function createRetryableTicket( - address destAddr, - uint256 arbTxCallValue, - uint256 maxSubmissionCost, - address submissionRefundAddress, - address valueRefundAddress, - uint256 maxGas, - uint256 gasPriceBid, - bytes calldata data + address _destAddr, + uint256 _arbTxCallValue, + uint256 _maxSubmissionCost, + address _submissionRefundAddress, + address _valueRefundAddress, + uint256 _maxGas, + uint256 _gasPriceBid, + bytes calldata _data ) external payable override returns (uint256) { - submissionRefundAddress = applyL1ToL2Alias(submissionRefundAddress); - valueRefundAddress = applyL1ToL2Alias(valueRefundAddress); + _submissionRefundAddress = AddressAliasHelper.applyL1ToL2Alias(_submissionRefundAddress); + _valueRefundAddress = AddressAliasHelper.applyL1ToL2Alias(_valueRefundAddress); return _deliverMessage( L1MessageType_submitRetryableTx, msg.sender, abi.encodePacked( - uint256(uint160(bytes20(destAddr))), - arbTxCallValue, + uint256(uint160(bytes20(_destAddr))), + _arbTxCallValue, msg.value, - maxSubmissionCost, - uint256(uint160(bytes20(submissionRefundAddress))), - uint256(uint160(bytes20(valueRefundAddress))), - maxGas, - gasPriceBid, - data.length, - data + _maxSubmissionCost, + uint256(uint160(bytes20(_submissionRefundAddress))), + uint256(uint160(bytes20(_valueRefundAddress))), + _maxGas, + _gasPriceBid, + _data.length, + _data ) ); } @@ -194,16 +183,16 @@ contract InboxMock is IInbox { /** * @dev Deliver a message to the bridge - * @param kind Type of the message - * @param sender Address that is sending the message - * @param messageDataHash keccak256 hash of the encoded message data + * @param _kind Type of the message + * @param _sender Address that is sending the message + * @param _messageDataHash keccak256 hash of the encoded message data * @return Message number returned by the bridge */ function deliverToBridge( - uint8 kind, - address sender, - bytes32 messageDataHash + uint8 _kind, + address _sender, + bytes32 _messageDataHash ) internal returns (uint256) { - return bridge.deliverMessageToInbox{ value: msg.value }(kind, sender, messageDataHash); + return bridge.deliverMessageToInbox{ value: msg.value }(_kind, _sender, _messageDataHash); } } diff --git a/contracts/tests/arbitrum/OutboxMock.sol b/contracts/tests/arbitrum/OutboxMock.sol index af16bdfdb..a529a975a 100644 --- a/contracts/tests/arbitrum/OutboxMock.sol +++ b/contracts/tests/arbitrum/OutboxMock.sol @@ -94,54 +94,54 @@ contract OutboxMock is IOutbox { * @notice (Mock) Executes a messages in an Outbox entry. * @dev This mocks what has to be called when finalizing an L2 to L1 transfer. * In our mock scenario, we don't validate and execute unconditionally. - * @param batchNum Index of OutboxEntry in outboxEntries array - * @param l2Sender sender of original message (i.e., caller of ArbSys.sendTxToL1) - * @param destAddr destination address for L1 contract call - * @param l2Block l2 block number at which sendTxToL1 call was made - * @param l1Block l1 block number at which sendTxToL1 call was made - * @param l2Timestamp l2 Timestamp at which sendTxToL1 call was made - * @param amount value in L1 message in wei - * @param calldataForL1 abi-encoded L1 message data + * @param _batchNum Index of OutboxEntry in outboxEntries array + * @param _l2Sender sender of original message (i.e., caller of ArbSys.sendTxToL1) + * @param _destAddr destination address for L1 contract call + * @param _l2Block l2 block number at which sendTxToL1 call was made + * @param _l1Block l1 block number at which sendTxToL1 call was made + * @param _l2Timestamp l2 Timestamp at which sendTxToL1 call was made + * @param _amount value in L1 message in wei + * @param _calldataForL1 abi-encoded L1 message data */ function executeTransaction( - uint256 batchNum, + uint256 _batchNum, bytes32[] calldata, // proof uint256, // index - address l2Sender, - address destAddr, - uint256 l2Block, - uint256 l1Block, - uint256 l2Timestamp, - uint256 amount, - bytes calldata calldataForL1 + address _l2Sender, + address _destAddr, + uint256 _l2Block, + uint256 _l1Block, + uint256 _l2Timestamp, + uint256 _amount, + bytes calldata _calldataForL1 ) external virtual { bytes32 outputId; context = L2ToL1Context({ - sender: l2Sender, - l2Block: uint128(l2Block), - l1Block: uint128(l1Block), - timestamp: uint128(l2Timestamp), - batchNum: uint128(batchNum), + sender: _l2Sender, + l2Block: uint128(_l2Block), + l1Block: uint128(_l1Block), + timestamp: uint128(_l2Timestamp), + batchNum: uint128(_batchNum), outputId: outputId }); // set and reset vars around execution so they remain valid during call - executeBridgeCall(destAddr, amount, calldataForL1); + executeBridgeCall(_destAddr, _amount, _calldataForL1); } /** * @dev Execute an L2-to-L1 function call by calling the bridge - * @param destAddr Address of the contract to call - * @param amount Callvalue for the function call - * @param data Calldata for the function call + * @param _destAddr Address of the contract to call + * @param _amount Callvalue for the function call + * @param _data Calldata for the function call */ function executeBridgeCall( - address destAddr, - uint256 amount, - bytes memory data + address _destAddr, + uint256 _amount, + bytes memory _data ) internal { - (bool success, bytes memory returndata) = bridge.executeCall(destAddr, amount, data); + (bool success, bytes memory returndata) = bridge.executeCall(_destAddr, _amount, _data); if (!success) { if (returndata.length > 0) { // solhint-disable-next-line no-inline-assembly diff --git a/contracts/token/IGraphToken.sol b/contracts/token/IGraphToken.sol index 41ca4838b..8255e18d5 100644 --- a/contracts/token/IGraphToken.sol +++ b/contracts/token/IGraphToken.sol @@ -9,6 +9,8 @@ interface IGraphToken is IERC20 { function burn(uint256 amount) external; + function burnFrom(address _from, uint256 amount) external; + function mint(address _to, uint256 _amount) external; // -- Mint Admin -- diff --git a/hardhat.config.ts b/hardhat.config.ts index 11557e15b..76391d3c7 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -62,7 +62,11 @@ const networkConfigs: NetworkConfig[] = [ { network: 'kovan', chainId: 42 }, { network: 'arbitrum-rinkeby', chainId: 421611, url: 'https://rinkeby.arbitrum.io/rpc' }, { network: 'arbitrum-one', chainId: 42161, url: 'https://arb1.arbitrum.io/rpc' }, - { network: 'arbitrum-nitro-devnet', chainId: 421612, url: 'https://nitro-devnet.arbitrum.io/rpc' }, + { + network: 'arbitrum-nitro-devnet', + chainId: 421612, + url: 'https://nitro-devnet.arbitrum.io/rpc', + }, ] function getAccountMnemonic() { diff --git a/test/gateway/l1GraphTokenGateway.test.ts b/test/gateway/l1GraphTokenGateway.test.ts index 6e69acf7a..f14204816 100644 --- a/test/gateway/l1GraphTokenGateway.test.ts +++ b/test/gateway/l1GraphTokenGateway.test.ts @@ -7,8 +7,7 @@ import { InboxMock } from '../../build/types/InboxMock' import { OutboxMock } from '../../build/types/OutboxMock' import { L1GraphTokenGateway } from '../../build/types/L1GraphTokenGateway' -import { NetworkFixture } from '../lib/fixtures' -import { deployContract } from '../lib/deployment' +import { NetworkFixture, ArbitrumL1Mocks, L1FixtureContracts } from '../lib/fixtures' import { getAccounts, @@ -30,6 +29,7 @@ describe('L1GraphTokenGateway', () => { let mockL2GRT: Account let mockL2Gateway: Account let pauseGuardian: Account + let mockL2Reservoir: Account let fixture: NetworkFixture let grt: GraphToken @@ -39,6 +39,9 @@ describe('L1GraphTokenGateway', () => { let inboxMock: InboxMock let outboxMock: OutboxMock + let arbitrumMocks: ArbitrumL1Mocks + let fixtureContracts: L1FixtureContracts + const senderTokens = toGRT('1000') const maxGas = toBN('1000000') const maxSubmissionCost = toBN('7') @@ -56,18 +59,26 @@ 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() - ;({ grt, l1GraphTokenGateway, bridgeEscrow } = await fixture.load(governor.signer)) + fixtureContracts = await fixture.load(governor.signer) + ;({ grt, l1GraphTokenGateway, bridgeEscrow } = fixtureContracts) // Give some funds to the token sender await grt.connect(governor.signer).mint(tokenSender.address, senderTokens) // Deploy contracts that mock Arbitrum's bridge contracts - bridgeMock = (await deployContract('BridgeMock', governor.signer)) as unknown as BridgeMock - inboxMock = (await deployContract('InboxMock', governor.signer)) as unknown as InboxMock - outboxMock = (await deployContract('OutboxMock', governor.signer)) as unknown as OutboxMock + arbitrumMocks = await fixture.loadArbitrumL1Mocks(governor.signer) + ;({ bridgeMock, inboxMock, outboxMock } = arbitrumMocks) }) beforeEach(async function () { @@ -362,23 +373,15 @@ describe('L1GraphTokenGateway', () => { await expect(senderBalance).eq(toGRT('990')) } before(async function () { - // First configure the Arbitrum bridge mocks - await bridgeMock.connect(governor.signer).setInbox(inboxMock.address, true) - await bridgeMock.connect(governor.signer).setOutbox(outboxMock.address, true) - await inboxMock.connect(governor.signer).setBridge(bridgeMock.address) - await outboxMock.connect(governor.signer).setBridge(bridgeMock.address) - - // Configure the gateway - await l1GraphTokenGateway - .connect(governor.signer) - .setArbitrumAddresses(inboxMock.address, mockRouter.address) - await l1GraphTokenGateway.connect(governor.signer).setL2TokenAddress(mockL2GRT.address) - await l1GraphTokenGateway - .connect(governor.signer) - .setL2CounterpartAddress(mockL2Gateway.address) - await l1GraphTokenGateway.connect(governor.signer).setEscrowAddress(bridgeEscrow.address) - await bridgeEscrow.connect(governor.signer).approveAll(l1GraphTokenGateway.address) - await l1GraphTokenGateway.connect(governor.signer).setPaused(false) + await fixture.configureL1Bridge( + governor.signer, + arbitrumMocks, + fixtureContracts, + mockRouter.address, + mockL2GRT.address, + mockL2Gateway.address, + mockL2Reservoir.address, + ) }) describe('calculateL2TokenAddress', function () { @@ -433,7 +436,7 @@ describe('L1GraphTokenGateway', () => { gasPriceBid, defaultData, { - value: defaultEthValue.add(1), + value: defaultEthValue.sub(1), }, ) await expect(tx).revertedWith('WRONG_ETH_VALUE') diff --git a/test/l2/l2GraphTokenGateway.test.ts b/test/l2/l2GraphTokenGateway.test.ts index 71909d587..d95c92871 100644 --- a/test/l2/l2GraphTokenGateway.test.ts +++ b/test/l2/l2GraphTokenGateway.test.ts @@ -1,11 +1,10 @@ import { expect, use } from 'chai' import { constants, ContractTransaction, Signer, utils } from 'ethers' -import hre from 'hardhat' import { L2GraphToken } from '../../build/types/L2GraphToken' import { L2GraphTokenGateway } from '../../build/types/L2GraphTokenGateway' -import { NetworkFixture } from '../lib/fixtures' +import { L2FixtureContracts, NetworkFixture } from '../lib/fixtures' import { FakeContract, smock } from '@defi-wonderland/smock' @@ -13,25 +12,15 @@ import path from 'path' import { Artifacts } from 'hardhat/internal/artifacts' const ARTIFACTS_PATH = path.resolve('build/contracts') const artifacts = new Artifacts(ARTIFACTS_PATH) -const rewardsManagerMockAbi = artifacts.readArtifactSync('RewardsManagerMock').abi +const reservoirMockAbi = artifacts.readArtifactSync('ReservoirMock').abi use(smock.matchers) -import { getAccounts, toGRT, Account, provider, applyL1ToL2Alias, toBN } from '../lib/testHelpers' +import { getAccounts, toGRT, Account, toBN, getL2SignerFromL1 } from '../lib/testHelpers' import { Interface } from 'ethers/lib/utils' const { AddressZero } = constants -// Adapted from: -// https://github.com/livepeer/arbitrum-lpt-bridge/blob/e1a81edda3594e434dbcaa4f1ebc95b7e67ecf2a/test/utils/messaging.ts#L5 -async function getL2SignerFromL1(l1Address: string): Promise { - const l2Address = applyL1ToL2Alias(l1Address) - await provider().send('hardhat_impersonateAccount', [l2Address]) - const l2Signer = await hre.ethers.getSigner(l2Address) - - return l2Signer -} - describe('L2GraphTokenGateway', () => { let me: Account let governor: Account @@ -42,16 +31,18 @@ describe('L2GraphTokenGateway', () => { let mockL1GRT: Account let mockL1Gateway: Account let pauseGuardian: Account + let mockL1Reservoir: Account let fixture: NetworkFixture let arbSysMock: FakeContract + let fixtureContracts: L2FixtureContracts let grt: L2GraphToken let l2GraphTokenGateway: L2GraphTokenGateway const senderTokens = toGRT('1000') const defaultData = '0x' - const rmmIface = new Interface(rewardsManagerMockAbi) - const notEmptyCallHookData = rmmIface.encodeFunctionData('pow', [toBN(1), toBN(2), toBN(3)]) + const mockIface = new Interface(reservoirMockAbi) + const notEmptyCallHookData = mockIface.encodeFunctionData('pow', [toBN(1), toBN(2), toBN(3)]) const defaultDataWithNotEmptyCallHookData = utils.defaultAbiCoder.encode( ['bytes', 'bytes'], ['0x', notEmptyCallHookData], @@ -68,10 +59,12 @@ describe('L2GraphTokenGateway', () => { mockL1Gateway, l2Receiver, pauseGuardian, + mockL1Reservoir, ] = await getAccounts() fixture = new NetworkFixture() - ;({ grt, l2GraphTokenGateway } = await fixture.loadL2(governor.signer)) + fixtureContracts = await fixture.loadL2(governor.signer) + ;({ grt, l2GraphTokenGateway } = fixtureContracts) // Give some funds to the token sender await grt.connect(governor.signer).mint(tokenSender.address, senderTokens) @@ -169,48 +162,6 @@ describe('L2GraphTokenGateway', () => { expect(await l2GraphTokenGateway.l1Counterpart()).eq(mockL1Gateway.address) }) }) - describe('addToCallhookWhitelist', function () { - it('is not callable by addreses that are not the governor', async function () { - const tx = l2GraphTokenGateway - .connect(tokenSender.signer) - .addToCallhookWhitelist(tokenSender.address) - await expect(tx).revertedWith('Caller must be Controller governor') - expect(await l2GraphTokenGateway.callhookWhitelist(tokenSender.address)).eq(false) - }) - it('adds an address to the callhook whitelist', async function () { - const tx = l2GraphTokenGateway - .connect(governor.signer) - .addToCallhookWhitelist(tokenSender.address) - await expect(tx) - .emit(l2GraphTokenGateway, 'AddedToCallhookWhitelist') - .withArgs(tokenSender.address) - expect(await l2GraphTokenGateway.callhookWhitelist(tokenSender.address)).eq(true) - }) - }) - describe('removeFromCallhookWhitelist', function () { - it('is not callable by addreses that are not the governor', async function () { - await l2GraphTokenGateway - .connect(governor.signer) - .addToCallhookWhitelist(tokenSender.address) - const tx = l2GraphTokenGateway - .connect(tokenSender.signer) - .removeFromCallhookWhitelist(tokenSender.address) - await expect(tx).revertedWith('Caller must be Controller governor') - expect(await l2GraphTokenGateway.callhookWhitelist(tokenSender.address)).eq(true) - }) - it('removes an address from the callhook whitelist', async function () { - await l2GraphTokenGateway - .connect(governor.signer) - .addToCallhookWhitelist(tokenSender.address) - const tx = l2GraphTokenGateway - .connect(governor.signer) - .removeFromCallhookWhitelist(tokenSender.address) - await expect(tx) - .emit(l2GraphTokenGateway, 'RemovedFromCallhookWhitelist') - .withArgs(tokenSender.address) - expect(await l2GraphTokenGateway.callhookWhitelist(tokenSender.address)).eq(false) - }) - }) describe('Pausable behavior', () => { it('cannot be paused or unpaused by someone other than governor or pauseGuardian', async () => { let tx = l2GraphTokenGateway.connect(tokenSender.signer).setPaused(false) @@ -297,17 +248,14 @@ describe('L2GraphTokenGateway', () => { await expect(senderBalance).eq(toGRT('990')) } before(async function () { - // Configure the L2 GRT - // Configure the gateway - await grt.connect(governor.signer).setGateway(l2GraphTokenGateway.address) - await grt.connect(governor.signer).setL1Address(mockL1GRT.address) - // Configure the gateway - await l2GraphTokenGateway.connect(governor.signer).setL2Router(mockRouter.address) - await l2GraphTokenGateway.connect(governor.signer).setL1TokenAddress(mockL1GRT.address) - await l2GraphTokenGateway - .connect(governor.signer) - .setL1CounterpartAddress(mockL1Gateway.address) - await l2GraphTokenGateway.connect(governor.signer).setPaused(false) + await fixture.configureL2Bridge( + governor.signer, + fixtureContracts, + mockRouter.address, + mockL1GRT.address, + mockL1Gateway.address, + mockL1Reservoir.address, + ) }) describe('calculateL2TokenAddress', function () { @@ -430,33 +378,19 @@ describe('L2GraphTokenGateway', () => { it('mints and sends tokens when called by the aliased gateway', async function () { await testValidFinalizeTransfer(defaultData) }) - it('does not call any callhooks if the sender is not whitelisted', async function () { - const rewardsManagerMock = await smock.fake('RewardsManagerMock', { - address: l2Receiver.address, - }) - rewardsManagerMock.pow.returns(1) - await testValidFinalizeTransfer(defaultDataWithNotEmptyCallHookData) - expect(rewardsManagerMock.pow).to.not.have.been.called - }) it('calls a callhook if the sender is whitelisted', async function () { - const rewardsManagerMock = await smock.fake('RewardsManagerMock', { + const reservoirMock = await smock.fake('ReservoirMock', { address: l2Receiver.address, }) - rewardsManagerMock.pow.returns(1) - await l2GraphTokenGateway - .connect(governor.signer) - .addToCallhookWhitelist(tokenSender.address) + reservoirMock.pow.returns(1) await testValidFinalizeTransfer(defaultDataWithNotEmptyCallHookData) - expect(rewardsManagerMock.pow).to.have.been.calledWith(toBN(1), toBN(2), toBN(3)) + expect(reservoirMock.pow).to.have.been.calledWith(toBN(1), toBN(2), toBN(3)) }) it('reverts if a callhook reverts', async function () { - const rewardsManagerMock = await smock.fake('RewardsManagerMock', { + const reservoirMock = await smock.fake('ReservoirMock', { address: l2Receiver.address, }) - rewardsManagerMock.pow.reverts() - await l2GraphTokenGateway - .connect(governor.signer) - .addToCallhookWhitelist(tokenSender.address) + reservoirMock.pow.reverts() const mockL1GatewayL2Alias = await getL2SignerFromL1(mockL1Gateway.address) await me.signer.sendTransaction({ to: await mockL1GatewayL2Alias.getAddress(), diff --git a/test/l2/l2Reservoir.test.ts b/test/l2/l2Reservoir.test.ts new file mode 100644 index 000000000..ebfe3f52e --- /dev/null +++ b/test/l2/l2Reservoir.test.ts @@ -0,0 +1,358 @@ +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 { + advanceBlocks, + getAccounts, + latestBlock, + toBN, + toGRT, + formatGRT, + Account, + RewardsTracker, + getL2SignerFromL1, +} 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 mockRouter: Account + let mockL1GRT: Account + let mockL1Gateway: Account + let mockL1Reservoir: Account + let fixture: NetworkFixture + + 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, + ): 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) + return tx + } + + before(async function () { + ;[governor, testAccount1, mockRouter, mockL1GRT, mockL1Gateway, mockL1Reservoir] = + 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, + 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('receiveDrip', async function () { + it('rejects the call when not called by the gateway', async function () { + const tx = l2Reservoir + .connect(governor.signer) + .receiveDrip(dripNormalizedSupply, dripIssuanceRate, toBN('0')) + 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'), + ) + 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'), + ) + 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'), + ) + 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('updates the normalized supply cache and issuance rate', async function () { + normalizedSupply = dripNormalizedSupply + let receiveDripTx = await l2Reservoir.populateTransaction.receiveDrip( + dripNormalizedSupply, + dripIssuanceRate, + toBN('0'), + ) + 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'), + ) + 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'), + ) + 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'), + ) + 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'), + ) + 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'), + ) + 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'), + ) + 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 fe5eed768..54f69e097 100644 --- a/test/lib/deployment.ts +++ b/test/lib/deployment.ts @@ -23,6 +23,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() @@ -58,6 +60,7 @@ export const defaults = { }, rewards: { issuanceRate: toGRT('1.000000023206889619'), // 5% annual rate + dripInterval: toBN('50400'), // 1 week in blocks (post-Merge) }, } @@ -235,7 +238,7 @@ export async function deployRewardsManager( return network.deployContractWithProxy( proxyAdmin, 'RewardsManager', - [controller, defaults.rewards.issuanceRate], + [controller], deployer, ) as unknown as RewardsManager } @@ -299,7 +302,33 @@ export async function deployL2GRT( return network.deployContractWithProxy( proxyAdmin, 'L2GraphToken', - [await deployer.getAddress(), toBN('0')], + [await deployer.getAddress()], 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 3a829c181..5a4f1e53a 100644 --- a/test/lib/fixtures.ts +++ b/test/lib/fixtures.ts @@ -2,7 +2,64 @@ import { utils, Wallet, Signer } from 'ethers' import * as deployment from './deployment' -import { evmSnapshot, evmRevert, initNetwork } from './testHelpers' +import { evmSnapshot, evmRevert, initNetwork, toBN } from './testHelpers' +import { BridgeMock } from '../../build/types/BridgeMock' +import { InboxMock } from '../../build/types/InboxMock' +import { OutboxMock } from '../../build/types/OutboxMock' +import { deployContract } from './deployment' +import { Controller } from '../../build/types/Controller' +import { DisputeManager } from '../../build/types/DisputeManager' +import { EpochManager } from '../../build/types/EpochManager' +import { GraphToken } from '../../build/types/GraphToken' +import { Curation } from '../../build/types/Curation' +import { GNS } from '../../build/types/GNS' +import { Staking } from '../../build/types/Staking' +import { RewardsManager } from '../../build/types/RewardsManager' +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 + disputeManager: DisputeManager + epochManager: EpochManager + grt: GraphToken + curation: Curation + gns: GNS + staking: Staking + rewardsManager: RewardsManager + serviceRegistry: ServiceRegistry + proxyAdmin: GraphProxyAdmin + l1GraphTokenGateway: L1GraphTokenGateway + bridgeEscrow: BridgeEscrow + l1Reservoir: L1Reservoir +} + +export interface L2FixtureContracts { + controller: Controller + disputeManager: DisputeManager + epochManager: EpochManager + grt: L2GraphToken + curation: Curation + gns: GNS + staking: Staking + rewardsManager: RewardsManager + serviceRegistry: ServiceRegistry + proxyAdmin: GraphProxyAdmin + l2GraphTokenGateway: L2GraphTokenGateway + l2Reservoir: L2Reservoir +} + +export interface ArbitrumL1Mocks { + bridgeMock: BridgeMock + inboxMock: InboxMock + outboxMock: OutboxMock +} export class NetworkFixture { lastSnapshotId: number @@ -11,11 +68,12 @@ export class NetworkFixture { this.lastSnapshotId = 0 } - async load( + async _loadLayer( deployer: Signer, slasher: Signer = Wallet.createRandom() as Signer, arbitrator: Signer = Wallet.createRandom() as Signer, - ): Promise { + isL2: boolean, + ): Promise { await initNetwork() // Roles @@ -30,7 +88,13 @@ export class NetworkFixture { controller.address, proxyAdmin, ) - const grt = await deployment.deployGRT(deployer) + let grt: GraphToken | L2GraphToken + if (isL2) { + grt = await deployment.deployL2GRT(deployer, proxyAdmin) + } else { + grt = await deployment.deployGRT(deployer) + } + const curation = await deployment.deployCuration(deployer, controller.address, proxyAdmin) const gns = await deployment.deployGNS(deployer, controller.address, proxyAdmin) const staking = await deployment.deployStaking(deployer, controller.address, proxyAdmin) @@ -51,17 +115,27 @@ export class NetworkFixture { proxyAdmin, ) - const l1GraphTokenGateway = await deployment.deployL1GraphTokenGateway( - deployer, - controller.address, - proxyAdmin, - ) - - const bridgeEscrow = await deployment.deployBridgeEscrow( - deployer, - controller.address, - proxyAdmin, - ) + 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, + controller.address, + proxyAdmin, + ) + bridgeEscrow = await deployment.deployBridgeEscrow(deployer, controller.address, proxyAdmin) + l1Reservoir = await deployment.deployL1Reservoir(deployer, controller.address, proxyAdmin) + } // Setup controller await controller.setContractProxy(utils.id('EpochManager'), epochManager.address) @@ -71,7 +145,13 @@ export class NetworkFixture { await controller.setContractProxy(utils.id('DisputeManager'), staking.address) await controller.setContractProxy(utils.id('RewardsManager'), rewardsManager.address) await controller.setContractProxy(utils.id('ServiceRegistry'), serviceRegistry.address) - await controller.setContractProxy(utils.id('GraphTokenGateway'), l1GraphTokenGateway.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 await curation.connect(deployer).syncAllContracts() @@ -80,68 +160,160 @@ export class NetworkFixture { await disputeManager.connect(deployer).syncAllContracts() await rewardsManager.connect(deployer).syncAllContracts() await staking.connect(deployer).syncAllContracts() - await l1GraphTokenGateway.connect(deployer).syncAllContracts() - await bridgeEscrow.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 grt.connect(deployer).addMinter(rewardsManager.address) await gns.connect(deployer).approveAll() + if (isL2) { + await grt.connect(deployer).addMinter(l2GraphTokenGateway.address) + 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 await controller.connect(deployer).setPaused(false) - return { - controller, - disputeManager, - epochManager, - grt, - curation, - gns, - staking, - rewardsManager, - serviceRegistry, - proxyAdmin, - l1GraphTokenGateway, - bridgeEscrow, + if (isL2) { + return { + controller, + disputeManager, + epochManager, + grt: grt as L2GraphToken, + curation, + gns, + staking, + rewardsManager, + serviceRegistry, + proxyAdmin, + l2GraphTokenGateway, + l2Reservoir, + } as L2FixtureContracts + } else { + return { + controller, + disputeManager, + epochManager, + grt: grt as GraphToken, + curation, + gns, + staking, + rewardsManager, + serviceRegistry, + proxyAdmin, + l1GraphTokenGateway, + bridgeEscrow, + l1Reservoir, + } as L1FixtureContracts } } - async loadL2(deployer: Signer): Promise { - await initNetwork() - - // Deploy contracts - const proxyAdmin = await deployment.deployProxyAdmin(deployer) - const controller = await deployment.deployController(deployer) - - const grt = await deployment.deployL2GRT(deployer, proxyAdmin) + async load( + deployer: Signer, + slasher: Signer = Wallet.createRandom() as Signer, + arbitrator: Signer = Wallet.createRandom() as Signer, + ): Promise { + return this._loadLayer(deployer, slasher, arbitrator, false) as unknown as L1FixtureContracts + } - const l2GraphTokenGateway = await deployment.deployL2GraphTokenGateway( - deployer, - controller.address, - proxyAdmin, - ) + async loadL2( + deployer: Signer, + slasher: Signer = Wallet.createRandom() as Signer, + arbitrator: Signer = Wallet.createRandom() as Signer, + ): Promise { + return this._loadLayer(deployer, slasher, arbitrator, true) as unknown as L2FixtureContracts + } - // Setup controller - await controller.setContractProxy(utils.id('GraphToken'), grt.address) - await controller.setContractProxy(utils.id('GraphTokenGateway'), l2GraphTokenGateway.address) + async loadArbitrumL1Mocks(deployer: Signer): Promise { + const bridgeMock = (await deployContract('BridgeMock', deployer)) as unknown as BridgeMock + const inboxMock = (await deployContract('InboxMock', deployer)) as unknown as InboxMock + const outboxMock = (await deployContract('OutboxMock', deployer)) as unknown as OutboxMock + return { + bridgeMock, + inboxMock, + outboxMock, + } + } - // Setup contracts - await l2GraphTokenGateway.connect(deployer).syncAllContracts() - await grt.connect(deployer).addMinter(l2GraphTokenGateway.address) + async configureL1Bridge( + deployer: Signer, + arbitrumMocks: ArbitrumL1Mocks, + l1FixtureContracts: L1FixtureContracts, + 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) + await arbitrumMocks.bridgeMock + .connect(deployer) + .setOutbox(arbitrumMocks.outboxMock.address, true) + await arbitrumMocks.inboxMock.connect(deployer).setBridge(arbitrumMocks.bridgeMock.address) + await arbitrumMocks.outboxMock.connect(deployer).setBridge(arbitrumMocks.bridgeMock.address) - // Unpause the protocol - await controller.connect(deployer).setPaused(false) + // Configure the gateway + await l1FixtureContracts.l1GraphTokenGateway + .connect(deployer) + .setArbitrumAddresses(arbitrumMocks.inboxMock.address, mockRouterAddress) + await l1FixtureContracts.l1GraphTokenGateway + .connect(deployer) + .setL2TokenAddress(mockL2GRTAddress) + await l1FixtureContracts.l1GraphTokenGateway + .connect(deployer) + .setL2CounterpartAddress(mockL2GatewayAddress) + 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) + } - return { - controller, - grt, - proxyAdmin, - l2GraphTokenGateway, - } + async configureL2Bridge( + deployer: Signer, + l2FixtureContracts: L2FixtureContracts, + mockRouterAddress: string, + mockL1GRTAddress: string, + mockL1GatewayAddress: string, + mockL1ReservoirAddress: string, + ): Promise { + // Configure the L2 GRT + // Configure the gateway + await l2FixtureContracts.grt + .connect(deployer) + .setGateway(l2FixtureContracts.l2GraphTokenGateway.address) + await l2FixtureContracts.grt.connect(deployer).setL1Address(mockL1GRTAddress) + // Configure the gateway + await l2FixtureContracts.l2GraphTokenGateway.connect(deployer).setL2Router(mockRouterAddress) + await l2FixtureContracts.l2GraphTokenGateway + .connect(deployer) + .setL1TokenAddress(mockL1GRTAddress) + await l2FixtureContracts.l2GraphTokenGateway + .connect(deployer) + .setL1CounterpartAddress(mockL1GatewayAddress) + await l2FixtureContracts.l2GraphTokenGateway.connect(deployer).setPaused(false) } async setUp(): Promise { this.lastSnapshotId = await evmSnapshot() + await initNetwork() } async tearDown(): Promise { diff --git a/test/lib/testHelpers.ts b/test/lib/testHelpers.ts index 59372dd68..048c0b5a4 100644 --- a/test/lib/testHelpers.ts +++ b/test/lib/testHelpers.ts @@ -3,6 +3,7 @@ import '@nomiclabs/hardhat-ethers' import '@nomiclabs/hardhat-waffle' import { providers, utils, BigNumber, Signer, Wallet } from 'ethers' import { formatUnits, getAddress } from 'ethers/lib/utils' +import { BigNumber as BN } from 'bignumber.js' import { EpochManager } from '../../build/types/EpochManager' @@ -132,3 +133,105 @@ export const applyL1ToL2Alias = (l1Address: string): string => { const mask = toBN(2).pow(160) 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 + 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 { + const l2Address = applyL1ToL2Alias(l1Address) + await provider().send('hardhat_impersonateAccount', [l2Address]) + const l2Signer = await hre.ethers.getSigner(l2Address) + + return l2Signer +} diff --git a/test/reservoir/l1Reservoir.test.ts b/test/reservoir/l1Reservoir.test.ts new file mode 100644 index 000000000..afc6233ca --- /dev/null +++ b/test/reservoir/l1Reservoir.test.ts @@ -0,0 +1,761 @@ +import { expect } from 'chai' +import { BigNumber, constants, utils } 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, + provider, +} 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 { SubgraphDeploymentID } from '@graphprotocol/common-ts' +import { Controller } from '../../build/types/Controller' +import { GraphProxyAdmin } from '../../build/types/GraphProxyAdmin' +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 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 mockRouter: Account + let mockL2GRT: Account + let mockL2Gateway: Account + let mockL2Reservoir: 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 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(governor.signer).drip(toBN(0), toBN(0), toBN(0)) + 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(governor.signer).drip(toBN(0), toBN(0), toBN(0)) + 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] = + await getAccounts() + + fixture = new NetworkFixture() + fixtureContracts = await fixture.load(governor.signer) + ;({ grt, l1Reservoir, bridgeEscrow, l1GraphTokenGateway, controller, proxyAdmin } = + 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, + ) + 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(governor.signer).drip(toBN(0), toBN(0), toBN(0)) + 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(governor.signer) + .drip(maxGas, gasPriceBid, maxSubmissionCost, { value: defaultEthValue }) + await expect(tx).emit(l1Reservoir, 'L2RewardsFractionUpdated').withArgs(newValue) + expect(await l1Reservoir.l2RewardsFraction()).eq(newValue) + }) + }) + }) + + // TODO test that rewardsManager.updateAccRewardsPerSignal is called when + // issuanceRate or l2RewardsFraction is updated + describe('drip', function () { + 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(governor.signer).drip(toBN(0), toBN(0), toBN(0)) + 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('has no effect if called a second time in the same block', 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) + 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 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') + .withArgs(actualAmount, toBN(0), expectedNextDeadline) + 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') + }) + 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 }) + 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(governor.signer) + .drip(maxGas, gasPriceBid, maxSubmissionCost, { 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'), + ]) + 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(governor.signer) + .drip(maxGas, gasPriceBid, maxSubmissionCost, { 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'), + ]) + 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(governor.signer) + .drip(maxGas, gasPriceBid, maxSubmissionCost, { 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 + ]) + 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(governor.signer) + .drip(maxGas, gasPriceBid, maxSubmissionCost, { 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'), + ]) + 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 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 = expectedTotalRewards.div(2) + + const tx2 = await l1Reservoir + .connect(governor.signer) + .drip(maxGas, gasPriceBid, maxSubmissionCost, { 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 + ]) + 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, + ) + }) + }) + + 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.drip(toBN(0), toBN(0), toBN(0)) + 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.drip(maxGas, gasPriceBid, maxSubmissionCost, { 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 7c6d45c12..b95b0464e 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, @@ -23,7 +22,11 @@ import { formatGRT, Account, advanceToNextEpoch, + provider, + RewardsTracker, } from '../lib/testHelpers' +import { L1Reservoir } from '../../build/types/L1Reservoir' +import { LogDescription } from 'ethers/lib/utils' const MAX_PPM = 1000000 @@ -47,71 +50,35 @@ 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 channelKey = deriveChannelKey() + const channelKey1 = deriveChannelKey() + const channelKey2 = deriveChannelKey() const subgraphDeploymentID1 = randomHexBytes() const subgraphDeploymentID2 = randomHexBytes() - const allocationID = channelKey.address - 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 - } + const allocationID1 = channelKey1.address + const allocationID2 = channelKey2.address - 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) - } + const metadata = HashZero - async accrued() { - const nBlocks = await this.elapsedBlocks() - 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) + tracker.snapshotPerSignal(await grt.balanceOf(curation.address)) // Jump await advanceBlocks(nBlocks) @@ -120,35 +87,41 @@ 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() 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).initialSnapshot(toBN(0)) + supplyBeforeDrip = await grt.totalSupply() }) beforeEach(async function () { @@ -160,32 +133,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 @@ -235,9 +182,102 @@ 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 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(governor.signer).drip(toBN(0), toBN(0), toBN(0)) + dripBlock = await latestBlock() }) describe('getNewRewardsPerSignal', function () { @@ -254,7 +294,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 () { @@ -262,78 +302,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 + tracker.snapshotPerSignal(prevSignal) // Update await rewardsManager.updateAccRewardsPerSignal() + 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 + tracker.snapshotPerSignal(prevSignal) // Jump await advanceBlocks(ISSUANCE_RATE_PERIODS) // Update await rewardsManager.updateAccRewardsPerSignal() + 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( @@ -351,27 +425,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() @@ -395,9 +477,9 @@ describe('Rewards', () => { indexer1.address, subgraphDeploymentID1, tokensToAllocate, - allocationID, + allocationID1, metadata, - await channelKey.generateProof(indexer1.address), + await channelKey1.generateProof(indexer1.address), ) // Jump @@ -422,34 +504,46 @@ 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( indexer1.address, subgraphDeploymentID1, tokensToAllocate, - allocationID, + allocationID1, metadata, - await channelKey.generateProof(indexer1.address), + 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( @@ -477,16 +571,16 @@ describe('Rewards', () => { indexer1.address, subgraphDeploymentID1, tokensToAllocate, - allocationID, + allocationID1, metadata, - await channelKey.generateProof(indexer1.address), + await channelKey1.generateProof(indexer1.address), ) // Jump await advanceBlocks(ISSUANCE_RATE_PERIODS) // Rewards - const contractRewards = await rewardsManager.getRewards(allocationID) + const contractRewards = await rewardsManager.getRewards(allocationID1) // We trust using this function in the test because we tested it // standalone in a previous test @@ -499,87 +593,83 @@ describe('Rewards', () => { }) }) - describe('takeRewards', function () { - interface DelegationParameters { - indexingRewardCut: BigNumber - queryFeeCut: BigNumber - cooldownBlocks: number - } + 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) - async function setupIndexerAllocation() { // Setup - await epochManager.setEpochLength(10) + await setupIndexerAllocation() + const firstSnapshotBlocks = new BN((await latestBlock()).sub(dripBlock).toString()) - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1.signer).mint(subgraphDeploymentID1, signalled1, 0) + // Jump + await advanceToNextEpoch(epochManager) - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1.signer).stake(tokensToAllocate) - await staking - .connect(indexer1.signer) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID, - metadata, - await channelKey.generateProof(indexer1.address), - ) - } + // 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, - allocationID, - metadata, - await channelKey.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 + // 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() @@ -587,23 +677,22 @@ 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(allocationID, randomHexBytes()) + .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(allocationID) + expect(event.allocationID).eq(allocationID1) expect(event.epoch).eq(await epochManager.currentEpoch()) expect(toRound(event.amount)).eq(toRound(expectedIndexingRewards)) @@ -615,7 +704,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 @@ -624,18 +713,20 @@ 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('should distribute rewards on closed allocation and send to destination', async function () { 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) @@ -646,24 +737,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(allocationID, randomHexBytes()) + .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(allocationID) + 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 @@ -674,7 +763,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 @@ -683,8 +772,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 () { @@ -695,14 +784,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 + 14 // Before state const beforeTokenSupply = await grt.totalSupply() @@ -710,7 +803,8 @@ describe('Rewards', () => { const beforeIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) // Close allocation. At this point rewards should be collected for that indexer - await staking.connect(indexer1.signer).closeAllocation(allocationID, randomHexBytes()) + await staking.connect(indexer1.signer).closeAllocation(allocationID1, randomHexBytes()) + const lastSnapshotBlocks = new BN((await latestBlock()).sub(dripBlock).toString()) // After state const afterTokenSupply = await grt.totalSupply() @@ -720,14 +814,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) @@ -737,74 +829,196 @@ 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 + await epochManager.setEpochLength(10) await rewardsManager .connect(governor.signer) .setSubgraphAvailabilityOracle(governor.address) await rewardsManager.connect(governor.signer).setDenied(subgraphDeploymentID1, true) + await advanceToNextEpoch(epochManager) await setupIndexerAllocation() + const firstSnapshotBlocks = new BN((await latestBlock()).sub(dripBlock).toString()) // Jump await advanceToNextEpoch(epochManager) + const supplyBefore = await grt.totalSupply() // Close allocation. At this point rewards should be collected for that indexer - const tx = staking.connect(indexer1.signer).closeAllocation(allocationID, randomHexBytes()) - await expect(tx) - .emit(rewardsManager, 'RewardsDenied') - .withArgs(indexer1.address, allocationID, await epochManager.currentEpoch()) + const tx = staking.connect(indexer1.signer).closeAllocation(allocationID1, randomHexBytes()) + 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) + + // 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) + + // Remove all signal from the subgraph + const curatorShares = await curation.getCuratorSignal( + curator1.address, + subgraphDeploymentID1, + ) + await curation.connect(curator1.signer).burn(subgraphDeploymentID1, curatorShares, 0) + + // Close allocation. At this point rewards should be collected for that indexer + await staking.connect(indexer1.signer).closeAllocation(allocationID1, randomHexBytes()) + }) }) - }) - 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('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) - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1.signer).stake(tokensToAllocate) - await staking - .connect(indexer1.signer) - .allocateFrom( + // 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, 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), + ) + + 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, - tokensToAllocate, - allocationID, + tokensToAlloc, + allocationID1, metadata, - await channelKey.generateProof(indexer1.address), + 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) - - // Remove all signal from the subgraph - const curatorShares = await curation.getCuratorSignal(curator1.address, subgraphDeploymentID1) - 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(allocationID, randomHexBytes()) + // 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) + }) }) }) })