diff --git a/.solcover.js b/.solcover.js index b10738c1f..8c5efb96b 100644 --- a/.solcover.js +++ b/.solcover.js @@ -7,4 +7,5 @@ module.exports = { }, skipFiles, istanbulFolder: './reports/coverage', + configureYulOptimizer: true, } diff --git a/addresses.json b/addresses.json index f51ed56c7..b5ef9034b 100644 --- a/addresses.json +++ b/addresses.json @@ -146,7 +146,7 @@ "txHash": "0x218dbb4fd680db263524fc6be36462c18f3e267b87951cd86296eabd4a381183" } }, - "Staking": { + "L1Staking": { "address": "0xF55041E37E12cD407ad00CE2910B8269B01263b9", "initArgs": [ { @@ -443,7 +443,7 @@ "txHash": "0xbc6e9171943020d30c22197282311f003e79374e6eeeaab9c360942bdf4193f4" } }, - "Staking": { + "L1Staking": { "address": "0x35e3Cb6B317690d662160d5d02A5b364578F62c9", "initArgs": [ "0x48eD7AfbaB432d1Fc6Ea84EEC70E745d9DAcaF3B", @@ -647,7 +647,7 @@ "txHash": "0xb1e63211ea7b036bf35423034bc60490b3b35b199bddc85200ea926b76e16a4e" } }, - "Staking": { + "L1Staking": { "address": "0x5f8e26fAcC23FA4cbd87b8d9Dbbd33D5047abDE1", "initArgs": [ "0x254dffcd3277C0b1660F6d42EFbB754edaBAbC2B", @@ -1099,7 +1099,7 @@ "txHash": "0x3bb004adf949e9c896e85f6e3124ecea0c223470e3a091e42539613d52679c4d" } }, - "Staking": { + "L2Staking": { "address": "0xcd549d0C43d915aEB21d3a331dEaB9B7aF186D26", "initArgs": [ "0x7f734E995010Aa8d28b912703093d532C37b6EAb", diff --git a/cli/commands/migrate.ts b/cli/commands/migrate.ts index 4c9c03768..079e1d731 100644 --- a/cli/commands/migrate.ts +++ b/cli/commands/migrate.ts @@ -30,7 +30,8 @@ let allContracts = [ 'SubgraphNFTDescriptor', 'SubgraphNFT', 'L1GNS', - 'Staking', + 'StakingExtension', + 'L1Staking', 'RewardsManager', 'DisputeManager', 'AllocationExchange', @@ -49,7 +50,8 @@ const l2Contracts = [ 'SubgraphNFTDescriptor', 'SubgraphNFT', 'L2GNS', - 'Staking', + 'StakingExtension', + 'L2Staking', 'RewardsManager', 'DisputeManager', 'AllocationExchange', diff --git a/cli/contracts.ts b/cli/contracts.ts index 3a20bc727..0c8da1b91 100644 --- a/cli/contracts.ts +++ b/cli/contracts.ts @@ -18,7 +18,8 @@ import { getContractAt } from './network' import { EpochManager } from '../build/types/EpochManager' import { DisputeManager } from '../build/types/DisputeManager' -import { Staking } from '../build/types/Staking' +import { L1Staking } from '../build/types/L1Staking' +import { L2Staking } from '../build/types/L2Staking' import { ServiceRegistry } from '../build/types/ServiceRegistry' import { Curation } from '../build/types/Curation' import { RewardsManager } from '../build/types/RewardsManager' @@ -40,11 +41,15 @@ import { L2GraphToken } from '../build/types/L2GraphToken' import { L2GraphTokenGateway } from '../build/types/L2GraphTokenGateway' import { BridgeEscrow } from '../build/types/BridgeEscrow' import { L2Curation } from '../build/types/L2Curation' +import { IL1Staking } from '../build/types/IL1Staking' +import { IL2Staking } from '../build/types/IL2Staking' +import { Interface } from 'ethers/lib/utils' +import { loadArtifact } from './artifacts' export interface NetworkContracts { EpochManager: EpochManager DisputeManager: DisputeManager - Staking: Staking + Staking: IL1Staking | IL2Staking ServiceRegistry: ServiceRegistry Curation: Curation | L2Curation L2Curation: L2Curation @@ -66,6 +71,8 @@ export interface NetworkContracts { L2GraphTokenGateway: L2GraphTokenGateway L1GNS: L1GNS L2GNS: L2GNS + L1Staking: IL1Staking + L2Staking: IL2Staking } export const loadAddressBookContract = ( @@ -97,6 +104,15 @@ export const loadContracts = ( contract.connect = getWrappedConnect(contract, contractName) contract = wrapCalls(contract, contractName) } + if (contractName == 'L1Staking') { + // Hack the contract into behaving like an IL1Staking + const iface = new Interface(loadArtifact('IL1Staking').abi) + contract = new Contract(contract.address, iface) as unknown as IL1Staking + } else if (contractName == 'L2Staking') { + // Hack the contract into behaving like an IL2Staking + const iface = new Interface(loadArtifact('IL2Staking').abi) + contract = new Contract(contract.address, iface) as unknown as IL2Staking + } contracts[contractName] = contract if (signerOrProvider) { @@ -110,12 +126,18 @@ export const loadContracts = ( if (signerOrProvider && chainIdIsL2(chainId) && contractName == 'L2GNS') { contracts['GNS'] = contracts[contractName] } + if (signerOrProvider && chainIdIsL2(chainId) && contractName == 'L2Staking') { + contracts['Staking'] = contracts[contractName] + } if (signerOrProvider && chainIdIsL2(chainId) && contractName == 'L2Curation') { contracts['Curation'] = contracts[contractName] } if (signerOrProvider && !chainIdIsL2(chainId) && contractName == 'L1GNS') { contracts['GNS'] = contracts[contractName] } + if (signerOrProvider && !chainIdIsL2(chainId) && contractName == 'L1Staking') { + contracts['Staking'] = contracts[contractName] + } } catch (err) { logger.warn(`Could not load contract ${contractName} - ${err.message}`) } diff --git a/cli/network.ts b/cli/network.ts index 9a0618ff3..5422eac73 100644 --- a/cli/network.ts +++ b/cli/network.ts @@ -18,6 +18,9 @@ import { AddressBook } from './address-book' import { loadArtifact } from './artifacts' import { defaultOverrides } from './defaults' import { GraphToken } from '../build/types/GraphToken' +import { Interface } from 'ethers/lib/utils' +import { IL1Staking } from '../build/types/IL1Staking' +import { IL2Staking } from '../build/types/IL2Staking' const { keccak256, randomBytes, parseUnits, hexlify } = utils @@ -197,7 +200,7 @@ export const deployContract = async ( // Deploy const factory = getContractFactory(name, libraries) - const contract = await factory.connect(sender).deploy(...args) + let contract = await factory.connect(sender).deploy(...args) const txHash = contract.deployTransaction.hash logger.info(`> Deploy ${name}, txHash: ${txHash}`) await sender.provider.waitForTransaction(txHash) @@ -209,6 +212,15 @@ export const deployContract = async ( logger.info(`= RuntimeCodeHash: ${runtimeCodeHash}`) logger.info(`${name} has been deployed to address: ${contract.address}`) + if (name == 'L1Staking') { + // Hack the contract into behaving like an IL1Staking + const iface = new Interface(loadArtifact('IL1Staking').abi) + contract = new Contract(contract.address, iface, sender) as unknown as IL1Staking + } else if (name == 'L2Staking') { + // Hack the contract into behaving like an IL2Staking + const iface = new Interface(loadArtifact('IL2Staking').abi) + contract = new Contract(contract.address, iface, sender) as unknown as IL2Staking + } return { contract, creationCodeHash, runtimeCodeHash, txHash, libraries } } diff --git a/config/graph.arbitrum-goerli.yml b/config/graph.arbitrum-goerli.yml index 0f2bd8642..014e83542 100644 --- a/config/graph.arbitrum-goerli.yml +++ b/config/graph.arbitrum-goerli.yml @@ -26,7 +26,7 @@ contracts: contractAddress: "${{RewardsManager.address}}" - fn: "setContractProxy" id: "0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034" # keccak256('Staking') - contractAddress: "${{Staking.address}}" + contractAddress: "${{L2Staking.address}}" - fn: "setContractProxy" id: "0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247" # keccak256('GraphToken') contractAddress: "${{L2GraphToken.address}}" @@ -100,7 +100,7 @@ contracts: minter: "${{L2GNS.address}}" - fn: "transferOwnership" owner: *governor - Staking: + L2Staking: proxy: true init: controller: "${{Controller.address}}" @@ -114,6 +114,7 @@ contracts: delegationRatio: 16 # delegated stake to indexer stake multiplier rebateAlphaNumerator: 77 # rebateAlphaNumerator / rebateAlphaDenominator rebateAlphaDenominator: 100 # rebateAlphaNumerator / rebateAlphaDenominator + extensionImpl: "${{StakingExtension.address}}" calls: - fn: "setDelegationTaxPercentage" delegationTaxPercentage: 5000 # parts per million @@ -137,7 +138,7 @@ contracts: AllocationExchange: init: graphToken: "${{L2GraphToken.address}}" - staking: "${{Staking.address}}" + staking: "${{L2Staking.address}}" governor: *allocationExchangeOwner authority: *authority calls: diff --git a/config/graph.arbitrum-localhost.yml b/config/graph.arbitrum-localhost.yml index 521ccc7c3..6aadcc011 100644 --- a/config/graph.arbitrum-localhost.yml +++ b/config/graph.arbitrum-localhost.yml @@ -26,7 +26,7 @@ contracts: contractAddress: "${{RewardsManager.address}}" - fn: "setContractProxy" id: "0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034" # keccak256('Staking') - contractAddress: "${{Staking.address}}" + contractAddress: "${{L2Staking.address}}" - fn: "setContractProxy" id: "0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247" # keccak256('GraphToken') contractAddress: "${{L2GraphToken.address}}" @@ -100,7 +100,7 @@ contracts: minter: "${{L2GNS.address}}" - fn: "transferOwnership" owner: *governor - Staking: + L2Staking: proxy: true init: controller: "${{Controller.address}}" @@ -114,6 +114,7 @@ contracts: delegationRatio: 16 # delegated stake to indexer stake multiplier rebateAlphaNumerator: 77 # rebateAlphaNumerator / rebateAlphaDenominator rebateAlphaDenominator: 100 # rebateAlphaNumerator / rebateAlphaDenominator + extensionImpl: "${{StakingExtension.address}}" calls: - fn: "setDelegationTaxPercentage" delegationTaxPercentage: 5000 # parts per million @@ -137,7 +138,7 @@ contracts: AllocationExchange: init: graphToken: "${{L2GraphToken.address}}" - staking: "${{Staking.address}}" + staking: "${{L2Staking.address}}" governor: *allocationExchangeOwner authority: *authority calls: diff --git a/config/graph.arbitrum-one.yml b/config/graph.arbitrum-one.yml index 4135a5032..8bf36eb93 100644 --- a/config/graph.arbitrum-one.yml +++ b/config/graph.arbitrum-one.yml @@ -26,7 +26,7 @@ contracts: contractAddress: "${{RewardsManager.address}}" - fn: "setContractProxy" id: "0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034" # keccak256('Staking') - contractAddress: "${{Staking.address}}" + contractAddress: "${{L2Staking.address}}" - fn: "setContractProxy" id: "0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247" # keccak256('GraphToken') contractAddress: "${{L2GraphToken.address}}" @@ -100,7 +100,7 @@ contracts: minter: "${{L2GNS.address}}" - fn: "transferOwnership" owner: *governor - Staking: + L2Staking: proxy: true init: controller: "${{Controller.address}}" @@ -114,6 +114,7 @@ contracts: delegationRatio: 16 # delegated stake to indexer stake multiplier rebateAlphaNumerator: 77 # rebateAlphaNumerator / rebateAlphaDenominator rebateAlphaDenominator: 100 # rebateAlphaNumerator / rebateAlphaDenominator + extensionImpl: "${{StakingExtension.address}}" calls: - fn: "setDelegationTaxPercentage" delegationTaxPercentage: 5000 # parts per million @@ -137,7 +138,7 @@ contracts: AllocationExchange: init: graphToken: "${{L2GraphToken.address}}" - staking: "${{Staking.address}}" + staking: "${{L2Staking.address}}" governor: *allocationExchangeOwner authority: *authority calls: diff --git a/config/graph.goerli.yml b/config/graph.goerli.yml index c6cd8adc7..fc5c63cc9 100644 --- a/config/graph.goerli.yml +++ b/config/graph.goerli.yml @@ -26,7 +26,7 @@ contracts: contractAddress: "${{RewardsManager.address}}" - fn: "setContractProxy" id: "0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034" # keccak256('Staking') - contractAddress: "${{Staking.address}}" + contractAddress: "${{L1Staking.address}}" - fn: "setContractProxy" id: "0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247" # keccak256('GraphToken') contractAddress: "${{GraphToken.address}}" @@ -103,7 +103,7 @@ contracts: minter: "${{L1GNS.address}}" - fn: "transferOwnership" owner: *governor - Staking: + L1Staking: proxy: true init: controller: "${{Controller.address}}" @@ -117,6 +117,7 @@ contracts: delegationRatio: 16 # delegated stake to indexer stake multiplier rebateAlphaNumerator: 77 # rebateAlphaNumerator / rebateAlphaDenominator rebateAlphaDenominator: 100 # rebateAlphaNumerator / rebateAlphaDenominator + extensionImpl: "${{StakingExtension.address}}" calls: - fn: "setDelegationTaxPercentage" delegationTaxPercentage: 5000 # parts per million @@ -140,7 +141,7 @@ contracts: AllocationExchange: init: graphToken: "${{GraphToken.address}}" - staking: "${{Staking.address}}" + staking: "${{L1Staking.address}}" governor: *allocationExchangeOwner authority: *authority calls: diff --git a/config/graph.localhost.yml b/config/graph.localhost.yml index 176f3080b..a2408d941 100644 --- a/config/graph.localhost.yml +++ b/config/graph.localhost.yml @@ -26,7 +26,7 @@ contracts: contractAddress: "${{RewardsManager.address}}" - fn: "setContractProxy" id: "0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034" # keccak256('Staking') - contractAddress: "${{Staking.address}}" + contractAddress: "${{L1Staking.address}}" - fn: "setContractProxy" id: "0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247" # keccak256('GraphToken') contractAddress: "${{GraphToken.address}}" @@ -103,7 +103,7 @@ contracts: minter: "${{L1GNS.address}}" - fn: "transferOwnership" owner: *governor - Staking: + L1Staking: proxy: true init: controller: "${{Controller.address}}" @@ -117,6 +117,7 @@ contracts: delegationRatio: 16 # delegated stake to indexer stake multiplier rebateAlphaNumerator: 77 # rebateAlphaNumerator / rebateAlphaDenominator rebateAlphaDenominator: 100 # rebateAlphaNumerator / rebateAlphaDenominator + extensionImpl: "${{StakingExtension.address}}" calls: - fn: "setDelegationTaxPercentage" delegationTaxPercentage: 5000 # parts per million @@ -140,7 +141,7 @@ contracts: AllocationExchange: init: graphToken: "${{GraphToken.address}}" - staking: "${{Staking.address}}" + staking: "${{L1Staking.address}}" governor: *allocationExchangeOwner authority: *authority calls: diff --git a/config/graph.mainnet.yml b/config/graph.mainnet.yml index c42ac90c8..e06a30407 100644 --- a/config/graph.mainnet.yml +++ b/config/graph.mainnet.yml @@ -26,7 +26,7 @@ contracts: contractAddress: "${{RewardsManager.address}}" - fn: "setContractProxy" id: "0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034" # keccak256('Staking') - contractAddress: "${{Staking.address}}" + contractAddress: "${{L1Staking.address}}" - fn: "setContractProxy" id: "0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247" # keccak256('GraphToken') contractAddress: "${{GraphToken.address}}" @@ -103,7 +103,7 @@ contracts: minter: "${{L1GNS.address}}" - fn: "transferOwnership" owner: *governor - Staking: + L1Staking: proxy: true init: controller: "${{Controller.address}}" @@ -117,6 +117,7 @@ contracts: delegationRatio: 16 # delegated stake to indexer stake multiplier rebateAlphaNumerator: 77 # rebateAlphaNumerator / rebateAlphaDenominator rebateAlphaDenominator: 100 # rebateAlphaNumerator / rebateAlphaDenominator + extensionImpl: "${{StakingExtension.address}}" calls: - fn: "setDelegationTaxPercentage" delegationTaxPercentage: 5000 # parts per million @@ -140,7 +141,7 @@ contracts: AllocationExchange: init: graphToken: "${{GraphToken.address}}" - staking: "${{Staking.address}}" + staking: "${{L1Staking.address}}" governor: *allocationExchangeOwner authority: *authority calls: diff --git a/contracts/l2/staking/IL2Staking.sol b/contracts/l2/staking/IL2Staking.sol new file mode 100644 index 000000000..73ff936ce --- /dev/null +++ b/contracts/l2/staking/IL2Staking.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity >=0.6.12 <0.8.0; +pragma abicoder v2; + +import { IStaking } from "../../staking/IStaking.sol"; +import { IL2StakingBase } from "./IL2StakingBase.sol"; + +/** + * @title Interface for the L2 Staking contract + * @notice This is the interface that should be used when interacting with the L2 Staking contract. + * It extends the IStaking interface with the functions that are specific to L2, adding the callhook receiver + * to receive transferred stake and delegation from L1. + * @dev Note that L2Staking doesn't actually inherit this interface. This is because of + * the custom setup of the Staking contract where part of the functionality is implemented + * in a separate contract (StakingExtension) to which calls are delegated through the fallback function. + */ +interface IL2Staking is IStaking, IL2StakingBase { + /// @dev Message codes for the L1 -> L2 bridge callhook + enum L1MessageCodes { + RECEIVE_INDEXER_STAKE_CODE, + RECEIVE_DELEGATION_CODE + } + + /// @dev Encoded message struct when receiving indexer stake through the bridge + struct ReceiveIndexerStakeData { + address indexer; + } + + /// @dev Encoded message struct when receiving delegation through the bridge + struct ReceiveDelegationData { + address indexer; + address delegator; + } +} diff --git a/contracts/l2/staking/IL2StakingBase.sol b/contracts/l2/staking/IL2StakingBase.sol new file mode 100644 index 000000000..8b8cd92ab --- /dev/null +++ b/contracts/l2/staking/IL2StakingBase.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; + +import { ICallhookReceiver } from "../../gateway/ICallhookReceiver.sol"; + +/** + * @title Base interface for the L2Staking contract. + * @notice This interface is used to define the callhook receiver interface that is implemented by L2Staking. + * @dev Note it includes only the L2-specific functionality, not the full IStaking interface. + */ +interface IL2StakingBase is ICallhookReceiver { + event TransferredDelegationReturnedToDelegator( + address indexed indexer, + address indexed delegator, + uint256 amount + ); +} diff --git a/contracts/l2/staking/L2Staking.sol b/contracts/l2/staking/L2Staking.sol new file mode 100644 index 000000000..2923dc04e --- /dev/null +++ b/contracts/l2/staking/L2Staking.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; +pragma abicoder v2; + +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; +import { Staking } from "../../staking/Staking.sol"; +import { IL2StakingBase } from "./IL2StakingBase.sol"; +import { IL2Staking } from "./IL2Staking.sol"; +import { Stakes } from "../../staking/libs/Stakes.sol"; + +/** + * @title L2Staking contract + * @dev This contract is the L2 variant of the Staking contract. It adds a function + * to receive an indexer's stake or delegation from L1. Note that this contract inherits Staking, + * which uses a StakingExtension contract to implement the full IStaking interface through delegatecalls. + */ +contract L2Staking is Staking, IL2StakingBase { + using SafeMath for uint256; + using Stakes for Stakes.Indexer; + + /** + * @dev Emitted when `delegator` delegated `tokens` to the `indexer`, the delegator + * gets `shares` for the delegation pool proportionally to the tokens staked. + * This is copied from IStakingExtension, but we can't inherit from it because we + * don't implement the full interface here. + */ + event StakeDelegated( + address indexed indexer, + address indexed delegator, + uint256 tokens, + uint256 shares + ); + + /** + * @dev Checks that the sender is the L2GraphTokenGateway as configured on the Controller. + */ + modifier onlyL2Gateway() { + require(msg.sender == address(graphTokenGateway()), "ONLY_GATEWAY"); + _; + } + + /** + * @notice Receive ETH into the L2Staking contract: this will always revert + * @dev This function is only here to prevent ETH from being sent to the contract + */ + receive() external payable { + revert("RECEIVE_ETH_NOT_ALLOWED"); + } + + /** + * @notice Receive tokens with a callhook from the bridge. + * @dev The encoded _data can contain information about an indexer's stake + * or a delegator's delegation. + * See L1MessageCodes in IL2Staking for the supported messages. + * @param _from Token sender in L1 + * @param _amount Amount of tokens that were transferred + * @param _data ABI-encoded callhook data which must include a uint8 code and either a ReceiveIndexerStakeData or ReceiveDelegationData struct. + */ + function onTokenTransfer( + address _from, + uint256 _amount, + bytes calldata _data + ) external override notPartialPaused onlyL2Gateway { + require(_from == counterpartStakingAddress, "ONLY_L1_STAKING_THROUGH_BRIDGE"); + (uint8 code, bytes memory functionData) = abi.decode(_data, (uint8, bytes)); + + if (code == uint8(IL2Staking.L1MessageCodes.RECEIVE_INDEXER_STAKE_CODE)) { + IL2Staking.ReceiveIndexerStakeData memory indexerData = abi.decode( + functionData, + (IL2Staking.ReceiveIndexerStakeData) + ); + _receiveIndexerStake(_amount, indexerData); + } else if (code == uint8(IL2Staking.L1MessageCodes.RECEIVE_DELEGATION_CODE)) { + IL2Staking.ReceiveDelegationData memory delegationData = abi.decode( + functionData, + (IL2Staking.ReceiveDelegationData) + ); + _receiveDelegation(_amount, delegationData); + } else { + revert("INVALID_CODE"); + } + } + + /** + * @dev Receive an Indexer's stake from L1. + * The specified amount is added to the indexer's stake; the indexer's + * address is specified in the _indexerData struct. + * @param _amount Amount of tokens that were transferred + * @param _indexerData struct containing the indexer's address + */ + function _receiveIndexerStake( + uint256 _amount, + IL2Staking.ReceiveIndexerStakeData memory _indexerData + ) internal { + address _indexer = _indexerData.indexer; + // Deposit tokens into the indexer stake + __stakes[_indexer].deposit(_amount); + + // Initialize the delegation pool the first time + if (__delegationPools[_indexer].updatedAtBlock == 0) { + _setDelegationParameters(_indexer, MAX_PPM, MAX_PPM, __delegationParametersCooldown); + } + + emit StakeDeposited(_indexer, _amount); + } + + /** + * @dev Receive a Delegator's delegation from L1. + * The specified amount is added to the delegator's delegation; the delegator's + * address and the indexer's address are specified in the _delegationData struct. + * Note that no delegation tax is applied here. + * @param _amount Amount of tokens that were transferred + * @param _delegationData struct containing the delegator's address and the indexer's address + */ + function _receiveDelegation( + uint256 _amount, + IL2Staking.ReceiveDelegationData memory _delegationData + ) internal { + // Get the delegation pool of the indexer + DelegationPool storage pool = __delegationPools[_delegationData.indexer]; + Delegation storage delegation = pool.delegators[_delegationData.delegator]; + + // Calculate shares to issue (without applying any delegation tax) + uint256 shares = (pool.tokens == 0) ? _amount : _amount.mul(pool.shares).div(pool.tokens); + + if (shares == 0) { + // If no shares would be issued (probably a rounding issue or attack), return the tokens to the delegator + graphToken().transfer(_delegationData.delegator, _amount); + emit TransferredDelegationReturnedToDelegator( + _delegationData.indexer, + _delegationData.delegator, + _amount + ); + } else { + // Update the delegation pool + pool.tokens = pool.tokens.add(_amount); + pool.shares = pool.shares.add(shares); + + // Update the individual delegation + delegation.shares = delegation.shares.add(shares); + + emit StakeDelegated( + _delegationData.indexer, + _delegationData.delegator, + _amount, + shares + ); + } + } +} diff --git a/contracts/staking/IL1GraphTokenLockTransferTool.sol b/contracts/staking/IL1GraphTokenLockTransferTool.sol new file mode 100644 index 000000000..b935682b9 --- /dev/null +++ b/contracts/staking/IL1GraphTokenLockTransferTool.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity >=0.6.12 <0.8.0; +pragma abicoder v2; + +/** + * @title Interface for the L1GraphTokenLockTransferTool contract + * @dev This interface defines the function to get the L2 wallet address for a given L1 token lock wallet. + * The Transfer Tool contract is implemented in the token-distribution repo: https://github.com/graphprotocol/token-distribution/pull/64 + * and is only included here to provide support in L1Staking for the transfer of stake and delegation + * owned by token lock contracts. See GIP-0046 for details: https://forum.thegraph.com/t/4023 + */ +interface IL1GraphTokenLockTransferTool { + /** + * @notice Pulls ETH from an L1 wallet's account to use for L2 ticket gas. + * @dev This function is only callable by the staking contract. + * @param _l1Wallet Address of the L1 token lock wallet + * @param _amount Amount of ETH to pull from the transfer tool contract + */ + function pullETH(address _l1Wallet, uint256 _amount) external; + + /** + * @notice Get the L2 token lock wallet address for a given L1 token lock wallet + * @dev In the actual L1GraphTokenLockTransferTool contract, this is simply the default getter for a public mapping variable. + * @param _l1Wallet Address of the L1 token lock wallet + * @return Address of the L2 token lock wallet if the wallet has an L2 counterpart, or address zero if + * the wallet doesn't have an L2 counterpart (or is not known to be a token lock wallet). + */ + function l2WalletAddress(address _l1Wallet) external view returns (address); +} diff --git a/contracts/staking/IL1Staking.sol b/contracts/staking/IL1Staking.sol new file mode 100644 index 000000000..a93cec246 --- /dev/null +++ b/contracts/staking/IL1Staking.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity >=0.6.12 <0.8.0; +pragma abicoder v2; + +import { IStaking } from "./IStaking.sol"; +import { IL1StakingBase } from "./IL1StakingBase.sol"; + +/** + * @title Interface for the L1 Staking contract + * @notice This is the interface that should be used when interacting with the L1 Staking contract. + * It extends the IStaking interface with the functions that are specific to L1, adding the transfer tools + * to send stake and delegation to L2. + * @dev Note that L1Staking doesn't actually inherit this interface. This is because of + * the custom setup of the Staking contract where part of the functionality is implemented + * in a separate contract (StakingExtension) to which calls are delegated through the fallback function. + */ +interface IL1Staking is IStaking, IL1StakingBase { + // Nothing to see here +} diff --git a/contracts/staking/IL1StakingBase.sol b/contracts/staking/IL1StakingBase.sol new file mode 100644 index 000000000..58749a247 --- /dev/null +++ b/contracts/staking/IL1StakingBase.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity >=0.6.12 <0.8.0; +pragma abicoder v2; + +import { IL1GraphTokenLockTransferTool } from "./IL1GraphTokenLockTransferTool.sol"; + +/** + * @title Base interface for the L1Staking contract. + * @notice This interface is used to define the transfer tools that are implemented in L1Staking. + * @dev Note it includes only the L1-specific functionality, not the full IStaking interface. + */ +interface IL1StakingBase { + /// @dev Emitted when an indexer transfers their stake to L2. + /// This can happen several times as indexers can transfer partial stake. + event IndexerStakeTransferredToL2( + address indexed indexer, + address indexed l2Indexer, + uint256 transferredStakeTokens + ); + + /// @dev Emitted when a delegator transfers their delegation to L2 + event DelegationTransferredToL2( + address indexed delegator, + address indexed l2Delegator, + address indexed indexer, + address l2Indexer, + uint256 transferredDelegationTokens + ); + + /// @dev Emitted when the L1GraphTokenLockTransferTool is set + event L1GraphTokenLockTransferToolSet(address l1GraphTokenLockTransferTool); + + /// @dev Emitted when a delegator unlocks their tokens ahead of time because the indexer has transferred to L2 + event StakeDelegatedUnlockedDueToL2Transfer(address indexed indexer, address indexed delegator); + + /** + * @notice Set the L1GraphTokenLockTransferTool contract address + * @dev This function can only be called by the governor. + * @param _l1GraphTokenLockTransferTool Address of the L1GraphTokenLockTransferTool contract + */ + function setL1GraphTokenLockTransferTool( + IL1GraphTokenLockTransferTool _l1GraphTokenLockTransferTool + ) external; + + /** + * @notice Send an indexer's stake to L2. + * @dev This function can only be called by the indexer (not an operator). + * It will validate that the remaining stake is sufficient to cover all the allocated + * stake, so the indexer might have to close some allocations before transferring. + * It will also check that the indexer's stake is not locked for withdrawal. + * Since the indexer address might be an L1-only contract, the function takes a beneficiary + * address that will be the indexer's address in L2. + * The caller must provide an amount of ETH to use for the L2 retryable ticket, that + * must be at least `_maxSubmissionCost + _gasPriceBid * _maxGas`. + * @param _l2Beneficiary Address of the indexer in L2. If the indexer has previously transferred stake, this must match the previously-used value. + * @param _amount Amount of stake GRT to transfer to L2 + * @param _maxGas Max gas to use for the L2 retryable ticket + * @param _gasPriceBid Gas price bid for the L2 retryable ticket + * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket + */ + function transferStakeToL2( + address _l2Beneficiary, + uint256 _amount, + uint256 _maxGas, + uint256 _gasPriceBid, + uint256 _maxSubmissionCost + ) external payable; + + /** + * @notice Send an indexer's stake to L2, from a GraphTokenLockWallet vesting contract. + * @dev This function can only be called by the indexer (not an operator). + * It will validate that the remaining stake is sufficient to cover all the allocated + * stake, so the indexer might have to close some allocations before transferring. + * It will also check that the indexer's stake is not locked for withdrawal. + * The L2 beneficiary for the stake will be determined by calling the L1GraphTokenLockTransferTool contract, + * so the caller must have previously transferred tokens through that first + * (see GIP-0046 for details: https://forum.thegraph.com/t/4023). + * The ETH for the L2 gas will be pulled from the L1GraphTokenLockTransferTool, so the owner of + * the GraphTokenLockWallet must have previously deposited at least `_maxSubmissionCost + _gasPriceBid * _maxGas` + * ETH into the L1GraphTokenLockTransferTool contract (using its depositETH function). + * @param _amount Amount of stake GRT to transfer to L2 + * @param _maxGas Max gas to use for the L2 retryable ticket + * @param _gasPriceBid Gas price bid for the L2 retryable ticket + * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket + */ + function transferLockedStakeToL2( + uint256 _amount, + uint256 _maxGas, + uint256 _gasPriceBid, + uint256 _maxSubmissionCost + ) external; + + /** + * @notice Send a delegator's delegated tokens to L2 + * @dev This function can only be called by the delegator. + * This function will validate that the indexer has transferred their stake using transferStakeToL2, + * and that the delegation is not locked for undelegation. + * Since the delegator's address might be an L1-only contract, the function takes a beneficiary + * address that will be the delegator's address in L2. + * The caller must provide an amount of ETH to use for the L2 retryable ticket, that + * must be at least `_maxSubmissionCost + _gasPriceBid * _maxGas`. + * @param _indexer Address of the indexer (in L1, before transferring to L2) + * @param _l2Beneficiary Address of the delegator in L2 + * @param _maxGas Max gas to use for the L2 retryable ticket + * @param _gasPriceBid Gas price bid for the L2 retryable ticket + * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket + */ + function transferDelegationToL2( + address _indexer, + address _l2Beneficiary, + uint256 _maxGas, + uint256 _gasPriceBid, + uint256 _maxSubmissionCost + ) external payable; + + /** + * @notice Send a delegator's delegated tokens to L2, for a GraphTokenLockWallet vesting contract + * @dev This function can only be called by the delegator. + * This function will validate that the indexer has transferred their stake using transferStakeToL2, + * and that the delegation is not locked for undelegation. + * The L2 beneficiary for the delegation will be determined by calling the L1GraphTokenLockTransferTool contract, + * so the caller must have previously transferred tokens through that first + * (see GIP-0046 for details: https://forum.thegraph.com/t/4023). + * The ETH for the L2 gas will be pulled from the L1GraphTokenLockTransferTool, so the owner of + * the GraphTokenLockWallet must have previously deposited at least `_maxSubmissionCost + _gasPriceBid * _maxGas` + * ETH into the L1GraphTokenLockTransferTool contract (using its depositETH function). + * @param _indexer Address of the indexer (in L1, before transferring to L2) + * @param _maxGas Max gas to use for the L2 retryable ticket + * @param _gasPriceBid Gas price bid for the L2 retryable ticket + * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket + */ + function transferLockedDelegationToL2( + address _indexer, + uint256 _maxGas, + uint256 _gasPriceBid, + uint256 _maxSubmissionCost + ) external; + + /** + * @notice Unlock a delegator's delegated tokens, if the indexer has transferred to L2 + * @dev This function can only be called by the delegator. + * This function will validate that the indexer has transferred their stake using transferStakeToL2, + * and that the indexer has no remaining stake in L1. + * The tokens must previously be locked for undelegation by calling `undelegate()`, + * and can be withdrawn with `withdrawDelegated()` immediately after calling this. + * @param _indexer Address of the indexer (in L1, before transferring to L2) + */ + function unlockDelegationToTransferredIndexer(address _indexer) external; +} diff --git a/contracts/staking/IStaking.sol b/contracts/staking/IStaking.sol index 86942e4d1..53ec76646 100644 --- a/contracts/staking/IStaking.sol +++ b/contracts/staking/IStaking.sol @@ -3,158 +3,21 @@ pragma solidity >=0.6.12 <0.8.0; pragma abicoder v2; -import "./IStakingData.sol"; - -interface IStaking is IStakingData { - // -- Allocation Data -- - - /** - * @dev Possible states an allocation can be - * States: - * - Null = indexer == address(0) - * - Active = not Null && tokens > 0 - * - Closed = Active && closedAtEpoch != 0 - * - Finalized = Closed && closedAtEpoch + channelDisputeEpochs > now() - * - Claimed = not Null && tokens == 0 - */ - enum AllocationState { - Null, - Active, - Closed, - Finalized, - Claimed - } - - // -- Configuration -- - - function setMinimumIndexerStake(uint256 _minimumIndexerStake) external; - - function setThawingPeriod(uint32 _thawingPeriod) external; - - function setCurationPercentage(uint32 _percentage) external; - - function setProtocolPercentage(uint32 _percentage) external; - - function setChannelDisputeEpochs(uint32 _channelDisputeEpochs) external; - - function setMaxAllocationEpochs(uint32 _maxAllocationEpochs) external; - - function setRebateRatio(uint32 _alphaNumerator, uint32 _alphaDenominator) external; - - function setDelegationRatio(uint32 _delegationRatio) external; - - function setDelegationParameters( - uint32 _indexingRewardCut, - uint32 _queryFeeCut, - uint32 _cooldownBlocks - ) external; - - function setDelegationParametersCooldown(uint32 _blocks) external; - - function setDelegationUnbondingPeriod(uint32 _delegationUnbondingPeriod) external; - - function setDelegationTaxPercentage(uint32 _percentage) external; - - function setSlasher(address _slasher, bool _allowed) external; - - function setAssetHolder(address _assetHolder, bool _allowed) external; - - // -- Operation -- - - function setOperator(address _operator, bool _allowed) external; - - function isOperator(address _operator, address _indexer) external view returns (bool); - - // -- Staking -- - - function stake(uint256 _tokens) external; - - function stakeTo(address _indexer, uint256 _tokens) external; - - function unstake(uint256 _tokens) external; - - function slash( - address _indexer, - uint256 _tokens, - uint256 _reward, - address _beneficiary - ) external; - - function withdraw() external; - - function setRewardsDestination(address _destination) external; - - // -- Delegation -- - - function delegate(address _indexer, uint256 _tokens) external returns (uint256); - - function undelegate(address _indexer, uint256 _shares) external returns (uint256); - - function withdrawDelegated(address _indexer, address _newIndexer) external returns (uint256); - - // -- Channel management and allocations -- - - function allocate( - bytes32 _subgraphDeploymentID, - uint256 _tokens, - address _allocationID, - bytes32 _metadata, - bytes calldata _proof - ) external; - - function allocateFrom( - address _indexer, - bytes32 _subgraphDeploymentID, - uint256 _tokens, - address _allocationID, - bytes32 _metadata, - bytes calldata _proof - ) external; - - function closeAllocation(address _allocationID, bytes32 _poi) external; - - function closeAllocationMany(CloseAllocationRequest[] calldata _requests) external; - - function closeAndAllocate( - address _oldAllocationID, - bytes32 _poi, - address _indexer, - bytes32 _subgraphDeploymentID, - uint256 _tokens, - address _allocationID, - bytes32 _metadata, - bytes calldata _proof - ) external; - - function collect(uint256 _tokens, address _allocationID) external; - - function claim(address _allocationID, bool _restake) external; - - function claimMany(address[] calldata _allocationID, bool _restake) external; - - // -- Getters and calculations -- - - function hasStake(address _indexer) external view returns (bool); - - function getIndexerStakedTokens(address _indexer) external view returns (uint256); - - function getIndexerCapacity(address _indexer) external view returns (uint256); - - function getAllocation(address _allocationID) external view returns (Allocation memory); - - function getAllocationState(address _allocationID) external view returns (AllocationState); - - function isAllocation(address _allocationID) external view returns (bool); - - function getSubgraphAllocatedTokens(bytes32 _subgraphDeploymentID) - external - view - returns (uint256); - - function getDelegation(address _indexer, address _delegator) - external - view - returns (Delegation memory); - - function isDelegator(address _indexer, address _delegator) external view returns (bool); +import { IStakingBase } from "./IStakingBase.sol"; +import { IStakingExtension } from "./IStakingExtension.sol"; +import { Stakes } from "./libs/Stakes.sol"; +import { IStakingData } from "./IStakingData.sol"; +import { Rebates } from "./libs/Rebates.sol"; +import { IMulticall } from "../base/IMulticall.sol"; +import { IManaged } from "../governance/IManaged.sol"; + +/** + * @title Interface for the Staking contract + * @notice This is the interface that should be used when interacting with the Staking contract. + * @dev Note that Staking doesn't actually inherit this interface. This is because of + * the custom setup of the Staking contract where part of the functionality is implemented + * in a separate contract (StakingExtension) to which calls are delegated through the fallback function. + */ +interface IStaking is IStakingBase, IStakingExtension, IMulticall, IManaged { + // Nothing to see here } diff --git a/contracts/staking/IStakingBase.sol b/contracts/staking/IStakingBase.sol new file mode 100644 index 000000000..b76cc488d --- /dev/null +++ b/contracts/staking/IStakingBase.sol @@ -0,0 +1,437 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity >=0.6.12 <0.8.0; +pragma abicoder v2; + +import { IStakingData } from "./IStakingData.sol"; + +/** + * @title Base interface for the Staking contract. + * @dev This interface includes only what's implemented in the base Staking contract. + * It does not include the L1 and L2 specific functionality. It also does not include + * several functions that are implemented in the StakingExtension contract, and are called + * via delegatecall through the fallback function. See IStaking.sol for an interface + * that includes the full functionality. + */ +interface IStakingBase is IStakingData { + /** + * @dev Emitted when `indexer` stakes `tokens` amount. + */ + event StakeDeposited(address indexed indexer, uint256 tokens); + + /** + * @dev Emitted when `indexer` unstaked and locked `tokens` amount until `until` block. + */ + event StakeLocked(address indexed indexer, uint256 tokens, uint256 until); + + /** + * @dev Emitted when `indexer` withdrew `tokens` staked. + */ + event StakeWithdrawn(address indexed indexer, uint256 tokens); + + /** + * @dev Emitted when `indexer` allocated `tokens` amount to `subgraphDeploymentID` + * during `epoch`. + * `allocationID` indexer derived address used to identify the allocation. + * `metadata` additional information related to the allocation. + */ + event AllocationCreated( + address indexed indexer, + bytes32 indexed subgraphDeploymentID, + uint256 epoch, + uint256 tokens, + address indexed allocationID, + bytes32 metadata + ); + + /** + * @dev Emitted when `indexer` collected `tokens` amount in `epoch` for `allocationID`. + * These funds are related to `subgraphDeploymentID`. + * The `from` value is the sender of the collected funds. + */ + event AllocationCollected( + address indexed indexer, + bytes32 indexed subgraphDeploymentID, + uint256 epoch, + uint256 tokens, + address indexed allocationID, + address from, + uint256 curationFees, + uint256 rebateFees + ); + + /** + * @dev Emitted when `indexer` close an allocation in `epoch` for `allocationID`. + * An amount of `tokens` get unallocated from `subgraphDeploymentID`. + * The `effectiveAllocation` are the tokens allocated from creation to closing. + * This event also emits the POI (proof of indexing) submitted by the indexer. + * `isPublic` is true if the sender was someone other than the indexer. + */ + event AllocationClosed( + address indexed indexer, + bytes32 indexed subgraphDeploymentID, + uint256 epoch, + uint256 tokens, + address indexed allocationID, + uint256 effectiveAllocation, + address sender, + bytes32 poi, + bool isPublic + ); + + /** + * @dev Emitted when `indexer` claimed a rebate on `subgraphDeploymentID` during `epoch` + * related to the `forEpoch` rebate pool. + * The rebate is for `tokens` amount and `unclaimedAllocationsCount` are left for claim + * in the rebate pool. `delegationFees` collected and sent to delegation pool. + */ + event RebateClaimed( + address indexed indexer, + bytes32 indexed subgraphDeploymentID, + address indexed allocationID, + uint256 epoch, + uint256 forEpoch, + uint256 tokens, + uint256 unclaimedAllocationsCount, + uint256 delegationFees + ); + + /** + * @dev Emitted when `indexer` update the delegation parameters for its delegation pool. + */ + event DelegationParametersUpdated( + address indexed indexer, + uint32 indexingRewardCut, + uint32 queryFeeCut, + uint32 cooldownBlocks + ); + + /** + * @dev Emitted when `caller` set `assetHolder` address as `allowed` to send funds + * to staking contract. + */ + event AssetHolderUpdate(address indexed caller, address indexed assetHolder, bool allowed); + + /** + * @dev Emitted when `indexer` set `operator` access. + */ + event SetOperator(address indexed indexer, address indexed operator, bool allowed); + + /** + * @dev Emitted when `indexer` set an address to receive rewards. + */ + event SetRewardsDestination(address indexed indexer, address indexed destination); + + /** + * @dev Emitted when `extensionImpl` was set as the address of the StakingExtension contract + * to which extended functionality is delegated. + */ + event ExtensionImplementationSet(address extensionImpl); + + /** + * @dev Possible states an allocation can be. + * States: + * - Null = indexer == address(0) + * - Active = not Null && tokens > 0 + * - Closed = Active && closedAtEpoch != 0 + * - Finalized = Closed && closedAtEpoch + channelDisputeEpochs > now() + * - Claimed = not Null && tokens == 0 + */ + enum AllocationState { + Null, + Active, + Closed, + Finalized, + Claimed + } + + /** + * @notice Initialize this contract. + * @param _controller Address of the controller that manages this contract + * @param _minimumIndexerStake Minimum amount of tokens that an indexer must stake + * @param _thawingPeriod Number of blocks that tokens get locked after unstaking + * @param _protocolPercentage Percentage of query fees that are burned as protocol fee (in PPM) + * @param _curationPercentage Percentage of query fees that are given to curators (in PPM) + * @param _channelDisputeEpochs The period in epochs that needs to pass before fees in rebate pool can be claimed + * @param _maxAllocationEpochs The maximum number of epochs that an allocation can be active + * @param _delegationUnbondingPeriod The period in epochs that tokens get locked after undelegating + * @param _delegationRatio The ratio between an indexer's own stake and the delegation they can use + * @param _rebateAlphaNumerator The numerator of the alpha factor used to calculate the rebate + * @param _rebateAlphaDenominator The denominator of the alpha factor used to calculate the rebate + * @param _extensionImpl Address of the StakingExtension implementation + */ + function initialize( + address _controller, + uint256 _minimumIndexerStake, + uint32 _thawingPeriod, + uint32 _protocolPercentage, + uint32 _curationPercentage, + uint32 _channelDisputeEpochs, + uint32 _maxAllocationEpochs, + uint32 _delegationUnbondingPeriod, + uint32 _delegationRatio, + uint32 _rebateAlphaNumerator, + uint32 _rebateAlphaDenominator, + address _extensionImpl + ) external; + + /** + * @notice Set the address of the StakingExtension implementation. + * @dev This function can only be called by the governor. + * @param _extensionImpl Address of the StakingExtension implementation + */ + function setExtensionImpl(address _extensionImpl) external; + + /** + * @notice Set the address of the counterpart (L1 or L2) staking contract. + * @dev This function can only be called by the governor. + * @param _counterpart Address of the counterpart staking contract in the other chain, without any aliasing. + */ + function setCounterpartStakingAddress(address _counterpart) external; + + /** + * @notice Set the minimum stake needed to be an Indexer + * @dev This function can only be called by the governor. + * @param _minimumIndexerStake Minimum amount of tokens that an indexer must stake + */ + function setMinimumIndexerStake(uint256 _minimumIndexerStake) external; + + /** + * @notice Set the number of blocks that tokens get locked after unstaking + * @dev This function can only be called by the governor. + * @param _thawingPeriod Number of blocks that tokens get locked after unstaking + */ + function setThawingPeriod(uint32 _thawingPeriod) external; + + /** + * @notice Set the curation percentage of query fees sent to curators. + * @dev This function can only be called by the governor. + * @param _percentage Percentage of query fees sent to curators + */ + function setCurationPercentage(uint32 _percentage) external; + + /** + * @notice Set a protocol percentage to burn when collecting query fees. + * @dev This function can only be called by the governor. + * @param _percentage Percentage of query fees to burn as protocol fee + */ + function setProtocolPercentage(uint32 _percentage) external; + + /** + * @notice Set the period in epochs that need to pass before fees in rebate pool can be claimed. + * @dev This function can only be called by the governor. + * @param _channelDisputeEpochs Period in epochs + */ + function setChannelDisputeEpochs(uint32 _channelDisputeEpochs) external; + + /** + * @notice Set the max time allowed for indexers to allocate on a subgraph + * before others are allowed to close the allocation. + * @dev This function can only be called by the governor. + * @param _maxAllocationEpochs Allocation duration limit in epochs + */ + function setMaxAllocationEpochs(uint32 _maxAllocationEpochs) external; + + /** + * @notice Set the rebate ratio (fees to allocated stake). + * @dev This function can only be called by the governor. + * @param _alphaNumerator Numerator of `alpha` in the cobb-douglas function + * @param _alphaDenominator Denominator of `alpha` in the cobb-douglas function + */ + function setRebateRatio(uint32 _alphaNumerator, uint32 _alphaDenominator) external; + + /** + * @notice Set an address as allowed asset holder. + * @dev This function can only be called by the governor. + * @param _assetHolder Address of allowed source for state channel funds + * @param _allowed True if asset holder is allowed + */ + function setAssetHolder(address _assetHolder, bool _allowed) external; + + /** + * @notice Authorize or unauthorize an address to be an operator for the caller. + * @param _operator Address to authorize or unauthorize + * @param _allowed Whether the operator is authorized or not + */ + function setOperator(address _operator, bool _allowed) external; + + /** + * @notice Deposit tokens on the indexer's stake. + * The amount staked must be over the minimumIndexerStake. + * @param _tokens Amount of tokens to stake + */ + function stake(uint256 _tokens) external; + + /** + * @notice Deposit tokens on the Indexer stake, on behalf of the Indexer. + * The amount staked must be over the minimumIndexerStake. + * @param _indexer Address of the indexer + * @param _tokens Amount of tokens to stake + */ + function stakeTo(address _indexer, uint256 _tokens) external; + + /** + * @notice Unstake tokens from the indexer stake, lock them until the thawing period expires. + * @dev NOTE: The function accepts an amount greater than the currently staked tokens. + * If that happens, it will try to unstake the max amount of tokens it can. + * The reason for this behaviour is to avoid time conditions while the transaction + * is in flight. + * @param _tokens Amount of tokens to unstake + */ + function unstake(uint256 _tokens) external; + + /** + * @notice Withdraw indexer tokens once the thawing period has passed. + */ + function withdraw() external; + + /** + * @notice Set the destination where to send rewards for an indexer. + * @param _destination Rewards destination address. If set to zero, rewards will be restaked + */ + function setRewardsDestination(address _destination) external; + + /** + * @notice Set the delegation parameters for the caller. + * @param _indexingRewardCut Percentage of indexing rewards left for the indexer + * @param _queryFeeCut Percentage of query fees left for the indexer + * @param _cooldownBlocks Period that need to pass to update delegation parameters + */ + function setDelegationParameters( + uint32 _indexingRewardCut, + uint32 _queryFeeCut, + uint32 _cooldownBlocks + ) external; + + /** + * @notice Allocate available tokens to a subgraph deployment. + * @param _subgraphDeploymentID ID of the SubgraphDeployment where tokens will be allocated + * @param _tokens Amount of tokens to allocate + * @param _allocationID The allocation identifier + * @param _metadata IPFS hash for additional information about the allocation + * @param _proof A 65-bytes Ethereum signed message of `keccak256(indexerAddress,allocationID)` + */ + function allocate( + bytes32 _subgraphDeploymentID, + uint256 _tokens, + address _allocationID, + bytes32 _metadata, + bytes calldata _proof + ) external; + + /** + * @notice Allocate available tokens to a subgraph deployment from and indexer's stake. + * The caller must be the indexer or the indexer's operator. + * @param _indexer Indexer address to allocate funds from. + * @param _subgraphDeploymentID ID of the SubgraphDeployment where tokens will be allocated + * @param _tokens Amount of tokens to allocate + * @param _allocationID The allocation identifier + * @param _metadata IPFS hash for additional information about the allocation + * @param _proof A 65-bytes Ethereum signed message of `keccak256(indexerAddress,allocationID)` + */ + function allocateFrom( + address _indexer, + bytes32 _subgraphDeploymentID, + uint256 _tokens, + address _allocationID, + bytes32 _metadata, + bytes calldata _proof + ) external; + + /** + * @notice Close an allocation and free the staked tokens. + * To be eligible for rewards a proof of indexing must be presented. + * Presenting a bad proof is subject to slashable condition. + * To opt out of rewards set _poi to 0x0 + * @param _allocationID The allocation identifier + * @param _poi Proof of indexing submitted for the allocated period + */ + function closeAllocation(address _allocationID, bytes32 _poi) external; + + /** + * @notice Collect query fees from state channels and assign them to an allocation. + * Funds received are only accepted from a valid sender. + * @dev To avoid reverting on the withdrawal from channel flow this function will: + * 1) Accept calls with zero tokens. + * 2) Accept calls after an allocation passed the dispute period, in that case, all + * the received tokens are burned. + * @param _tokens Amount of tokens to collect + * @param _allocationID Allocation where the tokens will be assigned + */ + function collect(uint256 _tokens, address _allocationID) external; + + /** + * @notice Claim tokens from the rebate pool. + * @param _allocationID Allocation from where we are claiming tokens + * @param _restake True if restake fees instead of transfer to indexer + */ + function claim(address _allocationID, bool _restake) external; + + /** + * @dev Claim tokens from the rebate pool for many allocations. + * @param _allocationID Array of allocations from where we are claiming tokens + * @param _restake True if restake fees instead of transfer to indexer + */ + function claimMany(address[] calldata _allocationID, bool _restake) external; + + /** + * @notice Return true if operator is allowed for indexer. + * @param _operator Address of the operator + * @param _indexer Address of the indexer + * @return True if operator is allowed for indexer, false otherwise + */ + function isOperator(address _operator, address _indexer) external view returns (bool); + + /** + * @notice Getter that returns if an indexer has any stake. + * @param _indexer Address of the indexer + * @return True if indexer has staked tokens + */ + function hasStake(address _indexer) external view returns (bool); + + /** + * @notice Get the total amount of tokens staked by the indexer. + * @param _indexer Address of the indexer + * @return Amount of tokens staked by the indexer + */ + function getIndexerStakedTokens(address _indexer) external view returns (uint256); + + /** + * @notice Get the total amount of tokens available to use in allocations. + * This considers the indexer stake and delegated tokens according to delegation ratio + * @param _indexer Address of the indexer + * @return Amount of tokens available to allocate including delegation + */ + function getIndexerCapacity(address _indexer) external view returns (uint256); + + /** + * @notice Return the allocation by ID. + * @param _allocationID Address used as allocation identifier + * @return Allocation data + */ + function getAllocation(address _allocationID) external view returns (Allocation memory); + + /** + * @notice Return the current state of an allocation + * @param _allocationID Allocation identifier + * @return AllocationState enum with the state of the allocation + */ + function getAllocationState(address _allocationID) external view returns (AllocationState); + + /** + * @notice Return if allocationID is used. + * @param _allocationID Address used as signer by the indexer for an allocation + * @return True if allocationID already used + */ + function isAllocation(address _allocationID) external view returns (bool); + + /** + * @notice Return the total amount of tokens allocated to subgraph. + * @param _subgraphDeploymentID Deployment ID for the subgraph + * @return Total tokens allocated to subgraph + */ + function getSubgraphAllocatedTokens(bytes32 _subgraphDeploymentID) + external + view + returns (uint256); +} diff --git a/contracts/staking/IStakingData.sol b/contracts/staking/IStakingData.sol index 348a5a7f9..4446077b5 100644 --- a/contracts/staking/IStakingData.sol +++ b/contracts/staking/IStakingData.sol @@ -2,6 +2,10 @@ pragma solidity >=0.6.12 <0.8.0; +/** + * @title Staking Data interface + * @dev This interface defines some structures used by the Staking contract. + */ interface IStakingData { /** * @dev Allocate GRT tokens for the purpose of serving queries of a subgraph deployment @@ -18,16 +22,6 @@ interface IStakingData { uint256 accRewardsPerAllocatedToken; // Snapshot used for reward calc } - /** - * @dev Represents a request to close an allocation with a specific proof of indexing. - * This is passed when calling closeAllocationMany to define the closing parameters for - * each allocation. - */ - struct CloseAllocationRequest { - address allocationID; - bytes32 poi; - } - // -- Delegation Data -- /** diff --git a/contracts/staking/IStakingExtension.sol b/contracts/staking/IStakingExtension.sol new file mode 100644 index 000000000..e63f5c035 --- /dev/null +++ b/contracts/staking/IStakingExtension.sol @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity >=0.6.12 <0.8.0; +pragma abicoder v2; + +import { IStakingData } from "./IStakingData.sol"; +import { Rebates } from "./libs/Rebates.sol"; +import { Stakes } from "./libs/Stakes.sol"; + +/** + * @title Interface for the StakingExtension contract + * @dev This interface defines the events and functions implemented + * in the StakingExtension contract, which is used to extend the functionality + * of the Staking contract while keeping it within the 24kB mainnet size limit. + * In particular, this interface includes delegation functions and various storage + * getters. + */ +interface IStakingExtension is IStakingData { + /** + * @dev DelegationPool struct as returned by delegationPools(), since + * the original DelegationPool in IStakingData.sol contains a nested mapping. + */ + struct DelegationPoolReturn { + uint32 cooldownBlocks; // Blocks to wait before updating parameters + uint32 indexingRewardCut; // in PPM + uint32 queryFeeCut; // in PPM + uint256 updatedAtBlock; // Block when the pool was last updated + uint256 tokens; // Total tokens as pool reserves + uint256 shares; // Total shares minted in the pool + } + + /** + * @dev Emitted when `delegator` delegated `tokens` to the `indexer`, the delegator + * gets `shares` for the delegation pool proportionally to the tokens staked. + */ + event StakeDelegated( + address indexed indexer, + address indexed delegator, + uint256 tokens, + uint256 shares + ); + + /** + * @dev Emitted when `delegator` undelegated `tokens` from `indexer`. + * Tokens get locked for withdrawal after a period of time. + */ + event StakeDelegatedLocked( + address indexed indexer, + address indexed delegator, + uint256 tokens, + uint256 shares, + uint256 until + ); + + /** + * @dev Emitted when `delegator` withdrew delegated `tokens` from `indexer`. + */ + event StakeDelegatedWithdrawn( + address indexed indexer, + address indexed delegator, + uint256 tokens + ); + + /** + * @dev Emitted when `indexer` was slashed for a total of `tokens` amount. + * Tracks `reward` amount of tokens given to `beneficiary`. + */ + event StakeSlashed( + address indexed indexer, + uint256 tokens, + uint256 reward, + address beneficiary + ); + + /** + * @dev Emitted when `caller` set `slasher` address as `allowed` to slash stakes. + */ + event SlasherUpdate(address indexed caller, address indexed slasher, bool allowed); + + /** + * @notice Set the delegation ratio. + * If set to 10 it means the indexer can use up to 10x the indexer staked amount + * from their delegated tokens + * @dev This function is only callable by the governor + * @param _delegationRatio Delegation capacity multiplier + */ + function setDelegationRatio(uint32 _delegationRatio) external; + + /** + * @notice Set the minimum time in blocks an indexer needs to wait to change delegation parameters. + * Indexers can set a custom amount time for their own cooldown, but it must be greater than this. + * @dev This function is only callable by the governor + * @param _blocks Number of blocks to set the delegation parameters cooldown period + */ + function setDelegationParametersCooldown(uint32 _blocks) external; + + /** + * @notice Set the time, in epochs, a Delegator needs to wait to withdraw tokens after undelegating. + * @dev This function is only callable by the governor + * @param _delegationUnbondingPeriod Period in epochs to wait for token withdrawals after undelegating + */ + function setDelegationUnbondingPeriod(uint32 _delegationUnbondingPeriod) external; + + /** + * @notice Set a delegation tax percentage to burn when delegated funds are deposited. + * @dev This function is only callable by the governor + * @param _percentage Percentage of delegated tokens to burn as delegation tax, expressed in parts per million + */ + function setDelegationTaxPercentage(uint32 _percentage) external; + + /** + * @notice Set or unset an address as allowed slasher. + * @dev This function can only be called by the governor. + * @param _slasher Address of the party allowed to slash indexers + * @param _allowed True if slasher is allowed + */ + function setSlasher(address _slasher, bool _allowed) external; + + /** + * @notice Delegate tokens to an indexer. + * @param _indexer Address of the indexer to which tokens are delegated + * @param _tokens Amount of tokens to delegate + * @return Amount of shares issued from the delegation pool + */ + function delegate(address _indexer, uint256 _tokens) external returns (uint256); + + /** + * @notice Undelegate tokens from an indexer. Tokens will be locked for the unbonding period. + * @param _indexer Address of the indexer to which tokens had been delegated + * @param _shares Amount of shares to return and undelegate tokens + * @return Amount of tokens returned for the shares of the delegation pool + */ + function undelegate(address _indexer, uint256 _shares) external returns (uint256); + + /** + * @notice Withdraw undelegated tokens once the unbonding period has passed, and optionally + * re-delegate to a new indexer. + * @param _indexer Withdraw available tokens delegated to indexer + * @param _newIndexer Re-delegate to indexer address if non-zero, withdraw if zero address + */ + function withdrawDelegated(address _indexer, address _newIndexer) external returns (uint256); + + /** + * @notice Slash the indexer stake. Delegated tokens are not subject to slashing. + * @dev Can only be called by the slasher role. + * @param _indexer Address of indexer to slash + * @param _tokens Amount of tokens to slash from the indexer stake + * @param _reward Amount of reward tokens to send to a beneficiary + * @param _beneficiary Address of a beneficiary to receive a reward for the slashing + */ + function slash( + address _indexer, + uint256 _tokens, + uint256 _reward, + address _beneficiary + ) external; + + /** + * @notice Return the delegation from a delegator to an indexer. + * @param _indexer Address of the indexer where funds have been delegated + * @param _delegator Address of the delegator + * @return Delegation data + */ + function getDelegation(address _indexer, address _delegator) + external + view + returns (Delegation memory); + + /** + * @notice Return whether the delegator has delegated to the indexer. + * @param _indexer Address of the indexer where funds have been delegated + * @param _delegator Address of the delegator + * @return True if delegator has tokens delegated to the indexer + */ + function isDelegator(address _indexer, address _delegator) external view returns (bool); + + /** + * @notice Returns amount of delegated tokens ready to be withdrawn after unbonding period. + * @param _delegation Delegation of tokens from delegator to indexer + * @return Amount of tokens to withdraw + */ + function getWithdraweableDelegatedTokens(Delegation memory _delegation) + external + view + returns (uint256); + + /** + * @notice Getter for the delegationRatio, i.e. the delegation capacity multiplier: + * If delegation ratio is 100, and an Indexer has staked 5 GRT, + * then they can use up to 500 GRT from the delegated stake + * @return Delegation ratio + */ + function delegationRatio() external view returns (uint32); + + /** + * @notice Getter for delegationParametersCooldown: + * Minimum time in blocks an indexer needs to wait to change delegation parameters + * @return Delegation parameters cooldown in blocks + */ + function delegationParametersCooldown() external view returns (uint32); + + /** + * @notice Getter for delegationUnbondingPeriod: + * Time in epochs a delegator needs to wait to withdraw delegated stake + * @return Delegation unbonding period in epochs + */ + function delegationUnbondingPeriod() external view returns (uint32); + + /** + * @notice Getter for delegationTaxPercentage: + * Percentage of tokens to tax a delegation deposit, expressed in parts per million + * @return Delegation tax percentage in parts per million + */ + function delegationTaxPercentage() external view returns (uint32); + + /** + * @notice Getter for delegationPools[_indexer]: + * gets the delegation pool structure for a particular indexer. + * @param _indexer Address of the indexer for which to query the delegation pool + * @return Delegation pool as a DelegationPoolReturn struct + */ + function delegationPools(address _indexer) external view returns (DelegationPoolReturn memory); + + /** + * @notice Getter for operatorAuth[_indexer][_maybeOperator]: + * returns true if the operator is authorized to operate on behalf of the indexer. + * @param _indexer The indexer address for which to query authorization + * @param _maybeOperator The address that may or may not be an operator + * @return True if the operator is authorized to operate on behalf of the indexer + */ + function operatorAuth(address _indexer, address _maybeOperator) external view returns (bool); + + /** + * @notice Getter for rewardsDestination[_indexer]: + * returns the address where the indexer's rewards are sent. + * @param _indexer The indexer address for which to query the rewards destination + * @return The address where the indexer's rewards are sent, zero if none is set in which case rewards are re-staked + */ + function rewardsDestination(address _indexer) external view returns (address); + + /** + * @notice Getter for assetHolders[_maybeAssetHolder]: + * returns true if the address is an asset holder, i.e. an entity that can collect + * query fees into the Staking contract. + * @param _maybeAssetHolder The address that may or may not be an asset holder + * @return True if the address is an asset holder + */ + function assetHolders(address _maybeAssetHolder) external view returns (bool); + + /** + * @notice Getter for subgraphAllocations[_subgraphDeploymentId]: + * returns the amount of tokens allocated to a subgraph deployment. + * @param _subgraphDeploymentId The subgraph deployment for which to query the allocations + * @return The amount of tokens allocated to the subgraph deployment + */ + function subgraphAllocations(bytes32 _subgraphDeploymentId) external view returns (uint256); + + /** + * @notice Getter for rebates[_epoch]: + * gets the rebate pool for a particular epoch. + * @param _epoch Epoch for which to query the rebate pool + * @return Rebate pool for the specified epoch, as a Rebates.Pool struct + */ + function rebates(uint256 _epoch) external view returns (Rebates.Pool memory); + + /** + * @notice Getter for slashers[_maybeSlasher]: + * returns true if the address is a slasher, i.e. an entity that can slash indexers + * @param _maybeSlasher Address for which to check the slasher role + * @return True if the address is a slasher + */ + function slashers(address _maybeSlasher) external view returns (bool); + + /** + * @notice Getter for minimumIndexerStake: the minimum + * amount of GRT that an indexer needs to stake. + * @return Minimum indexer stake in GRT + */ + function minimumIndexerStake() external view returns (uint256); + + /** + * @notice Getter for thawingPeriod: the time in blocks an + * indexer needs to wait to unstake tokens. + * @return Thawing period in blocks + */ + function thawingPeriod() external view returns (uint32); + + /** + * @notice Getter for curationPercentage: the percentage of + * query fees that are distributed to curators. + * @return Curation percentage in parts per million + */ + function curationPercentage() external view returns (uint32); + + /** + * @notice Getter for protocolPercentage: the percentage of + * query fees that are burned as protocol fees. + * @return Protocol percentage in parts per million + */ + function protocolPercentage() external view returns (uint32); + + /** + * @notice Getter for channelDisputeEpochs: the time in epochs + * between closing an allocation and the moment it becomes finalized so + * query fees can be claimed. + * @return Channel dispute period in epochs + */ + function channelDisputeEpochs() external view returns (uint32); + + /** + * @notice Getter for maxAllocationEpochs: the maximum time in epochs + * that an allocation can be open before anyone is allowed to close it. This + * also caps the effective allocation when sending the allocation's query fees + * to the rebate pool. + * @return Maximum allocation period in epochs + */ + function maxAllocationEpochs() external view returns (uint32); + + /** + * @notice Getter for alphaNumerator: the numerator of the Cobb-Douglas + * rebate ratio. + * @return Rebate ratio numerator + */ + function alphaNumerator() external view returns (uint32); + + /** + * @notice Getter for alphaDenominator: the denominator of the Cobb-Douglas + * rebate ratio. + * @return Rebate ratio denominator + */ + function alphaDenominator() external view returns (uint32); + + /** + * @notice Getter for stakes[_indexer]: + * gets the stake information for an indexer as a Stakes.Indexer struct. + * @param _indexer Indexer address for which to query the stake information + * @return Stake information for the specified indexer, as a Stakes.Indexer struct + */ + function stakes(address _indexer) external view returns (Stakes.Indexer memory); + + /** + * @notice Getter for allocations[_allocationID]: + * gets an allocation's information as an IStakingData.Allocation struct. + * @param _allocationID Allocation ID for which to query the allocation information + * @return The specified allocation, as an IStakingData.Allocation struct + */ + function allocations(address _allocationID) + external + view + returns (IStakingData.Allocation memory); +} diff --git a/contracts/staking/L1Staking.sol b/contracts/staking/L1Staking.sol new file mode 100644 index 000000000..cf9bca304 --- /dev/null +++ b/contracts/staking/L1Staking.sol @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; +pragma abicoder v2; + +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; + +import { ITokenGateway } from "../arbitrum/ITokenGateway.sol"; +import { Staking } from "./Staking.sol"; +import { Stakes } from "./libs/Stakes.sol"; +import { IStakingData } from "./IStakingData.sol"; +import { IL2Staking } from "../l2/staking/IL2Staking.sol"; +import { L1StakingV1Storage } from "./L1StakingStorage.sol"; +import { IGraphToken } from "../token/IGraphToken.sol"; +import { IL1StakingBase } from "./IL1StakingBase.sol"; +import { MathUtils } from "./libs/MathUtils.sol"; +import { IL1GraphTokenLockTransferTool } from "./IL1GraphTokenLockTransferTool.sol"; + +/** + * @title L1Staking contract + * @dev This contract is the L1 variant of the Staking contract. It adds functions + * to send an indexer's stake to L2, and to send delegation to L2 as well. + */ +contract L1Staking is Staking, L1StakingV1Storage, IL1StakingBase { + using Stakes for Stakes.Indexer; + using SafeMath for uint256; + + /** + * @notice Receive ETH into the Staking contract + * @dev Only the L1GraphTokenLockTransferTool can send ETH, as part of the + * transfer of stake/delegation for vesting lock wallets. + */ + receive() external payable { + require( + msg.sender == address(l1GraphTokenLockTransferTool), + "Only transfer tool can send ETH" + ); + } + + /** + * @notice Set the L1GraphTokenLockTransferTool contract address + * @dev This function can only be called by the governor. + * @param _l1GraphTokenLockTransferTool Address of the L1GraphTokenLockTransferTool contract + */ + function setL1GraphTokenLockTransferTool( + IL1GraphTokenLockTransferTool _l1GraphTokenLockTransferTool + ) external override onlyGovernor { + l1GraphTokenLockTransferTool = _l1GraphTokenLockTransferTool; + emit L1GraphTokenLockTransferToolSet(address(_l1GraphTokenLockTransferTool)); + } + + /** + * @notice Send an indexer's stake to L2. + * @dev This function can only be called by the indexer (not an operator). + * It will validate that the remaining stake is sufficient to cover all the allocated + * stake, so the indexer might have to close some allocations before transferring. + * It will also check that the indexer's stake is not locked for withdrawal. + * Since the indexer address might be an L1-only contract, the function takes a beneficiary + * address that will be the indexer's address in L2. + * The caller must provide an amount of ETH to use for the L2 retryable ticket, that + * must be at _exactly_ `_maxSubmissionCost + _gasPriceBid * _maxGas`. + * Any refunds for the submission fee or L2 gas will be lost. + * @param _l2Beneficiary Address of the indexer in L2. If the indexer has previously transferred stake, this must match the previously-used value. + * @param _amount Amount of stake GRT to transfer to L2 + * @param _maxGas Max gas to use for the L2 retryable ticket + * @param _gasPriceBid Gas price bid for the L2 retryable ticket + * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket + */ + function transferStakeToL2( + address _l2Beneficiary, + uint256 _amount, + uint256 _maxGas, + uint256 _gasPriceBid, + uint256 _maxSubmissionCost + ) external payable override notPartialPaused { + require( + msg.value == _maxSubmissionCost.add(_gasPriceBid.mul(_maxGas)), + "INVALID_ETH_AMOUNT" + ); + _transferStakeToL2( + msg.sender, + _l2Beneficiary, + _amount, + _maxGas, + _gasPriceBid, + _maxSubmissionCost, + msg.value + ); + } + + /** + * @notice Send an indexer's stake to L2, from a GraphTokenLockWallet vesting contract. + * @dev This function can only be called by the indexer (not an operator). + * It will validate that the remaining stake is sufficient to cover all the allocated + * stake, so the indexer might have to close some allocations before transferring. + * It will also check that the indexer's stake is not locked for withdrawal. + * The L2 beneficiary for the stake will be determined by calling the L1GraphTokenLockTransferTool contract, + * so the caller must have previously transferred tokens through that first + * (see GIP-0046 for details: https://forum.thegraph.com/t/4023). + * The ETH for the L2 gas will be pulled from the L1GraphTokenLockTransferTool, so the owner of + * the GraphTokenLockWallet must have previously deposited at least `_maxSubmissionCost + _gasPriceBid * _maxGas` + * ETH into the L1GraphTokenLockTransferTool contract (using its depositETH function). + * Any refunds for the submission fee or L2 gas will be lost. + * @param _amount Amount of stake GRT to transfer to L2 + * @param _maxGas Max gas to use for the L2 retryable ticket + * @param _gasPriceBid Gas price bid for the L2 retryable ticket + * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket + */ + function transferLockedStakeToL2( + uint256 _amount, + uint256 _maxGas, + uint256 _gasPriceBid, + uint256 _maxSubmissionCost + ) external override notPartialPaused { + address l2Beneficiary = l1GraphTokenLockTransferTool.l2WalletAddress(msg.sender); + require(l2Beneficiary != address(0), "LOCK NOT TRANSFERRED"); + uint256 balance = address(this).balance; + uint256 ethAmount = _maxSubmissionCost.add(_maxGas.mul(_gasPriceBid)); + l1GraphTokenLockTransferTool.pullETH(msg.sender, ethAmount); + require(address(this).balance == balance.add(ethAmount), "ETH TRANSFER FAILED"); + _transferStakeToL2( + msg.sender, + l2Beneficiary, + _amount, + _maxGas, + _gasPriceBid, + _maxSubmissionCost, + ethAmount + ); + } + + /** + * @notice Send a delegator's delegated tokens to L2 + * @dev This function can only be called by the delegator. + * This function will validate that the indexer has transferred their stake using transferStakeToL2, + * and that the delegation is not locked for undelegation. + * Since the delegator's address might be an L1-only contract, the function takes a beneficiary + * address that will be the delegator's address in L2. + * The caller must provide an amount of ETH to use for the L2 retryable ticket, that + * must be _exactly_ `_maxSubmissionCost + _gasPriceBid * _maxGas`. + * Any refunds for the submission fee or L2 gas will be lost. + * @param _indexer Address of the indexer (in L1, before transferring to L2) + * @param _l2Beneficiary Address of the delegator in L2 + * @param _maxGas Max gas to use for the L2 retryable ticket + * @param _gasPriceBid Gas price bid for the L2 retryable ticket + * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket + */ + function transferDelegationToL2( + address _indexer, + address _l2Beneficiary, + uint256 _maxGas, + uint256 _gasPriceBid, + uint256 _maxSubmissionCost + ) external payable override notPartialPaused { + require( + msg.value == _maxSubmissionCost.add(_gasPriceBid.mul(_maxGas)), + "INVALID_ETH_AMOUNT" + ); + _transferDelegationToL2( + msg.sender, + _indexer, + _l2Beneficiary, + _maxGas, + _gasPriceBid, + _maxSubmissionCost, + msg.value + ); + } + + /** + * @notice Send a delegator's delegated tokens to L2, for a GraphTokenLockWallet vesting contract + * @dev This function can only be called by the delegator. + * This function will validate that the indexer has transferred their stake using transferStakeToL2, + * and that the delegation is not locked for undelegation. + * The L2 beneficiary for the delegation will be determined by calling the L1GraphTokenLockTransferTool contract, + * so the caller must have previously transferred tokens through that first + * (see GIP-0046 for details: https://forum.thegraph.com/t/4023). + * The ETH for the L2 gas will be pulled from the L1GraphTokenLockTransferTool, so the owner of + * the GraphTokenLockWallet must have previously deposited at least `_maxSubmissionCost + _gasPriceBid * _maxGas` + * ETH into the L1GraphTokenLockTransferTool contract (using its depositETH function). + * Any refunds for the submission fee or L2 gas will be lost. + * @param _indexer Address of the indexer (in L1, before transferring to L2) + * @param _maxGas Max gas to use for the L2 retryable ticket + * @param _gasPriceBid Gas price bid for the L2 retryable ticket + * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket + */ + function transferLockedDelegationToL2( + address _indexer, + uint256 _maxGas, + uint256 _gasPriceBid, + uint256 _maxSubmissionCost + ) external override notPartialPaused { + address l2Beneficiary = l1GraphTokenLockTransferTool.l2WalletAddress(msg.sender); + require(l2Beneficiary != address(0), "LOCK NOT TRANSFERRED"); + uint256 balance = address(this).balance; + uint256 ethAmount = _maxSubmissionCost.add(_maxGas.mul(_gasPriceBid)); + l1GraphTokenLockTransferTool.pullETH(msg.sender, ethAmount); + require(address(this).balance == balance.add(ethAmount), "ETH TRANSFER FAILED"); + _transferDelegationToL2( + msg.sender, + _indexer, + l2Beneficiary, + _maxGas, + _gasPriceBid, + _maxSubmissionCost, + ethAmount + ); + } + + /** + * @notice Unlock a delegator's delegated tokens, if the indexer has transferred to L2 + * @dev This function can only be called by the delegator. + * This function will validate that the indexer has transferred their stake using transferStakeToL2, + * and that the indexer has no remaining stake in L1. + * The tokens must previously be locked for undelegation by calling `undelegate()`, + * and can be withdrawn with `withdrawDelegated()` immediately after calling this. + * @param _indexer Address of the indexer (in L1, before transferring to L2) + */ + function unlockDelegationToTransferredIndexer(address _indexer) + external + override + notPartialPaused + { + require( + indexerTransferredToL2[_indexer] != address(0) && __stakes[_indexer].tokensStaked == 0, + "indexer not transferred" + ); + + Delegation storage delegation = __delegationPools[_indexer].delegators[msg.sender]; + require(delegation.tokensLocked != 0, "! locked"); + + // Unlock the delegation + delegation.tokensLockedUntil = epochManager().currentEpoch(); + + // After this, the delegator should be able to withdraw in the current block + emit StakeDelegatedUnlockedDueToL2Transfer(_indexer, msg.sender); + } + + /** + * @dev Implements sending an indexer's stake to L2. + * This function can only be called by the indexer (not an operator). + * It will validate that the remaining stake is sufficient to cover all the allocated + * stake, so the indexer might have to close some allocations before transferring. + * It will also check that the indexer's stake is not locked for withdrawal. + * Since the indexer address might be an L1-only contract, the function takes a beneficiary + * address that will be the indexer's address in L2. + * @param _l2Beneficiary Address of the indexer in L2. If the indexer has previously transferred stake, this must match the previously-used value. + * @param _amount Amount of stake GRT to transfer to L2 + * @param _maxGas Max gas to use for the L2 retryable ticket + * @param _gasPriceBid Gas price bid for the L2 retryable ticket + * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket + * @param _ethAmount Amount of ETH to send with the retryable ticket + */ + function _transferStakeToL2( + address _indexer, + address _l2Beneficiary, + uint256 _amount, + uint256 _maxGas, + uint256 _gasPriceBid, + uint256 _maxSubmissionCost, + uint256 _ethAmount + ) internal { + Stakes.Indexer storage indexerStake = __stakes[_indexer]; + require(indexerStake.tokensStaked != 0, "tokensStaked == 0"); + // Indexers shouldn't be trying to withdraw tokens before transferring to L2. + // Allowing this would complicate our accounting so we require that they have no + // tokens locked for withdrawal. + require(indexerStake.tokensLocked == 0, "tokensLocked != 0"); + + require(_l2Beneficiary != address(0), "l2Beneficiary == 0"); + if (indexerTransferredToL2[_indexer] != address(0)) { + require( + indexerTransferredToL2[_indexer] == _l2Beneficiary, + "l2Beneficiary != previous" + ); + } else { + indexerTransferredToL2[_indexer] = _l2Beneficiary; + require(_amount >= __minimumIndexerStake, "!minimumIndexerStake sent"); + } + // Ensure minimum stake + indexerStake.tokensStaked = indexerStake.tokensStaked.sub(_amount); + require( + indexerStake.tokensStaked == 0 || indexerStake.tokensStaked >= __minimumIndexerStake, + "!minimumIndexerStake remaining" + ); + + IStakingData.DelegationPool storage delegationPool = __delegationPools[_indexer]; + + if (indexerStake.tokensStaked == 0) { + // require that no allocations are open + require(indexerStake.tokensAllocated == 0, "allocated"); + } else { + // require that the indexer has enough stake to cover all allocations + uint256 tokensDelegatedCap = indexerStake.tokensStaked.mul(uint256(__delegationRatio)); + uint256 tokensDelegatedCapacity = MathUtils.min( + delegationPool.tokens, + tokensDelegatedCap + ); + require( + indexerStake.tokensUsed() <= indexerStake.tokensStaked.add(tokensDelegatedCapacity), + "! allocation capacity" + ); + } + + IL2Staking.ReceiveIndexerStakeData memory functionData; + functionData.indexer = _l2Beneficiary; + + bytes memory extraData = abi.encode( + uint8(IL2Staking.L1MessageCodes.RECEIVE_INDEXER_STAKE_CODE), + abi.encode(functionData) + ); + + _sendTokensAndMessageToL2Staking( + _amount, + _maxGas, + _gasPriceBid, + _maxSubmissionCost, + _ethAmount, + extraData + ); + + emit IndexerStakeTransferredToL2(_indexer, _l2Beneficiary, _amount); + } + + /** + * @dev Implements sending a delegator's delegated tokens to L2. + * This function can only be called by the delegator. + * This function will validate that the indexer has transferred their stake using transferStakeToL2, + * and that the delegation is not locked for undelegation. + * Since the delegator's address might be an L1-only contract, the function takes a beneficiary + * address that will be the delegator's address in L2. + * @param _indexer Address of the indexer (in L1, before transferring to L2) + * @param _l2Beneficiary Address of the delegator in L2 + * @param _maxGas Max gas to use for the L2 retryable ticket + * @param _gasPriceBid Gas price bid for the L2 retryable ticket + * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket + * @param _ethAmount Amount of ETH to send with the retryable ticket + */ + function _transferDelegationToL2( + address _delegator, + address _indexer, + address _l2Beneficiary, + uint256 _maxGas, + uint256 _gasPriceBid, + uint256 _maxSubmissionCost, + uint256 _ethAmount + ) internal { + require(_l2Beneficiary != address(0), "l2Beneficiary == 0"); + require(indexerTransferredToL2[_indexer] != address(0), "indexer not transferred"); + + // Get the delegation pool of the indexer + DelegationPool storage pool = __delegationPools[_indexer]; + Delegation storage delegation = pool.delegators[_delegator]; + + // Check that the delegation is not locked for undelegation + require(delegation.tokensLocked == 0, "tokensLocked != 0"); + require(delegation.shares != 0, "delegation == 0"); + // Calculate tokens to get in exchange for the shares + uint256 tokensToSend = delegation.shares.mul(pool.tokens).div(pool.shares); + + // Update the delegation pool + pool.tokens = pool.tokens.sub(tokensToSend); + pool.shares = pool.shares.sub(delegation.shares); + + // Update the delegation + delegation.shares = 0; + bytes memory extraData; + { + IL2Staking.ReceiveDelegationData memory functionData; + functionData.indexer = indexerTransferredToL2[_indexer]; + functionData.delegator = _l2Beneficiary; + extraData = abi.encode( + uint8(IL2Staking.L1MessageCodes.RECEIVE_DELEGATION_CODE), + abi.encode(functionData) + ); + } + + _sendTokensAndMessageToL2Staking( + tokensToSend, + _maxGas, + _gasPriceBid, + _maxSubmissionCost, + _ethAmount, + extraData + ); + emit DelegationTransferredToL2( + _delegator, + _l2Beneficiary, + _indexer, + indexerTransferredToL2[_indexer], + tokensToSend + ); + } + + /** + * @dev Sends a message to the L2Staking with some extra data, + * also sending some tokens, using the L1GraphTokenGateway. + * @param _tokens Amount of tokens to send to L2 + * @param _maxGas Max gas to use for the L2 retryable ticket + * @param _gasPriceBid Gas price bid for the L2 retryable ticket + * @param _maxSubmissionCost Max submission cost for the L2 retryable ticket + * @param _value Amount of ETH to send with the message + * @param _extraData Extra data for the callhook on L2Staking + */ + function _sendTokensAndMessageToL2Staking( + uint256 _tokens, + uint256 _maxGas, + uint256 _gasPriceBid, + uint256 _maxSubmissionCost, + uint256 _value, + bytes memory _extraData + ) internal { + IGraphToken grt = graphToken(); + ITokenGateway gateway = graphTokenGateway(); + grt.approve(address(gateway), _tokens); + gateway.outboundTransfer{ value: _value }( + address(grt), + counterpartStakingAddress, + _tokens, + _maxGas, + _gasPriceBid, + abi.encode(_maxSubmissionCost, _extraData) + ); + } +} diff --git a/contracts/staking/L1StakingStorage.sol b/contracts/staking/L1StakingStorage.sol new file mode 100644 index 000000000..bd7a7f0ee --- /dev/null +++ b/contracts/staking/L1StakingStorage.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; +pragma abicoder v2; + +import { IL1GraphTokenLockTransferTool } from "./IL1GraphTokenLockTransferTool.sol"; + +/** + * @title L1StakingV1Storage + * @notice This contract holds all the L1-specific storage variables for the L1Staking contract, version 1 + * @dev When adding new versions, make sure to move the gap to the new version and + * reduce the size of the gap accordingly. + */ +abstract contract L1StakingV1Storage { + /// If an indexer has transferred to L2, this mapping will hold the indexer's address in L2 + mapping(address => address) public indexerTransferredToL2; + /// @dev For locked indexers/delegations, this contract holds the mapping of L1 to L2 addresses + IL1GraphTokenLockTransferTool internal l1GraphTokenLockTransferTool; + /// @dev Storage gap to keep storage slots fixed in future versions + uint256[50] private __gap; +} diff --git a/contracts/staking/Staking.sol b/contracts/staking/Staking.sol index 2bcc8d74d..7bf6de0e3 100644 --- a/contracts/staking/Staking.sol +++ b/contracts/staking/Staking.sol @@ -3,207 +3,96 @@ pragma solidity ^0.7.6; pragma abicoder v2; -import "@openzeppelin/contracts/cryptography/ECDSA.sol"; - -import "../base/Multicall.sol"; -import "../upgrades/GraphUpgradeable.sol"; -import "../utils/TokenUtils.sol"; - -import "./IStaking.sol"; -import "./StakingStorage.sol"; -import "./libs/MathUtils.sol"; -import "./libs/Rebates.sol"; -import "./libs/Stakes.sol"; +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; +import { ECDSA } from "@openzeppelin/contracts/cryptography/ECDSA.sol"; + +import { Multicall } from "../base/Multicall.sol"; +import { GraphUpgradeable } from "../upgrades/GraphUpgradeable.sol"; +import { TokenUtils } from "../utils/TokenUtils.sol"; +import { IGraphToken } from "../token/IGraphToken.sol"; +import { IStakingBase } from "./IStakingBase.sol"; +import { StakingV3Storage } from "./StakingStorage.sol"; +import { MathUtils } from "./libs/MathUtils.sol"; +import { Rebates } from "./libs/Rebates.sol"; +import { Stakes } from "./libs/Stakes.sol"; +import { Managed } from "../governance/Managed.sol"; +import { ICuration } from "../curation/ICuration.sol"; +import { IRewardsManager } from "../rewards/IRewardsManager.sol"; +import { StakingExtension } from "./StakingExtension.sol"; /** - * @title Staking contract + * @title Base Staking contract * @dev The Staking contract allows Indexers to Stake on Subgraphs. Indexers Stake by creating * Allocations on a Subgraph. It also allows Delegators to Delegate towards an Indexer. The * contract also has the slashing functionality. + * The contract is abstract as the implementation that is deployed depends on each layer: L1Staking on mainnet + * and L2Staking on Arbitrum. + * Note that this contract delegates part of its functionality to a StakingExtension contract. + * This is due to the 24kB contract size limit on Ethereum. */ -contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { +abstract contract Staking is StakingV3Storage, GraphUpgradeable, IStakingBase, Multicall { using SafeMath for uint256; using Stakes for Stakes.Indexer; using Rebates for Rebates.Pool; - // 100% in parts per million - uint32 private constant MAX_PPM = 1000000; - - // -- Events -- - - /** - * @dev Emitted when `indexer` update the delegation parameters for its delegation pool. - */ - event DelegationParametersUpdated( - address indexed indexer, - uint32 indexingRewardCut, - uint32 queryFeeCut, - uint32 cooldownBlocks - ); - - /** - * @dev Emitted when `indexer` stake `tokens` amount. - */ - event StakeDeposited(address indexed indexer, uint256 tokens); - - /** - * @dev Emitted when `indexer` unstaked and locked `tokens` amount `until` block. - */ - event StakeLocked(address indexed indexer, uint256 tokens, uint256 until); - - /** - * @dev Emitted when `indexer` withdrew `tokens` staked. - */ - event StakeWithdrawn(address indexed indexer, uint256 tokens); - - /** - * @dev Emitted when `indexer` was slashed for a total of `tokens` amount. - * Tracks `reward` amount of tokens given to `beneficiary`. - */ - event StakeSlashed( - address indexed indexer, - uint256 tokens, - uint256 reward, - address beneficiary - ); - - /** - * @dev Emitted when `delegator` delegated `tokens` to the `indexer`, the delegator - * gets `shares` for the delegation pool proportionally to the tokens staked. - */ - event StakeDelegated( - address indexed indexer, - address indexed delegator, - uint256 tokens, - uint256 shares - ); - - /** - * @dev Emitted when `delegator` undelegated `tokens` from `indexer`. - * Tokens get locked for withdrawal after a period of time. - */ - event StakeDelegatedLocked( - address indexed indexer, - address indexed delegator, - uint256 tokens, - uint256 shares, - uint256 until - ); - - /** - * @dev Emitted when `delegator` withdrew delegated `tokens` from `indexer`. - */ - event StakeDelegatedWithdrawn( - address indexed indexer, - address indexed delegator, - uint256 tokens - ); - - /** - * @dev Emitted when `indexer` allocated `tokens` amount to `subgraphDeploymentID` - * during `epoch`. - * `allocationID` indexer derived address used to identify the allocation. - * `metadata` additional information related to the allocation. - */ - event AllocationCreated( - address indexed indexer, - bytes32 indexed subgraphDeploymentID, - uint256 epoch, - uint256 tokens, - address indexed allocationID, - bytes32 metadata - ); - - /** - * @dev Emitted when `indexer` collected `tokens` amount in `epoch` for `allocationID`. - * These funds are related to `subgraphDeploymentID`. - * The `from` value is the sender of the collected funds. - */ - event AllocationCollected( - address indexed indexer, - bytes32 indexed subgraphDeploymentID, - uint256 epoch, - uint256 tokens, - address indexed allocationID, - address from, - uint256 curationFees, - uint256 rebateFees - ); - - /** - * @dev Emitted when `indexer` close an allocation in `epoch` for `allocationID`. - * An amount of `tokens` get unallocated from `subgraphDeploymentID`. - * The `effectiveAllocation` are the tokens allocated from creation to closing. - * This event also emits the POI (proof of indexing) submitted by the indexer. - * `isPublic` is true if the sender was someone other than the indexer. - */ - event AllocationClosed( - address indexed indexer, - bytes32 indexed subgraphDeploymentID, - uint256 epoch, - uint256 tokens, - address indexed allocationID, - uint256 effectiveAllocation, - address sender, - bytes32 poi, - bool isPublic - ); + /// @dev 100% in parts per million + uint32 internal constant MAX_PPM = 1000000; - /** - * @dev Emitted when `indexer` claimed a rebate on `subgraphDeploymentID` during `epoch` - * related to the `forEpoch` rebate pool. - * The rebate is for `tokens` amount and `unclaimedAllocationsCount` are left for claim - * in the rebate pool. `delegationFees` collected and sent to delegation pool. - */ - event RebateClaimed( - address indexed indexer, - bytes32 indexed subgraphDeploymentID, - address indexed allocationID, - uint256 epoch, - uint256 forEpoch, - uint256 tokens, - uint256 unclaimedAllocationsCount, - uint256 delegationFees - ); + // -- Events are declared in IStakingBase -- // /** - * @dev Emitted when `caller` set `slasher` address as `allowed` to slash stakes. + * @notice Delegates the current call to the StakingExtension implementation. + * @dev This function does not return to its internal call site, it will return directly to the + * external caller. */ - event SlasherUpdate(address indexed caller, address indexed slasher, bool allowed); + // solhint-disable-next-line payable-fallback, no-complex-fallback + fallback() external { + require(_implementation() != address(0), "only through proxy"); + // solhint-disable-next-line no-inline-assembly + assembly { + // (a) get free memory pointer + let ptr := mload(0x40) - /** - * @dev Emitted when `caller` set `assetHolder` address as `allowed` to send funds - * to staking contract. - */ - event AssetHolderUpdate(address indexed caller, address indexed assetHolder, bool allowed); + // (b) get address of the implementation + // CAREFUL here: this only works because extensionImpl is the first variable in this slot + // (otherwise we may have to apply an offset) + let impl := and(sload(extensionImpl.slot), 0xffffffffffffffffffffffffffffffffffffffff) - /** - * @dev Emitted when `indexer` set `operator` access. - */ - event SetOperator(address indexed indexer, address indexed operator, bool allowed); + // (1) copy incoming call data + calldatacopy(ptr, 0, calldatasize()) - /** - * @dev Emitted when `indexer` set an address to receive rewards. - */ - event SetRewardsDestination(address indexed indexer, address indexed destination); + // (2) forward call to logic contract + let result := delegatecall(gas(), impl, ptr, calldatasize(), 0, 0) + let size := returndatasize() - /** - * @dev Check if the caller is the slasher. - */ - modifier onlySlasher() { - require(slashers[msg.sender] == true, "!slasher"); - _; - } + // (3) retrieve return data + returndatacopy(ptr, 0, size) - /** - * @dev Check if the caller is authorized (indexer or operator) - */ - function _isAuth(address _indexer) private view returns (bool) { - return msg.sender == _indexer || isOperator(msg.sender, _indexer) == true; + // (4) forward return data back to caller + switch result + case 0 { + revert(ptr, size) + } + default { + return(ptr, size) + } + } } /** - * @dev Initialize this contract. + * @notice Initialize this contract. + * @param _controller Address of the controller that manages this contract + * @param _minimumIndexerStake Minimum amount of tokens that an indexer must stake + * @param _thawingPeriod Number of epochs that tokens get locked after unstaking + * @param _protocolPercentage Percentage of query fees that are burned as protocol fee (in PPM) + * @param _curationPercentage Percentage of query fees that are given to curators (in PPM) + * @param _channelDisputeEpochs The period in epochs that needs to pass before fees in rebate pool can be claimed + * @param _maxAllocationEpochs The maximum number of epochs that an allocation can be active + * @param _delegationUnbondingPeriod The period in epochs that tokens get locked after undelegating + * @param _delegationRatio The ratio between an indexer's own stake and the delegation they can use + * @param _rebateAlphaNumerator The numerator of the alpha factor used to calculate the rebate + * @param _rebateAlphaDenominator The denominator of the alpha factor used to calculate the rebate + * @param _extensionImpl Address of the StakingExtension implementation */ function initialize( address _controller, @@ -216,8 +105,9 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { uint32 _delegationUnbondingPeriod, uint32 _delegationRatio, uint32 _rebateAlphaNumerator, - uint32 _rebateAlphaDenominator - ) external onlyImpl { + uint32 _rebateAlphaDenominator, + address _extensionImpl + ) external override onlyImpl { Managed._initialize(_controller); // Settings @@ -230,52 +120,62 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { _setChannelDisputeEpochs(_channelDisputeEpochs); _setMaxAllocationEpochs(_maxAllocationEpochs); - _setDelegationUnbondingPeriod(_delegationUnbondingPeriod); - _setDelegationRatio(_delegationRatio); - _setDelegationParametersCooldown(0); - _setDelegationTaxPercentage(0); - _setRebateRatio(_rebateAlphaNumerator, _rebateAlphaDenominator); + + extensionImpl = _extensionImpl; + + // solhint-disable-next-line avoid-low-level-calls + (bool success, ) = extensionImpl.delegatecall( + abi.encodeWithSelector( + StakingExtension.initialize.selector, + _delegationUnbondingPeriod, + 0, + _delegationRatio, + 0 + ) + ); + require(success, "Extension init failed"); + emit ExtensionImplementationSet(_extensionImpl); } /** - * @dev Set the minimum indexer stake required to. - * @param _minimumIndexerStake Minimum indexer stake + * @notice Set the address of the StakingExtension implementation. + * @dev This function can only be called by the governor. + * @param _extensionImpl Address of the StakingExtension implementation */ - function setMinimumIndexerStake(uint256 _minimumIndexerStake) external override onlyGovernor { - _setMinimumIndexerStake(_minimumIndexerStake); + function setExtensionImpl(address _extensionImpl) external override onlyGovernor { + extensionImpl = _extensionImpl; + emit ExtensionImplementationSet(_extensionImpl); } /** - * @dev Internal: Set the minimum indexer stake required. - * @param _minimumIndexerStake Minimum indexer stake + * @notice Set the address of the counterpart (L1 or L2) staking contract. + * @dev This function can only be called by the governor. + * @param _counterpart Address of the counterpart staking contract in the other chain, without any aliasing. */ - function _setMinimumIndexerStake(uint256 _minimumIndexerStake) private { - require(_minimumIndexerStake > 0, "!minimumIndexerStake"); - minimumIndexerStake = _minimumIndexerStake; - emit ParameterUpdated("minimumIndexerStake"); + function setCounterpartStakingAddress(address _counterpart) external override onlyGovernor { + counterpartStakingAddress = _counterpart; + emit ParameterUpdated("counterpartStakingAddress"); } /** - * @dev Set the thawing period for unstaking. - * @param _thawingPeriod Period in blocks to wait for token withdrawals after unstaking + * @notice Set the minimum stake required to be an indexer. + * @param _minimumIndexerStake Minimum indexer stake */ - function setThawingPeriod(uint32 _thawingPeriod) external override onlyGovernor { - _setThawingPeriod(_thawingPeriod); + function setMinimumIndexerStake(uint256 _minimumIndexerStake) external override onlyGovernor { + _setMinimumIndexerStake(_minimumIndexerStake); } /** - * @dev Internal: Set the thawing period for unstaking. + * @notice Set the thawing period for unstaking. * @param _thawingPeriod Period in blocks to wait for token withdrawals after unstaking */ - function _setThawingPeriod(uint32 _thawingPeriod) private { - require(_thawingPeriod > 0, "!thawingPeriod"); - thawingPeriod = _thawingPeriod; - emit ParameterUpdated("thawingPeriod"); + function setThawingPeriod(uint32 _thawingPeriod) external override onlyGovernor { + _setThawingPeriod(_thawingPeriod); } /** - * @dev Set the curation percentage of query fees sent to curators. + * @notice Set the curation percentage of query fees sent to curators. * @param _percentage Percentage of query fees sent to curators */ function setCurationPercentage(uint32 _percentage) external override onlyGovernor { @@ -283,18 +183,7 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { } /** - * @dev Internal: Set the curation percentage of query fees sent to curators. - * @param _percentage Percentage of query fees sent to curators - */ - function _setCurationPercentage(uint32 _percentage) private { - // Must be within 0% to 100% (inclusive) - require(_percentage <= MAX_PPM, ">percentage"); - curationPercentage = _percentage; - emit ParameterUpdated("curationPercentage"); - } - - /** - * @dev Set a protocol percentage to burn when collecting query fees. + * @notice Set a protocol percentage to burn when collecting query fees. * @param _percentage Percentage of query fees to burn as protocol fee */ function setProtocolPercentage(uint32 _percentage) external override onlyGovernor { @@ -302,18 +191,7 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { } /** - * @dev Internal: Set a protocol percentage to burn when collecting query fees. - * @param _percentage Percentage of query fees to burn as protocol fee - */ - function _setProtocolPercentage(uint32 _percentage) private { - // Must be within 0% to 100% (inclusive) - require(_percentage <= MAX_PPM, ">percentage"); - protocolPercentage = _percentage; - emit ParameterUpdated("protocolPercentage"); - } - - /** - * @dev Set the period in epochs that need to pass before fees in rebate pool can be claimed. + * @notice Set the period in epochs that need to pass before fees in rebate pool can be claimed. * @param _channelDisputeEpochs Period in epochs */ function setChannelDisputeEpochs(uint32 _channelDisputeEpochs) external override onlyGovernor { @@ -321,17 +199,8 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { } /** - * @dev Internal: Set the period in epochs that need to pass before fees in rebate pool can be claimed. - * @param _channelDisputeEpochs Period in epochs - */ - function _setChannelDisputeEpochs(uint32 _channelDisputeEpochs) private { - require(_channelDisputeEpochs > 0, "!channelDisputeEpochs"); - channelDisputeEpochs = _channelDisputeEpochs; - emit ParameterUpdated("channelDisputeEpochs"); - } - - /** - * @dev Set the max time allowed for indexers stake on allocations. + * @notice Set the max time allowed for indexers to allocate on a subgraph + * before others are allowed to close the allocation. * @param _maxAllocationEpochs Allocation duration limit in epochs */ function setMaxAllocationEpochs(uint32 _maxAllocationEpochs) external override onlyGovernor { @@ -339,16 +208,7 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { } /** - * @dev Internal: Set the max time allowed for indexers stake on allocations. - * @param _maxAllocationEpochs Allocation duration limit in epochs - */ - function _setMaxAllocationEpochs(uint32 _maxAllocationEpochs) private { - maxAllocationEpochs = _maxAllocationEpochs; - emit ParameterUpdated("maxAllocationEpochs"); - } - - /** - * @dev Set the rebate ratio (fees to allocated stake). + * @notice Set the rebate ratio (fees to allocated stake). * @param _alphaNumerator Numerator of `alpha` in the cobb-douglas function * @param _alphaDenominator Denominator of `alpha` in the cobb-douglas function */ @@ -361,176 +221,239 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { } /** - * @dev Set the rebate ratio (fees to allocated stake). - * @param _alphaNumerator Numerator of `alpha` in the cobb-douglas function - * @param _alphaDenominator Denominator of `alpha` in the cobb-douglas function + * @notice Set an address as allowed asset holder. + * @param _assetHolder Address of allowed source for state channel funds + * @param _allowed True if asset holder is allowed */ - function _setRebateRatio(uint32 _alphaNumerator, uint32 _alphaDenominator) private { - require(_alphaNumerator > 0 && _alphaDenominator > 0, "!alpha"); - alphaNumerator = _alphaNumerator; - alphaDenominator = _alphaDenominator; - emit ParameterUpdated("rebateRatio"); + function setAssetHolder(address _assetHolder, bool _allowed) external override onlyGovernor { + require(_assetHolder != address(0), "!assetHolder"); + __assetHolders[_assetHolder] = _allowed; + emit AssetHolderUpdate(msg.sender, _assetHolder, _allowed); } /** - * @dev Set the delegation ratio. - * If set to 10 it means the indexer can use up to 10x the indexer staked amount - * from their delegated tokens - * @param _delegationRatio Delegation capacity multiplier + * @notice Authorize or unauthorize an address to be an operator for the caller. + * @param _operator Address to authorize or unauthorize + * @param _allowed Whether the operator is authorized or not */ - function setDelegationRatio(uint32 _delegationRatio) external override onlyGovernor { - _setDelegationRatio(_delegationRatio); + function setOperator(address _operator, bool _allowed) external override { + require(_operator != msg.sender, "operator == sender"); + __operatorAuth[msg.sender][_operator] = _allowed; + emit SetOperator(msg.sender, _operator, _allowed); } /** - * @dev Internal: Set the delegation ratio. - * If set to 10 it means the indexer can use up to 10x the indexer staked amount - * from their delegated tokens - * @param _delegationRatio Delegation capacity multiplier + * @notice Deposit tokens on the indexer's stake. + * The amount staked must be over the minimumIndexerStake. + * @param _tokens Amount of tokens to stake */ - function _setDelegationRatio(uint32 _delegationRatio) private { - delegationRatio = _delegationRatio; - emit ParameterUpdated("delegationRatio"); + function stake(uint256 _tokens) external override { + stakeTo(msg.sender, _tokens); } /** - * @dev Set the delegation parameters for the caller. - * @param _indexingRewardCut Percentage of indexing rewards left for delegators - * @param _queryFeeCut Percentage of query fees left for delegators - * @param _cooldownBlocks Period that need to pass to update delegation parameters + * @notice Unstake tokens from the indexer stake, lock them until the thawing period expires. + * @dev NOTE: The function accepts an amount greater than the currently staked tokens. + * If that happens, it will try to unstake the max amount of tokens it can. + * The reason for this behaviour is to avoid time conditions while the transaction + * is in flight. + * @param _tokens Amount of tokens to unstake */ - function setDelegationParameters( - uint32 _indexingRewardCut, - uint32 _queryFeeCut, - uint32 _cooldownBlocks - ) public override { - _setDelegationParameters(msg.sender, _indexingRewardCut, _queryFeeCut, _cooldownBlocks); - } + function unstake(uint256 _tokens) external override notPartialPaused { + address indexer = msg.sender; + Stakes.Indexer storage indexerStake = __stakes[indexer]; - /** - * @dev Set the delegation parameters for a particular indexer. - * @param _indexer Indexer to set delegation parameters - * @param _indexingRewardCut Percentage of indexing rewards left for delegators - * @param _queryFeeCut Percentage of query fees left for delegators - * @param _cooldownBlocks Period that need to pass to update delegation parameters - */ - function _setDelegationParameters( - address _indexer, - uint32 _indexingRewardCut, - uint32 _queryFeeCut, - uint32 _cooldownBlocks - ) private { - // Incentives must be within bounds - require(_queryFeeCut <= MAX_PPM, ">queryFeeCut"); - require(_indexingRewardCut <= MAX_PPM, ">indexingRewardCut"); + require(indexerStake.tokensStaked > 0, "!stake"); - // Cooldown period set by indexer cannot be below protocol global setting - require(_cooldownBlocks >= delegationParametersCooldown, " 0, "!stake-avail"); - // Verify the cooldown period passed - DelegationPool storage pool = delegationPools[_indexer]; - require( - pool.updatedAtBlock == 0 || - pool.updatedAtBlock.add(uint256(pool.cooldownBlocks)) <= block.number, - "!cooldown" - ); + // Ensure minimum stake + uint256 newStake = indexerStake.tokensSecureStake().sub(tokensToLock); + require(newStake == 0 || newStake >= __minimumIndexerStake, "!minimumIndexerStake"); - // Update delegation params - pool.indexingRewardCut = _indexingRewardCut; - pool.queryFeeCut = _queryFeeCut; - pool.cooldownBlocks = _cooldownBlocks; - pool.updatedAtBlock = block.number; + // Before locking more tokens, withdraw any unlocked ones if possible + uint256 tokensToWithdraw = indexerStake.tokensWithdrawable(); + if (tokensToWithdraw > 0) { + _withdraw(indexer); + } - emit DelegationParametersUpdated( - _indexer, - _indexingRewardCut, - _queryFeeCut, - _cooldownBlocks - ); - } + // Update the indexer stake locking tokens + indexerStake.lockTokens(tokensToLock, __thawingPeriod); - /** - * @dev Set the time in blocks an indexer needs to wait to change delegation parameters. - * @param _blocks Number of blocks to set the delegation parameters cooldown period - */ - function setDelegationParametersCooldown(uint32 _blocks) external override onlyGovernor { - _setDelegationParametersCooldown(_blocks); + emit StakeLocked(indexer, indexerStake.tokensLocked, indexerStake.tokensLockedUntil); } /** - * @dev Internal: Set the time in blocks an indexer needs to wait to change delegation parameters. - * @param _blocks Number of blocks to set the delegation parameters cooldown period + * @notice Withdraw indexer tokens once the thawing period has passed. */ - function _setDelegationParametersCooldown(uint32 _blocks) private { - delegationParametersCooldown = _blocks; - emit ParameterUpdated("delegationParametersCooldown"); + function withdraw() external override notPaused { + _withdraw(msg.sender); } /** - * @dev Set the period for undelegation of stake from indexer. - * @param _delegationUnbondingPeriod Period in epochs to wait for token withdrawals after undelegating + * @notice Set the destination where to send rewards for an indexer. + * @param _destination Rewards destination address. If set to zero, rewards will be restaked */ - function setDelegationUnbondingPeriod(uint32 _delegationUnbondingPeriod) - external - override - onlyGovernor - { - _setDelegationUnbondingPeriod(_delegationUnbondingPeriod); + function setRewardsDestination(address _destination) external override { + __rewardsDestination[msg.sender] = _destination; + emit SetRewardsDestination(msg.sender, _destination); } /** - * @dev Internal: Set the period for undelegation of stake from indexer. - * @param _delegationUnbondingPeriod Period in epochs to wait for token withdrawals after undelegating + * @notice Allocate available tokens to a subgraph deployment. + * @param _subgraphDeploymentID ID of the SubgraphDeployment where tokens will be allocated + * @param _tokens Amount of tokens to allocate + * @param _allocationID The allocation identifier + * @param _metadata IPFS hash for additional information about the allocation + * @param _proof A 65-bytes Ethereum signed message of `keccak256(indexerAddress,allocationID)` */ - function _setDelegationUnbondingPeriod(uint32 _delegationUnbondingPeriod) private { - require(_delegationUnbondingPeriod > 0, "!delegationUnbondingPeriod"); - delegationUnbondingPeriod = _delegationUnbondingPeriod; - emit ParameterUpdated("delegationUnbondingPeriod"); + function allocate( + bytes32 _subgraphDeploymentID, + uint256 _tokens, + address _allocationID, + bytes32 _metadata, + bytes calldata _proof + ) external override notPaused { + _allocate(msg.sender, _subgraphDeploymentID, _tokens, _allocationID, _metadata, _proof); } /** - * @dev Set a delegation tax percentage to burn when delegated funds are deposited. - * @param _percentage Percentage of delegated tokens to burn as delegation tax + * @notice Allocate available tokens to a subgraph deployment from and indexer's stake. + * The caller must be the indexer or the indexer's operator. + * @param _indexer Indexer address to allocate funds from. + * @param _subgraphDeploymentID ID of the SubgraphDeployment where tokens will be allocated + * @param _tokens Amount of tokens to allocate + * @param _allocationID The allocation identifier + * @param _metadata IPFS hash for additional information about the allocation + * @param _proof A 65-bytes Ethereum signed message of `keccak256(indexerAddress,allocationID)` */ - function setDelegationTaxPercentage(uint32 _percentage) external override onlyGovernor { - _setDelegationTaxPercentage(_percentage); + function allocateFrom( + address _indexer, + bytes32 _subgraphDeploymentID, + uint256 _tokens, + address _allocationID, + bytes32 _metadata, + bytes calldata _proof + ) external override notPaused { + _allocate(_indexer, _subgraphDeploymentID, _tokens, _allocationID, _metadata, _proof); } /** - * @dev Internal: Set a delegation tax percentage to burn when delegated funds are deposited. - * @param _percentage Percentage of delegated tokens to burn as delegation tax + * @notice Close an allocation and free the staked tokens. + * To be eligible for rewards a proof of indexing must be presented. + * Presenting a bad proof is subject to slashable condition. + * To opt out of rewards set _poi to 0x0 + * @param _allocationID The allocation identifier + * @param _poi Proof of indexing submitted for the allocated period */ - function _setDelegationTaxPercentage(uint32 _percentage) private { - // Must be within 0% to 100% (inclusive) - require(_percentage <= MAX_PPM, ">percentage"); - delegationTaxPercentage = _percentage; - emit ParameterUpdated("delegationTaxPercentage"); + function closeAllocation(address _allocationID, bytes32 _poi) external override notPaused { + _closeAllocation(_allocationID, _poi); } /** - * @dev Set or unset an address as allowed slasher. - * @param _slasher Address of the party allowed to slash indexers - * @param _allowed True if slasher is allowed + * @notice Collect query fees from state channels and assign them to an allocation. + * Funds received are only accepted from a valid sender. + * @dev To avoid reverting on the withdrawal from channel flow this function will: + * 1) Accept calls with zero tokens. + * 2) Accept calls after an allocation passed the dispute period, in that case, all + * the received tokens are burned. + * @param _tokens Amount of tokens to collect + * @param _allocationID Allocation where the tokens will be assigned */ - function setSlasher(address _slasher, bool _allowed) external override onlyGovernor { - require(_slasher != address(0), "!slasher"); - slashers[_slasher] = _allowed; - emit SlasherUpdate(msg.sender, _slasher, _allowed); - } + function collect(uint256 _tokens, address _allocationID) external override { + // Allocation identifier validation + require(_allocationID != address(0), "!alloc"); - /** - * @dev Set an address as allowed asset holder. - * @param _assetHolder Address of allowed source for state channel funds - * @param _allowed True if asset holder is allowed + // The contract caller must be an authorized asset holder + require(__assetHolders[msg.sender] == true, "!assetHolder"); + + // Allocation must exist + AllocationState allocState = _getAllocationState(_allocationID); + require(allocState != AllocationState.Null, "!collect"); + + // Get allocation + Allocation storage alloc = __allocations[_allocationID]; + uint256 queryFees = _tokens; + uint256 curationFees = 0; + bytes32 subgraphDeploymentID = alloc.subgraphDeploymentID; + + // Process query fees only if non-zero amount + if (queryFees > 0) { + // Pull tokens to collect from the authorized sender + IGraphToken graphToken = graphToken(); + TokenUtils.pullTokens(graphToken, msg.sender, _tokens); + + // -- Collect protocol tax -- + // If the Allocation is not active or closed we are going to charge a 100% protocol tax + uint256 usedProtocolPercentage = (allocState == AllocationState.Active || + allocState == AllocationState.Closed) + ? __protocolPercentage + : MAX_PPM; + uint256 protocolTax = _collectTax(graphToken, queryFees, usedProtocolPercentage); + queryFees = queryFees.sub(protocolTax); + + // -- Collect curation fees -- + // Only if the subgraph deployment is curated + curationFees = _collectCurationFees( + graphToken, + subgraphDeploymentID, + queryFees, + __curationPercentage + ); + queryFees = queryFees.sub(curationFees); + + // Add funds to the allocation + alloc.collectedFees = alloc.collectedFees.add(queryFees); + + // When allocation is closed redirect funds to the rebate pool + // This way we can keep collecting tokens even after the allocation is closed and + // before it gets to the finalized state. + if (allocState == AllocationState.Closed) { + Rebates.Pool storage rebatePool = __rebates[alloc.closedAtEpoch]; + rebatePool.fees = rebatePool.fees.add(queryFees); + } + } + + emit AllocationCollected( + alloc.indexer, + subgraphDeploymentID, + epochManager().currentEpoch(), + _tokens, + _allocationID, + msg.sender, + curationFees, + queryFees + ); + } + + /** + * @notice Claim tokens from the rebate pool. + * @param _allocationID Allocation from where we are claiming tokens + * @param _restake True if restake fees instead of transfer to indexer */ - function setAssetHolder(address _assetHolder, bool _allowed) external override onlyGovernor { - require(_assetHolder != address(0), "!assetHolder"); - assetHolders[_assetHolder] = _allowed; - emit AssetHolderUpdate(msg.sender, _assetHolder, _allowed); + function claim(address _allocationID, bool _restake) external override notPaused { + _claim(_allocationID, _restake); } /** - * @dev Return if allocationID is used. + * @dev Claim tokens from the rebate pool for many allocations. + * @param _allocationID Array of allocations from where we are claiming tokens + * @param _restake True if restake fees instead of transfer to indexer + */ + function claimMany(address[] calldata _allocationID, bool _restake) + external + override + notPaused + { + for (uint256 i = 0; i < _allocationID.length; i++) { + _claim(_allocationID[i], _restake); + } + } + + /** + * @notice Return if allocationID is used. * @param _allocationID Address used as signer by the indexer for an allocation * @return True if allocationID already used */ @@ -539,16 +462,16 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { } /** - * @dev Getter that returns if an indexer has any stake. + * @notice Getter that returns if an indexer has any stake. * @param _indexer Address of the indexer * @return True if indexer has staked tokens */ function hasStake(address _indexer) external view override returns (bool) { - return stakes[_indexer].tokensStaked > 0; + return __stakes[_indexer].tokensStaked > 0; } /** - * @dev Return the allocation by ID. + * @notice Return the allocation by ID. * @param _allocationID Address used as allocation identifier * @return Allocation data */ @@ -558,13 +481,13 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { override returns (Allocation memory) { - return allocations[_allocationID]; + return __allocations[_allocationID]; } /** - * @dev Return the current state of an allocation. - * @param _allocationID Address used as the allocation identifier - * @return AllocationState + * @notice Return the current state of an allocation + * @param _allocationID Allocation identifier + * @return AllocationState enum with the state of the allocation */ function getAllocationState(address _allocationID) external @@ -576,8 +499,8 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { } /** - * @dev Return the total amount of tokens allocated to subgraph. - * @param _subgraphDeploymentID Address used as the allocation identifier + * @notice Return the total amount of tokens allocated to subgraph. + * @param _subgraphDeploymentID Deployment ID for the subgraph * @return Total tokens allocated to subgraph */ function getSubgraphAllocatedTokens(bytes32 _subgraphDeploymentID) @@ -586,119 +509,27 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { override returns (uint256) { - return subgraphAllocations[_subgraphDeploymentID]; - } - - /** - * @dev Return the delegation from a delegator to an indexer. - * @param _indexer Address of the indexer where funds have been delegated - * @param _delegator Address of the delegator - * @return Delegation data - */ - function getDelegation(address _indexer, address _delegator) - external - view - override - returns (Delegation memory) - { - return delegationPools[_indexer].delegators[_delegator]; - } - - /** - * @dev Return whether the delegator has delegated to the indexer. - * @param _indexer Address of the indexer where funds have been delegated - * @param _delegator Address of the delegator - * @return True if delegator of indexer - */ - function isDelegator(address _indexer, address _delegator) public view override returns (bool) { - return delegationPools[_indexer].delegators[_delegator].shares > 0; + return __subgraphAllocations[_subgraphDeploymentID]; } /** - * @dev Get the total amount of tokens staked by the indexer. + * @notice Get the total amount of tokens staked by the indexer. * @param _indexer Address of the indexer * @return Amount of tokens staked by the indexer */ function getIndexerStakedTokens(address _indexer) external view override returns (uint256) { - return stakes[_indexer].tokensStaked; - } - - /** - * @dev Get the total amount of tokens available to use in allocations. - * This considers the indexer stake and delegated tokens according to delegation ratio - * @param _indexer Address of the indexer - * @return Amount of tokens staked by the indexer - */ - function getIndexerCapacity(address _indexer) public view override returns (uint256) { - Stakes.Indexer memory indexerStake = stakes[_indexer]; - uint256 tokensDelegated = delegationPools[_indexer].tokens; - - uint256 tokensDelegatedCap = indexerStake.tokensSecureStake().mul(uint256(delegationRatio)); - uint256 tokensDelegatedCapacity = MathUtils.min(tokensDelegated, tokensDelegatedCap); - - return indexerStake.tokensAvailableWithDelegation(tokensDelegatedCapacity); - } - - /** - * @dev Returns amount of delegated tokens ready to be withdrawn after unbonding period. - * @param _delegation Delegation of tokens from delegator to indexer - * @return Amount of tokens to withdraw - */ - function getWithdraweableDelegatedTokens(Delegation memory _delegation) - public - view - returns (uint256) - { - // There must be locked tokens and period passed - uint256 currentEpoch = epochManager().currentEpoch(); - if (_delegation.tokensLockedUntil > 0 && currentEpoch >= _delegation.tokensLockedUntil) { - return _delegation.tokensLocked; - } - return 0; - } - - /** - * @dev Authorize or unauthorize an address to be an operator. - * @param _operator Address to authorize - * @param _allowed Whether authorized or not - */ - function setOperator(address _operator, bool _allowed) external override { - require(_operator != msg.sender, "operator == sender"); - operatorAuth[msg.sender][_operator] = _allowed; - emit SetOperator(msg.sender, _operator, _allowed); - } - - /** - * @dev Return true if operator is allowed for indexer. - * @param _operator Address of the operator - * @param _indexer Address of the indexer - */ - function isOperator(address _operator, address _indexer) public view override returns (bool) { - return operatorAuth[_indexer][_operator]; - } - - /** - * @dev Deposit tokens on the indexer stake. - * @param _tokens Amount of tokens to stake - */ - function stake(uint256 _tokens) external override { - stakeTo(msg.sender, _tokens); + return __stakes[_indexer].tokensStaked; } /** - * @dev Deposit tokens on the indexer stake. + * @notice Deposit tokens on the Indexer stake, on behalf of the Indexer. + * The amount staked must be over the minimumIndexerStake. * @param _indexer Address of the indexer * @param _tokens Amount of tokens to stake */ function stakeTo(address _indexer, uint256 _tokens) public override notPartialPaused { require(_tokens > 0, "!tokens"); - // Ensure minimum stake - require( - stakes[_indexer].tokensSecureStake().add(_tokens) >= minimumIndexerStake, - "!minimumIndexerStake" - ); - // Transfer tokens to stake from caller to this contract TokenUtils.pullTokens(graphToken(), msg.sender, _tokens); @@ -707,349 +538,160 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { } /** - * @dev Unstake tokens from the indexer stake, lock them until thawing period expires. - * NOTE: The function accepts an amount greater than the currently staked tokens. - * If that happens, it will try to unstake the max amount of tokens it can. - * The reason for this behaviour is to avoid time conditions while the transaction - * is in flight. - * @param _tokens Amount of tokens to unstake - */ - function unstake(uint256 _tokens) external override notPartialPaused { - address indexer = msg.sender; - Stakes.Indexer storage indexerStake = stakes[indexer]; - - require(indexerStake.tokensStaked > 0, "!stake"); - - // Tokens to lock is capped to the available tokens - uint256 tokensToLock = MathUtils.min(indexerStake.tokensAvailable(), _tokens); - require(tokensToLock > 0, "!stake-avail"); - - // Ensure minimum stake - uint256 newStake = indexerStake.tokensSecureStake().sub(tokensToLock); - require(newStake == 0 || newStake >= minimumIndexerStake, "!minimumIndexerStake"); - - // Before locking more tokens, withdraw any unlocked ones if possible - uint256 tokensToWithdraw = indexerStake.tokensWithdrawable(); - if (tokensToWithdraw > 0) { - _withdraw(indexer); - } - - // Update the indexer stake locking tokens - indexerStake.lockTokens(tokensToLock, thawingPeriod); - - emit StakeLocked(indexer, indexerStake.tokensLocked, indexerStake.tokensLockedUntil); - } - - /** - * @dev Withdraw indexer tokens once the thawing period has passed. - */ - function withdraw() external override notPaused { - _withdraw(msg.sender); - } - - /** - * @dev Set the destination where to send rewards. - * @param _destination Rewards destination address. If set to zero, rewards will be restaked - */ - function setRewardsDestination(address _destination) external override { - rewardsDestination[msg.sender] = _destination; - emit SetRewardsDestination(msg.sender, _destination); - } - - /** - * @dev Slash the indexer stake. Delegated tokens are not subject to slashing. - * Can only be called by the slasher role. - * @param _indexer Address of indexer to slash - * @param _tokens Amount of tokens to slash from the indexer stake - * @param _reward Amount of reward tokens to send to a beneficiary - * @param _beneficiary Address of a beneficiary to receive a reward for the slashing - */ - function slash( - address _indexer, - uint256 _tokens, - uint256 _reward, - address _beneficiary - ) external override onlySlasher notPartialPaused { - Stakes.Indexer storage indexerStake = stakes[_indexer]; - - // Only able to slash a non-zero number of tokens - require(_tokens > 0, "!tokens"); - - // Rewards comes from tokens slashed balance - require(_tokens >= _reward, "rewards>slash"); - - // Cannot slash stake of an indexer without any or enough stake - require(indexerStake.tokensStaked > 0, "!stake"); - require(_tokens <= indexerStake.tokensStaked, "slash>stake"); - - // Validate beneficiary of slashed tokens - require(_beneficiary != address(0), "!beneficiary"); - - // Slashing more tokens than freely available (over allocation condition) - // Unlock locked tokens to avoid the indexer to withdraw them - if (_tokens > indexerStake.tokensAvailable() && indexerStake.tokensLocked > 0) { - uint256 tokensOverAllocated = _tokens.sub(indexerStake.tokensAvailable()); - uint256 tokensToUnlock = MathUtils.min(tokensOverAllocated, indexerStake.tokensLocked); - indexerStake.unlockTokens(tokensToUnlock); - } - - // Remove tokens to slash from the stake - indexerStake.release(_tokens); - - // -- Interactions -- - - IGraphToken graphToken = graphToken(); - - // Set apart the reward for the beneficiary and burn remaining slashed stake - TokenUtils.burnTokens(graphToken, _tokens.sub(_reward)); - - // Give the beneficiary a reward for slashing - TokenUtils.pushTokens(graphToken, _beneficiary, _reward); - - emit StakeSlashed(_indexer, _tokens, _reward, _beneficiary); - } - - /** - * @dev Delegate tokens to an indexer. - * @param _indexer Address of the indexer to delegate tokens to - * @param _tokens Amount of tokens to delegate - * @return Amount of shares issued of the delegation pool - */ - function delegate(address _indexer, uint256 _tokens) - external - override - notPartialPaused - returns (uint256) - { - address delegator = msg.sender; - - // Transfer tokens to delegate to this contract - TokenUtils.pullTokens(graphToken(), delegator, _tokens); - - // Update state - return _delegate(delegator, _indexer, _tokens); - } - - /** - * @dev Undelegate tokens from an indexer. - * @param _indexer Address of the indexer where tokens had been delegated - * @param _shares Amount of shares to return and undelegate tokens - * @return Amount of tokens returned for the shares of the delegation pool - */ - function undelegate(address _indexer, uint256 _shares) - external - override - notPartialPaused - returns (uint256) - { - return _undelegate(msg.sender, _indexer, _shares); - } - - /** - * @dev Withdraw delegated tokens once the unbonding period has passed. - * @param _indexer Withdraw available tokens delegated to indexer - * @param _delegateToIndexer Re-delegate to indexer address if non-zero, withdraw if zero address + * @notice Set the delegation parameters for the caller. + * @param _indexingRewardCut Percentage of indexing rewards left for the indexer + * @param _queryFeeCut Percentage of query fees left for the indexer + * @param _cooldownBlocks Period that need to pass to update delegation parameters */ - function withdrawDelegated(address _indexer, address _delegateToIndexer) - external - override - notPaused - returns (uint256) - { - return _withdrawDelegated(msg.sender, _indexer, _delegateToIndexer); + function setDelegationParameters( + uint32 _indexingRewardCut, + uint32 _queryFeeCut, + uint32 _cooldownBlocks + ) public override { + _setDelegationParameters(msg.sender, _indexingRewardCut, _queryFeeCut, _cooldownBlocks); } /** - * @dev Allocate available tokens to a subgraph deployment. - * @param _subgraphDeploymentID ID of the SubgraphDeployment where tokens will be allocated - * @param _tokens Amount of tokens to allocate - * @param _allocationID The allocation identifier - * @param _metadata IPFS hash for additional information about the allocation - * @param _proof A 65-bytes Ethereum signed message of `keccak256(indexerAddress,allocationID)` + * @notice Get the total amount of tokens available to use in allocations. + * This considers the indexer stake and delegated tokens according to delegation ratio + * @param _indexer Address of the indexer + * @return Amount of tokens available to allocate including delegation */ - function allocate( - bytes32 _subgraphDeploymentID, - uint256 _tokens, - address _allocationID, - bytes32 _metadata, - bytes calldata _proof - ) external override notPaused { - _allocate(msg.sender, _subgraphDeploymentID, _tokens, _allocationID, _metadata, _proof); - } + function getIndexerCapacity(address _indexer) public view override returns (uint256) { + Stakes.Indexer memory indexerStake = __stakes[_indexer]; + uint256 tokensDelegated = __delegationPools[_indexer].tokens; - /** - * @dev Allocate available tokens to a subgraph deployment. - * @param _indexer Indexer address to allocate funds from. - * @param _subgraphDeploymentID ID of the SubgraphDeployment where tokens will be allocated - * @param _tokens Amount of tokens to allocate - * @param _allocationID The allocation identifier - * @param _metadata IPFS hash for additional information about the allocation - * @param _proof A 65-bytes Ethereum signed message of `keccak256(indexerAddress,allocationID)` - */ - function allocateFrom( - address _indexer, - bytes32 _subgraphDeploymentID, - uint256 _tokens, - address _allocationID, - bytes32 _metadata, - bytes calldata _proof - ) external override notPaused { - _allocate(_indexer, _subgraphDeploymentID, _tokens, _allocationID, _metadata, _proof); - } + uint256 tokensDelegatedCap = indexerStake.tokensSecureStake().mul( + uint256(__delegationRatio) + ); + uint256 tokensDelegatedCapacity = MathUtils.min(tokensDelegated, tokensDelegatedCap); - /** - * @dev Close an allocation and free the staked tokens. - * To be eligible for rewards a proof of indexing must be presented. - * Presenting a bad proof is subject to slashable condition. - * To opt out for rewards set _poi to 0x0 - * @param _allocationID The allocation identifier - * @param _poi Proof of indexing submitted for the allocated period - */ - function closeAllocation(address _allocationID, bytes32 _poi) external override notPaused { - _closeAllocation(_allocationID, _poi); + return indexerStake.tokensAvailableWithDelegation(tokensDelegatedCapacity); } /** - * @dev Close multiple allocations and free the staked tokens. - * To be eligible for rewards a proof of indexing must be presented. - * Presenting a bad proof is subject to slashable condition. - * To opt out for rewards set _poi to 0x0 - * @param _requests An array of CloseAllocationRequest + * @notice Return true if operator is allowed for indexer. + * @param _operator Address of the operator + * @param _indexer Address of the indexer + * @return True if operator is allowed for indexer, false otherwise */ - function closeAllocationMany(CloseAllocationRequest[] calldata _requests) - external - override - notPaused - { - for (uint256 i = 0; i < _requests.length; i++) { - _closeAllocation(_requests[i].allocationID, _requests[i].poi); - } + function isOperator(address _operator, address _indexer) public view override returns (bool) { + return __operatorAuth[_indexer][_operator]; } /** - * @dev Close and allocate. This will perform a close and then create a new Allocation - * atomically on the same transaction. - * @param _closingAllocationID The identifier of the allocation to be closed - * @param _poi Proof of indexing submitted for the allocated period - * @param _indexer Indexer address to allocate funds from. - * @param _subgraphDeploymentID ID of the SubgraphDeployment where tokens will be allocated - * @param _tokens Amount of tokens to allocate - * @param _allocationID The allocation identifier - * @param _metadata IPFS hash for additional information about the allocation - * @param _proof A 65-bytes Ethereum signed message of `keccak256(indexerAddress,allocationID)` + * @dev Internal: Set the minimum indexer stake required. + * @param _minimumIndexerStake Minimum indexer stake */ - function closeAndAllocate( - address _closingAllocationID, - bytes32 _poi, - address _indexer, - bytes32 _subgraphDeploymentID, - uint256 _tokens, - address _allocationID, - bytes32 _metadata, - bytes calldata _proof - ) external override notPaused { - _closeAllocation(_closingAllocationID, _poi); - _allocate(_indexer, _subgraphDeploymentID, _tokens, _allocationID, _metadata, _proof); + function _setMinimumIndexerStake(uint256 _minimumIndexerStake) private { + require(_minimumIndexerStake > 0, "!minimumIndexerStake"); + __minimumIndexerStake = _minimumIndexerStake; + emit ParameterUpdated("minimumIndexerStake"); } /** - * @dev Collect query fees from state channels and assign them to an allocation. - * Funds received are only accepted from a valid sender. - * To avoid reverting on the withdrawal from channel flow this function will: - * 1) Accept calls with zero tokens. - * 2) Accept calls after an allocation passed the dispute period, in that case, all - * the received tokens are burned. - * @param _tokens Amount of tokens to collect - * @param _allocationID Allocation where the tokens will be assigned + * @dev Internal: Set the thawing period for unstaking. + * @param _thawingPeriod Period in blocks to wait for token withdrawals after unstaking */ - function collect(uint256 _tokens, address _allocationID) external override { - // Allocation identifier validation - require(_allocationID != address(0), "!alloc"); - - // The contract caller must be an authorized asset holder - require(assetHolders[msg.sender] == true, "!assetHolder"); - - // Allocation must exist - AllocationState allocState = _getAllocationState(_allocationID); - require(allocState != AllocationState.Null, "!collect"); - - // Get allocation - Allocation storage alloc = allocations[_allocationID]; - uint256 queryFees = _tokens; - uint256 curationFees = 0; - bytes32 subgraphDeploymentID = alloc.subgraphDeploymentID; - - // Process query fees only if non-zero amount - if (queryFees > 0) { - // Pull tokens to collect from the authorized sender - IGraphToken graphToken = graphToken(); - TokenUtils.pullTokens(graphToken, msg.sender, _tokens); - - // -- Collect protocol tax -- - // If the Allocation is not active or closed we are going to charge a 100% protocol tax - uint256 usedProtocolPercentage = (allocState == AllocationState.Active || - allocState == AllocationState.Closed) - ? protocolPercentage - : MAX_PPM; - uint256 protocolTax = _collectTax(graphToken, queryFees, usedProtocolPercentage); - queryFees = queryFees.sub(protocolTax); - - // -- Collect curation fees -- - // Only if the subgraph deployment is curated - curationFees = _collectCurationFees( - graphToken, - subgraphDeploymentID, - queryFees, - curationPercentage - ); - queryFees = queryFees.sub(curationFees); + function _setThawingPeriod(uint32 _thawingPeriod) private { + require(_thawingPeriod > 0, "!thawingPeriod"); + __thawingPeriod = _thawingPeriod; + emit ParameterUpdated("thawingPeriod"); + } - // Add funds to the allocation - alloc.collectedFees = alloc.collectedFees.add(queryFees); + /** + * @dev Internal: Set the curation percentage of query fees sent to curators. + * @param _percentage Percentage of query fees sent to curators + */ + function _setCurationPercentage(uint32 _percentage) private { + // Must be within 0% to 100% (inclusive) + require(_percentage <= MAX_PPM, ">percentage"); + __curationPercentage = _percentage; + emit ParameterUpdated("curationPercentage"); + } - // When allocation is closed redirect funds to the rebate pool - // This way we can keep collecting tokens even after the allocation is closed and - // before it gets to the finalized state. - if (allocState == AllocationState.Closed) { - Rebates.Pool storage rebatePool = rebates[alloc.closedAtEpoch]; - rebatePool.fees = rebatePool.fees.add(queryFees); - } - } + /** + * @dev Internal: Set a protocol percentage to burn when collecting query fees. + * @param _percentage Percentage of query fees to burn as protocol fee + */ + function _setProtocolPercentage(uint32 _percentage) private { + // Must be within 0% to 100% (inclusive) + require(_percentage <= MAX_PPM, ">percentage"); + __protocolPercentage = _percentage; + emit ParameterUpdated("protocolPercentage"); + } - emit AllocationCollected( - alloc.indexer, - subgraphDeploymentID, - epochManager().currentEpoch(), - _tokens, - _allocationID, - msg.sender, - curationFees, - queryFees - ); + /** + * @dev Internal: Set the period in epochs that need to pass before fees in rebate pool can be claimed. + * @param _channelDisputeEpochs Period in epochs + */ + function _setChannelDisputeEpochs(uint32 _channelDisputeEpochs) private { + require(_channelDisputeEpochs > 0, "!channelDisputeEpochs"); + __channelDisputeEpochs = _channelDisputeEpochs; + emit ParameterUpdated("channelDisputeEpochs"); } /** - * @dev Claim tokens from the rebate pool. - * @param _allocationID Allocation from where we are claiming tokens - * @param _restake True if restake fees instead of transfer to indexer + * @dev Internal: Set the max time allowed for indexers stake on allocations. + * @param _maxAllocationEpochs Allocation duration limit in epochs */ - function claim(address _allocationID, bool _restake) external override notPaused { - _claim(_allocationID, _restake); + function _setMaxAllocationEpochs(uint32 _maxAllocationEpochs) private { + __maxAllocationEpochs = _maxAllocationEpochs; + emit ParameterUpdated("maxAllocationEpochs"); } /** - * @dev Claim tokens from the rebate pool for many allocations. - * @param _allocationID Array of allocations from where we are claiming tokens - * @param _restake True if restake fees instead of transfer to indexer + * @dev Set the rebate ratio (fees to allocated stake). + * @param _alphaNumerator Numerator of `alpha` in the cobb-douglas function + * @param _alphaDenominator Denominator of `alpha` in the cobb-douglas function */ - function claimMany(address[] calldata _allocationID, bool _restake) - external - override - notPaused - { - for (uint256 i = 0; i < _allocationID.length; i++) { - _claim(_allocationID[i], _restake); - } + function _setRebateRatio(uint32 _alphaNumerator, uint32 _alphaDenominator) private { + require(_alphaNumerator > 0 && _alphaDenominator > 0, "!alpha"); + __alphaNumerator = _alphaNumerator; + __alphaDenominator = _alphaDenominator; + emit ParameterUpdated("rebateRatio"); + } + + /** + * @dev Set the delegation parameters for a particular indexer. + * @param _indexer Indexer to set delegation parameters + * @param _indexingRewardCut Percentage of indexing rewards left for delegators + * @param _queryFeeCut Percentage of query fees left for delegators + * @param _cooldownBlocks Period that need to pass to update delegation parameters + */ + function _setDelegationParameters( + address _indexer, + uint32 _indexingRewardCut, + uint32 _queryFeeCut, + uint32 _cooldownBlocks + ) internal { + // Incentives must be within bounds + require(_queryFeeCut <= MAX_PPM, ">queryFeeCut"); + require(_indexingRewardCut <= MAX_PPM, ">indexingRewardCut"); + + // Cooldown period set by indexer cannot be below protocol global setting + require(_cooldownBlocks >= __delegationParametersCooldown, "= __minimumIndexerStake, + "!minimumIndexerStake" + ); + // Deposit tokens into the indexer stake - stakes[_indexer].deposit(_tokens); + __stakes[_indexer].deposit(_tokens); // Initialize the delegation pool the first time - if (delegationPools[_indexer].updatedAtBlock == 0) { - _setDelegationParameters(_indexer, MAX_PPM, MAX_PPM, delegationParametersCooldown); + if (__delegationPools[_indexer].updatedAtBlock == 0) { + _setDelegationParameters(_indexer, MAX_PPM, MAX_PPM, __delegationParametersCooldown); } emit StakeDeposited(_indexer, _tokens); @@ -1078,7 +726,7 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { */ function _withdraw(address _indexer) private { // Get tokens available for withdraw and update balance - uint256 tokensToWithdraw = stakes[_indexer].withdrawTokens(); + uint256 tokensToWithdraw = __stakes[_indexer].withdrawTokens(); require(tokensToWithdraw > 0, "!tokens"); // Return tokens to the indexer @@ -1110,21 +758,19 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { require(_allocationID != address(0), "!alloc"); require(_getAllocationState(_allocationID) == AllocationState.Null, "!null"); - // Caller must prove that they own the private key for the allocationID adddress + // Caller must prove that they own the private key for the allocationID address // The proof is an Ethereum signed message of KECCAK256(indexerAddress,allocationID) bytes32 messageHash = keccak256(abi.encodePacked(_indexer, _allocationID)); bytes32 digest = ECDSA.toEthSignedMessageHash(messageHash); require(ECDSA.recover(digest, _proof) == _allocationID, "!proof"); + require( + __stakes[_indexer].tokensSecureStake() >= __minimumIndexerStake, + "!minimumIndexerStake" + ); if (_tokens > 0) { // Needs to have free capacity not used for other purposes to allocate require(getIndexerCapacity(_indexer) >= _tokens, "!capacity"); - } else { - // Allocating zero-tokens still needs to comply with stake requirements - require( - stakes[_indexer].tokensSecureStake() >= minimumIndexerStake, - "!minimumIndexerStake" - ); } // Creates an allocation @@ -1140,18 +786,18 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { 0, // Initialize effective allocation (_tokens > 0) ? _updateRewards(_subgraphDeploymentID) : 0 // Initialize accumulated rewards per stake allocated ); - allocations[_allocationID] = alloc; + __allocations[_allocationID] = alloc; // -- Rewards Distribution -- // Process non-zero-allocation rewards tracking if (_tokens > 0) { // Mark allocated tokens as used - stakes[_indexer].allocate(alloc.tokens); + __stakes[_indexer].allocate(alloc.tokens); // Track total allocations per subgraph // Used for rewards calculations - subgraphAllocations[alloc.subgraphDeploymentID] = subgraphAllocations[ + __subgraphAllocations[alloc.subgraphDeploymentID] = __subgraphAllocations[ alloc.subgraphDeploymentID ].add(alloc.tokens); } @@ -1177,7 +823,7 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { require(allocState == AllocationState.Active, "!active"); // Get allocation - Allocation memory alloc = allocations[_allocationID]; + Allocation memory alloc = __allocations[_allocationID]; // Validate that an allocation cannot be closed before one epoch alloc.closedAtEpoch = epochManager().currentEpoch(); @@ -1189,28 +835,28 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { // - After maxAllocationEpochs passed // - When the allocation is for non-zero amount of tokens bool isIndexer = _isAuth(alloc.indexer); - if (epochs <= maxAllocationEpochs || alloc.tokens == 0) { + if (epochs <= __maxAllocationEpochs || alloc.tokens == 0) { require(isIndexer, "!auth"); } // Close the allocation and start counting a period to settle remaining payments from // state channels. - allocations[_allocationID].closedAtEpoch = alloc.closedAtEpoch; + __allocations[_allocationID].closedAtEpoch = alloc.closedAtEpoch; // -- Rebate Pool -- // Calculate effective allocation for the amount of epochs it remained allocated alloc.effectiveAllocation = _getEffectiveAllocation( - maxAllocationEpochs, + __maxAllocationEpochs, alloc.tokens, epochs ); - allocations[_allocationID].effectiveAllocation = alloc.effectiveAllocation; + __allocations[_allocationID].effectiveAllocation = alloc.effectiveAllocation; // Account collected fees and effective allocation in rebate pool for the epoch - Rebates.Pool storage rebatePool = rebates[alloc.closedAtEpoch]; + Rebates.Pool storage rebatePool = __rebates[alloc.closedAtEpoch]; if (!rebatePool.exists()) { - rebatePool.init(alphaNumerator, alphaDenominator); + rebatePool.init(__alphaNumerator, __alphaDenominator); } rebatePool.addToPool(alloc.collectedFees, alloc.effectiveAllocation); @@ -1226,11 +872,11 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { } // Free allocated tokens from use - stakes[alloc.indexer].unallocate(alloc.tokens); + __stakes[alloc.indexer].unallocate(alloc.tokens); // Track total allocations per subgraph // Used for rewards calculations - subgraphAllocations[alloc.subgraphDeploymentID] = subgraphAllocations[ + __subgraphAllocations[alloc.subgraphDeploymentID] = __subgraphAllocations[ alloc.subgraphDeploymentID ].sub(alloc.tokens); } @@ -1259,13 +905,13 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { require(allocState == AllocationState.Finalized, "!finalized"); // Get allocation - Allocation memory alloc = allocations[_allocationID]; + Allocation memory alloc = __allocations[_allocationID]; // Only the indexer or operator can decide if to restake bool restake = _isAuth(alloc.indexer) ? _restake : false; // Process rebate reward - Rebates.Pool storage rebatePool = rebates[alloc.closedAtEpoch]; + Rebates.Pool storage rebatePool = __rebates[alloc.closedAtEpoch]; uint256 tokensToClaim = rebatePool.redeem(alloc.collectedFees, alloc.effectiveAllocation); // Add delegation rewards to the delegation pool @@ -1275,12 +921,12 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { // Purge allocation data except for: // - indexer: used in disputes and to avoid reusing an allocationID // - subgraphDeploymentID: used in disputes - allocations[_allocationID].tokens = 0; - allocations[_allocationID].createdAtEpoch = 0; // This avoid collect(), close() and claim() to be called - allocations[_allocationID].closedAtEpoch = 0; - allocations[_allocationID].collectedFees = 0; - allocations[_allocationID].effectiveAllocation = 0; - allocations[_allocationID].accRewardsPerAllocatedToken = 0; + __allocations[_allocationID].tokens = 0; + __allocations[_allocationID].createdAtEpoch = 0; // This avoid collect(), close() and claim() to be called + __allocations[_allocationID].closedAtEpoch = 0; + __allocations[_allocationID].collectedFees = 0; + __allocations[_allocationID].effectiveAllocation = 0; + __allocations[_allocationID].accRewardsPerAllocatedToken = 0; // -- Interactions -- @@ -1289,7 +935,7 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { // When all allocations processed then burn unclaimed fees and prune rebate pool if (rebatePool.unclaimedAllocationsCount == 0) { TokenUtils.burnTokens(graphToken, rebatePool.unclaimedFees()); - delete rebates[alloc.closedAtEpoch]; + delete __rebates[alloc.closedAtEpoch]; } // When there are tokens to claim from the rebate pool, transfer or restake @@ -1307,139 +953,6 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { ); } - /** - * @dev Delegate tokens to an indexer. - * @param _delegator Address of the delegator - * @param _indexer Address of the indexer to delegate tokens to - * @param _tokens Amount of tokens to delegate - * @return Amount of shares issued of the delegation pool - */ - function _delegate( - address _delegator, - address _indexer, - uint256 _tokens - ) private returns (uint256) { - // Only delegate a non-zero amount of tokens - require(_tokens > 0, "!tokens"); - // Only delegate to non-empty address - require(_indexer != address(0), "!indexer"); - // Only delegate to staked indexer - require(stakes[_indexer].tokensStaked > 0, "!stake"); - - // Get the delegation pool of the indexer - DelegationPool storage pool = delegationPools[_indexer]; - Delegation storage delegation = pool.delegators[_delegator]; - - // Collect delegation tax - uint256 delegationTax = _collectTax(graphToken(), _tokens, delegationTaxPercentage); - uint256 delegatedTokens = _tokens.sub(delegationTax); - - // Calculate shares to issue - uint256 shares = (pool.tokens == 0) - ? delegatedTokens - : delegatedTokens.mul(pool.shares).div(pool.tokens); - require(shares > 0, "!shares"); - - // Update the delegation pool - pool.tokens = pool.tokens.add(delegatedTokens); - pool.shares = pool.shares.add(shares); - - // Update the individual delegation - delegation.shares = delegation.shares.add(shares); - - emit StakeDelegated(_indexer, _delegator, delegatedTokens, shares); - - return shares; - } - - /** - * @dev Undelegate tokens from an indexer. - * @param _delegator Address of the delegator - * @param _indexer Address of the indexer where tokens had been delegated - * @param _shares Amount of shares to return and undelegate tokens - * @return Amount of tokens returned for the shares of the delegation pool - */ - function _undelegate( - address _delegator, - address _indexer, - uint256 _shares - ) private returns (uint256) { - // Can only undelegate a non-zero amount of shares - require(_shares > 0, "!shares"); - - // Get the delegation pool of the indexer - DelegationPool storage pool = delegationPools[_indexer]; - Delegation storage delegation = pool.delegators[_delegator]; - - // Delegator need to have enough shares in the pool to undelegate - require(delegation.shares >= _shares, "!shares-avail"); - - // Withdraw tokens if available - if (getWithdraweableDelegatedTokens(delegation) > 0) { - _withdrawDelegated(_delegator, _indexer, address(0)); - } - - // Calculate tokens to get in exchange for the shares - uint256 tokens = _shares.mul(pool.tokens).div(pool.shares); - - // Update the delegation pool - pool.tokens = pool.tokens.sub(tokens); - pool.shares = pool.shares.sub(_shares); - - // Update the delegation - delegation.shares = delegation.shares.sub(_shares); - delegation.tokensLocked = delegation.tokensLocked.add(tokens); - delegation.tokensLockedUntil = epochManager().currentEpoch().add(delegationUnbondingPeriod); - - emit StakeDelegatedLocked( - _indexer, - _delegator, - tokens, - _shares, - delegation.tokensLockedUntil - ); - - return tokens; - } - - /** - * @dev Withdraw delegated tokens once the unbonding period has passed. - * @param _delegator Delegator that is withdrawing tokens - * @param _indexer Withdraw available tokens delegated to indexer - * @param _delegateToIndexer Re-delegate to indexer address if non-zero, withdraw if zero address - */ - function _withdrawDelegated( - address _delegator, - address _indexer, - address _delegateToIndexer - ) private returns (uint256) { - // Get the delegation pool of the indexer - DelegationPool storage pool = delegationPools[_indexer]; - Delegation storage delegation = pool.delegators[_delegator]; - - // Validation - uint256 tokensToWithdraw = getWithdraweableDelegatedTokens(delegation); - require(tokensToWithdraw > 0, "!tokens"); - - // Reset lock - delegation.tokensLocked = 0; - delegation.tokensLockedUntil = 0; - - emit StakeDelegatedWithdrawn(_indexer, _delegator, tokensToWithdraw); - - // -- Interactions -- - - if (_delegateToIndexer != address(0)) { - // Re-delegate tokens to a new indexer - _delegate(_delegator, _delegateToIndexer, tokensToWithdraw); - } else { - // Return tokens to the delegator - TokenUtils.pushTokens(graphToken(), _delegator, tokensToWithdraw); - } - - return tokensToWithdraw; - } - /** * @dev Collect the delegation rewards for query fees. * This function will assign the collected fees to the delegation pool. @@ -1452,7 +965,7 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { returns (uint256) { uint256 delegationRewards = 0; - DelegationPool storage pool = delegationPools[_indexer]; + DelegationPool storage pool = __delegationPools[_indexer]; if (pool.tokens > 0 && pool.queryFeeCut < MAX_PPM) { uint256 indexerCut = uint256(pool.queryFeeCut).mul(_tokens).div(MAX_PPM); delegationRewards = _tokens.sub(indexerCut); @@ -1473,7 +986,7 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { returns (uint256) { uint256 delegationRewards = 0; - DelegationPool storage pool = delegationPools[_indexer]; + DelegationPool storage pool = __delegationPools[_indexer]; if (pool.tokens > 0 && pool.indexingRewardCut < MAX_PPM) { uint256 indexerCut = uint256(pool.indexingRewardCut).mul(_tokens).div(MAX_PPM); delegationRewards = _tokens.sub(indexerCut); @@ -1535,52 +1048,10 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { return tax; } - /** - * @dev Return the current state of an allocation - * @param _allocationID Allocation identifier - * @return AllocationState - */ - function _getAllocationState(address _allocationID) private view returns (AllocationState) { - Allocation storage alloc = allocations[_allocationID]; - - if (alloc.indexer == address(0)) { - return AllocationState.Null; - } - if (alloc.createdAtEpoch == 0) { - return AllocationState.Claimed; - } - - uint256 closedAtEpoch = alloc.closedAtEpoch; - if (closedAtEpoch == 0) { - return AllocationState.Active; - } - - uint256 epochs = epochManager().epochsSince(closedAtEpoch); - if (epochs >= channelDisputeEpochs) { - return AllocationState.Finalized; - } - return AllocationState.Closed; - } - - /** - * @dev Get the effective stake allocation considering epochs from allocation to closing. - * @param _maxAllocationEpochs Max amount of epochs to cap the allocated stake - * @param _tokens Amount of tokens allocated - * @param _numEpochs Number of epochs that passed from allocation to closing - * @return Effective allocated tokens across epochs - */ - function _getEffectiveAllocation( - uint256 _maxAllocationEpochs, - uint256 _tokens, - uint256 _numEpochs - ) private pure returns (uint256) { - bool shouldCap = _maxAllocationEpochs > 0 && _numEpochs > _maxAllocationEpochs; - return _tokens.mul((shouldCap) ? _maxAllocationEpochs : _numEpochs); - } - /** * @dev Triggers an update of rewards due to a change in allocations. * @param _subgraphDeploymentID Subgraph deployment updated + * @return Accumulated rewards per allocated token for the subgraph deployment */ function _updateRewards(bytes32 _subgraphDeploymentID) private returns (uint256) { IRewardsManager rewardsManager = rewardsManager(); @@ -1593,6 +1064,7 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { /** * @dev Assign rewards for the closed allocation to indexer and delegators. * @param _allocationID Allocation + * @param _indexer Address of the indexer that did the allocation */ function _distributeRewards(address _allocationID, address _indexer) private { IRewardsManager rewardsManager = rewardsManager(); @@ -1617,7 +1089,7 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { graphToken(), indexerRewards, _indexer, - rewardsDestination[_indexer] == address(0) + __rewardsDestination[_indexer] == address(0) ); } @@ -1641,7 +1113,7 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { _stake(_beneficiary, _amount); } else { // Transfer funds to the beneficiary's designated rewards destination if set - address destination = rewardsDestination[_beneficiary]; + address destination = __rewardsDestination[_beneficiary]; TokenUtils.pushTokens( _graphToken, destination == address(0) ? _beneficiary : destination, @@ -1649,4 +1121,57 @@ contract Staking is StakingV2Storage, GraphUpgradeable, IStaking, Multicall { ); } } + + /** + * @dev Check if the caller is authorized to operate on behalf of + * an indexer (i.e. the caller is the indexer or an operator) + * @param _indexer Indexer address + * @return True if the caller is authorized to operate on behalf of the indexer + */ + function _isAuth(address _indexer) private view returns (bool) { + return msg.sender == _indexer || isOperator(msg.sender, _indexer) == true; + } + + /** + * @dev Return the current state of an allocation + * @param _allocationID Allocation identifier + * @return AllocationState enum with the state of the allocation + */ + function _getAllocationState(address _allocationID) private view returns (AllocationState) { + Allocation storage alloc = __allocations[_allocationID]; + + if (alloc.indexer == address(0)) { + return AllocationState.Null; + } + if (alloc.createdAtEpoch == 0) { + return AllocationState.Claimed; + } + + uint256 closedAtEpoch = alloc.closedAtEpoch; + if (closedAtEpoch == 0) { + return AllocationState.Active; + } + + uint256 epochs = epochManager().epochsSince(closedAtEpoch); + if (epochs >= __channelDisputeEpochs) { + return AllocationState.Finalized; + } + return AllocationState.Closed; + } + + /** + * @dev Get the effective stake allocation considering epochs from allocation to closing. + * @param _maxAllocationEpochs Max amount of epochs to cap the allocated stake + * @param _tokens Amount of tokens allocated + * @param _numEpochs Number of epochs that passed from allocation to closing + * @return Effective allocated tokens across epochs + */ + function _getEffectiveAllocation( + uint256 _maxAllocationEpochs, + uint256 _tokens, + uint256 _numEpochs + ) private pure returns (uint256) { + bool shouldCap = _maxAllocationEpochs > 0 && _numEpochs > _maxAllocationEpochs; + return _tokens.mul((shouldCap) ? _maxAllocationEpochs : _numEpochs); + } } diff --git a/contracts/staking/StakingExtension.sol b/contracts/staking/StakingExtension.sol new file mode 100644 index 000000000..8b0657f19 --- /dev/null +++ b/contracts/staking/StakingExtension.sol @@ -0,0 +1,689 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; +pragma abicoder v2; + +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; +import { StakingV3Storage } from "./StakingStorage.sol"; +import { IStakingExtension } from "./IStakingExtension.sol"; +import { TokenUtils } from "../utils/TokenUtils.sol"; +import { IGraphToken } from "../token/IGraphToken.sol"; +import { GraphUpgradeable } from "../upgrades/GraphUpgradeable.sol"; +import { Rebates } from "./libs/Rebates.sol"; +import { Stakes } from "./libs/Stakes.sol"; +import { IStakingData } from "./IStakingData.sol"; +import { MathUtils } from "./libs/MathUtils.sol"; + +/** + * @title StakingExtension contract + * @dev This contract provides the logic to manage delegations and other Staking + * extension features (e.g. storage getters). It is meant to be called through delegatecall from the + * Staking contract, and is only kept separate to keep the Staking contract size + * within limits. + */ +contract StakingExtension is StakingV3Storage, GraphUpgradeable, IStakingExtension { + using SafeMath for uint256; + using Stakes for Stakes.Indexer; + + /// @dev 100% in parts per million + uint32 private constant MAX_PPM = 1000000; + + /** + * @dev Check if the caller is the slasher. + */ + modifier onlySlasher() { + require(__slashers[msg.sender] == true, "!slasher"); + _; + } + + /** + * @notice Initialize the StakingExtension contract + * @dev This function is meant to be delegatecalled from the Staking contract's + * initialize() function, so it uses the same access control check to ensure it is + * being called by the Staking implementation as part of the proxy upgrade process. + * @param _delegationUnbondingPeriod Delegation unbonding period in blocks + * @param _cooldownBlocks Minimum time between changes to delegation parameters, in blocks + * @param _delegationRatio Delegation capacity multiplier (e.g. 10 means 10x the indexer stake) + * @param _delegationTaxPercentage Percentage of delegated tokens to burn as delegation tax, expressed in parts per million + */ + function initialize( + uint32 _delegationUnbondingPeriod, + uint32 _cooldownBlocks, + uint32 _delegationRatio, + uint32 _delegationTaxPercentage + ) external onlyImpl { + _setDelegationUnbondingPeriod(_delegationUnbondingPeriod); + _setDelegationParametersCooldown(_cooldownBlocks); + _setDelegationRatio(_delegationRatio); + _setDelegationTaxPercentage(_delegationTaxPercentage); + } + + /** + * @notice Set a delegation tax percentage to burn when delegated funds are deposited. + * @dev This function is only callable by the governor + * @param _percentage Percentage of delegated tokens to burn as delegation tax, expressed in parts per million + */ + function setDelegationTaxPercentage(uint32 _percentage) external override onlyGovernor { + _setDelegationTaxPercentage(_percentage); + } + + /** + * @notice Set the delegation ratio. + * If set to 10 it means the indexer can use up to 10x the indexer staked amount + * from their delegated tokens + * @dev This function is only callable by the governor + * @param _delegationRatio Delegation capacity multiplier + */ + function setDelegationRatio(uint32 _delegationRatio) external override onlyGovernor { + _setDelegationRatio(_delegationRatio); + } + + /** + * @notice Set the minimum time in blocks an indexer needs to wait to change delegation parameters. + * Indexers can set a custom amount time for their own cooldown, but it must be greater than this. + * @dev This function is only callable by the governor + * @param _blocks Number of blocks to set the delegation parameters cooldown period + */ + function setDelegationParametersCooldown(uint32 _blocks) external override onlyGovernor { + _setDelegationParametersCooldown(_blocks); + } + + /** + * @notice Set the time, in epochs, a Delegator needs to wait to withdraw tokens after undelegating. + * @dev This function is only callable by the governor + * @param _delegationUnbondingPeriod Period in epochs to wait for token withdrawals after undelegating + */ + function setDelegationUnbondingPeriod(uint32 _delegationUnbondingPeriod) + external + override + onlyGovernor + { + _setDelegationUnbondingPeriod(_delegationUnbondingPeriod); + } + + /** + * @notice Set or unset an address as allowed slasher. + * @param _slasher Address of the party allowed to slash indexers + * @param _allowed True if slasher is allowed + */ + function setSlasher(address _slasher, bool _allowed) external override onlyGovernor { + require(_slasher != address(0), "!slasher"); + __slashers[_slasher] = _allowed; + emit SlasherUpdate(msg.sender, _slasher, _allowed); + } + + /** + * @notice Delegate tokens to an indexer. + * @param _indexer Address of the indexer to which tokens are delegated + * @param _tokens Amount of tokens to delegate + * @return Amount of shares issued from the delegation pool + */ + function delegate(address _indexer, uint256 _tokens) + external + override + notPartialPaused + returns (uint256) + { + address delegator = msg.sender; + + // Transfer tokens to delegate to this contract + TokenUtils.pullTokens(graphToken(), delegator, _tokens); + + // Update state + return _delegate(delegator, _indexer, _tokens); + } + + /** + * @notice Undelegate tokens from an indexer. Tokens will be locked for the unbonding period. + * @param _indexer Address of the indexer to which tokens had been delegated + * @param _shares Amount of shares to return and undelegate tokens + * @return Amount of tokens returned for the shares of the delegation pool + */ + function undelegate(address _indexer, uint256 _shares) + external + override + notPartialPaused + returns (uint256) + { + return _undelegate(msg.sender, _indexer, _shares); + } + + /** + * @notice Withdraw undelegated tokens once the unbonding period has passed, and optionally + * re-delegate to a new indexer. + * @param _indexer Withdraw available tokens delegated to indexer + * @param _newIndexer Re-delegate to indexer address if non-zero, withdraw if zero address + */ + function withdrawDelegated(address _indexer, address _newIndexer) + external + override + notPaused + returns (uint256) + { + return _withdrawDelegated(msg.sender, _indexer, _newIndexer); + } + + /** + * @notice Slash the indexer stake. Delegated tokens are not subject to slashing. + * @dev Can only be called by the slasher role. + * @param _indexer Address of indexer to slash + * @param _tokens Amount of tokens to slash from the indexer stake + * @param _reward Amount of reward tokens to send to a beneficiary + * @param _beneficiary Address of a beneficiary to receive a reward for the slashing + */ + function slash( + address _indexer, + uint256 _tokens, + uint256 _reward, + address _beneficiary + ) external override onlySlasher notPartialPaused { + Stakes.Indexer storage indexerStake = __stakes[_indexer]; + + // Only able to slash a non-zero number of tokens + require(_tokens > 0, "!tokens"); + + // Rewards comes from tokens slashed balance + require(_tokens >= _reward, "rewards>slash"); + + // Cannot slash stake of an indexer without any or enough stake + require(indexerStake.tokensStaked > 0, "!stake"); + require(_tokens <= indexerStake.tokensStaked, "slash>stake"); + + // Validate beneficiary of slashed tokens + require(_beneficiary != address(0), "!beneficiary"); + + // Slashing more tokens than freely available (over allocation condition) + // Unlock locked tokens to avoid the indexer to withdraw them + if (_tokens > indexerStake.tokensAvailable() && indexerStake.tokensLocked > 0) { + uint256 tokensOverAllocated = _tokens.sub(indexerStake.tokensAvailable()); + uint256 tokensToUnlock = MathUtils.min(tokensOverAllocated, indexerStake.tokensLocked); + indexerStake.unlockTokens(tokensToUnlock); + } + + // Remove tokens to slash from the stake + indexerStake.release(_tokens); + + // -- Interactions -- + + IGraphToken graphToken = graphToken(); + + // Set apart the reward for the beneficiary and burn remaining slashed stake + TokenUtils.burnTokens(graphToken, _tokens.sub(_reward)); + + // Give the beneficiary a reward for slashing + TokenUtils.pushTokens(graphToken, _beneficiary, _reward); + + emit StakeSlashed(_indexer, _tokens, _reward, _beneficiary); + } + + /** + * @notice Return the delegation from a delegator to an indexer. + * @param _indexer Address of the indexer where funds have been delegated + * @param _delegator Address of the delegator + * @return Delegation data + */ + function getDelegation(address _indexer, address _delegator) + external + view + override + returns (Delegation memory) + { + return __delegationPools[_indexer].delegators[_delegator]; + } + + /** + * @notice Getter for the delegationRatio, i.e. the delegation capacity multiplier: + * If delegation ratio is 100, and an Indexer has staked 5 GRT, + * then they can use up to 500 GRT from the delegated stake + * @return Delegation ratio + */ + function delegationRatio() external view override returns (uint32) { + return __delegationRatio; + } + + /** + * @notice Getter for delegationParametersCooldown: + * Minimum time in blocks an indexer needs to wait to change delegation parameters + * @return Delegation parameters cooldown in blocks + */ + function delegationParametersCooldown() external view override returns (uint32) { + return __delegationParametersCooldown; + } + + /** + * @notice Getter for delegationUnbondingPeriod: + * Time in epochs a delegator needs to wait to withdraw delegated stake + * @return Delegation unbonding period in epochs + */ + function delegationUnbondingPeriod() external view override returns (uint32) { + return __delegationUnbondingPeriod; + } + + /** + * @notice Getter for delegationTaxPercentage: + * Percentage of tokens to tax a delegation deposit, expressed in parts per million + * @return Delegation tax percentage in parts per million + */ + function delegationTaxPercentage() external view override returns (uint32) { + return __delegationTaxPercentage; + } + + /** + * @notice Getter for delegationPools[_indexer]: + * gets the delegation pool structure for a particular indexer. + * @param _indexer Address of the indexer for which to query the delegation pool + * @return Delegation pool as a DelegationPoolReturn struct + */ + function delegationPools(address _indexer) + external + view + override + returns (DelegationPoolReturn memory) + { + DelegationPool storage pool = __delegationPools[_indexer]; + return + DelegationPoolReturn( + pool.cooldownBlocks, // Blocks to wait before updating parameters + pool.indexingRewardCut, // in PPM + pool.queryFeeCut, // in PPM + pool.updatedAtBlock, // Block when the pool was last updated + pool.tokens, // Total tokens as pool reserves + pool.shares // Total shares minted in the pool + ); + } + + /** + * @notice Getter for rewardsDestination[_indexer]: + * returns the address where the indexer's rewards are sent. + * @param _indexer The indexer address for which to query the rewards destination + * @return The address where the indexer's rewards are sent, zero if none is set in which case rewards are re-staked + */ + function rewardsDestination(address _indexer) external view override returns (address) { + return __rewardsDestination[_indexer]; + } + + /** + * @notice Getter for assetHolders[_maybeAssetHolder]: + * returns true if the address is an asset holder, i.e. an entity that can collect + * query fees into the Staking contract. + * @param _maybeAssetHolder The address that may or may not be an asset holder + * @return True if the address is an asset holder + */ + function assetHolders(address _maybeAssetHolder) external view override returns (bool) { + return __assetHolders[_maybeAssetHolder]; + } + + /** + * @notice Getter for operatorAuth[_indexer][_maybeOperator]: + * returns true if the operator is authorized to operate on behalf of the indexer. + * @param _indexer The indexer address for which to query authorization + * @param _maybeOperator The address that may or may not be an operator + * @return True if the operator is authorized to operate on behalf of the indexer + */ + function operatorAuth(address _indexer, address _maybeOperator) + external + view + override + returns (bool) + { + return __operatorAuth[_indexer][_maybeOperator]; + } + + /** + * @notice Getter for subgraphAllocations[_subgraphDeploymentId]: + * returns the amount of tokens allocated to a subgraph deployment. + * @param _subgraphDeploymentId The subgraph deployment for which to query the allocations + * @return The amount of tokens allocated to the subgraph deployment + */ + function subgraphAllocations(bytes32 _subgraphDeploymentId) + external + view + override + returns (uint256) + { + return __subgraphAllocations[_subgraphDeploymentId]; + } + + /** + * @notice Getter for rebates[_epoch]: + * gets the rebate pool for a particular epoch. + * @param _epoch Epoch for which to query the rebate pool + * @return Rebate pool for the specified epoch, as a Rebates.Pool struct + */ + function rebates(uint256 _epoch) external view override returns (Rebates.Pool memory) { + return __rebates[_epoch]; + } + + /** + * @notice Getter for slashers[_maybeSlasher]: + * returns true if the address is a slasher, i.e. an entity that can slash indexers + * @param _maybeSlasher Address for which to check the slasher role + * @return True if the address is a slasher + */ + function slashers(address _maybeSlasher) external view override returns (bool) { + return __slashers[_maybeSlasher]; + } + + /** + * @notice Getter for minimumIndexerStake: the minimum + * amount of GRT that an indexer needs to stake. + * @return Minimum indexer stake in GRT + */ + function minimumIndexerStake() external view override returns (uint256) { + return __minimumIndexerStake; + } + + /** + * @notice Getter for thawingPeriod: the time in blocks an + * indexer needs to wait to unstake tokens. + * @return Thawing period in blocks + */ + function thawingPeriod() external view override returns (uint32) { + return __thawingPeriod; + } + + /** + * @notice Getter for curationPercentage: the percentage of + * query fees that are distributed to curators. + * @return Curation percentage in parts per million + */ + function curationPercentage() external view override returns (uint32) { + return __curationPercentage; + } + + /** + * @notice Getter for protocolPercentage: the percentage of + * query fees that are burned as protocol fees. + * @return Protocol percentage in parts per million + */ + function protocolPercentage() external view override returns (uint32) { + return __protocolPercentage; + } + + /** + * @notice Getter for channelDisputeEpochs: the time in epochs + * between closing an allocation and the moment it becomes finalized so + * query fees can be claimed. + * @return Channel dispute period in epochs + */ + function channelDisputeEpochs() external view override returns (uint32) { + return __channelDisputeEpochs; + } + + /** + * @notice Getter for maxAllocationEpochs: the maximum time in epochs + * that an allocation can be open before anyone is allowed to close it. This + * also caps the effective allocation when sending the allocation's query fees + * to the rebate pool. + * @return Maximum allocation period in epochs + */ + function maxAllocationEpochs() external view override returns (uint32) { + return __maxAllocationEpochs; + } + + /** + * @notice Getter for alphaNumerator: the numerator of the Cobb-Douglas + * rebate ratio. + * @return Rebate ratio numerator + */ + function alphaNumerator() external view override returns (uint32) { + return __alphaNumerator; + } + + /** + * @notice Getter for alphaDenominator: the denominator of the Cobb-Douglas + * rebate ratio. + * @return Rebate ratio denominator + */ + function alphaDenominator() external view override returns (uint32) { + return __alphaDenominator; + } + + /** + * @notice Getter for stakes[_indexer]: + * gets the stake information for an indexer as a Stakes.Indexer struct. + * @param _indexer Indexer address for which to query the stake information + * @return Stake information for the specified indexer, as a Stakes.Indexer struct + */ + function stakes(address _indexer) external view override returns (Stakes.Indexer memory) { + return __stakes[_indexer]; + } + + /** + * @notice Getter for allocations[_allocationID]: + * gets an allocation's information as an IStakingData.Allocation struct. + * @param _allocationID Allocation ID for which to query the allocation information + * @return The specified allocation, as an IStakingData.Allocation struct + */ + function allocations(address _allocationID) + external + view + override + returns (IStakingData.Allocation memory) + { + return __allocations[_allocationID]; + } + + /** + * @notice Return whether the delegator has delegated to the indexer. + * @param _indexer Address of the indexer where funds have been delegated + * @param _delegator Address of the delegator + * @return True if delegator has tokens delegated to the indexer + */ + function isDelegator(address _indexer, address _delegator) public view override returns (bool) { + return __delegationPools[_indexer].delegators[_delegator].shares > 0; + } + + /** + * @notice Returns amount of delegated tokens ready to be withdrawn after unbonding period. + * @param _delegation Delegation of tokens from delegator to indexer + * @return Amount of tokens to withdraw + */ + function getWithdraweableDelegatedTokens(Delegation memory _delegation) + public + view + override + returns (uint256) + { + // There must be locked tokens and period passed + uint256 currentEpoch = epochManager().currentEpoch(); + if (_delegation.tokensLockedUntil > 0 && currentEpoch >= _delegation.tokensLockedUntil) { + return _delegation.tokensLocked; + } + return 0; + } + + /** + * @dev Internal: Set a delegation tax percentage to burn when delegated funds are deposited. + * @param _percentage Percentage of delegated tokens to burn as delegation tax + */ + function _setDelegationTaxPercentage(uint32 _percentage) private { + // Must be within 0% to 100% (inclusive) + require(_percentage <= MAX_PPM, ">percentage"); + __delegationTaxPercentage = _percentage; + emit ParameterUpdated("delegationTaxPercentage"); + } + + /** + * @dev Internal: Set the delegation ratio. + * If set to 10 it means the indexer can use up to 10x the indexer staked amount + * from their delegated tokens + * @param _delegationRatio Delegation capacity multiplier + */ + function _setDelegationRatio(uint32 _delegationRatio) private { + __delegationRatio = _delegationRatio; + emit ParameterUpdated("delegationRatio"); + } + + /** + * @dev Internal: Set the time in blocks an indexer needs to wait to change delegation parameters. + * @param _blocks Number of blocks to set the delegation parameters cooldown period + */ + function _setDelegationParametersCooldown(uint32 _blocks) private { + __delegationParametersCooldown = _blocks; + emit ParameterUpdated("delegationParametersCooldown"); + } + + /** + * @dev Internal: Set the period for undelegation of stake from indexer. + * @param _delegationUnbondingPeriod Period in epochs to wait for token withdrawals after undelegating + */ + function _setDelegationUnbondingPeriod(uint32 _delegationUnbondingPeriod) private { + require(_delegationUnbondingPeriod > 0, "!delegationUnbondingPeriod"); + __delegationUnbondingPeriod = _delegationUnbondingPeriod; + emit ParameterUpdated("delegationUnbondingPeriod"); + } + + /** + * @dev Delegate tokens to an indexer. + * @param _delegator Address of the delegator + * @param _indexer Address of the indexer to delegate tokens to + * @param _tokens Amount of tokens to delegate + * @return Amount of shares issued of the delegation pool + */ + function _delegate( + address _delegator, + address _indexer, + uint256 _tokens + ) private returns (uint256) { + // Only delegate a non-zero amount of tokens + require(_tokens > 0, "!tokens"); + // Only delegate to non-empty address + require(_indexer != address(0), "!indexer"); + // Only delegate to staked indexer + require(__stakes[_indexer].tokensStaked > 0, "!stake"); + + // Get the delegation pool of the indexer + DelegationPool storage pool = __delegationPools[_indexer]; + Delegation storage delegation = pool.delegators[_delegator]; + + // Collect delegation tax + uint256 delegationTax = _collectTax(graphToken(), _tokens, __delegationTaxPercentage); + uint256 delegatedTokens = _tokens.sub(delegationTax); + + // Calculate shares to issue + uint256 shares = (pool.tokens == 0) + ? delegatedTokens + : delegatedTokens.mul(pool.shares).div(pool.tokens); + require(shares > 0, "!shares"); + + // Update the delegation pool + pool.tokens = pool.tokens.add(delegatedTokens); + pool.shares = pool.shares.add(shares); + + // Update the individual delegation + delegation.shares = delegation.shares.add(shares); + + emit StakeDelegated(_indexer, _delegator, delegatedTokens, shares); + + return shares; + } + + /** + * @dev Undelegate tokens from an indexer. + * @param _delegator Address of the delegator + * @param _indexer Address of the indexer where tokens had been delegated + * @param _shares Amount of shares to return and undelegate tokens + * @return Amount of tokens returned for the shares of the delegation pool + */ + function _undelegate( + address _delegator, + address _indexer, + uint256 _shares + ) private returns (uint256) { + // Can only undelegate a non-zero amount of shares + require(_shares > 0, "!shares"); + + // Get the delegation pool of the indexer + DelegationPool storage pool = __delegationPools[_indexer]; + Delegation storage delegation = pool.delegators[_delegator]; + + // Delegator need to have enough shares in the pool to undelegate + require(delegation.shares >= _shares, "!shares-avail"); + + // Withdraw tokens if available + if (getWithdraweableDelegatedTokens(delegation) > 0) { + _withdrawDelegated(_delegator, _indexer, address(0)); + } + + // Calculate tokens to get in exchange for the shares + uint256 tokens = _shares.mul(pool.tokens).div(pool.shares); + + // Update the delegation pool + pool.tokens = pool.tokens.sub(tokens); + pool.shares = pool.shares.sub(_shares); + + // Update the delegation + delegation.shares = delegation.shares.sub(_shares); + delegation.tokensLocked = delegation.tokensLocked.add(tokens); + delegation.tokensLockedUntil = epochManager().currentEpoch().add( + __delegationUnbondingPeriod + ); + + emit StakeDelegatedLocked( + _indexer, + _delegator, + tokens, + _shares, + delegation.tokensLockedUntil + ); + + return tokens; + } + + /** + * @dev Withdraw delegated tokens once the unbonding period has passed. + * @param _delegator Delegator that is withdrawing tokens + * @param _indexer Withdraw available tokens delegated to indexer + * @param _delegateToIndexer Re-delegate to indexer address if non-zero, withdraw if zero address + * @return Amount of tokens withdrawn or re-delegated + */ + function _withdrawDelegated( + address _delegator, + address _indexer, + address _delegateToIndexer + ) private returns (uint256) { + // Get the delegation pool of the indexer + DelegationPool storage pool = __delegationPools[_indexer]; + Delegation storage delegation = pool.delegators[_delegator]; + + // Validation + uint256 tokensToWithdraw = getWithdraweableDelegatedTokens(delegation); + require(tokensToWithdraw > 0, "!tokens"); + + // Reset lock + delegation.tokensLocked = 0; + delegation.tokensLockedUntil = 0; + + emit StakeDelegatedWithdrawn(_indexer, _delegator, tokensToWithdraw); + + // -- Interactions -- + + if (_delegateToIndexer != address(0)) { + // Re-delegate tokens to a new indexer + _delegate(_delegator, _delegateToIndexer, tokensToWithdraw); + } else { + // Return tokens to the delegator + TokenUtils.pushTokens(graphToken(), _delegator, tokensToWithdraw); + } + + return tokensToWithdraw; + } + + /** + * @dev Collect tax to burn for an amount of tokens. + * @param _graphToken Token to burn + * @param _tokens Total tokens received used to calculate the amount of tax to collect + * @param _percentage Percentage of tokens to burn as tax + * @return Amount of tax charged + */ + function _collectTax( + IGraphToken _graphToken, + uint256 _tokens, + uint256 _percentage + ) private returns (uint256) { + uint256 tax = uint256(_percentage).mul(_tokens).div(MAX_PPM); + TokenUtils.burnTokens(_graphToken, tax); // Burn tax if any + return tax; + } +} diff --git a/contracts/staking/StakingStorage.sol b/contracts/staking/StakingStorage.sol index d629cf8a8..e95356992 100644 --- a/contracts/staking/StakingStorage.sol +++ b/contracts/staking/StakingStorage.sol @@ -2,88 +2,119 @@ pragma solidity ^0.7.6; -import "../governance/Managed.sol"; - -import "./IStakingData.sol"; -import "./libs/Rebates.sol"; -import "./libs/Stakes.sol"; - +import { Managed } from "../governance/Managed.sol"; + +import { IStakingData } from "./IStakingData.sol"; +import { Rebates } from "./libs/Rebates.sol"; +import { Stakes } from "./libs/Stakes.sol"; + +/** + * @title StakingV1Storage + * @notice This contract holds all the storage variables for the Staking contract, version 1 + * @dev Note that we use a double underscore prefix for variable names; this prefix identifies + * variables that used to be public but are now internal, getters can be found on StakingExtension.sol. + */ +// solhint-disable-next-line max-states-count contract StakingV1Storage is Managed { // -- Staking -- - // Minimum amount of tokens an indexer needs to stake - uint256 public minimumIndexerStake; + /// @dev Minimum amount of tokens an indexer needs to stake + uint256 internal __minimumIndexerStake; + + /// @dev Time in blocks to unstake + uint32 internal __thawingPeriod; // in blocks - // Time in blocks to unstake - uint32 public thawingPeriod; // in blocks + /// @dev Percentage of fees going to curators + /// Parts per million. (Allows for 4 decimal points, 999,999 = 99.9999%) + uint32 internal __curationPercentage; - // Percentage of fees going to curators - // Parts per million. (Allows for 4 decimal points, 999,999 = 99.9999%) - uint32 public curationPercentage; + /// @dev Percentage of fees burned as protocol fee + /// Parts per million. (Allows for 4 decimal points, 999,999 = 99.9999%) + uint32 internal __protocolPercentage; - // Percentage of fees burned as protocol fee - // Parts per million. (Allows for 4 decimal points, 999,999 = 99.9999%) - uint32 public protocolPercentage; + /// @dev Period for allocation to be finalized + uint32 internal __channelDisputeEpochs; - // Period for allocation to be finalized - uint32 public channelDisputeEpochs; + /// @dev Maximum allocation time + uint32 internal __maxAllocationEpochs; - // Maximum allocation time - uint32 public maxAllocationEpochs; + /// @dev Rebate ratio numerator + uint32 internal __alphaNumerator; - // Rebate ratio - uint32 public alphaNumerator; - uint32 public alphaDenominator; + /// @dev Rebate ratio denominator + uint32 internal __alphaDenominator; - // Indexer stakes : indexer => Stake - mapping(address => Stakes.Indexer) public stakes; + /// @dev Indexer stakes : indexer => Stake + mapping(address => Stakes.Indexer) internal __stakes; - // Allocations : allocationID => Allocation - mapping(address => IStakingData.Allocation) public allocations; + /// @dev Allocations : allocationID => Allocation + mapping(address => IStakingData.Allocation) internal __allocations; - // Subgraph Allocations: subgraphDeploymentID => tokens - mapping(bytes32 => uint256) public subgraphAllocations; + /// @dev Subgraph Allocations: subgraphDeploymentID => tokens + mapping(bytes32 => uint256) internal __subgraphAllocations; - // Rebate pools : epoch => Pool - mapping(uint256 => Rebates.Pool) public rebates; + /// @dev Rebate pools : epoch => Pool + mapping(uint256 => Rebates.Pool) internal __rebates; // -- Slashing -- - // List of addresses allowed to slash stakes - mapping(address => bool) public slashers; + /// @dev List of addresses allowed to slash stakes + mapping(address => bool) internal __slashers; // -- Delegation -- - // Set the delegation capacity multiplier defined by the delegation ratio - // If delegation ratio is 100, and an Indexer has staked 5 GRT, - // then they can use up to 500 GRT from the delegated stake - uint32 public delegationRatio; + /// @dev Set the delegation capacity multiplier defined by the delegation ratio + /// If delegation ratio is 100, and an Indexer has staked 5 GRT, + /// then they can use up to 500 GRT from the delegated stake + uint32 internal __delegationRatio; - // Time in blocks an indexer needs to wait to change delegation parameters - uint32 public delegationParametersCooldown; + /// @dev Time in blocks an indexer needs to wait to change delegation parameters + uint32 internal __delegationParametersCooldown; - // Time in epochs a delegator needs to wait to withdraw delegated stake - uint32 public delegationUnbondingPeriod; // in epochs + /// @dev Time in epochs a delegator needs to wait to withdraw delegated stake + uint32 internal __delegationUnbondingPeriod; // in epochs - // Percentage of tokens to tax a delegation deposit - // Parts per million. (Allows for 4 decimal points, 999,999 = 99.9999%) - uint32 public delegationTaxPercentage; + /// @dev Percentage of tokens to tax a delegation deposit + /// Parts per million. (Allows for 4 decimal points, 999,999 = 99.9999%) + uint32 internal __delegationTaxPercentage; - // Delegation pools : indexer => DelegationPool - mapping(address => IStakingData.DelegationPool) public delegationPools; + /// @dev Delegation pools : indexer => DelegationPool + mapping(address => IStakingData.DelegationPool) internal __delegationPools; // -- Operators -- - // Operator auth : indexer => operator - mapping(address => mapping(address => bool)) public operatorAuth; + /// @dev Operator auth : indexer => operator => is authorized + mapping(address => mapping(address => bool)) internal __operatorAuth; // -- Asset Holders -- - // Allowed AssetHolders: assetHolder => is allowed - mapping(address => bool) public assetHolders; + /// @dev Allowed AssetHolders that can collect query fees: assetHolder => is allowed + mapping(address => bool) internal __assetHolders; } +/** + * @title StakingV2Storage + * @notice This contract holds all the storage variables for the Staking contract, version 2 + * @dev Note that we use a double underscore prefix for variable names; this prefix identifies + * variables that used to be public but are now internal, getters can be found on StakingExtension.sol. + */ contract StakingV2Storage is StakingV1Storage { - // Destination of accrued rewards : beneficiary => rewards destination - mapping(address => address) public rewardsDestination; + /// @dev Destination of accrued rewards : beneficiary => rewards destination + mapping(address => address) internal __rewardsDestination; +} + +/** + * @title StakingV3Storage + * @notice This contract holds all the storage variables for the base Staking contract, version 3. + * @dev Note that this is the first version that includes a storage gap - if adding + * future versions, make sure to move the gap to the new version and + * reduce the size of the gap accordingly. + */ +contract StakingV3Storage is StakingV2Storage { + /// @dev Address of the counterpart Staking contract on L1/L2 + address internal counterpartStakingAddress; + /// @dev Address of the StakingExtension implementation + address internal extensionImpl; + /// @dev Gap to allow adding variables in future upgrades (since L1Staking and L2Staking can have their own storage as well) + uint256[50] private __gap; } diff --git a/contracts/tests/L1GraphTokenLockTransferToolBadMock.sol b/contracts/tests/L1GraphTokenLockTransferToolBadMock.sol new file mode 100644 index 000000000..2dcb6e53a --- /dev/null +++ b/contracts/tests/L1GraphTokenLockTransferToolBadMock.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.7.6; +pragma experimental ABIEncoderV2; + +contract L1GraphTokenLockTransferToolBadMock { + mapping(address => address) public l2WalletAddress; + + function setL2WalletAddress(address _l1Address, address _l2Address) external { + l2WalletAddress[_l1Address] = _l2Address; + } + + // Sends 1 wei less than requested + function pullETH(address _l1Wallet, uint256 _amount) external { + require( + l2WalletAddress[_l1Wallet] != address(0), + "L1GraphTokenLockTransferToolMock: unknown L1 wallet" + ); + (bool success, ) = payable(msg.sender).call{ value: _amount - 1 }(""); + require(success, "L1GraphTokenLockTransferToolMock: ETH pull failed"); + } +} diff --git a/contracts/tests/L1GraphTokenLockTransferToolMock.sol b/contracts/tests/L1GraphTokenLockTransferToolMock.sol new file mode 100644 index 000000000..7a786beed --- /dev/null +++ b/contracts/tests/L1GraphTokenLockTransferToolMock.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.7.6; +pragma experimental ABIEncoderV2; + +contract L1GraphTokenLockTransferToolMock { + mapping(address => address) public l2WalletAddress; + + function setL2WalletAddress(address _l1Address, address _l2Address) external { + l2WalletAddress[_l1Address] = _l2Address; + } + + function pullETH(address _l1Wallet, uint256 _amount) external { + require( + l2WalletAddress[_l1Wallet] != address(0), + "L1GraphTokenLockTransferToolMock: unknown L1 wallet" + ); + (bool success, ) = payable(msg.sender).call{ value: _amount }(""); + require(success, "L1GraphTokenLockTransferToolMock: ETH pull failed"); + } +} diff --git a/e2e/deployment/config/controller.test.ts b/e2e/deployment/config/controller.test.ts index 647cb19f5..5bc4e6c04 100644 --- a/e2e/deployment/config/controller.test.ts +++ b/e2e/deployment/config/controller.test.ts @@ -13,7 +13,7 @@ describe('Controller configuration', () => { 'DisputeManager', 'EpochManager', 'RewardsManager', - 'Staking', + 'L1Staking', 'GraphToken', 'L1GraphTokenGateway', ] @@ -24,7 +24,7 @@ describe('Controller configuration', () => { 'DisputeManager', 'EpochManager', 'RewardsManager', - 'Staking', + 'L2Staking', 'L2GraphToken', 'L2GraphTokenGateway', ] diff --git a/e2e/deployment/config/staking.test.ts b/e2e/deployment/config/staking.test.ts index e2b1fe5e9..b5eb2c400 100644 --- a/e2e/deployment/config/staking.test.ts +++ b/e2e/deployment/config/staking.test.ts @@ -1,12 +1,20 @@ import { expect } from 'chai' import hre from 'hardhat' import { getItemValue } from '../../../cli/config' +import GraphChain from '../../../gre/helpers/chain' describe('Staking configuration', () => { const { graphConfig, contracts: { Staking, Controller, DisputeManager, AllocationExchange }, + chainId, } = hre.graph() + let contractName: string + if (GraphChain.isL2(chainId)) { + contractName = 'L2Staking' + } else { + contractName = 'L1Staking' + } it('should be controlled by Controller', async function () { const controller = await Staking.controller() @@ -25,61 +33,73 @@ describe('Staking configuration', () => { it('minimumIndexerStake should match "minimumIndexerStake" in the config file', async function () { const value = await Staking.minimumIndexerStake() - const expected = getItemValue(graphConfig, 'contracts/Staking/init/minimumIndexerStake') + const expected = getItemValue(graphConfig, `contracts/${contractName}/init/minimumIndexerStake`) expect(value).eq(expected) }) it('thawingPeriod should match "thawingPeriod" in the config file', async function () { const value = await Staking.thawingPeriod() - const expected = getItemValue(graphConfig, 'contracts/Staking/init/thawingPeriod') + const expected = getItemValue(graphConfig, `contracts/${contractName}/init/thawingPeriod`) expect(value).eq(expected) }) it('protocolPercentage should match "protocolPercentage" in the config file', async function () { const value = await Staking.protocolPercentage() - const expected = getItemValue(graphConfig, 'contracts/Staking/init/protocolPercentage') + const expected = getItemValue(graphConfig, `contracts/${contractName}/init/protocolPercentage`) expect(value).eq(expected) }) it('curationPercentage should match "curationPercentage" in the config file', async function () { const value = await Staking.curationPercentage() - const expected = getItemValue(graphConfig, 'contracts/Staking/init/curationPercentage') + const expected = getItemValue(graphConfig, `contracts/${contractName}/init/curationPercentage`) expect(value).eq(expected) }) it('channelDisputeEpochs should match "channelDisputeEpochs" in the config file', async function () { const value = await Staking.channelDisputeEpochs() - const expected = getItemValue(graphConfig, 'contracts/Staking/init/channelDisputeEpochs') + const expected = getItemValue( + graphConfig, + `contracts/${contractName}/init/channelDisputeEpochs`, + ) expect(value).eq(expected) }) it('maxAllocationEpochs should match "maxAllocationEpochs" in the config file', async function () { const value = await Staking.maxAllocationEpochs() - const expected = getItemValue(graphConfig, 'contracts/Staking/init/maxAllocationEpochs') + const expected = getItemValue(graphConfig, `contracts/${contractName}/init/maxAllocationEpochs`) expect(value).eq(expected) }) it('delegationUnbondingPeriod should match "delegationUnbondingPeriod" in the config file', async function () { const value = await Staking.delegationUnbondingPeriod() - const expected = getItemValue(graphConfig, 'contracts/Staking/init/delegationUnbondingPeriod') + const expected = getItemValue( + graphConfig, + `contracts/${contractName}/init/delegationUnbondingPeriod`, + ) expect(value).eq(expected) }) it('delegationRatio should match "delegationRatio" in the config file', async function () { const value = await Staking.delegationRatio() - const expected = getItemValue(graphConfig, 'contracts/Staking/init/delegationRatio') + const expected = getItemValue(graphConfig, `contracts/${contractName}/init/delegationRatio`) expect(value).eq(expected) }) it('alphaNumerator should match "rebateAlphaNumerator" in the config file', async function () { const value = await Staking.alphaNumerator() - const expected = getItemValue(graphConfig, 'contracts/Staking/init/rebateAlphaNumerator') + const expected = getItemValue( + graphConfig, + `contracts/${contractName}/init/rebateAlphaNumerator`, + ) expect(value).eq(expected) }) it('alphaDenominator should match "rebateAlphaDenominator" in the config file', async function () { const value = await Staking.alphaDenominator() - const expected = getItemValue(graphConfig, 'contracts/Staking/init/rebateAlphaDenominator') + const expected = getItemValue( + graphConfig, + `contracts/${contractName}/init/rebateAlphaDenominator`, + ) expect(value).eq(expected) }) diff --git a/test/disputes/poi.test.ts b/test/disputes/poi.test.ts index cded4d91e..4bc26e4f9 100644 --- a/test/disputes/poi.test.ts +++ b/test/disputes/poi.test.ts @@ -4,7 +4,7 @@ import { utils } from 'ethers' import { DisputeManager } from '../../build/types/DisputeManager' import { EpochManager } from '../../build/types/EpochManager' import { GraphToken } from '../../build/types/GraphToken' -import { Staking } from '../../build/types/Staking' +import { IStaking } from '../../build/types/IStaking' import { NetworkFixture } from '../lib/fixtures' import { @@ -35,7 +35,7 @@ describe('DisputeManager:POI', async () => { let disputeManager: DisputeManager let epochManager: EpochManager let grt: GraphToken - let staking: Staking + let staking: IStaking // Derive some channel keys for each indexer used to sign attestations const indexerChannelKey = deriveChannelKey() diff --git a/test/disputes/query.test.ts b/test/disputes/query.test.ts index b7548e842..595b502cb 100644 --- a/test/disputes/query.test.ts +++ b/test/disputes/query.test.ts @@ -5,7 +5,7 @@ import { createAttestation, Receipt } from '@graphprotocol/common-ts' import { DisputeManager } from '../../build/types/DisputeManager' import { EpochManager } from '../../build/types/EpochManager' import { GraphToken } from '../../build/types/GraphToken' -import { Staking } from '../../build/types/Staking' +import { IStaking } from '../../build/types/IStaking' import { NetworkFixture } from '../lib/fixtures' import { @@ -42,7 +42,7 @@ describe('DisputeManager:Query', async () => { let disputeManager: DisputeManager let epochManager: EpochManager let grt: GraphToken - let staking: Staking + let staking: IStaking // Derive some channel keys for each indexer used to sign attestations const indexer1ChannelKey = deriveChannelKey() diff --git a/test/gateway/l1GraphTokenGateway.test.ts b/test/gateway/l1GraphTokenGateway.test.ts index 1eb7ff134..86a4326a5 100644 --- a/test/gateway/l1GraphTokenGateway.test.ts +++ b/test/gateway/l1GraphTokenGateway.test.ts @@ -32,6 +32,7 @@ describe('L1GraphTokenGateway', () => { let mockL2Gateway: Account let pauseGuardian: Account let mockL2GNS: Account + let mockL2Staking: Account let fixture: NetworkFixture let grt: GraphToken @@ -74,6 +75,7 @@ describe('L1GraphTokenGateway', () => { mockL2Gateway, pauseGuardian, mockL2GNS, + mockL2Staking, ] = await getAccounts() // Dummy code on the mock router so that it appears as a contract @@ -302,6 +304,7 @@ describe('L1GraphTokenGateway', () => { mockL2GRT.address, mockL2Gateway.address, mockL2GNS.address, + mockL2Staking.address, ) let tx = l1GraphTokenGateway.connect(governor.signer).setPaused(true) await expect(tx).emit(l1GraphTokenGateway, 'PauseChanged').withArgs(true) @@ -334,6 +337,7 @@ describe('L1GraphTokenGateway', () => { mockL2GRT.address, mockL2Gateway.address, mockL2GNS.address, + mockL2Staking.address, ) await l1GraphTokenGateway.connect(governor.signer).setPauseGuardian(pauseGuardian.address) let tx = l1GraphTokenGateway.connect(pauseGuardian.signer).setPaused(true) @@ -440,6 +444,7 @@ describe('L1GraphTokenGateway', () => { mockL2GRT.address, mockL2Gateway.address, mockL2GNS.address, + mockL2Staking.address, ) }) diff --git a/test/gns.test.ts b/test/gns.test.ts index 723e3c913..98199cd52 100644 --- a/test/gns.test.ts +++ b/test/gns.test.ts @@ -63,6 +63,7 @@ describe('L1GNS', () => { let mockL2GRT: Account let mockL2Gateway: Account let mockL2GNS: Account + let mockL2Staking: Account let fixture: NetworkFixture @@ -222,8 +223,17 @@ describe('L1GNS', () => { } before(async function () { - ;[me, other, governor, another, mockRouter, mockL2GRT, mockL2Gateway, mockL2GNS] = - await getAccounts() + ;[ + me, + other, + governor, + another, + mockRouter, + mockL2GRT, + mockL2Gateway, + mockL2GNS, + mockL2Staking, + ] = await getAccounts() // Dummy code on the mock router so that it appears as a contract await provider().send('hardhat_setCode', [mockRouter.address, '0x1234']) fixture = new NetworkFixture() @@ -258,6 +268,7 @@ describe('L1GNS', () => { mockL2GRT.address, mockL2Gateway.address, mockL2GNS.address, + mockL2Staking.address, ) }) diff --git a/test/governance/pausing.test.ts b/test/governance/pausing.test.ts index 71aa3dc3d..372509c67 100644 --- a/test/governance/pausing.test.ts +++ b/test/governance/pausing.test.ts @@ -2,7 +2,7 @@ import { expect } from 'chai' import { constants } from 'ethers' import { Controller } from '../../build/types/Controller' -import { Staking } from '../../build/types/Staking' +import { IStaking } from '../../build/types/IStaking' import { getAccounts, Account, toGRT } from '../lib/testHelpers' import { NetworkFixture } from '../lib/fixtures' @@ -14,7 +14,7 @@ describe('Pausing', () => { let fixture: NetworkFixture - let staking: Staking + let staking: IStaking let controller: Controller const setPartialPause = async (account: Account, setValue: boolean) => { diff --git a/test/l2/l2GNS.test.ts b/test/l2/l2GNS.test.ts index f696a9ced..728decacf 100644 --- a/test/l2/l2GNS.test.ts +++ b/test/l2/l2GNS.test.ts @@ -48,6 +48,7 @@ describe('L2GNS', () => { let mockL1GRT: Account let mockL1Gateway: Account let mockL1GNS: Account + let mockL1Staking: Account let fixture: NetworkFixture let fixtureContracts: L2FixtureContracts @@ -113,7 +114,8 @@ describe('L2GNS', () => { before(async function () { newSubgraph0 = buildSubgraph() - ;[me, other, governor, mockRouter, mockL1GRT, mockL1Gateway, mockL1GNS] = await getAccounts() + ;[me, other, governor, mockRouter, mockL1GRT, mockL1Gateway, mockL1GNS, mockL1Staking] = + await getAccounts() fixture = new NetworkFixture() fixtureContracts = await fixture.loadL2(governor.signer) @@ -127,6 +129,7 @@ describe('L2GNS', () => { mockL1GRT.address, mockL1Gateway.address, mockL1GNS.address, + mockL1Staking.address, ) }) diff --git a/test/l2/l2GraphTokenGateway.test.ts b/test/l2/l2GraphTokenGateway.test.ts index 2a2595419..1a3078938 100644 --- a/test/l2/l2GraphTokenGateway.test.ts +++ b/test/l2/l2GraphTokenGateway.test.ts @@ -29,6 +29,7 @@ describe('L2GraphTokenGateway', () => { let mockL1Gateway: Account let pauseGuardian: Account let mockL1GNS: Account + let mockL1Staking: Account let fixture: NetworkFixture let arbSysMock: FakeContract @@ -57,6 +58,7 @@ describe('L2GraphTokenGateway', () => { l2Receiver, pauseGuardian, mockL1GNS, + mockL1Staking, ] = await getAccounts() fixture = new NetworkFixture() @@ -191,6 +193,7 @@ describe('L2GraphTokenGateway', () => { mockL1GRT.address, mockL1Gateway.address, mockL1GNS.address, + mockL1Staking.address, ) let tx = l2GraphTokenGateway.connect(governor.signer).setPaused(true) await expect(tx).emit(l2GraphTokenGateway, 'PauseChanged').withArgs(true) @@ -222,6 +225,7 @@ describe('L2GraphTokenGateway', () => { mockL1GRT.address, mockL1Gateway.address, mockL1GNS.address, + mockL1Staking.address, ) await l2GraphTokenGateway.connect(governor.signer).setPauseGuardian(pauseGuardian.address) let tx = l2GraphTokenGateway.connect(pauseGuardian.signer).setPaused(true) @@ -285,6 +289,7 @@ describe('L2GraphTokenGateway', () => { mockL1GRT.address, mockL1Gateway.address, mockL1GNS.address, + mockL1Staking.address, ) }) diff --git a/test/l2/l2Staking.test.ts b/test/l2/l2Staking.test.ts new file mode 100644 index 000000000..cba54df50 --- /dev/null +++ b/test/l2/l2Staking.test.ts @@ -0,0 +1,367 @@ +import { expect } from 'chai' +import { ethers, ContractTransaction, BigNumber } from 'ethers' +import { defaultAbiCoder, parseEther } from 'ethers/lib/utils' + +import { + getAccounts, + Account, + toGRT, + getL2SignerFromL1, + setAccountBalance, + latestBlock, + advanceBlocks, + deriveChannelKey, + randomHexBytes, + advanceToNextEpoch, +} from '../lib/testHelpers' +import { L2FixtureContracts, NetworkFixture } from '../lib/fixtures' +import { toBN } from '../lib/testHelpers' + +import { IL2Staking } from '../../build/types/IL2Staking' +import { L2GraphTokenGateway } from '../../build/types/L2GraphTokenGateway' +import { GraphToken } from '../../build/types/GraphToken' + +const { AddressZero } = ethers.constants + +const subgraphDeploymentID = randomHexBytes() +const channelKey = deriveChannelKey() +const allocationID = channelKey.address +const metadata = randomHexBytes(32) + +describe('L2Staking', () => { + let me: Account + let other: Account + let another: Account + let governor: Account + let mockRouter: Account + let mockL1GRT: Account + let mockL1Gateway: Account + let mockL1GNS: Account + let mockL1Staking: Account + let fixture: NetworkFixture + + let fixtureContracts: L2FixtureContracts + let l2GraphTokenGateway: L2GraphTokenGateway + let staking: IL2Staking + let grt: GraphToken + + const tokens10k = toGRT('10000') + const tokens100k = toGRT('100000') + const tokens1m = toGRT('1000000') + + // Allocate with test values + const allocate = async (tokens: BigNumber) => { + return staking + .connect(me.signer) + .allocateFrom( + me.address, + subgraphDeploymentID, + tokens, + allocationID, + metadata, + await channelKey.generateProof(me.address), + ) + } + + const gatewayFinalizeTransfer = async function ( + from: string, + to: string, + amount: BigNumber, + callhookData: string, + ): Promise { + const mockL1GatewayL2Alias = await getL2SignerFromL1(mockL1Gateway.address) + // Eth for gas: + await setAccountBalance(await mockL1GatewayL2Alias.getAddress(), parseEther('1')) + + const tx = l2GraphTokenGateway + .connect(mockL1GatewayL2Alias) + .finalizeInboundTransfer(mockL1GRT.address, from, to, amount, callhookData) + return tx + } + + before(async function () { + ;[ + me, + other, + another, + governor, + mockRouter, + mockL1GRT, + mockL1Gateway, + mockL1GNS, + mockL1Staking, + ] = await getAccounts() + + fixture = new NetworkFixture() + fixtureContracts = await fixture.loadL2(governor.signer) + ;({ l2GraphTokenGateway, staking, grt } = fixtureContracts) + + await grt.connect(governor.signer).mint(me.address, tokens1m) + await grt.connect(me.signer).approve(staking.address, tokens1m) + await grt.connect(governor.signer).mint(other.address, tokens1m) + await grt.connect(other.signer).approve(staking.address, tokens1m) + await fixture.configureL2Bridge( + governor.signer, + fixtureContracts, + mockRouter.address, + mockL1GRT.address, + mockL1Gateway.address, + mockL1GNS.address, + mockL1Staking.address, + ) + }) + + beforeEach(async function () { + await fixture.setUp() + }) + + afterEach(async function () { + await fixture.tearDown() + }) + + describe('receive()', function () { + it('should not allow receiving ETH', async function () { + const tx = me.signer.sendTransaction({ + to: staking.address, + value: parseEther('1'), + }) + await expect(tx).revertedWith('RECEIVE_ETH_NOT_ALLOWED') + }) + }) + describe('receiving indexer stake from L1 (onTokenTransfer)', function () { + it('cannot be called by someone other than the L2GraphTokenGateway', async function () { + const functionData = defaultAbiCoder.encode(['tuple(address)'], [[me.address]]) + + const callhookData = defaultAbiCoder.encode( + ['uint8', 'bytes'], + [toBN(0), functionData], // code = 1 means RECEIVE_INDEXER_CODE + ) + const tx = staking + .connect(me.signer) + .onTokenTransfer(mockL1GNS.address, tokens100k, callhookData) + await expect(tx).revertedWith('ONLY_GATEWAY') + }) + it('rejects calls if the L1 sender is not the L1Staking', async function () { + const functionData = defaultAbiCoder.encode(['tuple(address)'], [[me.address]]) + + const callhookData = defaultAbiCoder.encode( + ['uint8', 'bytes'], + [toBN(0), functionData], // code = 1 means RECEIVE_INDEXER_CODE + ) + const tx = gatewayFinalizeTransfer(me.address, staking.address, tokens100k, callhookData) + + await expect(tx).revertedWith('ONLY_L1_STAKING_THROUGH_BRIDGE') + }) + it('adds stake to a new indexer', async function () { + const functionData = defaultAbiCoder.encode(['tuple(address)'], [[me.address]]) + + const callhookData = defaultAbiCoder.encode( + ['uint8', 'bytes'], + [toBN(0), functionData], // code = 1 means RECEIVE_INDEXER_CODE + ) + const tx = gatewayFinalizeTransfer( + mockL1Staking.address, + staking.address, + tokens100k, + callhookData, + ) + + await expect(tx) + .emit(l2GraphTokenGateway, 'DepositFinalized') + .withArgs(mockL1GRT.address, mockL1Staking.address, staking.address, tokens100k) + await expect(tx).emit(staking, 'StakeDeposited').withArgs(me.address, tokens100k) + expect(await staking.getIndexerStakedTokens(me.address)).to.equal(tokens100k) + const delegationPool = await staking.delegationPools(me.address) + expect(delegationPool.indexingRewardCut).eq(toBN(1000000)) // 1 in PPM + expect(delegationPool.queryFeeCut).eq(toBN(1000000)) // 1 in PPM + }) + it('adds stake to an existing indexer that was already transferred', async function () { + const functionData = defaultAbiCoder.encode(['tuple(address)'], [[me.address]]) + + const callhookData = defaultAbiCoder.encode( + ['uint8', 'bytes'], + [toBN(0), functionData], // code = 1 means RECEIVE_INDEXER_CODE + ) + await gatewayFinalizeTransfer( + mockL1Staking.address, + staking.address, + tokens100k, + callhookData, + ) + const tx = gatewayFinalizeTransfer( + mockL1Staking.address, + staking.address, + tokens100k, + callhookData, + ) + + await expect(tx) + .emit(l2GraphTokenGateway, 'DepositFinalized') + .withArgs(mockL1GRT.address, mockL1Staking.address, staking.address, tokens100k) + await expect(tx).emit(staking, 'StakeDeposited').withArgs(me.address, tokens100k) + expect(await staking.getIndexerStakedTokens(me.address)).to.equal(tokens100k.add(tokens100k)) + }) + it('adds stake to an existing indexer that was staked in L2 (without changing delegation params)', async function () { + const functionData = defaultAbiCoder.encode(['tuple(address)'], [[me.address]]) + + const callhookData = defaultAbiCoder.encode( + ['uint8', 'bytes'], + [toBN(0), functionData], // code = 1 means RECEIVE_INDEXER_CODE + ) + await staking.connect(me.signer).stake(tokens100k) + await staking.connect(me.signer).setDelegationParameters(1000, 1000, 1000) + const tx = gatewayFinalizeTransfer( + mockL1Staking.address, + staking.address, + tokens100k, + callhookData, + ) + + await expect(tx) + .emit(l2GraphTokenGateway, 'DepositFinalized') + .withArgs(mockL1GRT.address, mockL1Staking.address, staking.address, tokens100k) + await expect(tx).emit(staking, 'StakeDeposited').withArgs(me.address, tokens100k) + expect(await staking.getIndexerStakedTokens(me.address)).to.equal(tokens100k.add(tokens100k)) + const delegationPool = await staking.delegationPools(me.address) + expect(delegationPool.indexingRewardCut).eq(toBN(1000)) + expect(delegationPool.queryFeeCut).eq(toBN(1000)) + }) + }) + + describe('receiving delegation from L1 (onTokenTransfer)', function () { + it('adds delegation for a new delegator', async function () { + await staking.connect(me.signer).stake(tokens100k) + + const functionData = defaultAbiCoder.encode( + ['tuple(address,address)'], + [[me.address, other.address]], + ) + + const callhookData = defaultAbiCoder.encode( + ['uint8', 'bytes'], + [toBN(1), functionData], // code = 1 means RECEIVE_DELEGATION_CODE + ) + const tx = gatewayFinalizeTransfer( + mockL1Staking.address, + staking.address, + tokens10k, + callhookData, + ) + + await expect(tx) + .emit(l2GraphTokenGateway, 'DepositFinalized') + .withArgs(mockL1GRT.address, mockL1Staking.address, staking.address, tokens10k) + const expectedShares = tokens10k + await expect(tx) + .emit(staking, 'StakeDelegated') + .withArgs(me.address, other.address, tokens10k, expectedShares) + const delegation = await staking.getDelegation(me.address, other.address) + expect(delegation.shares).to.equal(expectedShares) + }) + it('adds delegation for an existing delegator', async function () { + await staking.connect(me.signer).stake(tokens100k) + await staking.connect(other.signer).delegate(me.address, tokens10k) + + const functionData = defaultAbiCoder.encode( + ['tuple(address,address)'], + [[me.address, other.address]], + ) + + const callhookData = defaultAbiCoder.encode( + ['uint8', 'bytes'], + [toBN(1), functionData], // code = 1 means RECEIVE_DELEGATION_CODE + ) + const tx = gatewayFinalizeTransfer( + mockL1Staking.address, + staking.address, + tokens10k, + callhookData, + ) + + await expect(tx) + .emit(l2GraphTokenGateway, 'DepositFinalized') + .withArgs(mockL1GRT.address, mockL1Staking.address, staking.address, tokens10k) + const expectedNewShares = tokens10k + const expectedTotalShares = tokens10k.mul(2) + await expect(tx) + .emit(staking, 'StakeDelegated') + .withArgs(me.address, other.address, tokens10k, expectedNewShares) + const delegation = await staking.getDelegation(me.address, other.address) + expect(delegation.shares).to.equal(expectedTotalShares) + }) + it('returns delegation to the delegator if it would produce no shares', async function () { + await fixtureContracts.rewardsManager + .connect(governor.signer) + .setIssuancePerBlock(toGRT('114')) + + await staking.connect(me.signer).stake(tokens100k) + await staking.connect(me.signer).delegate(me.address, toBN(1)) // 1 weiGRT == 1 share + + await staking.connect(me.signer).setDelegationParameters(1000, 1000, 1000) + await grt.connect(me.signer).approve(fixtureContracts.curation.address, tokens10k) + await fixtureContracts.curation.connect(me.signer).mint(subgraphDeploymentID, tokens10k, 0) + + await allocate(tokens100k) + await advanceToNextEpoch(fixtureContracts.epochManager) + await advanceToNextEpoch(fixtureContracts.epochManager) + await staking.connect(me.signer).closeAllocation(allocationID, randomHexBytes(32)) + // Now there are some rewards sent to delegation pool, so 1 weiGRT is less than 1 share + + const functionData = defaultAbiCoder.encode( + ['tuple(address,address)'], + [[me.address, other.address]], + ) + + const callhookData = defaultAbiCoder.encode( + ['uint8', 'bytes'], + [toBN(1), functionData], // code = 1 means RECEIVE_DELEGATION_CODE + ) + const delegatorGRTBalanceBefore = await grt.balanceOf(other.address) + const tx = gatewayFinalizeTransfer( + mockL1Staking.address, + staking.address, + toBN(1), // Less than 1 share! + callhookData, + ) + + await expect(tx) + .emit(l2GraphTokenGateway, 'DepositFinalized') + .withArgs(mockL1GRT.address, mockL1Staking.address, staking.address, toBN(1)) + const delegation = await staking.getDelegation(me.address, other.address) + await expect(tx) + .emit(staking, 'TransferredDelegationReturnedToDelegator') + .withArgs(me.address, other.address, toBN(1)) + + expect(delegation.shares).to.equal(0) + const delegatorGRTBalanceAfter = await grt.balanceOf(other.address) + expect(delegatorGRTBalanceAfter.sub(delegatorGRTBalanceBefore)).to.equal(toBN(1)) + }) + }) + describe('onTokenTransfer with invalid messages', function () { + it('reverts if the code is invalid', async function () { + // This should never really happen unless the Arbitrum bridge is compromised, + // so we test it anyway to ensure it's a well-defined behavior. + // code 2 does not exist: + const callhookData = defaultAbiCoder.encode(['uint8', 'bytes'], [toBN(2), '0x12345678']) + const tx = gatewayFinalizeTransfer( + mockL1Staking.address, + staking.address, + toGRT('1'), + callhookData, + ) + await expect(tx).revertedWith('INVALID_CODE') + }) + it('reverts if the message encoding is invalid', async function () { + // This should never really happen unless the Arbitrum bridge is compromised, + // so we test it anyway to ensure it's a well-defined behavior. + const callhookData = defaultAbiCoder.encode(['address', 'uint128'], [AddressZero, toBN(2)]) + const tx = gatewayFinalizeTransfer( + mockL1Staking.address, + staking.address, + toGRT('1'), + callhookData, + ) + await expect(tx).reverted // abi.decode will fail with no reason + }) + }) +}) diff --git a/test/lib/deployment.ts b/test/lib/deployment.ts index bf4c6ac17..f8884bcab 100644 --- a/test/lib/deployment.ts +++ b/test/lib/deployment.ts @@ -15,7 +15,9 @@ import { EpochManager } from '../../build/types/EpochManager' import { GNS } from '../../build/types/GNS' import { GraphToken } from '../../build/types/GraphToken' import { ServiceRegistry } from '../../build/types/ServiceRegistry' -import { Staking } from '../../build/types/Staking' +import { StakingExtension } from '../../build/types/StakingExtension' +import { IL1Staking } from '../../build/types/IL1Staking' +import { IL2Staking } from '../../build/types/IL2Staking' import { RewardsManager } from '../../build/types/RewardsManager' import { GraphGovernance } from '../../build/types/GraphGovernance' import { SubgraphNFT } from '../../build/types/SubgraphNFT' @@ -25,10 +27,17 @@ import { L2GraphToken } from '../../build/types/L2GraphToken' import { BridgeEscrow } from '../../build/types/BridgeEscrow' import { L2GNS } from '../../build/types/L2GNS' import { L1GNS } from '../../build/types/L1GNS' +import path from 'path' +import { Artifacts } from 'hardhat/internal/artifacts' // Disable logging for tests logger.pause() +const ARTIFACTS_PATH = path.resolve('build/contracts') +const artifacts = new Artifacts(ARTIFACTS_PATH) +const iL1StakingAbi = artifacts.readArtifactSync('IL1Staking').abi +const iL2StakingAbi = artifacts.readArtifactSync('IL2Staking').abi + // Default configuration used in tests export const defaults = { @@ -250,14 +259,48 @@ export async function deployServiceRegistry( ) as unknown as Promise } -export async function deployStaking( +export async function deployL1Staking( deployer: Signer, controller: string, proxyAdmin: GraphProxyAdmin, -): Promise { - return network.deployContractWithProxy( +): Promise { + const extensionImpl = (await deployContract( + 'StakingExtension', + deployer, + )) as unknown as StakingExtension + return (await network.deployContractWithProxy( + proxyAdmin, + 'L1Staking', + [ + controller, + defaults.staking.minimumIndexerStake, + defaults.staking.thawingPeriod, + 0, + 0, + defaults.staking.channelDisputeEpochs, + defaults.staking.maxAllocationEpochs, + defaults.staking.delegationUnbondingPeriod, + 0, + defaults.staking.alphaNumerator, + defaults.staking.alphaDenominator, + extensionImpl.address, + ], + deployer, + )) as unknown as IL1Staking +} + +export async function deployL2Staking( + deployer: Signer, + controller: string, + proxyAdmin: GraphProxyAdmin, +): Promise { + const extensionImpl = (await deployContract( + 'StakingExtension', + deployer, + )) as unknown as StakingExtension + return (await network.deployContractWithProxy( proxyAdmin, - 'Staking', + 'L2Staking', [ controller, defaults.staking.minimumIndexerStake, @@ -270,9 +313,10 @@ export async function deployStaking( 0, defaults.staking.alphaNumerator, defaults.staking.alphaDenominator, + extensionImpl.address, ], deployer, - ) as unknown as Staking + )) as unknown as IL2Staking } export async function deployRewardsManager( diff --git a/test/lib/fixtures.ts b/test/lib/fixtures.ts index 4490fe915..69dd945b2 100644 --- a/test/lib/fixtures.ts +++ b/test/lib/fixtures.ts @@ -15,7 +15,8 @@ import { Curation } from '../../build/types/Curation' import { L2Curation } from '../../build/types/L2Curation' import { L1GNS } from '../../build/types/L1GNS' import { L2GNS } from '../../build/types/L2GNS' -import { Staking } from '../../build/types/Staking' +import { IL1Staking } from '../../build/types/IL1Staking' +import { IL2Staking } from '../../build/types/IL2Staking' import { RewardsManager } from '../../build/types/RewardsManager' import { ServiceRegistry } from '../../build/types/ServiceRegistry' import { GraphProxyAdmin } from '../../build/types/GraphProxyAdmin' @@ -31,7 +32,7 @@ export interface L1FixtureContracts { grt: GraphToken curation: Curation gns: L1GNS - staking: Staking + staking: IL1Staking rewardsManager: RewardsManager serviceRegistry: ServiceRegistry proxyAdmin: GraphProxyAdmin @@ -46,7 +47,7 @@ export interface L2FixtureContracts { grt: L2GraphToken curation: L2Curation gns: L2GNS - staking: Staking + staking: IL2Staking rewardsManager: RewardsManager serviceRegistry: ServiceRegistry proxyAdmin: GraphProxyAdmin @@ -100,12 +101,14 @@ export class NetworkFixture { curation = await deployment.deployCuration(deployer, controller.address, proxyAdmin) } let gns: L1GNS | L2GNS + let staking: IL1Staking | IL2Staking if (isL2) { gns = await deployment.deployL2GNS(deployer, controller.address, proxyAdmin) + staking = await deployment.deployL2Staking(deployer, controller.address, proxyAdmin) } else { gns = await deployment.deployL1GNS(deployer, controller.address, proxyAdmin) + staking = await deployment.deployL1Staking(deployer, controller.address, proxyAdmin) } - const staking = await deployment.deployStaking(deployer, controller.address, proxyAdmin) const disputeManager = await deployment.deployDisputeManager( deployer, controller.address, @@ -173,9 +176,7 @@ export class NetworkFixture { await staking.connect(deployer).setSlasher(slasherAddress, true) await gns.connect(deployer).approveAll() - if (!isL2) { - await grt.connect(deployer).addMinter(rewardsManager.address) - } + await grt.connect(deployer).addMinter(rewardsManager.address) // Unpause the protocol await controller.connect(deployer).setPaused(false) @@ -247,6 +248,7 @@ export class NetworkFixture { mockL2GRTAddress: string, mockL2GatewayAddress: string, mockL2GNSAddress: string, + mockL2StakingAddress: string, ): Promise { // First configure the Arbitrum bridge mocks await arbitrumMocks.bridgeMock.connect(deployer).setInbox(arbitrumMocks.inboxMock.address, true) @@ -276,6 +278,12 @@ export class NetworkFixture { await l1FixtureContracts.l1GraphTokenGateway .connect(deployer) .addToCallhookAllowlist(l1FixtureContracts.gns.address) + await l1FixtureContracts.staking + .connect(deployer) + .setCounterpartStakingAddress(mockL2StakingAddress) + await l1FixtureContracts.l1GraphTokenGateway + .connect(deployer) + .addToCallhookAllowlist(l1FixtureContracts.staking.address) await l1FixtureContracts.l1GraphTokenGateway.connect(deployer).setPaused(false) } @@ -286,6 +294,7 @@ export class NetworkFixture { mockL1GRTAddress: string, mockL1GatewayAddress: string, mockL1GNSAddress: string, + mockL1StakingAddress: string, ): Promise { // Configure the L2 GRT // Configure the gateway @@ -302,6 +311,9 @@ export class NetworkFixture { .connect(deployer) .setL1CounterpartAddress(mockL1GatewayAddress) await l2FixtureContracts.gns.connect(deployer).setCounterpartGNSAddress(mockL1GNSAddress) + await l2FixtureContracts.staking + .connect(deployer) + .setCounterpartStakingAddress(mockL1StakingAddress) await l2FixtureContracts.l2GraphTokenGateway.connect(deployer).setPaused(false) } diff --git a/test/payments/allocationExchange.test.ts b/test/payments/allocationExchange.test.ts index 03a1a3229..fba672e03 100644 --- a/test/payments/allocationExchange.test.ts +++ b/test/payments/allocationExchange.test.ts @@ -3,7 +3,7 @@ import { BigNumber, constants, Wallet } from 'ethers' import { AllocationExchange } from '../../build/types/AllocationExchange' import { GraphToken } from '../../build/types/GraphToken' -import { Staking } from '../../build/types/Staking' +import { IStaking } from '../../build/types/IStaking' import { NetworkFixture } from '../lib/fixtures' import * as deployment from '../lib/deployment' @@ -33,7 +33,7 @@ describe('AllocationExchange', () => { let fixture: NetworkFixture let grt: GraphToken - let staking: Staking + let staking: IStaking let allocationExchange: AllocationExchange async function createVoucher( diff --git a/test/rewards/rewards.test.ts b/test/rewards/rewards.test.ts index 3c72214a9..6bf881e8f 100644 --- a/test/rewards/rewards.test.ts +++ b/test/rewards/rewards.test.ts @@ -9,7 +9,7 @@ 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 { Staking } from '../../build/types/Staking' +import { IStaking } from '../../build/types/IStaking' import { advanceBlocks, @@ -45,7 +45,7 @@ describe('Rewards', () => { let grt: GraphToken let curation: Curation let epochManager: EpochManager - let staking: Staking + let staking: IStaking let rewardsManager: RewardsManager // Derive some channel keys for each indexer used to sign attestations diff --git a/test/serviceRegisty.test.ts b/test/serviceRegisty.test.ts index 726afa2f2..14027b5c3 100644 --- a/test/serviceRegisty.test.ts +++ b/test/serviceRegisty.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai' import { ServiceRegistry } from '../build/types/ServiceRegistry' -import { Staking } from '../build/types/Staking' +import { IStaking } from '../build/types/IStaking' import { getAccounts, Account } from './lib/testHelpers' import { NetworkFixture } from './lib/fixtures' @@ -14,7 +14,7 @@ describe('ServiceRegistry', () => { let fixture: NetworkFixture let serviceRegistry: ServiceRegistry - let staking: Staking + let staking: IStaking const shouldRegister = async (url: string, geohash: string) => { // Register the indexer service diff --git a/test/staking/allocation.test.ts b/test/staking/allocation.test.ts index 790c528d1..490100c25 100644 --- a/test/staking/allocation.test.ts +++ b/test/staking/allocation.test.ts @@ -4,7 +4,7 @@ import { constants, BigNumber, PopulatedTransaction } from 'ethers' import { Curation } from '../../build/types/Curation' import { EpochManager } from '../../build/types/EpochManager' import { GraphToken } from '../../build/types/GraphToken' -import { Staking } from '../../build/types/Staking' +import { IStaking } from '../../build/types/IStaking' import { NetworkFixture } from '../lib/fixtures' import { @@ -51,7 +51,7 @@ describe('Staking:Allocation', () => { let curation: Curation let epochManager: EpochManager let grt: GraphToken - let staking: Staking + let staking: IStaking // Test values @@ -310,6 +310,11 @@ describe('Staking:Allocation', () => { expect(beforeOperator).eq(true) expect(afterOperator).eq(false) }) + it('should reject setting the operator to the msg.sender', async function () { + await expect( + staking.connect(indexer.signer).setOperator(indexer.address, true), + ).to.be.revertedWith('operator == sender') + }) }) describe('rewardsDestination', function () { @@ -354,7 +359,7 @@ describe('Staking:Allocation', () => { it('reject allocate if no tokens staked', async function () { const tx = allocate(toBN('1')) - await expect(tx).revertedWith('!capacity') + await expect(tx).revertedWith('!minimumIndexerStake') }) it('reject allocate zero tokens if no minimum stake', async function () { @@ -374,6 +379,7 @@ describe('Staking:Allocation', () => { }) it('should allocate', async function () { + await advanceToNextEpoch(epochManager) await shouldAllocate(tokensToAllocate) }) @@ -414,6 +420,7 @@ describe('Staking:Allocation', () => { }) it('reject allocate reusing an allocation ID', async function () { + await advanceToNextEpoch(epochManager) const someTokensToAllocate = toGRT('10') await shouldAllocate(someTokensToAllocate) const tx = allocate(someTokensToAllocate) @@ -582,6 +589,8 @@ describe('Staking:Allocation', () => { for (const tokensToAllocate of [toBN(100), toBN(0)]) { context(`> with ${tokensToAllocate} allocated tokens`, async function () { beforeEach(async function () { + // Advance to next epoch to avoid creating the allocation + // right at the epoch boundary, which would mess up the tests. await advanceToNextEpoch(epochManager) await allocate(tokensToAllocate) }) @@ -954,4 +963,111 @@ describe('Staking:Allocation', () => { }) } }) + + describe('claimMany', function () { + beforeEach(async function () { + // Stake + await staking.connect(indexer.signer).stake(tokensToStake) + + // Set channel dispute period to one epoch + await staking.connect(governor.signer).setChannelDisputeEpochs(toBN('1')) + + // Fund wallets + await grt.connect(governor.signer).mint(assetHolder.address, tokensToCollect.mul(2)) + await grt.connect(assetHolder.signer).approve(staking.address, tokensToCollect.mul(2)) + }) + it('should claim many rebates with restake', async function () { + // Allocate + await allocate(toBN(100)) + + // Create a second allocation with a different allocationID + const channelKey2 = deriveChannelKey() + const allocationID2 = channelKey2.address + const metadata2 = randomHexBytes(32) + const poi2 = randomHexBytes() + const subgraphDeploymentID2 = randomHexBytes(32) + + await staking + .connect(indexer.signer) + .allocateFrom( + indexer.address, + subgraphDeploymentID2, + toBN(200), + allocationID2, + metadata2, + await channelKey2.generateProof(indexer.address), + ) + + // Collect some funds + await staking.connect(assetHolder.signer).collect(tokensToCollect, allocationID) + await staking.connect(assetHolder.signer).collect(tokensToCollect, allocationID2) + + // Advance blocks to get the allocation in epoch where it can be closed + await advanceToNextEpoch(epochManager) + + // Close the allocations + await staking.connect(indexer.signer).closeAllocation(allocationID, poi) + await advanceToNextEpoch(epochManager) // Make sure they fall in different rebate pools + await staking.connect(indexer.signer).closeAllocation(allocationID2, poi2) + + // Advance blocks to get the allocation in epoch where it can be claimed + await advanceToNextEpoch(epochManager) + + // Before state + const beforeIndexerStake = await staking.getIndexerStakedTokens(indexer.address) + const beforeAlloc1 = await staking.allocations(allocationID) + const beforeAlloc2 = await staking.allocations(allocationID2) + + // Claim with restake + expect(await staking.getAllocationState(allocationID)).eq(AllocationState.Finalized) + expect(await staking.getAllocationState(allocationID2)).eq(AllocationState.Finalized) + const tx = await staking + .connect(indexer.signer) + .claimMany([allocationID, allocationID2], true) + + // Verify that the claimed tokens are restaked + const afterIndexerStake = await staking.getIndexerStakedTokens(indexer.address) + const tokensToClaim = beforeAlloc1.effectiveAllocation.eq(0) + ? toBN(0) + : beforeAlloc1.collectedFees + const tokensToClaim2 = beforeAlloc2.effectiveAllocation.eq(0) + ? toBN(0) + : beforeAlloc2.collectedFees + expect(afterIndexerStake).eq(beforeIndexerStake.add(tokensToClaim).add(tokensToClaim2)) + }) + }) + + describe('isAllocation', function () { + it('should return true if allocation exists', async function () { + // Allocate + await staking.connect(indexer.signer).stake(tokensToStake) + await allocate(toBN(100)) + + // Check + expect(await staking.isAllocation(allocationID)).eq(true) + }) + it('should still return true after an allocation is closed', async function () { + // Allocate + await staking.connect(indexer.signer).stake(tokensToStake) + await allocate(toBN(100)) + + // Collect some funds + await grt.connect(governor.signer).mint(assetHolder.address, tokensToCollect) + await grt.connect(assetHolder.signer).approve(staking.address, tokensToCollect) + await staking.connect(assetHolder.signer).collect(tokensToCollect, allocationID) + + // Advance blocks to get the allocation in epoch where it can be closed + await advanceToNextEpoch(epochManager) + + // Close the allocation + await staking.connect(indexer.signer).closeAllocation(allocationID, poi) + + // Check + expect(await staking.isAllocation(allocationID)).eq(true) + }) + it('should return false if allocation does not exist', async function () { + // Check + expect(await staking.isAllocation(allocationID)).eq(false) + }) + }) }) diff --git a/test/staking/configuration.test.ts b/test/staking/configuration.test.ts index 52aeaa843..46a465701 100644 --- a/test/staking/configuration.test.ts +++ b/test/staking/configuration.test.ts @@ -1,11 +1,15 @@ import { expect } from 'chai' +import { ethers } from 'hardhat' import { constants } from 'ethers' -import { Staking } from '../../build/types/Staking' +import { IStaking } from '../../build/types/IStaking' -import { defaults } from '../lib/deployment' +import { defaults, deployContract } from '../lib/deployment' import { NetworkFixture } from '../lib/fixtures' import { getAccounts, toBN, toGRT, Account } from '../lib/testHelpers' +import { GraphProxy } from '../../build/types/GraphProxy' +import { GraphProxyAdmin } from '../../build/types/GraphProxyAdmin' +import { network } from '../../cli' const { AddressZero } = constants @@ -19,13 +23,14 @@ describe('Staking:Config', () => { let fixture: NetworkFixture - let staking: Staking + let staking: IStaking + let proxyAdmin: GraphProxyAdmin before(async function () { ;[me, other, governor, slasher] = await getAccounts() fixture = new NetworkFixture() - ;({ staking } = await fixture.load(governor.signer, slasher.signer)) + ;({ staking, proxyAdmin } = await fixture.load(governor.signer, slasher.signer)) }) beforeEach(async function () { @@ -228,4 +233,27 @@ describe('Staking:Config', () => { await expect(tx).revertedWith('Only Controller governor') }) }) + + describe('Staking and StakingExtension', function () { + it('does not allow calling the fallback from the Staking implementation', async function () { + const impl = await proxyAdmin.getProxyImplementation(staking.address) + + const factory = await ethers.getContractFactory('StakingExtension') + const implAsStaking = factory.attach(impl) as IStaking + const tx = implAsStaking.connect(other.signer).setDelegationRatio(50) + await expect(tx).revertedWith('only through proxy') + }) + it('can set the staking extension implementation with setExtensionImpl', async function () { + const newImpl = await network.deployContract('StakingExtension', [], governor.signer) + const tx = await staking.connect(governor.signer).setExtensionImpl(newImpl.contract.address) + await expect(tx) + .emit(staking, 'ExtensionImplementationSet') + .withArgs(newImpl.contract.address) + }) + it('rejects calls to setExtensionImpl from non-governor', async function () { + const newImpl = await network.deployContract('StakingExtension', [], governor.signer) + const tx = staking.connect(other.signer).setExtensionImpl(newImpl.contract.address) + await expect(tx).revertedWith('Only Controller governor') + }) + }) }) diff --git a/test/staking/delegation.test.ts b/test/staking/delegation.test.ts index e097dfd9f..8c8310443 100644 --- a/test/staking/delegation.test.ts +++ b/test/staking/delegation.test.ts @@ -3,7 +3,7 @@ import { constants, BigNumber } from 'ethers' import { EpochManager } from '../../build/types/EpochManager' import { GraphToken } from '../../build/types/GraphToken' -import { Staking } from '../../build/types/Staking' +import { IStaking } from '../../build/types/IStaking' import { NetworkFixture } from '../lib/fixtures' import { @@ -34,7 +34,7 @@ describe('Staking::Delegation', () => { let epochManager: EpochManager let grt: GraphToken - let staking: Staking + let staking: IStaking // Test values const poi = randomHexBytes() @@ -675,4 +675,15 @@ describe('Staking::Delegation', () => { await expect(tx).revertedWith('!shares') }) }) + describe('isDelegator', function () { + it('should return true if the address is a delegator', async function () { + await staking.connect(indexer.signer).stake(toGRT('1000')) + await shouldDelegate(delegator, toGRT('1')) + expect(await staking.isDelegator(indexer.address, delegator.address)).eq(true) + }) + + it('should return false if the address is not a delegator', async function () { + expect(await staking.isDelegator(indexer.address, delegator.address)).eq(false) + }) + }) }) diff --git a/test/staking/l2Transfer.test.ts b/test/staking/l2Transfer.test.ts new file mode 100644 index 000000000..8f9a714ab --- /dev/null +++ b/test/staking/l2Transfer.test.ts @@ -0,0 +1,1028 @@ +import { expect } from 'chai' +import { constants, BigNumber } from 'ethers' +import { defaultAbiCoder, parseEther } from 'ethers/lib/utils' + +import { GraphToken } from '../../build/types/GraphToken' +import { IL1Staking } from '../../build/types/IL1Staking' +import { IController } from '../../build/types/IController' +import { L1GraphTokenGateway } from '../../build/types/L1GraphTokenGateway' +import { L1GraphTokenLockTransferToolMock } from '../../build/types/L1GraphTokenLockTransferToolMock' +import { L1GraphTokenLockTransferToolBadMock } from '../../build/types/L1GraphTokenLockTransferToolBadMock' + +import { ArbitrumL1Mocks, L1FixtureContracts, NetworkFixture } from '../lib/fixtures' + +import { + deriveChannelKey, + getAccounts, + randomHexBytes, + toBN, + toGRT, + provider, + Account, + setAccountBalance, + impersonateAccount, +} from '../lib/testHelpers' +import { deployContract } from '../lib/deployment' + +const { AddressZero } = constants + +describe('L1Staking:L2Transfer', () => { + let me: Account + let governor: Account + let indexer: Account + let slasher: Account + let l2Indexer: Account + let delegator: Account + let l2Delegator: Account + let mockRouter: Account + let mockL2GRT: Account + let mockL2Gateway: Account + let mockL2GNS: Account + let mockL2Staking: Account + + let fixture: NetworkFixture + let fixtureContracts: L1FixtureContracts + + let grt: GraphToken + let staking: IL1Staking + let controller: IController + let l1GraphTokenGateway: L1GraphTokenGateway + let arbitrumMocks: ArbitrumL1Mocks + let l1GraphTokenLockTransferTool: L1GraphTokenLockTransferToolMock + let l1GraphTokenLockTransferToolBad: L1GraphTokenLockTransferToolBadMock + + // Test values + const indexerTokens = toGRT('10000000') + const delegatorTokens = toGRT('1000000') + const tokensToStake = toGRT('200000') + const subgraphDeploymentID = randomHexBytes() + const channelKey = deriveChannelKey() + const allocationID = channelKey.address + const metadata = randomHexBytes(32) + const minimumIndexerStake = toGRT('100000') + const delegationTaxPPM = 10000 // 1% + // Dummy L2 gas values + const maxGas = toBN('1000000') + const gasPriceBid = toBN('1000000000') + const maxSubmissionCost = toBN('1000000000') + + // Allocate with test values + const allocate = async (tokens: BigNumber) => { + return staking + .connect(indexer.signer) + .allocateFrom( + indexer.address, + subgraphDeploymentID, + tokens, + allocationID, + metadata, + await channelKey.generateProof(indexer.address), + ) + } + + before(async function () { + ;[ + me, + governor, + indexer, + slasher, + delegator, + l2Indexer, + mockRouter, + mockL2GRT, + mockL2Gateway, + mockL2GNS, + mockL2Staking, + l2Delegator, + ] = await getAccounts() + + fixture = new NetworkFixture() + fixtureContracts = await fixture.load(governor.signer, slasher.signer) + ;({ grt, staking, l1GraphTokenGateway, controller } = fixtureContracts) + // Dummy code on the mock router so that it appears as a contract + await provider().send('hardhat_setCode', [mockRouter.address, '0x1234']) + arbitrumMocks = await fixture.loadArbitrumL1Mocks(governor.signer) + await fixture.configureL1Bridge( + governor.signer, + arbitrumMocks, + fixtureContracts, + mockRouter.address, + mockL2GRT.address, + mockL2Gateway.address, + mockL2GNS.address, + mockL2Staking.address, + ) + + l1GraphTokenLockTransferTool = (await deployContract( + 'L1GraphTokenLockTransferToolMock', + governor.signer, + )) as unknown as L1GraphTokenLockTransferToolMock + + l1GraphTokenLockTransferToolBad = (await deployContract( + 'L1GraphTokenLockTransferToolBadMock', + governor.signer, + )) as unknown as L1GraphTokenLockTransferToolBadMock + + await setAccountBalance(l1GraphTokenLockTransferTool.address, parseEther('1')) + await setAccountBalance(l1GraphTokenLockTransferToolBad.address, parseEther('1')) + + await staking + .connect(governor.signer) + .setL1GraphTokenLockTransferTool(l1GraphTokenLockTransferTool.address) + + // Give some funds to the indexer and approve staking contract to use funds on indexer behalf + await grt.connect(governor.signer).mint(indexer.address, indexerTokens) + await grt.connect(indexer.signer).approve(staking.address, indexerTokens) + + await grt.connect(governor.signer).mint(delegator.address, delegatorTokens) + await grt.connect(delegator.signer).approve(staking.address, delegatorTokens) + + await staking.connect(governor.signer).setMinimumIndexerStake(minimumIndexerStake) + await staking.connect(governor.signer).setDelegationTaxPercentage(delegationTaxPPM) // 1% + }) + + beforeEach(async function () { + await fixture.setUp() + }) + + afterEach(async function () { + await fixture.tearDown() + }) + + context('> when not staked', function () { + describe('transferStakeToL2', function () { + it('should not allow transferring for someone who has not staked', async function () { + const tx = staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + tokensToStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await expect(tx).revertedWith('tokensStaked == 0') + }) + }) + }) + + context('> when staked', function () { + const shouldTransferIndexerStake = async ( + amountToSend: BigNumber, + options: { + expectedSeqNum?: number + l2Beneficiary?: string + } = {}, + ) => { + const l2Beneficiary = options.l2Beneficiary ?? l2Indexer.address + const expectedSeqNum = options.expectedSeqNum ?? 1 + const tx = staking + .connect(indexer.signer) + .transferStakeToL2(l2Beneficiary, amountToSend, maxGas, gasPriceBid, maxSubmissionCost, { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }) + const expectedFunctionData = defaultAbiCoder.encode(['tuple(address)'], [[l2Indexer.address]]) + + const expectedCallhookData = defaultAbiCoder.encode( + ['uint8', 'bytes'], + [toBN(0), expectedFunctionData], // code = 1 means RECEIVE_INDEXER_CODE + ) + const expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( + grt.address, + staking.address, + mockL2Staking.address, + amountToSend, + expectedCallhookData, + ) + + await expect(tx) + .emit(l1GraphTokenGateway, 'TxToL2') + .withArgs(staking.address, mockL2Gateway.address, toBN(expectedSeqNum), expectedL2Data) + } + + beforeEach(async function () { + await staking.connect(indexer.signer).stake(tokensToStake) + }) + + describe('receive()', function () { + it('should not allow receiving funds from a random address', async function () { + const tx = indexer.signer.sendTransaction({ + to: staking.address, + value: parseEther('1'), + }) + await expect(tx).revertedWith('Only transfer tool can send ETH') + }) + it('should allow receiving funds from the transfer tool', async function () { + const impersonatedTransferTool = await impersonateAccount( + l1GraphTokenLockTransferTool.address, + ) + const tx = impersonatedTransferTool.sendTransaction({ + to: staking.address, + value: parseEther('1'), + }) + await expect(tx).to.not.be.reverted + }) + }) + describe('transferStakeToL2', function () { + it('should not allow transferring if the protocol is partially paused', async function () { + await controller.setPartialPaused(true) + + const tx = staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + tokensToStake.sub(minimumIndexerStake), + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await expect(tx).revertedWith('Partial-paused') + }) + it('should not allow transferring but leaving less than the minimum indexer stake', async function () { + const tx = staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + tokensToStake.sub(minimumIndexerStake).add(1), + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await expect(tx).revertedWith('!minimumIndexerStake remaining') + }) + it('should not allow transferring less than the minimum indexer stake the first time', async function () { + const tx = staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + minimumIndexerStake.sub(1), + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await expect(tx).revertedWith('!minimumIndexerStake sent') + }) + it('should not allow transferring if there are tokens locked for withdrawal', async function () { + await staking.connect(indexer.signer).unstake(tokensToStake) + const tx = staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + tokensToStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await expect(tx).revertedWith('tokensLocked != 0') + }) + it('should not allow transferring to a beneficiary that is address zero', async function () { + const tx = staking + .connect(indexer.signer) + .transferStakeToL2(AddressZero, tokensToStake, maxGas, gasPriceBid, maxSubmissionCost, { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }) + await expect(tx).revertedWith('l2Beneficiary == 0') + }) + it('should not allow transferring the whole stake if there are open allocations', async function () { + await allocate(toGRT('10')) + const tx = staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + tokensToStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await expect(tx).revertedWith('allocated') + }) + it('should not allow transferring partial stake if the remaining indexer capacity is insufficient for open allocations', async function () { + // We set delegation ratio == 1 so an indexer can only use as much delegation as their own stake + await staking.connect(governor.signer).setDelegationRatio(1) + const tokensToDelegate = toGRT('202100') + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + + // Now the indexer has 200k tokens staked and 200k tokens delegated + await allocate(toGRT('400000')) + + // But if we try to transfer even 100k, we will not have enough indexer capacity to cover the open allocation + const tx = staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + toGRT('100000'), + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await expect(tx).revertedWith('! allocation capacity') + }) + it('should not allow transferring if the ETH sent is more than required', async function () { + const tx = staking + .connect(indexer.signer) + .transferStakeToL2( + indexer.address, + tokensToStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)).add(1), + }, + ) + await expect(tx).revertedWith('INVALID_ETH_AMOUNT') + }) + it('sends the tokens and a message through the L1GraphTokenGateway', async function () { + const amountToSend = minimumIndexerStake + await shouldTransferIndexerStake(amountToSend) + // Check that the indexer stake was reduced by the sent amount + expect((await staking.stakes(indexer.address)).tokensStaked).to.equal( + tokensToStake.sub(amountToSend), + ) + }) + it('should allow transferring the whole stake if there are no open allocations', async function () { + await shouldTransferIndexerStake(tokensToStake) + // Check that the indexer stake was reduced by the sent amount + expect((await staking.stakes(indexer.address)).tokensStaked).to.equal(0) + }) + it('should allow transferring partial stake if the remaining capacity can cover the allocations', async function () { + // We set delegation ratio == 1 so an indexer can only use as much delegation as their own stake + await staking.connect(governor.signer).setDelegationRatio(1) + const tokensToDelegate = toGRT('200000') + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + + // Now the indexer has 200k tokens staked and 200k tokens delegated, + // but they allocate 200k + await allocate(toGRT('200000')) + + // If we transfer 100k, we will still have enough indexer capacity to cover the open allocation + const amountToSend = toGRT('100000') + await shouldTransferIndexerStake(amountToSend) + // Check that the indexer stake was reduced by the sent amount + expect((await staking.stakes(indexer.address)).tokensStaked).to.equal( + tokensToStake.sub(amountToSend), + ) + }) + it('allows transferring several times to the same beneficiary', async function () { + // Stake a bit more so we're still over the minimum stake after transferring twice + await staking.connect(indexer.signer).stake(tokensToStake) + await shouldTransferIndexerStake(minimumIndexerStake) + await shouldTransferIndexerStake(toGRT('1000'), { expectedSeqNum: 2 }) + expect((await staking.stakes(indexer.address)).tokensStaked).to.equal( + tokensToStake.mul(2).sub(minimumIndexerStake).sub(toGRT('1000')), + ) + }) + it('should not allow transferring to a different beneficiary the second time', async function () { + await shouldTransferIndexerStake(minimumIndexerStake) + const tx = staking.connect(indexer.signer).transferStakeToL2( + indexer.address, // Note this is different from l2Indexer used before + minimumIndexerStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await expect(tx).revertedWith('l2Beneficiary != previous') + }) + }) + + describe('transferLockedStakeToL2', function () { + it('should not allow transferring if the protocol is partially paused', async function () { + await controller.setPartialPaused(true) + + const tx = staking + .connect(indexer.signer) + .transferLockedStakeToL2(minimumIndexerStake, maxGas, gasPriceBid, maxSubmissionCost) + await expect(tx).revertedWith('Partial-paused') + }) + it('sends a message through L1GraphTokenGateway like transferStakeToL2, but gets the beneficiary and ETH from a transfer tool contract', async function () { + const amountToSend = minimumIndexerStake + + await l1GraphTokenLockTransferTool.setL2WalletAddress(indexer.address, l2Indexer.address) + const oldTransferToolEthBalance = await provider().getBalance( + l1GraphTokenLockTransferTool.address, + ) + const tx = staking + .connect(indexer.signer) + .transferLockedStakeToL2(minimumIndexerStake, maxGas, gasPriceBid, maxSubmissionCost) + const expectedFunctionData = defaultAbiCoder.encode( + ['tuple(address)'], + [[l2Indexer.address]], + ) + + const expectedCallhookData = defaultAbiCoder.encode( + ['uint8', 'bytes'], + [toBN(0), expectedFunctionData], // code = 0 means RECEIVE_INDEXER_CODE + ) + const expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( + grt.address, + staking.address, + mockL2Staking.address, + amountToSend, + expectedCallhookData, + ) + + await expect(tx) + .emit(l1GraphTokenGateway, 'TxToL2') + .withArgs(staking.address, mockL2Gateway.address, toBN(1), expectedL2Data) + expect(await provider().getBalance(l1GraphTokenLockTransferTool.address)).to.equal( + oldTransferToolEthBalance.sub(maxSubmissionCost).sub(gasPriceBid.mul(maxGas)), + ) + }) + it('should not allow transferring if the transfer tool contract returns a zero address beneficiary', async function () { + const tx = staking + .connect(indexer.signer) + .transferLockedStakeToL2(minimumIndexerStake, maxGas, gasPriceBid, maxSubmissionCost) + await expect(tx).revertedWith('LOCK NOT TRANSFERRED') + }) + it('should not allow transferring if the transfer tool contract does not provide enough ETH', async function () { + await staking + .connect(governor.signer) + .setL1GraphTokenLockTransferTool(l1GraphTokenLockTransferToolBad.address) + await l1GraphTokenLockTransferToolBad.setL2WalletAddress(indexer.address, l2Indexer.address) + const tx = staking + .connect(indexer.signer) + .transferLockedStakeToL2(minimumIndexerStake, maxGas, gasPriceBid, maxSubmissionCost) + await expect(tx).revertedWith('ETH TRANSFER FAILED') + }) + }) + describe('unlockDelegationToTransferredIndexer', function () { + beforeEach(async function () { + await staking.connect(governor.signer).setDelegationUnbondingPeriod(28) // epochs + }) + it('allows a delegator to a transferred indexer to withdraw locked delegation before the unbonding period', async function () { + const tokensToDelegate = toGRT('10000') + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + const actualDelegation = tokensToDelegate.sub( + tokensToDelegate.mul(delegationTaxPPM).div(1000000), + ) + await staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + tokensToStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await staking.connect(delegator.signer).undelegate(indexer.address, actualDelegation) + const tx = await staking + .connect(delegator.signer) + .unlockDelegationToTransferredIndexer(indexer.address) + await expect(tx) + .emit(staking, 'StakeDelegatedUnlockedDueToL2Transfer') + .withArgs(indexer.address, delegator.address) + const tx2 = await staking + .connect(delegator.signer) + .withdrawDelegated(indexer.address, AddressZero) + await expect(tx2) + .emit(staking, 'StakeDelegatedWithdrawn') + .withArgs(indexer.address, delegator.address, actualDelegation) + }) + it('rejects calls if the protocol is partially paused', async function () { + await controller.setPartialPaused(true) + + const tx = staking + .connect(delegator.signer) + .unlockDelegationToTransferredIndexer(indexer.address) + await expect(tx).revertedWith('Partial-paused') + }) + it('rejects calls if the indexer has not transferred their stake to L2', async function () { + const tokensToDelegate = toGRT('10000') + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + const tx = staking + .connect(delegator.signer) + .unlockDelegationToTransferredIndexer(indexer.address) + await expect(tx).revertedWith('indexer not transferred') + }) + it('rejects calls if the indexer has only transferred part of their stake but not all', async function () { + const tokensToDelegate = toGRT('10000') + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + await staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + minimumIndexerStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + const tx = staking + .connect(delegator.signer) + .unlockDelegationToTransferredIndexer(indexer.address) + await expect(tx).revertedWith('indexer not transferred') + }) + it('rejects calls if the delegator has not undelegated first', async function () { + const tokensToDelegate = toGRT('10000') + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + await staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + tokensToStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + const tx = staking + .connect(delegator.signer) + .unlockDelegationToTransferredIndexer(indexer.address) + await expect(tx).revertedWith('! locked') + }) + it('rejects calls if the caller is not a delegator', async function () { + await staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + tokensToStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + const tx = staking + .connect(delegator.signer) + .unlockDelegationToTransferredIndexer(indexer.address) + // The function checks for tokensLockedUntil so this is the error we should get: + await expect(tx).revertedWith('! locked') + }) + }) + describe('transferDelegationToL2', function () { + it('rejects calls if the protocol is partially paused', async function () { + await controller.setPartialPaused(true) + + const tx = staking + .connect(delegator.signer) + .transferDelegationToL2( + indexer.address, + l2Delegator.address, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await expect(tx).revertedWith('Partial-paused') + }) + it('rejects calls if the delegated indexer has not transferred stake to L2', async function () { + const tokensToDelegate = toGRT('10000') + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + + const tx = staking + .connect(delegator.signer) + .transferDelegationToL2( + indexer.address, + l2Delegator.address, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await expect(tx).revertedWith('indexer not transferred') + }) + it('rejects calls if the beneficiary is zero', async function () { + const tokensToDelegate = toGRT('10000') + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + await staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + minimumIndexerStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + + const tx = staking + .connect(delegator.signer) + .transferDelegationToL2( + indexer.address, + AddressZero, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await expect(tx).revertedWith('l2Beneficiary == 0') + }) + it('rejects calls if the delegator has tokens locked for undelegation', async function () { + const tokensToDelegate = toGRT('10000') + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + await staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + minimumIndexerStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await staking.connect(delegator.signer).undelegate(indexer.address, toGRT('1')) + + const tx = staking + .connect(delegator.signer) + .transferDelegationToL2( + indexer.address, + l2Delegator.address, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await expect(tx).revertedWith('tokensLocked != 0') + }) + it('rejects calls if the delegator has no tokens delegated to the indexer', async function () { + await staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + minimumIndexerStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + + const tx = staking + .connect(delegator.signer) + .transferDelegationToL2( + indexer.address, + l2Delegator.address, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await expect(tx).revertedWith('delegation == 0') + }) + it('sends all the tokens delegated to the indexer to the beneficiary on L2, using the gateway', async function () { + const tokensToDelegate = toGRT('10000') + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + const actualDelegation = tokensToDelegate.sub( + tokensToDelegate.mul(delegationTaxPPM).div(1000000), + ) + await staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + minimumIndexerStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + + const expectedFunctionData = defaultAbiCoder.encode( + ['tuple(address,address)'], + [[l2Indexer.address, l2Delegator.address]], + ) + + const expectedCallhookData = defaultAbiCoder.encode( + ['uint8', 'bytes'], + [toBN(1), expectedFunctionData], // code = 1 means RECEIVE_DELEGATION_CODE + ) + const expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( + grt.address, + staking.address, + mockL2Staking.address, + actualDelegation, + expectedCallhookData, + ) + + const tx = staking + .connect(delegator.signer) + .transferDelegationToL2( + indexer.address, + l2Delegator.address, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + // seqNum is 2 because the first bridge call was in transferStakeToL2 + await expect(tx) + .emit(l1GraphTokenGateway, 'TxToL2') + .withArgs(staking.address, mockL2Gateway.address, toBN(2), expectedL2Data) + await expect(tx) + .emit(staking, 'DelegationTransferredToL2') + .withArgs( + delegator.address, + l2Delegator.address, + indexer.address, + l2Indexer.address, + actualDelegation, + ) + }) + it('sets the delegation shares to zero so cannot be called twice', async function () { + const tokensToDelegate = toGRT('10000') + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + await staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + minimumIndexerStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + + await staking + .connect(delegator.signer) + .transferDelegationToL2( + indexer.address, + l2Delegator.address, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + + const tx = staking + .connect(delegator.signer) + .transferDelegationToL2( + indexer.address, + l2Delegator.address, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await expect(tx).revertedWith('delegation == 0') + }) + it('can be called again if the delegator added more delegation (edge case)', async function () { + const tokensToDelegate = toGRT('10000') + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + const actualDelegation = tokensToDelegate.sub( + tokensToDelegate.mul(delegationTaxPPM).div(1000000), + ) + await staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + minimumIndexerStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + + await staking + .connect(delegator.signer) + .transferDelegationToL2( + indexer.address, + l2Delegator.address, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + + const tx = staking + .connect(delegator.signer) + .transferDelegationToL2( + indexer.address, + l2Delegator.address, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await expect(tx) + .emit(staking, 'DelegationTransferredToL2') + .withArgs( + delegator.address, + l2Delegator.address, + indexer.address, + l2Indexer.address, + actualDelegation, + ) + }) + it('rejects calls if the ETH value is larger than expected', async function () { + const tokensToDelegate = toGRT('10000') + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + await staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + minimumIndexerStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + + const tx = staking + .connect(delegator.signer) + .transferDelegationToL2( + indexer.address, + l2Delegator.address, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)).add(1), + }, + ) + await expect(tx).revertedWith('INVALID_ETH_AMOUNT') + }) + }) + describe('transferLockedDelegationToL2', function () { + it('rejects calls if the protocol is partially paused', async function () { + await controller.setPartialPaused(true) + + const tx = staking + .connect(delegator.signer) + .transferLockedDelegationToL2(indexer.address, maxGas, gasPriceBid, maxSubmissionCost) + await expect(tx).revertedWith('Partial-paused') + }) + it('sends delegated tokens to L2 like transferDelegationToL2, but gets the beneficiary and ETH from the L1GraphTokenLockTransferTool', async function () { + const tokensToDelegate = toGRT('10000') + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + const actualDelegation = tokensToDelegate.sub( + tokensToDelegate.mul(delegationTaxPPM).div(1000000), + ) + + await staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + minimumIndexerStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + + const expectedFunctionData = defaultAbiCoder.encode( + ['tuple(address,address)'], + [[l2Indexer.address, l2Delegator.address]], + ) + + const expectedCallhookData = defaultAbiCoder.encode( + ['uint8', 'bytes'], + [toBN(1), expectedFunctionData], // code = 1 means RECEIVE_DELEGATION_CODE + ) + const expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( + grt.address, + staking.address, + mockL2Staking.address, + actualDelegation, + expectedCallhookData, + ) + + await l1GraphTokenLockTransferTool.setL2WalletAddress( + delegator.address, + l2Delegator.address, + ) + + const oldTransferToolEthBalance = await provider().getBalance( + l1GraphTokenLockTransferTool.address, + ) + const tx = staking + .connect(delegator.signer) + .transferLockedDelegationToL2(indexer.address, maxGas, gasPriceBid, maxSubmissionCost) + // seqNum is 2 because the first bridge call was in transferStakeToL2 + await expect(tx) + .emit(l1GraphTokenGateway, 'TxToL2') + .withArgs(staking.address, mockL2Gateway.address, toBN(2), expectedL2Data) + await expect(tx) + .emit(staking, 'DelegationTransferredToL2') + .withArgs( + delegator.address, + l2Delegator.address, + indexer.address, + l2Indexer.address, + actualDelegation, + ) + expect(await provider().getBalance(l1GraphTokenLockTransferTool.address)).to.equal( + oldTransferToolEthBalance.sub(maxSubmissionCost).sub(gasPriceBid.mul(maxGas)), + ) + }) + it('rejects calls if the transfer tool contract returns a zero address beneficiary', async function () { + const tokensToDelegate = toGRT('10000') + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + + await staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + minimumIndexerStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + + const tx = staking + .connect(delegator.signer) + .transferLockedDelegationToL2(indexer.address, maxGas, gasPriceBid, maxSubmissionCost) + await expect(tx).revertedWith('LOCK NOT TRANSFERRED') + }) + it('rejects calls if the transfer tool contract does not provide enough ETH', async function () { + const tokensToDelegate = toGRT('10000') + await staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate) + + await staking + .connect(indexer.signer) + .transferStakeToL2( + l2Indexer.address, + minimumIndexerStake, + maxGas, + gasPriceBid, + maxSubmissionCost, + { + value: maxSubmissionCost.add(gasPriceBid.mul(maxGas)), + }, + ) + await staking + .connect(governor.signer) + .setL1GraphTokenLockTransferTool(l1GraphTokenLockTransferToolBad.address) + + await l1GraphTokenLockTransferToolBad.setL2WalletAddress( + delegator.address, + l2Delegator.address, + ) + const tx = staking + .connect(delegator.signer) + .transferLockedDelegationToL2(indexer.address, maxGas, gasPriceBid, maxSubmissionCost) + await expect(tx).revertedWith('ETH TRANSFER FAILED') + }) + }) + }) +}) diff --git a/test/staking/staking.test.ts b/test/staking/staking.test.ts index b769c8f57..e7d8f31b7 100644 --- a/test/staking/staking.test.ts +++ b/test/staking/staking.test.ts @@ -2,7 +2,7 @@ import { expect } from 'chai' import { constants, BigNumber, Event } from 'ethers' import { GraphToken } from '../../build/types/GraphToken' -import { Staking } from '../../build/types/Staking' +import { IStaking } from '../../build/types/IStaking' import { NetworkFixture } from '../lib/fixtures' @@ -39,7 +39,7 @@ describe('Staking:Stakes', () => { let fixture: NetworkFixture let grt: GraphToken - let staking: Staking + let staking: IStaking // Test values const indexerTokens = toGRT('1000') @@ -115,8 +115,8 @@ describe('Staking:Stakes', () => { }) it('reject stake less than minimum indexer stake', async function () { - expect(toGRT('1')).lte(await staking.minimumIndexerStake()) - const tx = staking.connect(indexer.signer).stake(toGRT('1')) + const amount = (await staking.minimumIndexerStake()).sub(toGRT('1')) + const tx = staking.connect(indexer.signer).stake(amount) await expect(tx).revertedWith('!minimumIndexerStake') }) diff --git a/test/upgrade/admin.test.ts b/test/upgrade/admin.test.ts index a20640943..71e30b777 100644 --- a/test/upgrade/admin.test.ts +++ b/test/upgrade/admin.test.ts @@ -5,7 +5,7 @@ import '@nomiclabs/hardhat-ethers' import { GraphProxy } from '../../build/types/GraphProxy' import { Curation } from '../../build/types/Curation' import { GraphProxyAdmin } from '../../build/types/GraphProxyAdmin' -import { Staking } from '../../build/types/Staking' +import { IStaking } from '../../build/types/IStaking' import * as deployment from '../lib/deployment' import { NetworkFixture } from '../lib/fixtures' @@ -24,7 +24,7 @@ describe('Upgrades', () => { let proxyAdmin: GraphProxyAdmin let curation: Curation - let staking: Staking + let staking: IStaking let stakingProxy: GraphProxy before(async function () {