From a81fbc19d4913fbcbfc0dca7bca59f9af541db6b Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Thu, 22 Oct 2026 11:55:57 -0300 Subject: [PATCH 01/20] chore: remove legacy contracts-task package and verifydeployed workflow --- .github/workflows/verifydeployed.yml | 70 ------- package.json | 2 +- packages/contracts/task/CHANGELOG.md | 24 --- .../task/config/graph.arbitrum-goerli.yml | 152 -------------- .../task/config/graph.arbitrum-hardhat.yml | 163 --------------- .../task/config/graph.arbitrum-localhost.yml | 164 --------------- .../task/config/graph.arbitrum-one.yml | 152 -------------- .../task/config/graph.arbitrum-sepolia.yml | 163 --------------- .../contracts/task/config/graph.goerli.yml | 161 --------------- .../contracts/task/config/graph.hardhat.yml | 160 --------------- .../contracts/task/config/graph.localhost.yml | 161 --------------- .../contracts/task/config/graph.mainnet.yml | 161 --------------- .../contracts/task/config/graph.sepolia.yml | 161 --------------- packages/contracts/task/hardhat.config.ts | 190 ------------------ packages/contracts/task/package.json | 73 ------- packages/contracts/task/prettier.config.cjs | 5 - packages/contracts/task/src/address-book.ts | 123 ------------ packages/contracts/task/src/config.ts | 157 --------------- packages/contracts/task/src/index.ts | 16 -- .../contracts/task/tasks/bridge/deposits.ts | 110 ---------- packages/contracts/task/tasks/bridge/to-l2.ts | 53 ----- .../task/tasks/bridge/withdrawals.ts | 102 ---------- .../contracts/task/tasks/contract/deploy.ts | 40 ---- .../contracts/task/tasks/contract/upgrade.ts | 36 ---- .../contracts/task/tasks/deployment/config.ts | 53 ----- packages/contracts/task/tasks/e2e/e2e.ts | 148 -------------- .../contracts/task/tasks/migrate/bridge.ts | 46 ----- .../contracts/task/tasks/migrate/protocol.ts | 25 --- packages/contracts/task/tasks/test-upgrade.ts | 54 ----- .../contracts/task/tasks/verify/defender.ts | 121 ----------- .../contracts/task/tasks/verify/sourcify.ts | 95 --------- .../contracts/task/tasks/verify/verify.ts | 157 --------------- packages/contracts/task/tsconfig.json | 10 - packages/testing/foundry.toml | 3 +- pnpm-lock.yaml | 146 -------------- 35 files changed, 2 insertions(+), 3455 deletions(-) delete mode 100644 .github/workflows/verifydeployed.yml delete mode 100644 packages/contracts/task/CHANGELOG.md delete mode 100644 packages/contracts/task/config/graph.arbitrum-goerli.yml delete mode 100644 packages/contracts/task/config/graph.arbitrum-hardhat.yml delete mode 100644 packages/contracts/task/config/graph.arbitrum-localhost.yml delete mode 100644 packages/contracts/task/config/graph.arbitrum-one.yml delete mode 100644 packages/contracts/task/config/graph.arbitrum-sepolia.yml delete mode 100644 packages/contracts/task/config/graph.goerli.yml delete mode 100644 packages/contracts/task/config/graph.hardhat.yml delete mode 100644 packages/contracts/task/config/graph.localhost.yml delete mode 100644 packages/contracts/task/config/graph.mainnet.yml delete mode 100644 packages/contracts/task/config/graph.sepolia.yml delete mode 100644 packages/contracts/task/hardhat.config.ts delete mode 100644 packages/contracts/task/package.json delete mode 100644 packages/contracts/task/prettier.config.cjs delete mode 100644 packages/contracts/task/src/address-book.ts delete mode 100644 packages/contracts/task/src/config.ts delete mode 100644 packages/contracts/task/src/index.ts delete mode 100644 packages/contracts/task/tasks/bridge/deposits.ts delete mode 100644 packages/contracts/task/tasks/bridge/to-l2.ts delete mode 100644 packages/contracts/task/tasks/bridge/withdrawals.ts delete mode 100644 packages/contracts/task/tasks/contract/deploy.ts delete mode 100644 packages/contracts/task/tasks/contract/upgrade.ts delete mode 100644 packages/contracts/task/tasks/deployment/config.ts delete mode 100644 packages/contracts/task/tasks/e2e/e2e.ts delete mode 100644 packages/contracts/task/tasks/migrate/bridge.ts delete mode 100644 packages/contracts/task/tasks/migrate/protocol.ts delete mode 100644 packages/contracts/task/tasks/test-upgrade.ts delete mode 100644 packages/contracts/task/tasks/verify/defender.ts delete mode 100644 packages/contracts/task/tasks/verify/sourcify.ts delete mode 100644 packages/contracts/task/tasks/verify/verify.ts delete mode 100644 packages/contracts/task/tsconfig.json diff --git a/.github/workflows/verifydeployed.yml b/.github/workflows/verifydeployed.yml deleted file mode 100644 index d61ecd95a..000000000 --- a/.github/workflows/verifydeployed.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Verify deployed contracts - -on: - workflow_dispatch: - inputs: - contracts: - description: 'List of deployed contracts to verify (space delimited)' - required: true - type: string - network: - description: 'Network where the contracts are deployed' - required: true - type: choice - default: mainnet - options: - - mainnet - - arbitrum-one - - goerli - - arbitrum-goerli - -jobs: - build: - name: Compile contracts - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - submodules: recursive - - name: Set up environment - uses: ./.github/actions/setup - - - name: Build - run: | - pushd packages/contracts - pnpm build - - - name: Save build artifacts - uses: actions/upload-artifact@v7 - with: - name: contract-artifacts - path: | - packages/contracts/artifacts - packages/contracts/cache/*.json - - verify: - name: Verify deployments - runs-on: ubuntu-latest - needs: build - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Set up environment - uses: ./.github/actions/setup - - name: Build - run: | - pushd packages/contracts - pnpm build || pnpm build - - name: Get build artifacts - uses: actions/download-artifact@v8 - with: - name: contract-artifacts - - - name: Verify contracts on Defender - run: cd packages/contracts && pnpm hardhat --network ${{ inputs.network }} verify-defender ${{ inputs.contracts }} - env: - DEFENDER_API_KEY: '${{ secrets.DEFENDER_API_KEY }}' - DEFENDER_API_SECRET: '${{ secrets.DEFENDER_API_SECRET }}' - INFURA_KEY: '${{ secrets.INFURA_KEY }}' - WORKFLOW_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' diff --git a/package.json b/package.json index 764c889c6..a29080efa 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "lint:forge": "pnpm -r run lint:forge", "lint:md": "markdownlint --fix --ignore-path .gitignore 'packages/**/*.md' 'docs/**/*.md' '*.md'; prettier -w --cache --log-level warn 'packages/**/*.md' 'docs/**/*.md' '*.md'", "lint:json": "prettier -w --cache --log-level warn 'packages/**/*.{json,json5}' '.changeset/**/*.json' '*.{json,json5}'", - "lint:yaml": "npx yaml-lint .github/**/*.{yml,yaml} packages/contracts/task/config/*.yml; prettier -w --cache --log-level warn 'packages/**/*.{yml,yaml}' '.github/**/*.{yml,yaml}'", + "lint:yaml": "npx yaml-lint .github/**/*.{yml,yaml}; prettier -w --cache --log-level warn 'packages/**/*.{yml,yaml}' '.github/**/*.{yml,yaml}'", "test": "pnpm build && pnpm -r run test:self", "test:prod": "FOUNDRY_PROFILE=prod pnpm test", "test:coverage": "pnpm build && pnpm -r run build:self:coverage && pnpm -r run test:coverage:self" diff --git a/packages/contracts/task/CHANGELOG.md b/packages/contracts/task/CHANGELOG.md deleted file mode 100644 index 715d4a8a8..000000000 --- a/packages/contracts/task/CHANGELOG.md +++ /dev/null @@ -1,24 +0,0 @@ -# @graphprotocol/contracts-task - -## 1.0.3 - -### Patch Changes - -- Updated dependencies - - @graphprotocol/contracts@7.3.0 - -## 1.0.2 - -### Patch Changes - -- Updated dependencies - - @graphprotocol/contracts@7.2.1 - - @graphprotocol/sdk@0.6.2 - -## 1.0.1 - -### Patch Changes - -- Updated dependencies - - @graphprotocol/contracts@7.2.0 - - @graphprotocol/sdk@0.6.1 diff --git a/packages/contracts/task/config/graph.arbitrum-goerli.yml b/packages/contracts/task/config/graph.arbitrum-goerli.yml deleted file mode 100644 index 10c57d7ec..000000000 --- a/packages/contracts/task/config/graph.arbitrum-goerli.yml +++ /dev/null @@ -1,152 +0,0 @@ -general: - arbitrator: &arbitrator '0xF89688d5d44d73cc4dE880857A3940487076e5A4' # Arbitration Council (TODO: update) - governor: &governor '0x5CeeeE16F30357d49c50bcd7F520ca6527cf388a' # Graph Council (TODO: update) - authority: &authority '0xac01B0b3B2Dc5D8E0D484c02c4d077C15C96a7b4' # Authority that signs payment vouchers - availabilityOracle: &availabilityOracle '0xa99a56fa38a6b9553853c84e11458aeccdad509b' # Subgraph Availability Oracle (TODO: update) - pauseGuardian: &pauseGuardian '0x4B6C90B9fE29dfa521188B6547989C23d613b79B' # Protocol pause guardian (TODO: update) - allocationExchangeOwner: &allocationExchangeOwner '0x05F359b1319f1Ca9b799CB6386F31421c2c49dBA' # Allocation Exchange owner (TODO: update) - -contracts: - Controller: - calls: - - fn: 'setContractProxy' - id: '0xe6876326c1291dfcbbd3864a6816d698cd591defc7aa2153d7f9c4c04016c89f' # keccak256('Curation') - contractAddress: '${{L2Curation.address}}' - - fn: 'setContractProxy' - id: '0x39605a6c26a173774ca666c67ef70cf491880e5d3d6d0ca66ec0a31034f15ea3' # keccak256('GNS') - contractAddress: '${{L2GNS.address}}' - - fn: 'setContractProxy' - id: '0xf942813d07d17b56de9a9afc8de0ced6e8c053bbfdcc87b7badea4ddcf27c307' # keccak256('DisputeManager') - contractAddress: '${{DisputeManager.address}}' - - fn: 'setContractProxy' - id: '0xc713c3df6d14cdf946460395d09af88993ee2b948b1a808161494e32c5f67063' # keccak256('EpochManager') - contractAddress: '${{EpochManager.address}}' - - fn: 'setContractProxy' - id: '0x966f1e8d8d8014e05f6ec4a57138da9be1f7c5a7f802928a18072f7c53180761' # keccak256('RewardsManager') - contractAddress: '${{RewardsManager.address}}' - - fn: 'setContractProxy' - id: '0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034' # keccak256('Staking') - contractAddress: '${{L2Staking.address}}' - - fn: 'setContractProxy' - id: '0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247' # keccak256('GraphToken') - contractAddress: '${{L2GraphToken.address}}' - - fn: 'setContractProxy' - id: '0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0' # keccak256('GraphTokenGateway') - contractAddress: '${{L2GraphTokenGateway.address}}' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - - fn: 'transferOwnership' - owner: *governor - GraphProxyAdmin: - calls: - - fn: 'transferOwnership' - owner: *governor - ServiceRegistry: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - EpochManager: - proxy: true - init: - controller: '${{Controller.address}}' - lengthInBlocks: 554 # length in hours = lengthInBlocks*13/60/60 (~13 second blocks) - L2GraphToken: - proxy: true - init: - owner: '${{Env.deployer}}' - calls: - - fn: 'addMinter' - minter: '${{RewardsManager.address}}' - - fn: 'renounceMinter' - - fn: 'transferOwnership' - owner: *governor - L2Curation: - proxy: true - init: - controller: '${{Controller.address}}' - curationTokenMaster: '${{GraphCurationToken.address}}' - curationTaxPercentage: 10000 # in parts per million - minimumCurationDeposit: '1' # in wei - calls: - - fn: 'syncAllContracts' - DisputeManager: - proxy: true - init: - controller: '${{Controller.address}}' - arbitrator: *arbitrator - minimumDeposit: '10000000000000000000000' # in wei - fishermanRewardPercentage: 500000 # in parts per million - idxSlashingPercentage: 25000 # in parts per million - qrySlashingPercentage: 25000 # in parts per million - calls: - - fn: 'syncAllContracts' - L2GNS: - proxy: true - init: - controller: '${{Controller.address}}' - subgraphNFT: '${{SubgraphNFT.address}}' - calls: - - fn: 'approveAll' - - fn: 'syncAllContracts' - SubgraphNFT: - init: - governor: '${{Env.deployer}}' - calls: - - fn: 'setTokenDescriptor' - tokenDescriptor: '${{SubgraphNFTDescriptor.address}}' - - fn: 'setMinter' - minter: '${{L2GNS.address}}' - - fn: 'transferOwnership' - owner: *governor - L2Staking: - proxy: true - init: - controller: '${{Controller.address}}' - minimumIndexerStake: '100000000000000000000000' # in wei - thawingPeriod: 6646 # in blocks - protocolPercentage: 10000 # in parts per million - curationPercentage: 100000 # in parts per million - maxAllocationEpochs: 8 # in epochs - delegationUnbondingPeriod: 12 # in epochs - delegationRatio: 16 # delegated stake to indexer stake multiplier - rebateParameters: - alphaNumerator: 100 # alphaNumerator / alphaDenominator - alphaDenominator: 100 # alphaNumerator / alphaDenominator - lambdaNumerator: 60 # lambdaNumerator / lambdaDenominator - lambdaDenominator: 100 # lambdaNumerator / lambdaDenominator - extensionImpl: '${{StakingExtension.address}}' - calls: - - fn: 'setDelegationTaxPercentage' - delegationTaxPercentage: 5000 # parts per million - - fn: 'setSlasher' - slasher: '${{DisputeManager.address}}' - allowed: true - - fn: 'syncAllContracts' - RewardsManager: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'setIssuancePerBlock' - issuancePerBlock: '6036500000000000000' # per block increase of total supply, blocks in a year = 365*60*60*24/12 - - fn: 'setSubgraphAvailabilityOracle' - subgraphAvailabilityOracle: *availabilityOracle - - fn: 'syncAllContracts' - AllocationExchange: - init: - graphToken: '${{L2GraphToken.address}}' - staking: '${{L2Staking.address}}' - governor: *allocationExchangeOwner - authority: *authority - calls: - - fn: 'approveAll' - L2GraphTokenGateway: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian diff --git a/packages/contracts/task/config/graph.arbitrum-hardhat.yml b/packages/contracts/task/config/graph.arbitrum-hardhat.yml deleted file mode 100644 index e8f35847f..000000000 --- a/packages/contracts/task/config/graph.arbitrum-hardhat.yml +++ /dev/null @@ -1,163 +0,0 @@ -general: - arbitrator: &arbitrator '0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0' # Arbitration Council - governor: &governor '0x22d491Bde2303f2f43325b2108D26f1eAbA1e32b' # Graph Council - authority: &authority '0xE11BA2b4D45Eaed5996Cd0823791E0C93114882d' # Authority that signs payment vouchers - availabilityOracles: &availabilityOracles # Subgraph Availability Oracles - - '0xd03ea8624C8C5987235048901fB614fDcA89b117' - - '0xd03ea8624C8C5987235048901fB614fDcA89b117' - - '0xd03ea8624C8C5987235048901fB614fDcA89b117' - - '0xd03ea8624C8C5987235048901fB614fDcA89b117' - - '0xd03ea8624C8C5987235048901fB614fDcA89b117' - pauseGuardian: &pauseGuardian '0x95cED938F7991cd0dFcb48F0a06a40FA1aF46EBC' # Protocol pause guardian - allocationExchangeOwner: &allocationExchangeOwner '0x3E5e9111Ae8eB78Fe1CC3bb8915d5D461F3Ef9A9' # Allocation Exchange owner - -contracts: - Controller: - calls: - - fn: 'setContractProxy' - id: '0xe6876326c1291dfcbbd3864a6816d698cd591defc7aa2153d7f9c4c04016c89f' # keccak256('Curation') - contractAddress: '${{L2Curation.address}}' - - fn: 'setContractProxy' - id: '0x39605a6c26a173774ca666c67ef70cf491880e5d3d6d0ca66ec0a31034f15ea3' # keccak256('GNS') - contractAddress: '${{L2GNS.address}}' - - fn: 'setContractProxy' - id: '0xf942813d07d17b56de9a9afc8de0ced6e8c053bbfdcc87b7badea4ddcf27c307' # keccak256('DisputeManager') - contractAddress: '${{DisputeManager.address}}' - - fn: 'setContractProxy' - id: '0xc713c3df6d14cdf946460395d09af88993ee2b948b1a808161494e32c5f67063' # keccak256('EpochManager') - contractAddress: '${{EpochManager.address}}' - - fn: 'setContractProxy' - id: '0x966f1e8d8d8014e05f6ec4a57138da9be1f7c5a7f802928a18072f7c53180761' # keccak256('RewardsManager') - contractAddress: '${{RewardsManager.address}}' - - fn: 'setContractProxy' - id: '0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034' # keccak256('Staking') - contractAddress: '${{L2Staking.address}}' - - fn: 'setContractProxy' - id: '0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247' # keccak256('GraphToken') - contractAddress: '${{L2GraphToken.address}}' - - fn: 'setContractProxy' - id: '0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0' # keccak256('GraphTokenGateway') - contractAddress: '${{L2GraphTokenGateway.address}}' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - - fn: 'transferOwnership' - owner: *governor - GraphProxyAdmin: - calls: - - fn: 'transferOwnership' - owner: *governor - ServiceRegistry: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - EpochManager: - proxy: true - init: - controller: '${{Controller.address}}' - lengthInBlocks: 60 # length in hours = lengthInBlocks*13/60/60 (~13 second blocks) - L2GraphToken: - proxy: true - init: - owner: '${{Env.deployer}}' - calls: - - fn: 'addMinter' - minter: '${{RewardsManager.address}}' - - fn: 'transferOwnership' - owner: *governor - L2Curation: - proxy: true - init: - controller: '${{Controller.address}}' - curationTokenMaster: '${{GraphCurationToken.address}}' - curationTaxPercentage: 0 # in parts per million - minimumCurationDeposit: '1' # in wei - calls: - - fn: 'syncAllContracts' - DisputeManager: - proxy: true - init: - controller: '${{Controller.address}}' - arbitrator: *arbitrator - minimumDeposit: '100000000000000000000' # in wei - fishermanRewardPercentage: 1000 # in parts per million - qrySlashingPercentage: 1000 # in parts per million - idxSlashingPercentage: 100000 # in parts per million - calls: - - fn: 'syncAllContracts' - L2GNS: - proxy: true - init: - controller: '${{Controller.address}}' - subgraphNFT: '${{SubgraphNFT.address}}' - calls: - - fn: 'approveAll' - - fn: 'syncAllContracts' - SubgraphNFT: - init: - governor: '${{Env.deployer}}' - calls: - - fn: 'setTokenDescriptor' - tokenDescriptor: '${{SubgraphNFTDescriptor.address}}' - - fn: 'setMinter' - minter: '${{L2GNS.address}}' - - fn: 'transferOwnership' - owner: *governor - L2Staking: - proxy: true - init: - controller: '${{Controller.address}}' - minimumIndexerStake: '10000000000000000000' # in wei - thawingPeriod: 20 # in blocks - protocolPercentage: 0 # in parts per million - curationPercentage: 0 # in parts per million - maxAllocationEpochs: 5 # in epochs - delegationUnbondingPeriod: 1 # in epochs - delegationRatio: 16 # delegated stake to indexer stake multiplier - rebateParameters: - alphaNumerator: 100 # alphaNumerator / alphaDenominator - alphaDenominator: 100 # alphaNumerator / alphaDenominator - lambdaNumerator: 60 # lambdaNumerator / lambdaDenominator - lambdaDenominator: 100 # lambdaNumerator / lambdaDenominator - extensionImpl: '${{StakingExtension.address}}' - calls: - - fn: 'setDelegationTaxPercentage' - delegationTaxPercentage: 0 # parts per million - - fn: 'setSlasher' - slasher: '${{DisputeManager.address}}' - allowed: true - - fn: 'syncAllContracts' - RewardsManager: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'setIssuancePerBlock' - issuancePerBlock: '114155251141552511415' # per block increase of total supply, blocks in a year = 365*60*60*24/12 - - fn: 'setSubgraphAvailabilityOracle' - subgraphAvailabilityOracle: '${{SubgraphAvailabilityManager.address}}' - - fn: 'syncAllContracts' - AllocationExchange: - init: - graphToken: '${{L2GraphToken.address}}' - staking: '${{L2Staking.address}}' - governor: *allocationExchangeOwner - authority: *authority - calls: - - fn: 'approveAll' - L2GraphTokenGateway: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - SubgraphAvailabilityManager: - init: - governor: *governor - rewardsManager: '${{RewardsManager.address}}' - executionThreshold: 5 - voteTimeLimit: 300 - oracles: *availabilityOracles diff --git a/packages/contracts/task/config/graph.arbitrum-localhost.yml b/packages/contracts/task/config/graph.arbitrum-localhost.yml deleted file mode 100644 index b09508fef..000000000 --- a/packages/contracts/task/config/graph.arbitrum-localhost.yml +++ /dev/null @@ -1,164 +0,0 @@ -general: - arbitrator: &arbitrator '0x4237154FE0510FdE3575656B60c68a01B9dCDdF8' # Arbitration Council - governor: &governor '0x1257227a2ECA34834940110f7B5e341A5143A2c4' # Graph Council - authority: &authority '0x12B8D08b116E1E3cc29eE9Cf42bB0AA8129C3215' # Authority that signs payment vouchers - availabilityOracles: &availabilityOracles # Subgraph Availability Oracles - - '0x7694a48065f063a767a962610C6717c59F36b445' - - '0x7694a48065f063a767a962610C6717c59F36b445' - - '0x7694a48065f063a767a962610C6717c59F36b445' - - '0x7694a48065f063a767a962610C6717c59F36b445' - - '0x7694a48065f063a767a962610C6717c59F36b445' - pauseGuardian: &pauseGuardian '0x601060e0DC5349AA55EC73df5A58cB0FC1cD2e3C' # Protocol pause guardian - allocationExchangeOwner: &allocationExchangeOwner '0xbD38F7b67a591A5cc7D642e1026E5095B819d952' # Allocation Exchange owner - -contracts: - Controller: - calls: - - fn: 'setContractProxy' - id: '0xe6876326c1291dfcbbd3864a6816d698cd591defc7aa2153d7f9c4c04016c89f' # keccak256('Curation') - contractAddress: '${{L2Curation.address}}' - - fn: 'setContractProxy' - id: '0x39605a6c26a173774ca666c67ef70cf491880e5d3d6d0ca66ec0a31034f15ea3' # keccak256('GNS') - contractAddress: '${{L2GNS.address}}' - - fn: 'setContractProxy' - id: '0xf942813d07d17b56de9a9afc8de0ced6e8c053bbfdcc87b7badea4ddcf27c307' # keccak256('DisputeManager') - contractAddress: '${{DisputeManager.address}}' - - fn: 'setContractProxy' - id: '0xc713c3df6d14cdf946460395d09af88993ee2b948b1a808161494e32c5f67063' # keccak256('EpochManager') - contractAddress: '${{EpochManager.address}}' - - fn: 'setContractProxy' - id: '0x966f1e8d8d8014e05f6ec4a57138da9be1f7c5a7f802928a18072f7c53180761' # keccak256('RewardsManager') - contractAddress: '${{RewardsManager.address}}' - - fn: 'setContractProxy' - id: '0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034' # keccak256('Staking') - contractAddress: '${{L2Staking.address}}' - - fn: 'setContractProxy' - id: '0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247' # keccak256('GraphToken') - contractAddress: '${{L2GraphToken.address}}' - - fn: 'setContractProxy' - id: '0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0' # keccak256('GraphTokenGateway') - contractAddress: '${{L2GraphTokenGateway.address}}' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - - fn: 'transferOwnership' - owner: *governor - GraphProxyAdmin: - calls: - - fn: 'transferOwnership' - owner: *governor - ServiceRegistry: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - EpochManager: - proxy: true - init: - controller: '${{Controller.address}}' - lengthInBlocks: 554 # length in hours = lengthInBlocks*13/60/60 (~13 second blocks) - L2GraphToken: - proxy: true - init: - owner: '${{Env.deployer}}' - calls: - - fn: 'addMinter' - minter: '${{RewardsManager.address}}' - - fn: 'renounceMinter' - - fn: 'transferOwnership' - owner: *governor - L2Curation: - proxy: true - init: - controller: '${{Controller.address}}' - curationTokenMaster: '${{GraphCurationToken.address}}' - curationTaxPercentage: 10000 # in parts per million - minimumCurationDeposit: '1' # in wei - calls: - - fn: 'syncAllContracts' - DisputeManager: - proxy: true - init: - controller: '${{Controller.address}}' - arbitrator: *arbitrator - minimumDeposit: '10000000000000000000000' # in wei - fishermanRewardPercentage: 500000 # in parts per million - idxSlashingPercentage: 25000 # in parts per million - qrySlashingPercentage: 25000 # in parts per million - calls: - - fn: 'syncAllContracts' - L2GNS: - proxy: true - init: - controller: '${{Controller.address}}' - subgraphNFT: '${{SubgraphNFT.address}}' - calls: - - fn: 'approveAll' - - fn: 'syncAllContracts' - SubgraphNFT: - init: - governor: '${{Env.deployer}}' - calls: - - fn: 'setTokenDescriptor' - tokenDescriptor: '${{SubgraphNFTDescriptor.address}}' - - fn: 'setMinter' - minter: '${{L2GNS.address}}' - - fn: 'transferOwnership' - owner: *governor - L2Staking: - proxy: true - init: - controller: '${{Controller.address}}' - minimumIndexerStake: '100000000000000000000000' # in wei - thawingPeriod: 6646 # in blocks - protocolPercentage: 10000 # in parts per million - curationPercentage: 100000 # in parts per million - maxAllocationEpochs: 4 # in epochs - delegationUnbondingPeriod: 12 # in epochs - delegationRatio: 16 # delegated stake to indexer stake multiplier - rebateParameters: - alphaNumerator: 100 # alphaNumerator / alphaDenominator - alphaDenominator: 100 # alphaNumerator / alphaDenominator - lambdaNumerator: 60 # lambdaNumerator / lambdaDenominator - lambdaDenominator: 100 # lambdaNumerator / lambdaDenominator - extensionImpl: '${{StakingExtension.address}}' - calls: - - fn: 'setDelegationTaxPercentage' - delegationTaxPercentage: 5000 # parts per million - - fn: 'setSlasher' - slasher: '${{DisputeManager.address}}' - allowed: true - - fn: 'syncAllContracts' - RewardsManager: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'setIssuancePerBlock' - issuancePerBlock: '6036500000000000000' # per block increase of total supply, blocks in a year = 365*60*60*24/12 - - fn: 'setSubgraphAvailabilityOracle' - subgraphAvailabilityOracle: '${{SubgraphAvailabilityManager.address}}' - - fn: 'syncAllContracts' - AllocationExchange: - init: - graphToken: '${{L2GraphToken.address}}' - staking: '${{L2Staking.address}}' - governor: *allocationExchangeOwner - authority: *authority - calls: - - fn: 'approveAll' - L2GraphTokenGateway: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - SubgraphAvailabilityManager: - init: - governor: *governor - rewardsManager: '${{RewardsManager.address}}' - executionThreshold: 5 - voteTimeLimit: 300 - oracles: *availabilityOracles diff --git a/packages/contracts/task/config/graph.arbitrum-one.yml b/packages/contracts/task/config/graph.arbitrum-one.yml deleted file mode 100644 index c3796b548..000000000 --- a/packages/contracts/task/config/graph.arbitrum-one.yml +++ /dev/null @@ -1,152 +0,0 @@ -general: - arbitrator: &arbitrator '0x113DC95e796836b8F0Fa71eE7fB42f221740c3B0' # Arbitration Council - governor: &governor '0x8C6de8F8D562f3382417340A6994601eE08D3809' # Graph Council - authority: &authority '0x4a06858f104B2aB1e1185AB7E09F7B5d3b700479' # Authority that signs payment vouchers - availabilityOracle: &availabilityOracle '0x1BEB2266f264Cebd9C6FBE1ceB394a8d944401c1' # Subgraph Availability Oracle - pauseGuardian: &pauseGuardian '0xB0aD33a21b98bCA1761729A105e2E34e27153aAE' # Protocol pause guardian - allocationExchangeOwner: &allocationExchangeOwner '0x270Ea4ea9e8A699f8fE54515E3Bb2c418952623b' # Allocation Exchange owner - -contracts: - Controller: - calls: - - fn: 'setContractProxy' - id: '0xe6876326c1291dfcbbd3864a6816d698cd591defc7aa2153d7f9c4c04016c89f' # keccak256('Curation') - contractAddress: '${{L2Curation.address}}' - - fn: 'setContractProxy' - id: '0x39605a6c26a173774ca666c67ef70cf491880e5d3d6d0ca66ec0a31034f15ea3' # keccak256('GNS') - contractAddress: '${{L2GNS.address}}' - - fn: 'setContractProxy' - id: '0xf942813d07d17b56de9a9afc8de0ced6e8c053bbfdcc87b7badea4ddcf27c307' # keccak256('DisputeManager') - contractAddress: '${{DisputeManager.address}}' - - fn: 'setContractProxy' - id: '0xc713c3df6d14cdf946460395d09af88993ee2b948b1a808161494e32c5f67063' # keccak256('EpochManager') - contractAddress: '${{EpochManager.address}}' - - fn: 'setContractProxy' - id: '0x966f1e8d8d8014e05f6ec4a57138da9be1f7c5a7f802928a18072f7c53180761' # keccak256('RewardsManager') - contractAddress: '${{RewardsManager.address}}' - - fn: 'setContractProxy' - id: '0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034' # keccak256('Staking') - contractAddress: '${{L2Staking.address}}' - - fn: 'setContractProxy' - id: '0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247' # keccak256('GraphToken') - contractAddress: '${{L2GraphToken.address}}' - - fn: 'setContractProxy' - id: '0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0' # keccak256('GraphTokenGateway') - contractAddress: '${{L2GraphTokenGateway.address}}' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - - fn: 'transferOwnership' - owner: *governor - GraphProxyAdmin: - calls: - - fn: 'transferOwnership' - owner: *governor - ServiceRegistry: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - EpochManager: - proxy: true - init: - controller: '${{Controller.address}}' - lengthInBlocks: 6646 # length in hours = lengthInBlocks*13/60/60 (~13 second blocks) - L2GraphToken: - proxy: true - init: - owner: '${{Env.deployer}}' - calls: - - fn: 'addMinter' - minter: '${{RewardsManager.address}}' - - fn: 'renounceMinter' - - fn: 'transferOwnership' - owner: *governor - L2Curation: - proxy: true - init: - controller: '${{Controller.address}}' - curationTokenMaster: '${{GraphCurationToken.address}}' - curationTaxPercentage: 10000 # in parts per million - minimumCurationDeposit: '1' # in wei - calls: - - fn: 'syncAllContracts' - DisputeManager: - proxy: true - init: - controller: '${{Controller.address}}' - arbitrator: *arbitrator - minimumDeposit: '10000000000000000000000' # in wei - fishermanRewardPercentage: 500000 # in parts per million - idxSlashingPercentage: 25000 # in parts per million - qrySlashingPercentage: 25000 # in parts per million - calls: - - fn: 'syncAllContracts' - L2GNS: - proxy: true - init: - controller: '${{Controller.address}}' - subgraphNFT: '${{SubgraphNFT.address}}' - calls: - - fn: 'approveAll' - - fn: 'syncAllContracts' - SubgraphNFT: - init: - governor: '${{Env.deployer}}' - calls: - - fn: 'setTokenDescriptor' - tokenDescriptor: '${{SubgraphNFTDescriptor.address}}' - - fn: 'setMinter' - minter: '${{L2GNS.address}}' - - fn: 'transferOwnership' - owner: *governor - L2Staking: - proxy: true - init: - controller: '${{Controller.address}}' - minimumIndexerStake: '100000000000000000000000' # in wei - thawingPeriod: 186092 # in blocks - protocolPercentage: 10000 # in parts per million - curationPercentage: 100000 # in parts per million - maxAllocationEpochs: 28 # in epochs - delegationUnbondingPeriod: 28 # in epochs - delegationRatio: 16 # delegated stake to indexer stake multiplier - rebateParameters: - alphaNumerator: 100 # alphaNumerator / alphaDenominator - alphaDenominator: 100 # alphaNumerator / alphaDenominator - lambdaNumerator: 60 # lambdaNumerator / lambdaDenominator - lambdaDenominator: 100 # lambdaNumerator / lambdaDenominator - extensionImpl: '${{StakingExtension.address}}' - calls: - - fn: 'setDelegationTaxPercentage' - delegationTaxPercentage: 5000 # parts per million - - fn: 'setSlasher' - slasher: '${{DisputeManager.address}}' - allowed: true - - fn: 'syncAllContracts' - RewardsManager: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'setIssuancePerBlock' - issuancePerBlock: '6036500000000000000' # per block increase of total supply, blocks in a year = 365*60*60*24/12 - - fn: 'setSubgraphAvailabilityOracle' - subgraphAvailabilityOracle: *availabilityOracle - - fn: 'syncAllContracts' - AllocationExchange: - init: - graphToken: '${{L2GraphToken.address}}' - staking: '${{L2Staking.address}}' - governor: *allocationExchangeOwner - authority: *authority - calls: - - fn: 'approveAll' - L2GraphTokenGateway: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian diff --git a/packages/contracts/task/config/graph.arbitrum-sepolia.yml b/packages/contracts/task/config/graph.arbitrum-sepolia.yml deleted file mode 100644 index 4d4c0785e..000000000 --- a/packages/contracts/task/config/graph.arbitrum-sepolia.yml +++ /dev/null @@ -1,163 +0,0 @@ -general: - arbitrator: &arbitrator '0x1726A5d52e279d02ff4732eCeB2D67BFE5Add328' # EOA (TODO: update to a multisig) - governor: &governor '0x72ee30d43Fb5A90B3FE983156C5d2fBE6F6d07B3' # EOA (TODO: update to a multisig) - authority: &authority '0x49D4CFC037430cA9355B422bAeA7E9391e1d3215' # Authority that signs payment vouchers - availabilityOracles: &availabilityOracles # Array of Subgraph Availability Oracles - - '0x5e4e823Ed094c035133eEC5Ec0d08ae1Af04e9Fa' - - '0x5aeE4c46cF9260E85E630ca7d9D757D5D5DbaFD6' - - '0x7369Cf2a917296c36f506144f3dE552403d1e1f1' - - '0x1e1f84c07e0471fc979f6f08228b0bd34cda9e54' - - '0x711aEA1f358DFAf74D34B4B525F9190e631F406C' - pauseGuardian: &pauseGuardian '0xa0444508232dA3FA6C2f96a5f105f3f0cc0d20D7' # Protocol pause guardian - allocationExchangeOwner: &allocationExchangeOwner '0x72ee30d43Fb5A90B3FE983156C5d2fBE6F6d07B3' # Allocation Exchange owner - -contracts: - Controller: - calls: - - fn: 'setContractProxy' - id: '0xe6876326c1291dfcbbd3864a6816d698cd591defc7aa2153d7f9c4c04016c89f' # keccak256('Curation') - contractAddress: '${{L2Curation.address}}' - - fn: 'setContractProxy' - id: '0x39605a6c26a173774ca666c67ef70cf491880e5d3d6d0ca66ec0a31034f15ea3' # keccak256('GNS') - contractAddress: '${{L2GNS.address}}' - - fn: 'setContractProxy' - id: '0xf942813d07d17b56de9a9afc8de0ced6e8c053bbfdcc87b7badea4ddcf27c307' # keccak256('DisputeManager') - contractAddress: '${{DisputeManager.address}}' - - fn: 'setContractProxy' - id: '0xc713c3df6d14cdf946460395d09af88993ee2b948b1a808161494e32c5f67063' # keccak256('EpochManager') - contractAddress: '${{EpochManager.address}}' - - fn: 'setContractProxy' - id: '0x966f1e8d8d8014e05f6ec4a57138da9be1f7c5a7f802928a18072f7c53180761' # keccak256('RewardsManager') - contractAddress: '${{RewardsManager.address}}' - - fn: 'setContractProxy' - id: '0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034' # keccak256('Staking') - contractAddress: '${{L2Staking.address}}' - - fn: 'setContractProxy' - id: '0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247' # keccak256('GraphToken') - contractAddress: '${{L2GraphToken.address}}' - - fn: 'setContractProxy' - id: '0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0' # keccak256('GraphTokenGateway') - contractAddress: '${{L2GraphTokenGateway.address}}' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - - fn: 'transferOwnership' - owner: *governor - GraphProxyAdmin: - calls: - - fn: 'transferOwnership' - owner: *governor - ServiceRegistry: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - EpochManager: - proxy: true - init: - controller: '${{Controller.address}}' - lengthInBlocks: 554 # length in hours = lengthInBlocks*13/60/60 (~13 second blocks) - L2GraphToken: - proxy: true - init: - owner: '${{Env.deployer}}' - calls: - - fn: 'addMinter' - minter: '${{RewardsManager.address}}' - - fn: 'transferOwnership' - owner: *governor - L2Curation: - proxy: true - init: - controller: '${{Controller.address}}' - curationTokenMaster: '${{GraphCurationToken.address}}' - curationTaxPercentage: 10000 # in parts per million - minimumCurationDeposit: '1' # in wei - calls: - - fn: 'syncAllContracts' - DisputeManager: - proxy: true - init: - controller: '${{Controller.address}}' - arbitrator: *arbitrator - minimumDeposit: '10000000000000000000000' # in wei - fishermanRewardPercentage: 500000 # in parts per million - idxSlashingPercentage: 25000 # in parts per million - qrySlashingPercentage: 25000 # in parts per million - calls: - - fn: 'syncAllContracts' - L2GNS: - proxy: true - init: - controller: '${{Controller.address}}' - subgraphNFT: '${{SubgraphNFT.address}}' - calls: - - fn: 'approveAll' - - fn: 'syncAllContracts' - SubgraphNFT: - init: - governor: '${{Env.deployer}}' - calls: - - fn: 'setTokenDescriptor' - tokenDescriptor: '${{SubgraphNFTDescriptor.address}}' - - fn: 'setMinter' - minter: '${{L2GNS.address}}' - - fn: 'transferOwnership' - owner: *governor - L2Staking: - proxy: true - init: - controller: '${{Controller.address}}' - minimumIndexerStake: '100000000000000000000000' # in wei - thawingPeriod: 6646 # in blocks - protocolPercentage: 10000 # in parts per million - curationPercentage: 100000 # in parts per million - maxAllocationEpochs: 8 # in epochs - delegationUnbondingPeriod: 12 # in epochs - delegationRatio: 16 # delegated stake to indexer stake multiplier - rebateParameters: - alphaNumerator: 100 # alphaNumerator / alphaDenominator - alphaDenominator: 100 # alphaNumerator / alphaDenominator - lambdaNumerator: 60 # lambdaNumerator / lambdaDenominator - lambdaDenominator: 100 # lambdaNumerator / lambdaDenominator - extensionImpl: '${{StakingExtension.address}}' - calls: - - fn: 'setDelegationTaxPercentage' - delegationTaxPercentage: 5000 # parts per million - - fn: 'setSlasher' - slasher: '${{DisputeManager.address}}' - allowed: true - - fn: 'syncAllContracts' - RewardsManager: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'setIssuancePerBlock' - issuancePerBlock: '6036500000000000000' # per block increase of total supply, blocks in a year = 365*60*60*24/12 - - fn: 'setSubgraphAvailabilityOracle' - subgraphAvailabilityOracle: '${{SubgraphAvailabilityManager.address}}' - - fn: 'syncAllContracts' - AllocationExchange: - init: - graphToken: '${{L2GraphToken.address}}' - staking: '${{L2Staking.address}}' - governor: *allocationExchangeOwner - authority: *authority - calls: - - fn: 'approveAll' - L2GraphTokenGateway: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - SubgraphAvailabilityManager: - init: - governor: *governor - rewardsManager: '${{RewardsManager.address}}' - executionThreshold: 5 - voteTimeLimit: 300 - oracles: *availabilityOracles diff --git a/packages/contracts/task/config/graph.goerli.yml b/packages/contracts/task/config/graph.goerli.yml deleted file mode 100644 index f4128f936..000000000 --- a/packages/contracts/task/config/graph.goerli.yml +++ /dev/null @@ -1,161 +0,0 @@ -general: - arbitrator: &arbitrator '0xFD01aa87BeB04D0ac764FC298aCFd05FfC5439cD' # Arbitration Council - governor: &governor '0xf1135bFF22512FF2A585b8d4489426CE660f204c' # Graph Council - authority: &authority '0x52e498aE9B8A5eE2A5Cd26805F06A9f29A7F489F' # Authority that signs payment vouchers - availabilityOracle: &availabilityOracle '0xDC6785e39Cba34A6d258148e3E83E24285118796' # Subgraph Availability Oracle - pauseGuardian: &pauseGuardian '0x6855D551CaDe60754D145fb5eDCD90912D860262' # Protocol pause guardian - allocationExchangeOwner: &allocationExchangeOwner '0xf1135bFF22512FF2A585b8d4489426CE660f204c' # Allocation Exchange owner - -contracts: - Controller: - calls: - - fn: 'setContractProxy' - id: '0xe6876326c1291dfcbbd3864a6816d698cd591defc7aa2153d7f9c4c04016c89f' # keccak256('Curation') - contractAddress: '${{Curation.address}}' - - fn: 'setContractProxy' - id: '0x39605a6c26a173774ca666c67ef70cf491880e5d3d6d0ca66ec0a31034f15ea3' # keccak256('GNS') - contractAddress: '${{L1GNS.address}}' - - fn: 'setContractProxy' - id: '0xf942813d07d17b56de9a9afc8de0ced6e8c053bbfdcc87b7badea4ddcf27c307' # keccak256('DisputeManager') - contractAddress: '${{DisputeManager.address}}' - - fn: 'setContractProxy' - id: '0xc713c3df6d14cdf946460395d09af88993ee2b948b1a808161494e32c5f67063' # keccak256('EpochManager') - contractAddress: '${{EpochManager.address}}' - - fn: 'setContractProxy' - id: '0x966f1e8d8d8014e05f6ec4a57138da9be1f7c5a7f802928a18072f7c53180761' # keccak256('RewardsManager') - contractAddress: '${{RewardsManager.address}}' - - fn: 'setContractProxy' - id: '0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034' # keccak256('Staking') - contractAddress: '${{L1Staking.address}}' - - fn: 'setContractProxy' - id: '0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247' # keccak256('GraphToken') - contractAddress: '${{GraphToken.address}}' - - fn: 'setContractProxy' - id: '0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0' # keccak256('GraphTokenGateway') - contractAddress: '${{L1GraphTokenGateway.address}}' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - - fn: 'transferOwnership' - owner: *governor - GraphProxyAdmin: - calls: - - fn: 'transferOwnership' - owner: *governor - ServiceRegistry: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - EpochManager: - proxy: true - init: - controller: '${{Controller.address}}' - lengthInBlocks: 554 # length in hours = lengthInBlocks*13/60/60 (~13 second blocks) - GraphToken: - init: - initialSupply: '10000000000000000000000000000' # in wei - calls: - - fn: 'addMinter' - minter: '${{RewardsManager.address}}' - - fn: 'addMinter' - minter: '${{L1GraphTokenGateway.address}}' - - fn: 'renounceMinter' - - fn: 'transferOwnership' - owner: *governor - Curation: - proxy: true - init: - controller: '${{Controller.address}}' - bondingCurve: '${{BancorFormula.address}}' - curationTokenMaster: '${{GraphCurationToken.address}}' - reserveRatio: 500000 # in parts per million - curationTaxPercentage: 10000 # in parts per million - minimumCurationDeposit: '1000000000000000000' # in wei - calls: - - fn: 'syncAllContracts' - DisputeManager: - proxy: true - init: - controller: '${{Controller.address}}' - arbitrator: *arbitrator - minimumDeposit: '10000000000000000000000' # in wei - fishermanRewardPercentage: 500000 # in parts per million - idxSlashingPercentage: 25000 # in parts per million - qrySlashingPercentage: 25000 # in parts per million - calls: - - fn: 'syncAllContracts' - L1GNS: - proxy: true - init: - controller: '${{Controller.address}}' - subgraphNFT: '${{SubgraphNFT.address}}' - calls: - - fn: 'approveAll' - - fn: 'syncAllContracts' - SubgraphNFT: - init: - governor: '${{Env.deployer}}' - calls: - - fn: 'setTokenDescriptor' - tokenDescriptor: '${{SubgraphNFTDescriptor.address}}' - - fn: 'setMinter' - minter: '${{L1GNS.address}}' - - fn: 'transferOwnership' - owner: *governor - L1Staking: - proxy: true - init: - controller: '${{Controller.address}}' - minimumIndexerStake: '100000000000000000000000' # in wei - thawingPeriod: 6646 # in blocks - protocolPercentage: 10000 # in parts per million - curationPercentage: 100000 # in parts per million - maxAllocationEpochs: 4 # in epochs - delegationUnbondingPeriod: 12 # in epochs - delegationRatio: 16 # delegated stake to indexer stake multiplier - rebateParameters: - alphaNumerator: 100 # alphaNumerator / alphaDenominator - alphaDenominator: 100 # alphaNumerator / alphaDenominator - lambdaNumerator: 60 # lambdaNumerator / lambdaDenominator - lambdaDenominator: 100 # lambdaNumerator / lambdaDenominator - extensionImpl: '${{StakingExtension.address}}' - calls: - - fn: 'setDelegationTaxPercentage' - delegationTaxPercentage: 5000 # parts per million - - fn: 'setSlasher' - slasher: '${{DisputeManager.address}}' - allowed: true - - fn: 'syncAllContracts' - RewardsManager: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'setIssuancePerBlock' - issuancePerBlock: '114693500000000000000' # per block increase of total supply, blocks in a year = 365*60*60*24/12 - - fn: 'setSubgraphAvailabilityOracle' - subgraphAvailabilityOracle: *availabilityOracle - - fn: 'syncAllContracts' - AllocationExchange: - init: - graphToken: '${{GraphToken.address}}' - staking: '${{L1Staking.address}}' - governor: *allocationExchangeOwner - authority: *authority - calls: - - fn: 'approveAll' - L1GraphTokenGateway: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - BridgeEscrow: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' diff --git a/packages/contracts/task/config/graph.hardhat.yml b/packages/contracts/task/config/graph.hardhat.yml deleted file mode 100644 index c5e15f2dd..000000000 --- a/packages/contracts/task/config/graph.hardhat.yml +++ /dev/null @@ -1,160 +0,0 @@ -general: - arbitrator: &arbitrator '0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0' # Arbitration Council - governor: &governor '0x22d491Bde2303f2f43325b2108D26f1eAbA1e32b' # Governor Council - authority: &authority '0xE11BA2b4D45Eaed5996Cd0823791E0C93114882d' # Authority that signs payment vouchers - availabilityOracle: &availabilityOracle '0xd03ea8624C8C5987235048901fB614fDcA89b117' # Subgraph Availability Oracle - pauseGuardian: &pauseGuardian '0x95cED938F7991cd0dFcb48F0a06a40FA1aF46EBC' # Protocol pause guardian - allocationExchangeOwner: &allocationExchangeOwner '0x3E5e9111Ae8eB78Fe1CC3bb8915d5D461F3Ef9A9' # Allocation Exchange owner - -contracts: - Controller: - calls: - - fn: 'setContractProxy' - id: '0xe6876326c1291dfcbbd3864a6816d698cd591defc7aa2153d7f9c4c04016c89f' # keccak256('Curation') - contractAddress: '${{Curation.address}}' - - fn: 'setContractProxy' - id: '0x39605a6c26a173774ca666c67ef70cf491880e5d3d6d0ca66ec0a31034f15ea3' # keccak256('GNS') - contractAddress: '${{L1GNS.address}}' - - fn: 'setContractProxy' - id: '0xf942813d07d17b56de9a9afc8de0ced6e8c053bbfdcc87b7badea4ddcf27c307' # keccak256('DisputeManager') - contractAddress: '${{DisputeManager.address}}' - - fn: 'setContractProxy' - id: '0xc713c3df6d14cdf946460395d09af88993ee2b948b1a808161494e32c5f67063' # keccak256('EpochManager') - contractAddress: '${{EpochManager.address}}' - - fn: 'setContractProxy' - id: '0x966f1e8d8d8014e05f6ec4a57138da9be1f7c5a7f802928a18072f7c53180761' # keccak256('RewardsManager') - contractAddress: '${{RewardsManager.address}}' - - fn: 'setContractProxy' - id: '0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034' # keccak256('Staking') - contractAddress: '${{L1Staking.address}}' - - fn: 'setContractProxy' - id: '0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247' # keccak256('GraphToken') - contractAddress: '${{GraphToken.address}}' - - fn: 'setContractProxy' - id: '0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0' # keccak256('GraphTokenGateway') - contractAddress: '${{L1GraphTokenGateway.address}}' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - - fn: 'transferOwnership' - owner: *governor - GraphProxyAdmin: - calls: - - fn: 'transferOwnership' - owner: *governor - ServiceRegistry: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - EpochManager: - proxy: true - init: - controller: '${{Controller.address}}' - lengthInBlocks: 60 # length in hours = lengthInBlocks*13/60/60 (~13 second blocks) - GraphToken: - init: - initialSupply: '10000000000000000000000000000' # in wei - calls: - - fn: 'addMinter' - minter: '${{RewardsManager.address}}' - - fn: 'addMinter' - minter: '${{L1GraphTokenGateway.address}}' - - fn: 'transferOwnership' - owner: *governor - Curation: - proxy: true - init: - controller: '${{Controller.address}}' - bondingCurve: '${{BancorFormula.address}}' - curationTokenMaster: '${{GraphCurationToken.address}}' - reserveRatio: 500000 # in parts per million - curationTaxPercentage: 0 # in parts per million - minimumCurationDeposit: '100000000000000000000' # in wei - calls: - - fn: 'syncAllContracts' - DisputeManager: - proxy: true - init: - controller: '${{Controller.address}}' - arbitrator: *arbitrator - minimumDeposit: '100000000000000000000' # in wei - fishermanRewardPercentage: 1000 # in parts per million - qrySlashingPercentage: 1000 # in parts per million - idxSlashingPercentage: 100000 # in parts per million - calls: - - fn: 'syncAllContracts' - L1GNS: - proxy: true - init: - controller: '${{Controller.address}}' - subgraphNFT: '${{SubgraphNFT.address}}' - calls: - - fn: 'approveAll' - - fn: 'syncAllContracts' - SubgraphNFT: - init: - governor: '${{Env.deployer}}' - calls: - - fn: 'setTokenDescriptor' - tokenDescriptor: '${{SubgraphNFTDescriptor.address}}' - - fn: 'setMinter' - minter: '${{L1GNS.address}}' - - fn: 'transferOwnership' - owner: *governor - L1Staking: - proxy: true - init: - controller: '${{Controller.address}}' - minimumIndexerStake: '10000000000000000000' # in wei - thawingPeriod: 20 # in blocks - protocolPercentage: 0 # in parts per million - curationPercentage: 0 # in parts per million - maxAllocationEpochs: 5 # in epochs - delegationUnbondingPeriod: 1 # in epochs - delegationRatio: 16 # delegated stake to indexer stake multiplier - rebateParameters: - alphaNumerator: 100 # alphaNumerator / alphaDenominator - alphaDenominator: 100 # alphaNumerator / alphaDenominator - lambdaNumerator: 60 # lambdaNumerator / lambdaDenominator - lambdaDenominator: 100 # lambdaNumerator / lambdaDenominator - extensionImpl: '${{StakingExtension.address}}' - calls: - - fn: 'setDelegationTaxPercentage' - delegationTaxPercentage: 0 # parts per million - - fn: 'setSlasher' - slasher: '${{DisputeManager.address}}' - allowed: true - - fn: 'syncAllContracts' - RewardsManager: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'setIssuancePerBlock' - issuancePerBlock: '114155251141552511415' # per block increase of total supply, blocks in a year = 365*60*60*24/12 - - fn: 'setSubgraphAvailabilityOracle' - subgraphAvailabilityOracle: *availabilityOracle - - fn: 'syncAllContracts' - AllocationExchange: - init: - graphToken: '${{GraphToken.address}}' - staking: '${{L1Staking.address}}' - governor: *allocationExchangeOwner - authority: *authority - calls: - - fn: 'approveAll' - L1GraphTokenGateway: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - BridgeEscrow: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' diff --git a/packages/contracts/task/config/graph.localhost.yml b/packages/contracts/task/config/graph.localhost.yml deleted file mode 100644 index 0a2b2f75d..000000000 --- a/packages/contracts/task/config/graph.localhost.yml +++ /dev/null @@ -1,161 +0,0 @@ -general: - arbitrator: &arbitrator '0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0' # Arbitration Council - governor: &governor '0x22d491Bde2303f2f43325b2108D26f1eAbA1e32b' # Governor Council - authority: &authority '0xE11BA2b4D45Eaed5996Cd0823791E0C93114882d' # Authority that signs payment vouchers - availabilityOracle: &availabilityOracle '0xd03ea8624C8C5987235048901fB614fDcA89b117' # Subgraph Availability Oracle - pauseGuardian: &pauseGuardian '0x95cED938F7991cd0dFcb48F0a06a40FA1aF46EBC' # Protocol pause guardian - allocationExchangeOwner: &allocationExchangeOwner '0x3E5e9111Ae8eB78Fe1CC3bb8915d5D461F3Ef9A9' # Allocation Exchange owner - -contracts: - Controller: - calls: - - fn: 'setContractProxy' - id: '0xe6876326c1291dfcbbd3864a6816d698cd591defc7aa2153d7f9c4c04016c89f' # keccak256('Curation') - contractAddress: '${{Curation.address}}' - - fn: 'setContractProxy' - id: '0x39605a6c26a173774ca666c67ef70cf491880e5d3d6d0ca66ec0a31034f15ea3' # keccak256('GNS') - contractAddress: '${{L1GNS.address}}' - - fn: 'setContractProxy' - id: '0xf942813d07d17b56de9a9afc8de0ced6e8c053bbfdcc87b7badea4ddcf27c307' # keccak256('DisputeManager') - contractAddress: '${{DisputeManager.address}}' - - fn: 'setContractProxy' - id: '0xc713c3df6d14cdf946460395d09af88993ee2b948b1a808161494e32c5f67063' # keccak256('EpochManager') - contractAddress: '${{EpochManager.address}}' - - fn: 'setContractProxy' - id: '0x966f1e8d8d8014e05f6ec4a57138da9be1f7c5a7f802928a18072f7c53180761' # keccak256('RewardsManager') - contractAddress: '${{RewardsManager.address}}' - - fn: 'setContractProxy' - id: '0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034' # keccak256('Staking') - contractAddress: '${{L1Staking.address}}' - - fn: 'setContractProxy' - id: '0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247' # keccak256('GraphToken') - contractAddress: '${{GraphToken.address}}' - - fn: 'setContractProxy' - id: '0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0' # keccak256('GraphTokenGateway') - contractAddress: '${{L1GraphTokenGateway.address}}' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - - fn: 'transferOwnership' - owner: *governor - GraphProxyAdmin: - calls: - - fn: 'transferOwnership' - owner: *governor - ServiceRegistry: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - EpochManager: - proxy: true - init: - controller: '${{Controller.address}}' - lengthInBlocks: 554 # length in hours = lengthInBlocks*13/60/60 (~13 second blocks) - GraphToken: - init: - initialSupply: '10000000000000000000000000000' # in wei - calls: - - fn: 'addMinter' - minter: '${{RewardsManager.address}}' - - fn: 'addMinter' - minter: '${{L1GraphTokenGateway.address}}' - - fn: 'renounceMinter' - - fn: 'transferOwnership' - owner: *governor - Curation: - proxy: true - init: - controller: '${{Controller.address}}' - bondingCurve: '${{BancorFormula.address}}' - curationTokenMaster: '${{GraphCurationToken.address}}' - reserveRatio: 500000 # in parts per million - curationTaxPercentage: 10000 # in parts per million - minimumCurationDeposit: '1000000000000000000' # in wei - calls: - - fn: 'syncAllContracts' - DisputeManager: - proxy: true - init: - controller: '${{Controller.address}}' - arbitrator: *arbitrator - minimumDeposit: '10000000000000000000000' # in wei - fishermanRewardPercentage: 500000 # in parts per million - idxSlashingPercentage: 25000 # in parts per million - qrySlashingPercentage: 25000 # in parts per million - calls: - - fn: 'syncAllContracts' - L1GNS: - proxy: true - init: - controller: '${{Controller.address}}' - subgraphNFT: '${{SubgraphNFT.address}}' - calls: - - fn: 'approveAll' - - fn: 'syncAllContracts' - SubgraphNFT: - init: - governor: '${{Env.deployer}}' - calls: - - fn: 'setTokenDescriptor' - tokenDescriptor: '${{SubgraphNFTDescriptor.address}}' - - fn: 'setMinter' - minter: '${{L1GNS.address}}' - - fn: 'transferOwnership' - owner: *governor - L1Staking: - proxy: true - init: - controller: '${{Controller.address}}' - minimumIndexerStake: '100000000000000000000000' # in wei - thawingPeriod: 6646 # in blocks - protocolPercentage: 10000 # in parts per million - curationPercentage: 100000 # in parts per million - maxAllocationEpochs: 4 # in epochs - delegationUnbondingPeriod: 12 # in epochs - delegationRatio: 16 # delegated stake to indexer stake multiplier - rebateParameters: - alphaNumerator: 100 # alphaNumerator / alphaDenominator - alphaDenominator: 100 # alphaNumerator / alphaDenominator - lambdaNumerator: 60 # lambdaNumerator / lambdaDenominator - lambdaDenominator: 100 # lambdaNumerator / lambdaDenominator - extensionImpl: '${{StakingExtension.address}}' - calls: - - fn: 'setDelegationTaxPercentage' - delegationTaxPercentage: 5000 # parts per million - - fn: 'setSlasher' - slasher: '${{DisputeManager.address}}' - allowed: true - - fn: 'syncAllContracts' - RewardsManager: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'setIssuancePerBlock' - issuancePerBlock: '114693500000000000000' # per block increase of total supply, blocks in a year = 365*60*60*24/12 - - fn: 'setSubgraphAvailabilityOracle' - subgraphAvailabilityOracle: *availabilityOracle - - fn: 'syncAllContracts' - AllocationExchange: - init: - graphToken: '${{GraphToken.address}}' - staking: '${{L1Staking.address}}' - governor: *allocationExchangeOwner - authority: *authority - calls: - - fn: 'approveAll' - L1GraphTokenGateway: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - BridgeEscrow: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' diff --git a/packages/contracts/task/config/graph.mainnet.yml b/packages/contracts/task/config/graph.mainnet.yml deleted file mode 100644 index 93aefea25..000000000 --- a/packages/contracts/task/config/graph.mainnet.yml +++ /dev/null @@ -1,161 +0,0 @@ -general: - arbitrator: &arbitrator '0xE1FDD398329C6b74C14cf19100316f0826a492d3' # Arbitration Council - governor: &governor '0x48301Fe520f72994d32eAd72E2B6A8447873CF50' # Graph Council - authority: &authority '0xF994fB0f5c06B31E364B868886140cC30A7fcF15' # Authority that signs payment vouchers - availabilityOracle: &availabilityOracle '0xE24f399999D47D276Da88FAa7646e2C597822333' # Subgraph Availability Oracle - pauseGuardian: &pauseGuardian '0x8290362Aba20D17c51995085369E001Bad99B21c' # Protocol pause guardian - allocationExchangeOwner: &allocationExchangeOwner '0x74Db79268e63302d3FC69FB5a7627F7454a41732' # Allocation Exchange owner - -contracts: - Controller: - calls: - - fn: 'setContractProxy' - id: '0xe6876326c1291dfcbbd3864a6816d698cd591defc7aa2153d7f9c4c04016c89f' # keccak256('Curation') - contractAddress: '${{Curation.address}}' - - fn: 'setContractProxy' - id: '0x39605a6c26a173774ca666c67ef70cf491880e5d3d6d0ca66ec0a31034f15ea3' # keccak256('GNS') - contractAddress: '${{L1GNS.address}}' - - fn: 'setContractProxy' - id: '0xf942813d07d17b56de9a9afc8de0ced6e8c053bbfdcc87b7badea4ddcf27c307' # keccak256('DisputeManager') - contractAddress: '${{DisputeManager.address}}' - - fn: 'setContractProxy' - id: '0xc713c3df6d14cdf946460395d09af88993ee2b948b1a808161494e32c5f67063' # keccak256('EpochManager') - contractAddress: '${{EpochManager.address}}' - - fn: 'setContractProxy' - id: '0x966f1e8d8d8014e05f6ec4a57138da9be1f7c5a7f802928a18072f7c53180761' # keccak256('RewardsManager') - contractAddress: '${{RewardsManager.address}}' - - fn: 'setContractProxy' - id: '0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034' # keccak256('Staking') - contractAddress: '${{L1Staking.address}}' - - fn: 'setContractProxy' - id: '0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247' # keccak256('GraphToken') - contractAddress: '${{GraphToken.address}}' - - fn: 'setContractProxy' - id: '0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0' # keccak256('GraphTokenGateway') - contractAddress: '${{L1GraphTokenGateway.address}}' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - - fn: 'transferOwnership' - owner: *governor - GraphProxyAdmin: - calls: - - fn: 'transferOwnership' - owner: *governor - ServiceRegistry: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - EpochManager: - proxy: true - init: - controller: '${{Controller.address}}' - lengthInBlocks: 6646 # length in hours = lengthInBlocks*13/60/60 (~13 second blocks) - GraphToken: - init: - initialSupply: '10000000000000000000000000000' # in wei - calls: - - fn: 'addMinter' - minter: '${{RewardsManager.address}}' - - fn: 'addMinter' - minter: '${{L1GraphTokenGateway.address}}' - - fn: 'renounceMinter' - - fn: 'transferOwnership' - owner: *governor - Curation: - proxy: true - init: - controller: '${{Controller.address}}' - bondingCurve: '${{BancorFormula.address}}' - curationTokenMaster: '${{GraphCurationToken.address}}' - reserveRatio: 500000 # in parts per million - curationTaxPercentage: 10000 # in parts per million - minimumCurationDeposit: '1000000000000000000' # in wei - calls: - - fn: 'syncAllContracts' - DisputeManager: - proxy: true - init: - controller: '${{Controller.address}}' - arbitrator: *arbitrator - minimumDeposit: '10000000000000000000000' # in wei - fishermanRewardPercentage: 500000 # in parts per million - idxSlashingPercentage: 25000 # in parts per million - qrySlashingPercentage: 25000 # in parts per million - calls: - - fn: 'syncAllContracts' - L1GNS: - proxy: true - init: - controller: '${{Controller.address}}' - subgraphNFT: '${{SubgraphNFT.address}}' - calls: - - fn: 'approveAll' - - fn: 'syncAllContracts' - SubgraphNFT: - init: - governor: '${{Env.deployer}}' - calls: - - fn: 'setTokenDescriptor' - tokenDescriptor: '${{SubgraphNFTDescriptor.address}}' - - fn: 'setMinter' - minter: '${{L1GNS.address}}' - - fn: 'transferOwnership' - owner: *governor - L1Staking: - proxy: true - init: - controller: '${{Controller.address}}' - minimumIndexerStake: '100000000000000000000000' # in wei - thawingPeriod: 186092 # in blocks - protocolPercentage: 10000 # in parts per million - curationPercentage: 100000 # in parts per million - maxAllocationEpochs: 28 # in epochs - delegationUnbondingPeriod: 28 # in epochs - delegationRatio: 16 # delegated stake to indexer stake multiplier - rebateParameters: - alphaNumerator: 100 # alphaNumerator / alphaDenominator - alphaDenominator: 100 # alphaNumerator / alphaDenominator - lambdaNumerator: 60 # lambdaNumerator / lambdaDenominator - lambdaDenominator: 100 # lambdaNumerator / lambdaDenominator - extensionImpl: '${{StakingExtension.address}}' - calls: - - fn: 'setDelegationTaxPercentage' - delegationTaxPercentage: 5000 # parts per million - - fn: 'setSlasher' - slasher: '${{DisputeManager.address}}' - allowed: true - - fn: 'syncAllContracts' - RewardsManager: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'setIssuancePerBlock' - issuancePerBlock: '114693500000000000000' # per block increase of total supply, blocks in a year = 365*60*60*24/12 - - fn: 'setSubgraphAvailabilityOracle' - subgraphAvailabilityOracle: *availabilityOracle - - fn: 'syncAllContracts' - AllocationExchange: - init: - graphToken: '${{GraphToken.address}}' - staking: '${{L1Staking.address}}' - governor: *allocationExchangeOwner - authority: *authority - calls: - - fn: 'approveAll' - L1GraphTokenGateway: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - BridgeEscrow: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' diff --git a/packages/contracts/task/config/graph.sepolia.yml b/packages/contracts/task/config/graph.sepolia.yml deleted file mode 100644 index f73bca3d7..000000000 --- a/packages/contracts/task/config/graph.sepolia.yml +++ /dev/null @@ -1,161 +0,0 @@ -general: - arbitrator: &arbitrator '0xd6ff9e98F0Fd99ccB658832F586e23F4D8Cb8Bad' # Arbitration Council - governor: &governor '0x4EBf30832eC2db76aE228D5d239083B59f530d1f' # Graph Council - authority: &authority '0x840daec5dF962D49cf2EFd789c4E40A7b7e0117D' # Authority that signs payment vouchers - availabilityOracle: &availabilityOracle '0x840daec5dF962D49cf2EFd789c4E40A7b7e0117D' # Subgraph Availability Oracle - pauseGuardian: &pauseGuardian '0x382688E15Cc894D04cf3313b26a4F2c93C8fDe06' # Protocol pause guardian - allocationExchangeOwner: &allocationExchangeOwner '0x4EBf30832eC2db76aE228D5d239083B59f530d1f' # Allocation Exchange owner - -contracts: - Controller: - calls: - - fn: 'setContractProxy' - id: '0xe6876326c1291dfcbbd3864a6816d698cd591defc7aa2153d7f9c4c04016c89f' # keccak256('Curation') - contractAddress: '${{Curation.address}}' - - fn: 'setContractProxy' - id: '0x39605a6c26a173774ca666c67ef70cf491880e5d3d6d0ca66ec0a31034f15ea3' # keccak256('GNS') - contractAddress: '${{L1GNS.address}}' - - fn: 'setContractProxy' - id: '0xf942813d07d17b56de9a9afc8de0ced6e8c053bbfdcc87b7badea4ddcf27c307' # keccak256('DisputeManager') - contractAddress: '${{DisputeManager.address}}' - - fn: 'setContractProxy' - id: '0xc713c3df6d14cdf946460395d09af88993ee2b948b1a808161494e32c5f67063' # keccak256('EpochManager') - contractAddress: '${{EpochManager.address}}' - - fn: 'setContractProxy' - id: '0x966f1e8d8d8014e05f6ec4a57138da9be1f7c5a7f802928a18072f7c53180761' # keccak256('RewardsManager') - contractAddress: '${{RewardsManager.address}}' - - fn: 'setContractProxy' - id: '0x1df41cd916959d1163dc8f0671a666ea8a3e434c13e40faef527133b5d167034' # keccak256('Staking') - contractAddress: '${{L1Staking.address}}' - - fn: 'setContractProxy' - id: '0x45fc200c7e4544e457d3c5709bfe0d520442c30bbcbdaede89e8d4a4bbc19247' # keccak256('GraphToken') - contractAddress: '${{GraphToken.address}}' - - fn: 'setContractProxy' - id: '0xd362cac9cb75c10d67bcc0b7eeb0b1ef48bb5420b556c092d4fd7f758816fcf0' # keccak256('GraphTokenGateway') - contractAddress: '${{L1GraphTokenGateway.address}}' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - - fn: 'transferOwnership' - owner: *governor - GraphProxyAdmin: - calls: - - fn: 'transferOwnership' - owner: *governor - ServiceRegistry: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - EpochManager: - proxy: true - init: - controller: '${{Controller.address}}' - lengthInBlocks: 554 # length in hours = lengthInBlocks*13/60/60 (~13 second blocks) - GraphToken: - init: - initialSupply: '10000000000000000000000000000' # in wei - calls: - - fn: 'addMinter' - minter: '${{RewardsManager.address}}' - - fn: 'addMinter' - minter: '${{L1GraphTokenGateway.address}}' - - fn: 'renounceMinter' - - fn: 'transferOwnership' - owner: *governor - Curation: - proxy: true - init: - controller: '${{Controller.address}}' - bondingCurve: '${{BancorFormula.address}}' - curationTokenMaster: '${{GraphCurationToken.address}}' - reserveRatio: 500000 # in parts per million - curationTaxPercentage: 10000 # in parts per million - minimumCurationDeposit: '1000000000000000000' # in wei - calls: - - fn: 'syncAllContracts' - DisputeManager: - proxy: true - init: - controller: '${{Controller.address}}' - arbitrator: *arbitrator - minimumDeposit: '10000000000000000000000' # in wei - fishermanRewardPercentage: 500000 # in parts per million - idxSlashingPercentage: 25000 # in parts per million - qrySlashingPercentage: 25000 # in parts per million - calls: - - fn: 'syncAllContracts' - L1GNS: - proxy: true - init: - controller: '${{Controller.address}}' - subgraphNFT: '${{SubgraphNFT.address}}' - calls: - - fn: 'approveAll' - - fn: 'syncAllContracts' - SubgraphNFT: - init: - governor: '${{Env.deployer}}' - calls: - - fn: 'setTokenDescriptor' - tokenDescriptor: '${{SubgraphNFTDescriptor.address}}' - - fn: 'setMinter' - minter: '${{L1GNS.address}}' - - fn: 'transferOwnership' - owner: *governor - L1Staking: - proxy: true - init: - controller: '${{Controller.address}}' - minimumIndexerStake: '100000000000000000000000' # in wei - thawingPeriod: 6646 # in blocks - protocolPercentage: 10000 # in parts per million - curationPercentage: 100000 # in parts per million - maxAllocationEpochs: 4 # in epochs - delegationUnbondingPeriod: 12 # in epochs - delegationRatio: 16 # delegated stake to indexer stake multiplier - rebateParameters: - alphaNumerator: 100 # alphaNumerator / alphaDenominator - alphaDenominator: 100 # alphaNumerator / alphaDenominator - lambdaNumerator: 60 # lambdaNumerator / lambdaDenominator - lambdaDenominator: 100 # lambdaNumerator / lambdaDenominator - extensionImpl: '${{StakingExtension.address}}' - calls: - - fn: 'setDelegationTaxPercentage' - delegationTaxPercentage: 5000 # parts per million - - fn: 'setSlasher' - slasher: '${{DisputeManager.address}}' - allowed: true - - fn: 'syncAllContracts' - RewardsManager: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'setIssuancePerBlock' - issuancePerBlock: '114693500000000000000' # per block increase of total supply, blocks in a year = 365*60*60*24/12 - - fn: 'setSubgraphAvailabilityOracle' - subgraphAvailabilityOracle: *availabilityOracle - - fn: 'syncAllContracts' - AllocationExchange: - init: - graphToken: '${{GraphToken.address}}' - staking: '${{L1Staking.address}}' - governor: *allocationExchangeOwner - authority: *authority - calls: - - fn: 'approveAll' - L1GraphTokenGateway: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' - - fn: 'setPauseGuardian' - pauseGuardian: *pauseGuardian - BridgeEscrow: - proxy: true - init: - controller: '${{Controller.address}}' - calls: - - fn: 'syncAllContracts' diff --git a/packages/contracts/task/hardhat.config.ts b/packages/contracts/task/hardhat.config.ts deleted file mode 100644 index aa4223a88..000000000 --- a/packages/contracts/task/hardhat.config.ts +++ /dev/null @@ -1,190 +0,0 @@ -// Deployment-focused Hardhat configuration -import '@graphprotocol/sdk/gre' -import '@nomiclabs/hardhat-ethers' -import '@nomiclabs/hardhat-etherscan' -import '@typechain/hardhat' -import 'dotenv/config' -import 'hardhat-abi-exporter' -import 'hardhat-contract-sizer' -import 'hardhat-storage-layout' -// Deployment tasks -import './tasks/bridge/deposits' -import './tasks/bridge/to-l2' -import './tasks/bridge/withdrawals' -import './tasks/contract/deploy' -import './tasks/contract/upgrade' -import './tasks/deployment/config' -import './tasks/e2e/e2e' -import './tasks/migrate/bridge' -import './tasks/migrate/protocol' -import './tasks/test-upgrade' -import './tasks/verify/defender' -import './tasks/verify/sourcify' -import './tasks/verify/verify' - -import { configDir } from '@graphprotocol/contracts' -import { HardhatUserConfig } from 'hardhat/config' -import { HttpNetworkUserConfig } from 'hardhat/types' -import path from 'path' - -// Networks - -interface NetworkConfig { - network: string - chainId: number - url?: string - gas?: number | 'auto' - gasPrice?: number | 'auto' - graphConfig?: string -} - -// Network configurations for deployment -const networkConfigs: NetworkConfig[] = [ - { network: 'mainnet', chainId: 1, graphConfig: path.join(configDir, 'graph.mainnet.yml') }, - { network: 'goerli', chainId: 5, graphConfig: path.join(configDir, 'graph.goerli.yml') }, - { network: 'sepolia', chainId: 11155111, graphConfig: path.join(configDir, 'graph.sepolia.yml') }, - { - network: 'arbitrum-one', - chainId: 42161, - url: 'https://arb1.arbitrum.io/rpc', - graphConfig: path.join(configDir, 'graph.arbitrum-one.yml'), - }, - { - network: 'arbitrum-goerli', - chainId: 421613, - url: 'https://goerli-rollup.arbitrum.io/rpc', - graphConfig: path.join(configDir, 'graph.arbitrum-goerli.yml'), - }, - { - network: 'arbitrum-sepolia', - chainId: 421614, - url: 'https://sepolia-rollup.arbitrum.io/rpcblock', - graphConfig: path.join(configDir, 'graph.arbitrum-sepolia.yml'), - }, -] - -function getAccountsKeys() { - if (process.env.MNEMONIC) return { mnemonic: process.env.MNEMONIC } - if (process.env.PRIVATE_KEY) return [process.env.PRIVATE_KEY] - return 'remote' -} - -function getDefaultProviderURL(network: string) { - return `https://${network}.infura.io/v3/${process.env.INFURA_KEY}` -} - -// Default mnemonics for testing -const DEFAULT_TEST_MNEMONIC = 'myth like bonus scare over problem client lizard pioneer submit female collect' -const DEFAULT_L2_TEST_MNEMONIC = 'urge never interest human any economy gentle canvas anxiety pave unlock find' - -const config: HardhatUserConfig = { - graph: { - addressBook: process.env.ADDRESS_BOOK || 'addresses.json', - disableSecureAccounts: true, - }, - solidity: { - compilers: [ - { - version: '0.7.6', - settings: { - optimizer: { - enabled: true, - runs: 200, - }, - outputSelection: { - '*': { - '*': ['storageLayout'], - }, - }, - }, - }, - ], - }, - paths: { - sources: '../contracts', - artifacts: '../artifacts', - cache: '../cache', - tests: './test', - }, - defaultNetwork: 'hardhat', - networks: { - hardhat: { - chainId: 1337, - loggingEnabled: false, - gas: 12000000, - gasPrice: 'auto', - initialBaseFeePerGas: 0, - blockGasLimit: 12000000, - accounts: { - mnemonic: DEFAULT_TEST_MNEMONIC, - }, - hardfork: 'london', - // Graph Protocol extensions - graphConfig: path.join(configDir, 'graph.hardhat.yml'), - addressBook: process.env.ADDRESS_BOOK || '../addresses.json', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, - localhost: { - chainId: 1337, - url: 'http://127.0.0.1:8545', - accounts: process.env.FORK === 'true' ? getAccountsKeys() : { mnemonic: DEFAULT_TEST_MNEMONIC }, - graphConfig: path.join(configDir, 'graph.localhost.yml'), - addressBook: '../addresses-local.json', - } as HttpNetworkUserConfig, - localnitrol1: { - chainId: 1337, - url: 'http://127.0.0.1:8545', - accounts: { mnemonic: DEFAULT_TEST_MNEMONIC }, - graphConfig: path.join(configDir, 'graph.localhost.yml'), - addressBook: '../addresses-local.json', - } as HttpNetworkUserConfig, - localnitrol2: { - chainId: 412346, - url: 'http://127.0.0.1:8547', - accounts: { mnemonic: DEFAULT_L2_TEST_MNEMONIC }, - graphConfig: path.join(configDir, 'graph.arbitrum-localhost.yml'), - addressBook: '../addresses-local.json', - } as HttpNetworkUserConfig, - }, - etherscan: { - apiKey: { - mainnet: process.env.ETHERSCAN_API_KEY || '', - goerli: process.env.ETHERSCAN_API_KEY || '', - sepolia: process.env.ETHERSCAN_API_KEY || '', - arbitrumOne: process.env.ARBISCAN_API_KEY || '', - arbitrumGoerli: process.env.ARBISCAN_API_KEY || '', - arbitrumSepolia: process.env.ARBISCAN_API_KEY || '', - }, - }, - typechain: { - outDir: '../types', - target: 'ethers-v5', - }, - contractSizer: { - alphaSort: true, - runOnCompile: false, - disambiguatePaths: false, - }, -} - -// Setup network providers -if (config.networks) { - for (const netConfig of networkConfigs) { - const networkConfig: HttpNetworkUserConfig & { graphConfig?: string } = { - chainId: netConfig.chainId, - url: netConfig.url ? netConfig.url : getDefaultProviderURL(netConfig.network), - gas: netConfig.gas || 'auto', - gasPrice: netConfig.gasPrice || 'auto', - accounts: getAccountsKeys(), - } - - if (netConfig.graphConfig) { - networkConfig.graphConfig = netConfig.graphConfig - } - - config.networks[netConfig.network] = networkConfig - } -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export default config as any diff --git a/packages/contracts/task/package.json b/packages/contracts/task/package.json deleted file mode 100644 index fd9bb85d1..000000000 --- a/packages/contracts/task/package.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "name": "@graphprotocol/contracts-task", - "version": "1.0.3", - "private": true, - "description": "Task utilities for @graphprotocol/contracts", - "author": "Edge & Node", - "license": "GPL-2.0-or-later", - "main": "src/index.ts", - "types": "src/index.ts", - "exports": { - ".": { - "default": "./src/index.ts", - "types": "./src/index.ts" - } - }, - "scripts": { - "build": "tsc --build", - "clean": "rm -rf build types", - "deploy": "hardhat migrate", - "deploy-localhost": "hardhat migrate --force --skip-confirmation --disable-secure-accounts --network localhost --graph-config config/graph.localhost.yml --address-book addresses-local.json", - "verify": "hardhat verify", - "lint": "pnpm lint:ts; pnpm lint:json", - "lint:ts": "eslint '**/*.{js,ts,cjs,mjs,jsx,tsx}' --fix --cache; prettier -w --cache --log-level warn '**/*.{js,ts,cjs,mjs,jsx,tsx}'", - "lint:json": "prettier -w --cache --log-level warn '**/*.json'" - }, - "dependencies": { - "@graphprotocol/contracts": "workspace:^", - "@graphprotocol/sdk": "0.6.0", - "axios": "^1.16.1", - "console-table-printer": "^2.14.1" - }, - "devDependencies": { - "@arbitrum/sdk": "~3.1.13", - "@ethersproject/abi": "^5.8.0", - "@ethersproject/abstract-provider": "^5.8.0", - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/providers": "^5.8.0", - "@graphprotocol/common-ts": "^1.8.3", - "@nomicfoundation/hardhat-network-helpers": "^1.0.0", - "@nomiclabs/hardhat-ethers": "^2.2.3", - "@nomiclabs/hardhat-etherscan": "^3.1.0", - "@openzeppelin/contracts": "3.4.2", - "@openzeppelin/contracts-upgradeable": "3.4.2", - "@openzeppelin/hardhat-upgrades": "^1.22.1", - "@typechain/ethers-v5": "^10.2.1", - "@typechain/hardhat": "^6.1.2", - "@types/glob": "^8.1.0", - "@types/node": "^20.17.50", - "arbos-precompiles": "^1.0.2", - "dotenv": "^16.5.0", - "eslint": "catalog:", - "ethers": "^5.7.0", - "form-data": "^4.0.0", - "glob": "catalog:", - "graphql": "^16.11.0", - "graphql-tag": "^2.12.4", - "hardhat": "catalog:", - "hardhat-abi-exporter": "^2.11.0", - "hardhat-contract-sizer": "^2.10.0", - "hardhat-gas-reporter": "^1.0.8", - "hardhat-storage-layout": "^0.1.7", - "prettier": "catalog:", - "prettier-plugin-solidity": "catalog:", - "ts-node": "^10.9.2", - "typechain": "^8.3.2", - "typescript": "catalog:", - "winston": "^3.3.3", - "yaml": "^1.10.2", - "yaml-lint": "catalog:", - "yargs": "^17.0.0" - } -} diff --git a/packages/contracts/task/prettier.config.cjs b/packages/contracts/task/prettier.config.cjs deleted file mode 100644 index 8eb0a0bee..000000000 --- a/packages/contracts/task/prettier.config.cjs +++ /dev/null @@ -1,5 +0,0 @@ -const baseConfig = require('../prettier.config.cjs') - -module.exports = { - ...baseConfig, -} diff --git a/packages/contracts/task/src/address-book.ts b/packages/contracts/task/src/address-book.ts deleted file mode 100644 index 50867d53c..000000000 --- a/packages/contracts/task/src/address-book.ts +++ /dev/null @@ -1,123 +0,0 @@ -import fs from 'fs' -import path from 'path' - -/** - * Address book entry structure - */ -export interface AddressBookEntry { - address: string - constructorArgs?: unknown[] - initArgs?: unknown[] - creationCodeHash?: string - runtimeCodeHash?: string - txHash?: string - proxy?: boolean - implementation?: { - address: string - constructorArgs?: unknown[] - creationCodeHash?: string - runtimeCodeHash?: string - txHash?: string - libraries?: Record - } - libraries?: Record -} - -/** - * Address book structure - chainId -> contractName -> entry - */ -export interface AddressBook { - [chainId: string]: { - [contractName: string]: AddressBookEntry - } -} - -/** - * Load an address book from the contracts package using module resolution - * This function works like an API - it finds address books using module resolution, - * not relative to the calling code's location. - * @param filename Name of the address book file (e.g., 'addresses-local.json') - * @returns The parsed address book object - */ -export const loadAddressBook = (filename: string): AddressBook => { - let addressBookPath: string - let addressBook: AddressBook - - // Use module resolution to find @graphprotocol/contracts address books - try { - const contractsModulePath = require.resolve('@graphprotocol/contracts') - addressBookPath = path.resolve(path.dirname(contractsModulePath), filename) - } catch { - // Fallback to local address book (parent of deploy package) - // __dirname is deploy/src, so we need to go up two levels: deploy/src -> deploy -> contracts - addressBookPath = path.resolve(__dirname, '..', '..', filename) - } - - try { - if (!fs.existsSync(addressBookPath)) { - throw new Error(`Address book file not found: ${addressBookPath}`) - } - - const content = fs.readFileSync(addressBookPath, 'utf8') - addressBook = JSON.parse(content) - } catch (error) { - const message = error instanceof Error ? error.message : error - throw new Error(`Could not load address book ${filename}: ${message}`) - } - - return addressBook -} - -/** - * Write an address book to the contracts package with proper formatting - * This ensures consistent formatting using the deploy package's prettier config - * @param filename Name of the address book file (e.g., 'addresses-local.json') - * @param addressBook The address book object to write - */ -export const writeAddressBook = (filename: string, addressBook: AddressBook): void => { - let addressBookPath: string - - // Use module resolution to find @graphprotocol/contracts location for writing - try { - const contractsModulePath = require.resolve('@graphprotocol/contracts') - addressBookPath = path.resolve(path.dirname(contractsModulePath), filename) - } catch { - // Fallback to local address book (parent of deploy package) - // __dirname is deploy/src, so we need to go up two levels: deploy/src -> deploy -> contracts - addressBookPath = path.resolve(__dirname, '..', '..', filename) - } - - try { - // Format with proper indentation (2 spaces) for consistency - const content = JSON.stringify(addressBook, null, 2) + '\n' - fs.writeFileSync(addressBookPath, content, 'utf8') - } catch (error) { - const message = error instanceof Error ? error.message : error - throw new Error(`Could not write address book ${filename}: ${message}`) - } -} - -/** - * Get a specific contract entry from an address book - * @param addressBook The address book object - * @param chainId The chain ID to look up - * @param contractName The contract name to find - * @returns The address book entry for the contract - */ -export const getAddressBookEntry = ( - addressBook: AddressBook, - chainId: number, - contractName: string, -): AddressBookEntry => { - const chainData = addressBook[chainId.toString()] - if (!chainData) { - throw new Error(`No addresses found for chain ID ${chainId}`) - } - - const entry = chainData[contractName] - if (!entry) { - throw new Error(`Contract ${contractName} not found in address book for chain ${chainId}`) - } - - return entry -} diff --git a/packages/contracts/task/src/config.ts b/packages/contracts/task/src/config.ts deleted file mode 100644 index 463dcad2c..000000000 --- a/packages/contracts/task/src/config.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { - ABRefReplace, - AddressBook, - ContractConfig, - ContractConfigCall, - ContractConfigParam, - ContractParam, -} from '@graphprotocol/sdk' -import fs from 'fs' -import YAML from 'yaml' -import { Scalar, YAMLMap } from 'yaml/types' - -// TODO: tidy this up -const ABRefMatcher = /\${{([A-Z]\w.+)}}/ - -function parseConfigValue(value: string, addressBook: AddressBook, deployerAddress: string) { - return isAddressBookRef(value) - ? parseAddressBookRef(addressBook, value, [{ ref: 'Env.deployer', replace: deployerAddress }]) - : value -} - -function isAddressBookRef(value: string): boolean { - return ABRefMatcher.test(value) -} - -function parseAddressBookRef(addressBook: AddressBook, value: string, abInject: ABRefReplace[]): string { - const valueMatch = ABRefMatcher.exec(value) - if (valueMatch === null) { - throw new Error('Could not parse address book reference') - } - const ref = valueMatch[1] - const [contractName, contractAttr] = ref.split('.') - - // This is a convention to inject variables into the config, for example the deployer address - const inject = abInject.find((ab) => ab.ref === ref) - if (inject) { - return inject.replace - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const entry = addressBook.getEntry(contractName) as { [key: string]: any } - return entry[contractAttr] -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function readConfig(path: string, retainMetadata = false): any { - const file = fs.readFileSync(path, 'utf8') - return retainMetadata ? YAML.parseDocument(file) : YAML.parse(file) -} - -export function loadCallParams( - values: Array, - addressBook: AddressBook, - deployerAddress: string, -): Array { - return values.map((value) => parseConfigValue(value as string, addressBook, deployerAddress)) -} - -export function getContractConfig( - config: Record, - addressBook: AddressBook, - name: string, - deployerAddress: string, -): ContractConfig { - const contractConfig = ((config.contracts as Record)[name] as Record) || {} - const contractParams: Array = [] - const contractCalls: Array = [] - let proxy = false - - const optsList = Object.entries(contractConfig) as Array> - for (const [name, value] of optsList) { - // Process constructor params - if (name.startsWith('init')) { - const initList = Object.entries(contractConfig.init as Record) as Array> - for (const [initName, initValue] of initList) { - contractParams.push({ - name: initName, - value: parseConfigValue(initValue, addressBook, deployerAddress), - }) - } - continue - } - - // Process contract calls - if (name.startsWith('calls')) { - for (const entry of contractConfig.calls as Array>) { - const fn = entry['fn'] as string - const params = Object.values(entry).slice(1) as Array // skip fn - contractCalls.push({ fn, params }) - } - continue - } - - // Process proxy - if (name.startsWith('proxy')) { - proxy = Boolean(value) - continue - } - } - - return { - params: contractParams, - calls: contractCalls, - proxy, - } -} - -// YAML helper functions -const getNode = (doc: YAML.Document.Parsed, path: string[]): YAMLMap | undefined => { - try { - let node: YAMLMap | undefined - for (const p of path) { - node = node === undefined ? doc.get(p) : node.get(p) - } - return node - } catch { - throw new Error(`Could not find node: ${path}.`) - } -} - -function getItem(node: YAMLMap, key: string): Scalar { - if (!node.has(key)) { - throw new Error(`Could not find item: ${key}.`) - } - return node.get(key, true) as Scalar -} - -function getItemFromPath(doc: YAML.Document.Parsed, path: string) { - const splitPath = path.split('/') - const itemKey = splitPath.pop() - if (itemKey === undefined) { - throw new Error('Badly formed path.') - } - - const node = getNode(doc, splitPath) - if (node === undefined) { - return undefined - } - - const item = getItem(node, itemKey) - return item -} - -export const getItemValue = (doc: YAML.Document.Parsed, path: string): unknown => { - const item = getItemFromPath(doc, path) - return item?.value -} - -export const updateItemValue = (doc: YAML.Document.Parsed, path: string, value: unknown): boolean => { - const item = getItemFromPath(doc, path) - if (item === undefined) { - throw new Error(`Could not find item: ${path}.`) - } - const updated = item.value !== value - item.value = value - return updated -} diff --git a/packages/contracts/task/src/index.ts b/packages/contracts/task/src/index.ts deleted file mode 100644 index 1828da12b..000000000 --- a/packages/contracts/task/src/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Configuration utilities -export { getContractConfig, getItemValue, loadCallParams, readConfig, updateItemValue } from './config' - -// Address book utilities -export { getAddressBookEntry, loadAddressBook, writeAddressBook } from './address-book' - -// Types -export type { AddressBook, AddressBookEntry } from './address-book' -export type { - ABRefReplace, - ContractConfig, - ContractConfigCall, - ContractConfigParam, - ContractList, - ContractParam, -} from '@graphprotocol/sdk' diff --git a/packages/contracts/task/tasks/bridge/deposits.ts b/packages/contracts/task/tasks/bridge/deposits.ts deleted file mode 100644 index 366d55a0d..000000000 --- a/packages/contracts/task/tasks/bridge/deposits.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { L1ToL2MessageStatus } from '@arbitrum/sdk' -import { getL1ToL2MessageStatus } from '@graphprotocol/sdk' -import { GraphRuntimeEnvironmentOptions, greTask } from '@graphprotocol/sdk/gre' -import { Table } from 'console-table-printer' -import { ethers, Event } from 'ethers' -import { HardhatRuntimeEnvironment } from 'hardhat/types' - -interface PrintEvent { - blockNumber: string - tx: string - amount: string - status: string -} - -greTask('bridge:deposits', 'List deposits initiated on L1GraphTokenGateway') - .addOptionalParam('startBlock', 'Start block for the search') - .addOptionalParam('endBlock', 'End block for the search') - .setAction( - async ( - taskArgs: GraphRuntimeEnvironmentOptions & { startBlock?: string; endBlock?: string }, - hre: HardhatRuntimeEnvironment, - ) => { - console.log('> L1GraphTokenGateway deposits') - - const graph = hre.graph(taskArgs) - if (!graph.l1) { - throw new Error('L1 network not available') - } - if (!graph.l2) { - throw new Error('L2 network not available') - } - const gateway = graph.l1.contracts.L1GraphTokenGateway - if (!gateway) { - throw new Error('L1GraphTokenGateway contract not found') - } - console.log(`Tracking 'DepositInitiated' events on ${gateway.address}`) - - const startBlock = taskArgs.startBlock ? parseInt(taskArgs.startBlock) : 0 - const endBlock = taskArgs.endBlock ? parseInt(taskArgs.endBlock) : 'latest' - console.log(`Searching blocks from block ${startBlock} to block ${endBlock}`) - - const rawEvents = await gateway.queryFilter(gateway.filters.DepositInitiated(), startBlock, endBlock) - const events = await Promise.all( - rawEvents.map(async (e: Event) => { - if (!e.args) { - throw new Error('Event args not available') - } - return { - blockNumber: `${e.blockNumber} (${new Date( - (await graph.l1!.provider.getBlock(e.blockNumber)).timestamp * 1000, - ).toLocaleString()})`, - tx: `${e.transactionHash} ${e.args.from} -> ${e.args.to}`, - amount: ethers.utils.formatEther(e.args.amount), - status: emojifyRetryableStatus( - await getL1ToL2MessageStatus(e.transactionHash, graph.l1!.provider, graph.l2!.provider), - ), - } - }), - ) - - const total = events.reduce( - (acc: ethers.BigNumber, e: PrintEvent) => acc.add(ethers.utils.parseEther(e.amount)), - ethers.BigNumber.from(0), - ) - console.log(`Found ${events.length} deposits with a total of ${ethers.utils.formatEther(total)} GRT`) - - console.log( - 'L1 to L2 message status reference: 🚧 = not yet created, ❌ = creation failed, ⚠️ = funds deposited on L2, ✅ = redeemed, ⌛ = expired', - ) - - printEvents(events) - }, - ) - -function printEvents(events: PrintEvent[]) { - const tablePrinter = new Table({ - charLength: { '🚧': 2, '✅': 2, '⚠️': 1, '⌛': 2, '❌': 2 }, - columns: [ - { name: 'status', color: 'green', alignment: 'center' }, - { name: 'blockNumber', color: 'green', alignment: 'center' }, - { - name: 'tx', - color: 'green', - alignment: 'center', - maxLen: 88, - }, - { name: 'amount', color: 'green', alignment: 'center' }, - ], - }) - - events.map((e: PrintEvent) => tablePrinter.addRow(e)) - tablePrinter.printTable() -} - -function emojifyRetryableStatus(status: L1ToL2MessageStatus): string { - switch (status) { - case L1ToL2MessageStatus.NOT_YET_CREATED: - return '🚧' - case L1ToL2MessageStatus.CREATION_FAILED: - return '❌' - case L1ToL2MessageStatus.FUNDS_DEPOSITED_ON_L2: - return '⚠️ ' - case L1ToL2MessageStatus.REDEEMED: - return '✅' - case L1ToL2MessageStatus.EXPIRED: - return '⌛' - default: - return '❌' - } -} diff --git a/packages/contracts/task/tasks/bridge/to-l2.ts b/packages/contracts/task/tasks/bridge/to-l2.ts deleted file mode 100644 index f098de433..000000000 --- a/packages/contracts/task/tasks/bridge/to-l2.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { sendToL2 } from '@graphprotocol/sdk' -import { greTask } from '@graphprotocol/sdk/gre' -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { BigNumber } from 'ethers' - -greTask('bridge:send-to-l2', 'Bridge GRT tokens from L1 to L2') - .addParam('amount', 'Amount of tokens to bridge') - .addOptionalParam('sender', 'Address of the sender, must be managed by the provider node. L1 deployer if empty.') - .addOptionalParam('recipient', 'Receiving address in L2. Same to L1 address if empty.') - .addOptionalParam('deploymentFile', 'Nitro testnode deployment file. Must specify if using nitro test nodes.') - .setAction(async (taskArgs, hre) => { - console.log('> Sending GRT to L2') - const graph = hre.graph(taskArgs) - - if (!graph.l1) { - throw new Error('L1 network not available') - } - if (!graph.l2) { - throw new Error('L2 network not available') - } - - // If local, add nitro test node networks to sdk - if (taskArgs.deploymentFile) { - console.log('> Adding nitro test node network to sdk') - await hre.run('migrate:nitro:register', { deploymentFile: taskArgs.deploymentFile }) - } - - // Get the sender, use L1 deployer if not provided - const sender = taskArgs.sender - ? await SignerWithAddress.create(graph.l1.provider.getSigner(taskArgs.sender)) - : await graph.l1.getDeployer() - console.log(`> Using wallet ${sender.address}`) - - // Patch sendToL2 opts - taskArgs.l2Provider = graph.l2.provider - taskArgs.amount = hre.ethers.utils.parseEther(taskArgs.amount) // sendToL2 expects amount in GRT - - // L2 provider gas limit estimation has been hit or miss in CI, 400k should be more than enough - if (process.env.CI) { - taskArgs.maxGas = BigNumber.from('400000') - } - - await sendToL2(graph.contracts, sender, { - l2Provider: graph.l2!.provider, - amount: taskArgs.amount, - recipient: taskArgs.recipient, - maxGas: taskArgs.maxGas, - gasPriceBid: taskArgs.gasPriceBid, - maxSubmissionCost: taskArgs.maxSubmissionCost, - }) - - console.log('Done!') - }) diff --git a/packages/contracts/task/tasks/bridge/withdrawals.ts b/packages/contracts/task/tasks/bridge/withdrawals.ts deleted file mode 100644 index e0f729385..000000000 --- a/packages/contracts/task/tasks/bridge/withdrawals.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { L2ToL1MessageStatus } from '@arbitrum/sdk' -import { getL2ToL1MessageStatus } from '@graphprotocol/sdk' -import { GraphRuntimeEnvironmentOptions, greTask } from '@graphprotocol/sdk/gre' -import { Table } from 'console-table-printer' -import { ethers } from 'ethers' -import { HardhatRuntimeEnvironment } from 'hardhat/types' - -interface PrintEvent { - blockNumber: string - tx: string - amount: string - status: string -} - -greTask('bridge:withdrawals', 'List withdrawals initiated on L2GraphTokenGateway') - .addOptionalParam('startBlock', 'Start block for the search') - .addOptionalParam('endBlock', 'End block for the search') - .setAction( - async ( - taskArgs: GraphRuntimeEnvironmentOptions & { startBlock?: string; endBlock?: string }, - hre: HardhatRuntimeEnvironment, - ) => { - console.log('> L2GraphTokenGateway withdrawals') - - const graph = hre.graph(taskArgs) - if (!graph.l2) { - throw new Error('L2 network not available') - } - if (!graph.l1) { - throw new Error('L1 network not available') - } - const gateway = graph.l2.contracts.L2GraphTokenGateway - if (!gateway) { - throw new Error('L2GraphTokenGateway contract not found') - } - console.log(`Tracking 'WithdrawalInitiated' events on ${gateway.address}`) - - const startBlock = taskArgs.startBlock ? parseInt(taskArgs.startBlock) : 0 - const endBlock = taskArgs.endBlock ? parseInt(taskArgs.endBlock) : 'latest' - console.log(`Searching blocks from block ${startBlock} to block ${endBlock}`) - - const events = await Promise.all( - (await gateway.queryFilter(gateway.filters.WithdrawalInitiated(), startBlock, endBlock)).map( - async (e: ethers.Event) => { - if (!e.args) { - throw new Error('Event args not available') - } - return { - blockNumber: `${e.blockNumber} (${new Date( - (await graph.l2!.provider.getBlock(e.blockNumber)).timestamp * 1000, - ).toLocaleString()})`, - tx: `${e.transactionHash} ${e.args.from} -> ${e.args.to}`, - amount: ethers.utils.formatEther(e.args.amount), - status: emojifyL2ToL1Status( - await getL2ToL1MessageStatus(e.transactionHash, graph.l1!.provider, graph.l2!.provider), - ), - } - }, - ), - ) - - const total = events.reduce((acc, e) => acc.add(ethers.utils.parseEther(e.amount)), ethers.BigNumber.from(0)) - console.log(`Found ${events.length} withdrawals for a total of ${ethers.utils.formatEther(total)} GRT`) - - console.log('L2 to L1 message status reference: 🚧 = unconfirmed, ⚠️ = confirmed, ✅ = executed') - - printEvents(events) - }, - ) - -function printEvents(events: PrintEvent[]) { - const tablePrinter = new Table({ - charLength: { '🚧': 2, '✅': 2, '⚠️': 1, '❌': 2 }, - columns: [ - { name: 'status', color: 'green', alignment: 'center' }, - { name: 'blockNumber', color: 'green' }, - { - name: 'tx', - color: 'green', - alignment: 'center', - maxLen: 88, - }, - { name: 'amount', color: 'green' }, - ], - }) - - events.map((e) => tablePrinter.addRow(e)) - tablePrinter.printTable() -} - -function emojifyL2ToL1Status(status: L2ToL1MessageStatus): string { - switch (status) { - case L2ToL1MessageStatus.UNCONFIRMED: - return '🚧' - case L2ToL1MessageStatus.CONFIRMED: - return '⚠️ ' - case L2ToL1MessageStatus.EXECUTED: - return '✅' - default: - return '❌' - } -} diff --git a/packages/contracts/task/tasks/contract/deploy.ts b/packages/contracts/task/tasks/contract/deploy.ts deleted file mode 100644 index eeeda59ec..000000000 --- a/packages/contracts/task/tasks/contract/deploy.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { confirm, deploy, DeployType } from '@graphprotocol/sdk' -import { greTask } from '@graphprotocol/sdk/gre' - -greTask('contract:deploy', 'Deploy a contract') - .addPositionalParam('contract', 'Name of the contract to deploy') - .addOptionalPositionalParam( - 'init', - 'Initialization arguments for the contract constructor. Provide arguments as comma-separated values', - ) - .addParam('deployType', 'Choose deploy, deploy-save, deploy-with-proxy, deploy-with-proxy-save') - .addFlag('skipConfirmation', 'Skip confirmation prompt on write actions') - .addFlag('buildAcceptTx', '...') - .setAction(async (taskArgs, hre) => { - const graph = hre.graph(taskArgs) - const deployer = await graph.getDeployer() - - if (!Object.values(DeployType).includes(taskArgs.deployType)) { - throw new Error(`Deploy type ${taskArgs.deployType} not supported`) - } - - console.log(`Deploying ${taskArgs.contract}...`) - console.log(`Init: ${taskArgs.init}`) - console.log(`Deploy type: ${taskArgs.deployType}`) - console.log(`Deployer: ${deployer.address}`) - console.log(`Chain ID: ${graph.chainId}`) - - const sure = await confirm(`Are you sure to deploy ${taskArgs.contract}?`, taskArgs.skipConfirmation) - if (!sure) return - - const deployment = await deploy( - taskArgs.deployType, - deployer, - { - name: taskArgs.contract, - args: taskArgs.init?.split(',') || [], - }, - graph.addressBook, - ) - console.log(`Contract deployed at ${deployment.contract.address}`) - }) diff --git a/packages/contracts/task/tasks/contract/upgrade.ts b/packages/contracts/task/tasks/contract/upgrade.ts deleted file mode 100644 index 1f82bb255..000000000 --- a/packages/contracts/task/tasks/contract/upgrade.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { deploy, DeployType, GraphNetworkAddressBook } from '@graphprotocol/sdk' -import { greTask } from '@graphprotocol/sdk/gre' - -greTask('contract:upgrade', 'Upgrades a contract') - .addParam('contract', 'Name of the contract to upgrade') - .addOptionalVariadicPositionalParam('init', 'Initialization arguments for the contract constructor') - .setAction(async (taskArgs, hre) => { - const graph = hre.graph(taskArgs) - - const { GraphProxyAdmin } = graph.contracts - const { governor } = await graph.getNamedAccounts() - const deployer = await graph.getDeployer() - - const contract = (graph.contracts as unknown as Record)[taskArgs.contract] - if (!contract) { - throw new Error(`Contract ${taskArgs.contract} not found in address book`) - } - console.log(`Upgrading ${taskArgs.contract}...`) - - // Deploy new implementation - const { contract: implementation } = await deploy( - DeployType.DeployImplementationAndSave, - deployer, - { - name: taskArgs.contract, - args: taskArgs.init || [], - }, - new GraphNetworkAddressBook(taskArgs.addressBook, graph.chainId), - ) - console.log(`New implementation deployed at ${implementation.address}`) - - // Upgrade proxy and accept implementation - await GraphProxyAdmin.connect(governor).upgrade(contract.address, implementation.address) - await GraphProxyAdmin.connect(governor).acceptProxy(implementation.address, contract.address) - console.log(`Proxy upgraded to ${implementation.address}`) - }) diff --git a/packages/contracts/task/tasks/deployment/config.ts b/packages/contracts/task/tasks/deployment/config.ts deleted file mode 100644 index 0f6458882..000000000 --- a/packages/contracts/task/tasks/deployment/config.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - confirm, - GraphNetworkConfigContractList, - GraphNetworkConfigGeneralParams, - updateContractParams, - updateGeneralParams, - writeConfig, -} from '@graphprotocol/sdk' -import { greTask } from '@graphprotocol/sdk/gre' - -greTask('update-config', 'Update graph config parameters with onchain data') - .addFlag('dryRun', "Only print the changes, don't write them to the config file") - .addFlag('skipConfirmation', 'Skip confirmation prompt on write actions.') - .setAction(async (taskArgs, hre) => { - const networkName = hre.network.name - const configFile = taskArgs.graphConfig - const dryRun = taskArgs.dryRun - const skipConfirmation = taskArgs.skipConfirmation - - console.log('## Update graph config ##') - console.log(`Network: ${networkName}`) - console.log(`Config file: ${configFile}\n`) - - // Prompt to avoid accidentally overwriting the config file with data from another network - if (!configFile.includes(networkName)) { - const sure = await confirm( - `Config file ${configFile} doesn't match 'graph..yml'. Are you sure you want to continue?`, - skipConfirmation, - ) - if (!sure) return - } - - const { graphConfig, contracts } = hre.graph({ graphConfig: configFile }) - - // general parameters - console.log(`> General`) - for (const param of GraphNetworkConfigGeneralParams) { - await updateGeneralParams(contracts, param, graphConfig) - } - - // contracts parameters - for (const contract of GraphNetworkConfigContractList) { - console.log(`> ${contract.name}`) - await updateContractParams(contracts, contract, graphConfig) - } - - if (dryRun) { - console.log('\n Dry run enabled, printing changes to console (no files updated)\n') - console.log(graphConfig.toString()) - } else { - writeConfig(configFile, graphConfig.toString()) - } - }) diff --git a/packages/contracts/task/tasks/e2e/e2e.ts b/packages/contracts/task/tasks/e2e/e2e.ts deleted file mode 100644 index 42b3fe066..000000000 --- a/packages/contracts/task/tasks/e2e/e2e.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { isGraphL1ChainId } from '@graphprotocol/sdk' -import { greTask } from '@graphprotocol/sdk/gre' -import fs from 'fs' -import { globSync } from 'glob' -import { TASK_TEST } from 'hardhat/builtin-tasks/task-names' -import { runScriptWithHardhat } from 'hardhat/internal/util/scripts-runner' -import { HardhatRuntimeEnvironment, TaskArguments } from 'hardhat/types' - -const CONFIG_TESTS = 'test/e2e/deployment/config/**/*.test.ts' -const INIT_TESTS = 'test/e2e/deployment/init/**/*.test.ts' - -// Built-in test & run tasks don't support GRE arguments -// so we pass them by overriding GRE config object -const setGraphConfig = (args: TaskArguments, hre: HardhatRuntimeEnvironment) => { - const greArgs = ['graphConfig', 'l1GraphConfig', 'l2GraphConfig', 'addressBook', 'disableSecureAccounts', 'fork'] - - for (const arg of greArgs) { - if (args[arg]) { - if (arg === 'graphConfig') { - const l1 = isGraphL1ChainId(hre.config.networks[hre.network.name].chainId) - hre.config.graph[l1 ? 'l1GraphConfig' : 'l2GraphConfig'] = args[arg] - } else { - ;(hre.config.graph as Record)[arg] = args[arg] - } - } - } -} - -greTask('e2e', 'Run all e2e tests') - .addFlag('skipBridge', 'Skip bridge tests') - .setAction(async (args, hre: HardhatRuntimeEnvironment) => { - let testFiles = [...globSync(CONFIG_TESTS), ...globSync(INIT_TESTS)] - - if (args.skipBridge) { - testFiles = testFiles.filter((file) => !/l1|l2/.test(file)) - } - - // Disable secure accounts, we don't need them for this task - hre.config.graph.disableSecureAccounts = true - - setGraphConfig(args, hre) - await hre.run(TASK_TEST, { - testFiles: testFiles, - }) - }) - -greTask('e2e:config', 'Run deployment configuration e2e tests').setAction( - async (args, hre: HardhatRuntimeEnvironment) => { - const files = globSync(CONFIG_TESTS) - - // Disable secure accounts, we don't need them for this task - hre.config.graph.disableSecureAccounts = true - - setGraphConfig(args, hre) - await hre.run(TASK_TEST, { - testFiles: files, - }) - }, -) - -greTask('e2e:init', 'Run deployment initialization e2e tests').setAction( - async (args, hre: HardhatRuntimeEnvironment) => { - const files = globSync(INIT_TESTS) - - // Disable secure accounts, we don't need them for this task - hre.config.graph.disableSecureAccounts = true - - setGraphConfig(args, hre) - await hre.run(TASK_TEST, { - testFiles: files, - }) - }, -) - -greTask('e2e:scenario', 'Run scenario scripts and e2e tests') - .addPositionalParam('scenario', 'Name of the scenario to run') - .addFlag('skipScript', "Don't run scenario script") - .setAction(async (args, hre: HardhatRuntimeEnvironment) => { - setGraphConfig(args, hre) - - const script = `test/e2e/scenarios/${args.scenario}.ts` - const test = `test/e2e/scenarios/${args.scenario}.test.ts` - - console.log(`> Running scenario: ${args.scenario}`) - console.log(`- script file: ${script}`) - console.log(`- test file: ${test}`) - - if (!args.skipScript) { - if (fs.existsSync(script)) { - await runScriptWithHardhat(hre.hardhatArguments, script, [ - args.addressBook, - args.graphConfig, - args.l1GraphConfig, - args.l2GraphConfig, - args.disableSecureAccounts, - ]) - } else { - console.log(`No script found for scenario ${args.scenario}`) - } - } - - if (fs.existsSync(test)) { - await hre.run(TASK_TEST, { - testFiles: [test], - }) - } else { - throw new Error(`No test found for scenario ${args.scenario}`) - } - }) - -greTask('e2e:upgrade', 'Run upgrade tests') - .addPositionalParam('upgrade', 'Name of the upgrade to run') - .addFlag('post', 'Wether to run pre/post upgrade scripts') - .setAction(async (args, hre: HardhatRuntimeEnvironment) => { - setGraphConfig(args, hre) - await runUpgrade(args, hre, args.post ? 'post' : 'pre') - }) - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function runUpgrade(args: { [key: string]: any }, hre: HardhatRuntimeEnvironment, type: 'pre' | 'post') { - const script = `test/e2e/upgrades/${args.upgrade}/${type}-upgrade.ts` - const test = `test/e2e/upgrades/${args.upgrade}/${type}-upgrade.test.ts` - - console.log(`> Running ${type}-upgrade: ${args.upgrade}`) - console.log(`- script file: ${script}`) - console.log(`- test file: ${test}`) - - // Run script - if (fs.existsSync(script)) { - console.log(`> Running ${type}-upgrade script: ${script}`) - await runScriptWithHardhat(hre.hardhatArguments, script, [ - args.addressBook, - args.graphConfig, - args.l1GraphConfig, - args.l2GraphConfig, - args.disableSecureAccounts, - args.fork, - ]) - } - - // Run test - if (fs.existsSync(test)) { - console.log(`> Running ${type}-upgrade test: ${test}`) - await hre.run(TASK_TEST, { - testFiles: [test], - }) - } -} diff --git a/packages/contracts/task/tasks/migrate/bridge.ts b/packages/contracts/task/tasks/migrate/bridge.ts deleted file mode 100644 index 5420f6851..000000000 --- a/packages/contracts/task/tasks/migrate/bridge.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { configureL1Bridge, configureL2Bridge, setPausedBridge } from '@graphprotocol/sdk' -import { greTask } from '@graphprotocol/sdk/gre' - -greTask('migrate:bridge', 'Configure and unpause bridge') - .addOptionalParam( - 'arbitrumAddressBook', - 'The path to the address book file for Arbitrum deployments', - './arbitrum-addresses.json', - ) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .setAction(async (taskArgs: any, hre: any) => { - const graph = hre.graph(taskArgs) - - if (!graph.l1) { - throw new Error('L1 network not available') - } - if (!graph.l2) { - throw new Error('L2 network not available') - } - - const { governor: l1Governor } = await graph.l1.getNamedAccounts() - const { governor: l2Governor } = await graph.l2.getNamedAccounts() - - await configureL1Bridge(graph.l1.contracts, l1Governor, { - l2GRTAddress: graph.l2.contracts.GraphToken.address, - l2GRTGatewayAddress: graph.l2.contracts.L2GraphTokenGateway?.address || '', - l2GNSAddress: graph.l2.contracts.L2GNS?.address || '', - l2StakingAddress: graph.l2.contracts.L2Staking?.address || '', - arbAddressBookPath: taskArgs.arbitrumAddressBook, - chainId: graph.l1.chainId, - }) - - await configureL2Bridge(graph.l2.contracts, l2Governor, { - l1GRTAddress: graph.l1.contracts.GraphToken.address, - l1GRTGatewayAddress: graph.l1.contracts.L1GraphTokenGateway?.address || '', - l1GNSAddress: graph.l1.contracts.L1GNS?.address || '', - l1StakingAddress: graph.l1.contracts.L1Staking?.address || '', - arbAddressBookPath: taskArgs.arbitrumAddressBook, - chainId: graph.l2.chainId, - }) - - await setPausedBridge(graph.l1.contracts, l1Governor, { paused: false }) - await setPausedBridge(graph.l2.contracts, l2Governor, { paused: false }) - - console.log('Done!') - }) diff --git a/packages/contracts/task/tasks/migrate/protocol.ts b/packages/contracts/task/tasks/migrate/protocol.ts deleted file mode 100644 index 38664a573..000000000 --- a/packages/contracts/task/tasks/migrate/protocol.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { deployGraphNetwork, GraphChainId } from '@graphprotocol/sdk' -import { greTask } from '@graphprotocol/sdk/gre' - -greTask('migrate', 'Deploy protocol contracts') - .addFlag('skipConfirmation', 'Skip confirmation prompt on write actions') - .addFlag('skipPostDeploy', 'Skip accepting ownership and unpausing protocol after deploying') - .addFlag('force', 'Deploy contract even if its already deployed') - .addFlag('buildAcceptTx', '...') - .setAction(async (taskArgs, hre) => { - const graph = hre.graph(taskArgs) - - await deployGraphNetwork( - taskArgs.addressBook, - taskArgs.graphConfig, - graph.chainId as GraphChainId, // TODO: fix type - await graph.getDeployer(), - graph.provider, - { - governor: taskArgs.skipPostDeploy ? undefined : (await graph.getNamedAccounts()).governor, - forceDeploy: taskArgs.force, - skipConfirmation: taskArgs.skipConfirmation, - buildAcceptTx: taskArgs.buildAcceptTx, - }, - ) - }) diff --git a/packages/contracts/task/tasks/test-upgrade.ts b/packages/contracts/task/tasks/test-upgrade.ts deleted file mode 100644 index 5939d0ad0..000000000 --- a/packages/contracts/task/tasks/test-upgrade.ts +++ /dev/null @@ -1,54 +0,0 @@ -import '@openzeppelin/hardhat-upgrades' - -import fs from 'fs' -import { task } from 'hardhat/config' - -function saveProxyAddresses(data: Record) { - try { - fs.writeFileSync('.proxies.json', JSON.stringify(data, null, 2)) - } catch (e) { - console.log(`Error saving artifacts: ${(e as Error).message}`) - } -} - -interface UpgradeableContract { - name: string - libraries?: string[] -} - -const UPGRADEABLE_CONTRACTS: UpgradeableContract[] = [ - { name: 'EpochManager' }, - { name: 'Curation' }, - { name: 'Staking', libraries: ['LibExponential'] }, - { name: 'DisputeManager' }, - { name: 'RewardsManager' }, - { name: 'ServiceRegistry' }, -] - -task('test:upgrade-setup', 'Deploy contracts using an OZ proxy').setAction(async (_, hre) => { - const contractAddresses: { [key: string]: string } = {} - for (const upgradeableContract of UPGRADEABLE_CONTRACTS) { - // Deploy libraries - const deployedLibraries: { [key: string]: string } = {} - if (upgradeableContract.libraries) { - for (const libraryName of upgradeableContract.libraries) { - const libraryFactory = await hre.ethers.getContractFactory(libraryName) - const libraryInstance = await libraryFactory.deploy() - deployedLibraries[libraryName] = libraryInstance.address - } - } - - // Deploy contract with Proxy - const contractFactory = await hre.ethers.getContractFactory(upgradeableContract.name, { - libraries: deployedLibraries, - }) - const deployedContract = await hre.upgrades.deployProxy(contractFactory, { - initializer: false, - unsafeAllowLinkedLibraries: true, - }) - contractAddresses[upgradeableContract.name] = deployedContract.address - } - - // Save proxies to a file - saveProxyAddresses(contractAddresses) -}) diff --git a/packages/contracts/task/tasks/verify/defender.ts b/packages/contracts/task/tasks/verify/defender.ts deleted file mode 100644 index bef0130aa..000000000 --- a/packages/contracts/task/tasks/verify/defender.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { task } from 'hardhat/config' -import { HardhatRuntimeEnvironment as HRE } from 'hardhat/types' -// Define the VerificationResponse type locally since @openzeppelin/hardhat-defender is deprecated -// and the functionality has been moved to @openzeppelin/hardhat-upgrades -interface VerificationResponse { - matchType: 'EXACT' | 'PARTIAL' | 'NO_MATCH' -} -import { GraphNetworkContractName } from '@graphprotocol/sdk' - -async function main(_args: { referenceUrl?: string; contracts: GraphNetworkContractName[] }, _hre: HRE) { - // NOTE: This task is currently disabled because @openzeppelin/hardhat-defender has been deprecated - // and the functionality has been moved to @openzeppelin/hardhat-upgrades, but the API has changed. - // TODO: Update this task to use the new OpenZeppelin Defender API or remove it entirely. - - console.error('ERROR: The verify-defender task is currently disabled.') - console.error('The @openzeppelin/hardhat-defender package has been deprecated.') - console.error('Please use alternative verification methods such as:') - console.error('- yarn hardhat verifyAll --network --graph-config ') - console.error('- yarn hardhat sourcifyAll --network ') - - throw new Error('verify-defender task is disabled due to deprecated dependencies') - - /* DISABLED CODE - kept for reference - const { referenceUrl, contracts } = args - const { defender, network, graph } = hre - const summaryPath = process.env.GITHUB_STEP_SUMMARY - if (summaryPath) appendFileSync(summaryPath, `# Contracts deployment verification\n\n`) - - const workflowUrl - = referenceUrl - || process.env.WORKFLOW_URL - || execSync(`git config --get remote.origin.url`).toString().trim() - const addressBook = graph().addressBook - const errs = [] - - for (const contractName of contracts) { - if (!isGraphNetworkContractName(contractName)) { - throw new Error(`Invalid contract name: ${contractName as string}`) - } - const entry = addressBook.getEntry(contractName) - if (!entry || entry.address === constants.AddressZero) { - errs.push([contractName, { message: `Entry not found on address book.` }]) - continue - } - - const addressToVerify = entry.implementation?.address ?? entry.address - console.error(`Verifying artifact for ${contractName} at ${addressToVerify}`) - - try { - const response = await defender.verifyDeployment(addressToVerify, contractName, workflowUrl) - console.error(`Bytecode match for ${contractName} is ${response.matchType}`) - if (summaryPath) { - appendFileSync( - summaryPath, - `- ${contractName} at ${etherscanLink(network.name, addressToVerify)} is ${ - response.matchType - } ${emojiForMatch(response.matchType)}\n`, - ) - } - if (response.matchType === 'NO_MATCH') { - errs.push([contractName, { message: `No bytecode match.` }]) - } - } catch (err) { - if (summaryPath) { - appendFileSync( - summaryPath, - `- ${contractName} at ${etherscanLink( - network.name, - addressToVerify, - )} failed to verify :x:\n`, - ) - } - console.error(`Error verifying artifact: ${err.message}`) - errs.push([contractName, err]) - } - } - - if (errs.length > 0) { - throw new Error( - `Some verifications failed:\n${errs - .map(([name, err]) => ` ${name}: ${err.message}`) - .join('\n')}`, - ) - } - */ -} - -function _ietherscanLink(network: string, address: string): string { - switch (network) { - case 'mainnet': - return `[\`${address}\`](https://etherscan.io/address/${address})` - case 'arbitrum-one': - return `[\`${address}\`](https://arbiscan.io/address/${address})` - case 'goerli': - return `[\`${address}\`](https://goerli.etherscan.io/address/${address})` - case 'arbitrum-goerli': - return `[\`${address}\`](https://goerli.arbiscan.io/address/${address})` - default: - return `\`${address}\`` - } -} - -function _iemojiForMatch(matchType: VerificationResponse['matchType']): string { - switch (matchType) { - case 'EXACT': - return ':heavy_check_mark:' - case 'PARTIAL': - return ':warning:' - case 'NO_MATCH': - return ':x:' - } -} - -task('verify-defender') - .addVariadicPositionalParam('contracts', 'List of contracts to verify') - .addOptionalParam( - 'referenceUrl', - 'URL to link to for artifact verification (defaults to $WORKFLOW_URL or the remote.origin.url of the repository)', - ) - .setDescription('Verifies deployed implementations on Defender') - .setAction(main) diff --git a/packages/contracts/task/tasks/verify/sourcify.ts b/packages/contracts/task/tasks/verify/sourcify.ts deleted file mode 100644 index 04030e573..000000000 --- a/packages/contracts/task/tasks/verify/sourcify.ts +++ /dev/null @@ -1,95 +0,0 @@ -import axios, { AxiosError } from 'axios' -import FormData from 'form-data' -import { HardhatRuntimeEnvironment } from 'hardhat/types' -import { Readable } from 'stream' - -// Helper function to safely extract error information -function getErrorMessage(error: unknown): string { - if (error instanceof AxiosError) { - if (error.response?.data) { - return JSON.stringify(error.response.data) - } - return error.message - } - if (error instanceof Error) { - return error.message - } - return String(error) -} - -// Inspired by: -// - https://github.com/wighawag/hardhat-deploy/blob/9c8cd433a37188e793181b727222e2d22aef34b0/src/sourcify.ts -// - https://github.com/zoey-t/hardhat-sourcify/blob/26f10a08eb6cf97700c78989bf42b009c9cb3275/src/sourcify.ts -export async function submitSourcesToSourcify( - hre: HardhatRuntimeEnvironment, - contract: { - source: string - name: string - address: string - fqn: string - }, -): Promise { - const chainId = hre.network.config.chainId - const sourcifyUrl = 'https://sourcify.dev/server/' - - // Get contract metadata - const contractBuildInfo = await hre.artifacts.getBuildInfo(contract.fqn) - if (!contractBuildInfo) { - throw new Error(`Build info not found for contract ${contract.fqn}`) - } - const contractOutput = contractBuildInfo.output.contracts[contract.source]?.[contract.name] - if (!contractOutput) { - throw new Error(`Contract output not found for ${contract.name}`) - } - const contractMetadata = (contractOutput as { metadata?: string }).metadata - - if (contractMetadata === undefined) { - console.error( - `Contract ${contract.name} was deployed without saving metadata. Cannot submit to sourcify, skipping.`, - ) - return - } - - // Check if contract already verified - try { - const checkResponse = await axios.get( - `${sourcifyUrl}checkByAddresses?addresses=${contract.address.toLowerCase()}&chainIds=${chainId}`, - ) - const { data: checkData } = checkResponse - if (checkData[0].status === 'perfect') { - console.log(`already verified: ${contract.name} (${contract.address}), skipping.`) - return - } - } catch (e) { - console.error(getErrorMessage(e)) - } - - console.log(`verifying ${contract.name} (${contract.address} on chain ${chainId}) ...`) - - // Build form data - const formData = new FormData() - formData.append('address', contract.address) - formData.append('chain', chainId) - - const fileStream = new Readable() - fileStream.push(contractMetadata) - fileStream.push(null) - formData.append('files', fileStream) - - // Verify contract - try { - const submissionResponse = await axios.post(sourcifyUrl, formData, { - headers: formData.getHeaders(), - }) - const { status } = submissionResponse.data.result[0] - if (status === 'perfect') { - console.log(` => contract ${contract.name} is now verified`) - } else if (status === 'partial') { - console.log(` => contract ${contract.name} is partially verified`) - } else { - console.error(` => contract ${contract.name} is not verified`) - } - } catch (e) { - console.error(getErrorMessage(e)) - } -} diff --git a/packages/contracts/task/tasks/verify/verify.ts b/packages/contracts/task/tasks/verify/verify.ts deleted file mode 100644 index def5b8c06..000000000 --- a/packages/contracts/task/tasks/verify/verify.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { ContractConfigParam, getContractConfig, readConfig } from '@graphprotocol/contracts-task' -import { GraphRuntimeEnvironmentOptions, greTask } from '@graphprotocol/sdk/gre' -import fs from 'fs' -import { TASK_COMPILE } from 'hardhat/builtin-tasks/task-names' -import { task } from 'hardhat/config' -import * as types from 'hardhat/internal/core/params/argumentTypes' -import { HardhatRuntimeEnvironment } from 'hardhat/types/runtime' -import { isFullyQualifiedName, parseFullyQualifiedName } from 'hardhat/utils/contract-names' -import path from 'path' - -import { submitSourcesToSourcify } from './sourcify' - -interface SourcifyArgs { - address: string - contract: string -} - -task('sourcify', 'Verifies contract on sourcify') - .addPositionalParam('address', 'Address of the smart contract to verify', undefined, types.string) - .addParam('contract', 'Fully qualified name of the contract to verify.', undefined, types.string) - .setAction(async (args: SourcifyArgs, hre: HardhatRuntimeEnvironment) => { - if (!isFullyQualifiedName(args.contract)) { - throw new Error('Invalid fully qualified name of the contract.') - } - - const { contractName, sourceName: contractSource } = parseFullyQualifiedName(args.contract) - - if (!fs.existsSync(contractSource)) { - throw new Error(`Contract source ${contractSource} not found.`) - } - - await hre.run(TASK_COMPILE) - await submitSourcesToSourcify(hre, { - source: contractSource, - name: contractName, - address: args.address, - fqn: args.contract, - }) - }) - -greTask('sourcifyAll', 'Verifies all contracts on sourcify').setAction( - async (_args: GraphRuntimeEnvironmentOptions, hre: HardhatRuntimeEnvironment) => { - const chainId = hre.network.config.chainId - const chainName = hre.network.name - - if (!chainId || !chainName) { - throw new Error('Cannot verify contracts without a network') - } - console.log(`> Verifying all contracts on chain ${chainName}[${chainId}]...`) - const { addressBook } = hre.graph({ addressBook: _args.addressBook }) - - for (const contractName of addressBook.listEntries()) { - console.log(`\n> Verifying contract ${contractName}...`) - - const contractPath = getContractPath(contractName) - if (contractPath) { - const contract = addressBook.getEntry(contractName) - if (contract.implementation) { - console.log('Contract is upgradeable, verifying proxy...') - - await hre.run('sourcify', { - address: contract.address, - contract: 'contracts/upgrades/GraphProxy.sol:GraphProxy', - }) - } - - // Verify implementation - await hre.run('sourcify', { - address: contract.implementation?.address ?? contract.address, - contract: `${contractPath}:${contractName}`, - }) - } else { - console.log(`Contract ${contractName} not found.`) - } - } - }, -) - -greTask('verifyAll', 'Verifies all contracts on etherscan').setAction( - async (args: GraphRuntimeEnvironmentOptions, hre: HardhatRuntimeEnvironment) => { - const chainId = hre.network.config.chainId - const chainName = hre.network.name - - if (!chainId || !chainName) { - throw new Error('Cannot verify contracts without a network') - } - - console.log(`> Verifying all contracts on chain ${chainName}[${chainId}]...`) - const { addressBook, getDeployer } = hre.graph({ - addressBook: args.addressBook, - graphConfig: args.graphConfig, - }) - const graphConfig = readConfig(args.graphConfig || '', false) - const deployer = (await getDeployer()).address - for (const contractName of addressBook.listEntries()) { - console.log(`\n> Verifying contract ${contractName}...`) - - const contractConfig = getContractConfig(graphConfig, addressBook, contractName, deployer) - const contractPath = getContractPath(contractName) - const constructorParams = contractConfig.params.map((p: ContractConfigParam) => p.value.toString()) - - if (contractPath) { - const contract = addressBook.getEntry(contractName) - - if (contract.implementation) { - console.log('Contract is upgradeable, verifying proxy...') - const proxyAdmin = addressBook.getEntry('GraphProxyAdmin') - - // Verify proxy - await safeVerify(hre, { - address: contract.address, - contract: 'contracts/upgrades/GraphProxy.sol:GraphProxy', - constructorArgsParams: [contract.implementation.address, proxyAdmin.address], - }) - } - - // Verify implementation - console.log('Verifying implementation...') - await safeVerify(hre, { - address: contract.implementation?.address ?? contract.address, - contract: `${contractPath}:${contractName}`, - constructorArgsParams: contract.implementation ? [] : constructorParams, - }) - } else { - console.log(`Contract ${contractName} not found.`) - } - } - }, -) - -// etherscan API throws errors if the contract is already verified -async function safeVerify(hre: HardhatRuntimeEnvironment, taskArguments: unknown): Promise { - try { - await hre.run('verify', taskArguments) - } catch (error) { - console.log((error as Error).message) - } -} - -function getContractPath(contract: string): string | undefined { - const files = readDirRecursive('contracts/') - return files.find((f) => path.basename(f) === `${contract}.sol`) -} - -function readDirRecursive(dir: string, allFiles: string[] = []) { - const files = fs.readdirSync(dir) - - for (const file of files) { - if (fs.statSync(path.join(dir, file)).isDirectory()) { - allFiles = readDirRecursive(path.join(dir, file), allFiles) - } else { - allFiles.push(path.join(dir, file)) - } - } - - return allFiles -} diff --git a/packages/contracts/task/tsconfig.json b/packages/contracts/task/tsconfig.json deleted file mode 100644 index f60d075ba..000000000 --- a/packages/contracts/task/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "rootDir": "..", - "outDir": "./build", - "declarationDir": "./types" - }, - "include": ["src/**/*.ts", "tasks/**/*.ts"], - "exclude": ["node_modules", "build", "types", "cache"] -} diff --git a/packages/testing/foundry.toml b/packages/testing/foundry.toml index be0a761b5..723dc7044 100644 --- a/packages/testing/foundry.toml +++ b/packages/testing/foundry.toml @@ -33,8 +33,7 @@ via_ir = true [lint] # Disable lint-on-build: forge's lint walker follows symlinks without cycle detection and -# infinite-loops on this package's pnpm-hoisted node_modules -# (e.g., @graphprotocol/contracts/task/node_modules/@graphprotocol/contracts/task/...). +# infinite-loops on this package's pnpm-hoisted node_modules. # `forge lint contracts/` (scoped) still works as a separate command. lint_on_build = false exclude_lints = ["mixed-case-function", "mixed-case-variable", "block-timestamp"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed3e7e688..635854dbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -521,139 +521,6 @@ importers: specifier: ^17.0.0 version: 17.7.2 - packages/contracts/task: - dependencies: - '@graphprotocol/contracts': - specifier: workspace:^ - version: link:.. - '@graphprotocol/sdk': - specifier: 0.6.0 - version: 0.6.0(bufferutil@4.0.9)(encoding@0.1.13)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10) - axios: - specifier: ^1.16.1 - version: 1.17.0(debug@4.4.3) - console-table-printer: - specifier: ^2.14.1 - version: 2.14.6 - devDependencies: - '@arbitrum/sdk': - specifier: ~3.1.13 - version: 3.1.13(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@ethersproject/abi': - specifier: ^5.8.0 - version: 5.8.0 - '@ethersproject/abstract-provider': - specifier: ^5.8.0 - version: 5.8.0 - '@ethersproject/abstract-signer': - specifier: ^5.8.0 - version: 5.8.0 - '@ethersproject/bytes': - specifier: ^5.8.0 - version: 5.8.0 - '@ethersproject/providers': - specifier: ^5.8.0 - version: 5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@graphprotocol/common-ts': - specifier: ^1.8.3 - version: 1.8.7(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10) - '@nomicfoundation/hardhat-network-helpers': - specifier: ^1.0.0 - version: 1.1.0(hardhat@2.28.6(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) - '@nomiclabs/hardhat-ethers': - specifier: ^2.2.3 - version: 2.2.3(ethers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.28.6(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) - '@nomiclabs/hardhat-etherscan': - specifier: ^3.1.0 - version: 3.1.8(hardhat@2.28.6(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) - '@openzeppelin/contracts': - specifier: 3.4.2 - version: 3.4.2 - '@openzeppelin/contracts-upgradeable': - specifier: 3.4.2 - version: 3.4.2 - '@openzeppelin/hardhat-upgrades': - specifier: ^1.22.1 - version: 1.28.0(@nomiclabs/hardhat-ethers@2.2.3(ethers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.28.6(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)))(@nomiclabs/hardhat-etherscan@3.1.8(hardhat@2.28.6(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)))(encoding@0.1.13)(ethers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.28.6(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) - '@typechain/ethers-v5': - specifier: ^10.2.1 - version: 10.2.1(@ethersproject/abi@5.8.0)(@ethersproject/providers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(ethers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(typechain@8.3.2(patch_hash=b34ed6afcf99760666fdc85ecb2094fdd20ce509f947eb09cef21665a2a6a1d6)(typescript@5.9.3))(typescript@5.9.3) - '@typechain/hardhat': - specifier: ^6.1.2 - version: 6.1.6(@ethersproject/abi@5.8.0)(@ethersproject/providers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@typechain/ethers-v5@10.2.1(@ethersproject/abi@5.8.0)(@ethersproject/providers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(ethers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(typechain@8.3.2(patch_hash=b34ed6afcf99760666fdc85ecb2094fdd20ce509f947eb09cef21665a2a6a1d6)(typescript@5.9.3))(typescript@5.9.3))(ethers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.28.6(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10))(typechain@8.3.2(patch_hash=b34ed6afcf99760666fdc85ecb2094fdd20ce509f947eb09cef21665a2a6a1d6)(typescript@5.9.3)) - '@types/glob': - specifier: ^8.1.0 - version: 8.1.0 - '@types/node': - specifier: ^20.17.50 - version: 20.19.14 - arbos-precompiles: - specifier: ^1.0.2 - version: 1.0.2(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10) - dotenv: - specifier: ^16.5.0 - version: 16.6.1 - eslint: - specifier: 'catalog:' - version: 9.39.2(jiti@2.5.1) - ethers: - specifier: ^5.7.0 - version: 5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) - form-data: - specifier: ^4.0.0 - version: 4.0.4 - glob: - specifier: 'catalog:' - version: 11.0.3 - graphql: - specifier: ^16.11.0 - version: 16.11.0 - graphql-tag: - specifier: ^2.12.4 - version: 2.12.6(graphql@16.11.0) - hardhat: - specifier: 'catalog:' - version: 2.28.6(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10) - hardhat-abi-exporter: - specifier: ^2.11.0 - version: 2.11.0(hardhat@2.28.6(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) - hardhat-contract-sizer: - specifier: ^2.10.0 - version: 2.10.1(hardhat@2.28.6(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) - hardhat-gas-reporter: - specifier: ^1.0.8 - version: 1.0.10(bufferutil@4.0.9)(hardhat@2.28.6(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10) - hardhat-storage-layout: - specifier: ^0.1.7 - version: 0.1.7(hardhat@2.28.6(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) - prettier: - specifier: 'catalog:' - version: 3.8.1 - prettier-plugin-solidity: - specifier: 'catalog:' - version: 2.1.0(prettier@3.8.1) - ts-node: - specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.14)(typescript@5.9.3) - typechain: - specifier: ^8.3.2 - version: 8.3.2(patch_hash=b34ed6afcf99760666fdc85ecb2094fdd20ce509f947eb09cef21665a2a6a1d6)(typescript@5.9.3) - typescript: - specifier: 'catalog:' - version: 5.9.3 - winston: - specifier: ^3.3.3 - version: 3.17.0 - yaml: - specifier: ^1.10.2 - version: 1.10.2 - yaml-lint: - specifier: 'catalog:' - version: 1.7.0 - yargs: - specifier: ^17.0.0 - version: 17.7.2 - packages/data-edge: devDependencies: '@nomicfoundation/hardhat-chai-matchers': @@ -4302,9 +4169,6 @@ packages: '@types/glob@7.2.0': resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} - '@types/glob@8.1.0': - resolution: {integrity: sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==} - '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} @@ -4342,9 +4206,6 @@ packages: '@types/levelup@4.3.3': resolution: {integrity: sha512-K+OTIjJcZHVlZQN1HmU64VtrC0jC3dXWQozuEIR9zVvltIk90zaGPM2AgT+fIkChpzHhFE3YnvFLCbLtzAmexA==} - '@types/minimatch@5.1.2': - resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} - '@types/minimatch@6.0.0': resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==} deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed. @@ -15177,11 +15038,6 @@ snapshots: '@types/minimatch': 6.0.0 '@types/node': 20.19.14 - '@types/glob@8.1.0': - dependencies: - '@types/minimatch': 5.1.2 - '@types/node': 20.19.14 - '@types/graceful-fs@4.1.9': dependencies: '@types/node': 20.19.41 @@ -15220,8 +15076,6 @@ snapshots: '@types/level-errors': 3.0.2 '@types/node': 20.19.14 - '@types/minimatch@5.1.2': {} - '@types/minimatch@6.0.0': dependencies: minimatch: 10.1.1 From c0d3c9e246eb3cfcbfc5e32e43fa0adbc5f18290 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Thu, 22 Oct 2026 11:58:35 -0300 Subject: [PATCH 02/20] test(token-distribution): use IStaking ABI from interfaces instead of contracts types --- packages/token-distribution/package.json | 1 - .../test/l1TokenLockTransferTool.test.ts | 6 +++--- .../token-distribution/test/l2TokenLockManager.test.ts | 10 +++++----- .../token-distribution/test/tokenLockWallet.test.ts | 8 ++++---- pnpm-lock.yaml | 3 --- 5 files changed, 12 insertions(+), 16 deletions(-) diff --git a/packages/token-distribution/package.json b/packages/token-distribution/package.json index 0c0e2314f..2f411d1d7 100644 --- a/packages/token-distribution/package.json +++ b/packages/token-distribution/package.json @@ -45,7 +45,6 @@ "@ethersproject/hardware-wallets": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@graphprotocol/client-cli": "^2.2.22", - "@graphprotocol/contracts": "workspace:^", "@graphprotocol/interfaces": "workspace:^", "@graphql-yoga/plugin-persisted-operations": "^3.13.5", "@nomiclabs/hardhat-ethers": "^2.2.3", diff --git a/packages/token-distribution/test/l1TokenLockTransferTool.test.ts b/packages/token-distribution/test/l1TokenLockTransferTool.test.ts index 470102793..5ac18499f 100644 --- a/packages/token-distribution/test/l1TokenLockTransferTool.test.ts +++ b/packages/token-distribution/test/l1TokenLockTransferTool.test.ts @@ -1,9 +1,9 @@ import '@nomiclabs/hardhat-ethers' import 'hardhat-deploy' -import { Staking__factory } from '@graphprotocol/contracts/types' +import IStakingArtifact from '@graphprotocol/interfaces/artifacts/contracts/contracts/staking/IStaking.sol/IStaking.json' import { expect } from 'chai' -import { BigNumber, constants, Signer } from 'ethers' +import { BigNumber, constants, Contract, Signer } from 'ethers' import { defaultAbiCoder, hexValue, keccak256, parseEther } from 'ethers/lib/utils' import { deployments, ethers, upgrades } from 'hardhat' import { DeployOptions } from 'hardhat-deploy/types' @@ -492,7 +492,7 @@ describe('L1GraphTokenLockTransferTool', () => { }) it('accepts calls from a wallet that has funds staked in the protocol', async function () { // Use the tokenLock contract as if it were the Staking contract - const lockAsStaking = Staking__factory.connect(tokenLock.address, deployer.signer) + const lockAsStaking = new Contract(tokenLock.address, IStakingArtifact.abi, deployer.signer) const stakeAmount = toGRT('1000') // Stake some funds await lockAsStaking.connect(beneficiary.signer).stake(stakeAmount) diff --git a/packages/token-distribution/test/l2TokenLockManager.test.ts b/packages/token-distribution/test/l2TokenLockManager.test.ts index 74bbe67cf..b48c992a3 100644 --- a/packages/token-distribution/test/l2TokenLockManager.test.ts +++ b/packages/token-distribution/test/l2TokenLockManager.test.ts @@ -1,9 +1,9 @@ import '@nomiclabs/hardhat-ethers' import 'hardhat-deploy' -import { Staking, Staking__factory } from '@graphprotocol/contracts/types' +import IStakingArtifact from '@graphprotocol/interfaces/artifacts/contracts/contracts/staking/IStaking.sol/IStaking.json' import { expect } from 'chai' -import { constants, Wallet } from 'ethers' +import { constants, Contract, Wallet } from 'ethers' import { defaultAbiCoder, keccak256 } from 'ethers/lib/utils' import { deployments, ethers } from 'hardhat' import { DeployOptions } from 'hardhat-deploy/types' @@ -75,7 +75,7 @@ describe('L2GraphTokenLockManager', () => { let tokenLockImplementation: L2GraphTokenLockWallet let tokenLockManager: L2GraphTokenLockManager let staking: StakingMock - let lockAsStaking: Staking + let lockAsStaking: Contract let initArgs: TokenLockParameters @@ -175,7 +175,7 @@ describe('L2GraphTokenLockManager', () => { // Stake in the protocol using the lock as a Staking contract const amount = toGRT('10000000') - lockAsStaking = Staking__factory.connect(tokenLock.address, deployer.signer) + lockAsStaking = new Contract(tokenLock.address, IStakingArtifact.abi, deployer.signer) await lockAsStaking.connect(beneficiary.signer).stake(amount) // Check that the staking contract received the tokens @@ -389,7 +389,7 @@ describe('L2GraphTokenLockManager', () => { // Stake in the protocol using the lock as a Staking contract const amount = toGRT('100000') - const lockAsStaking = Staking__factory.connect(tokenLock.address, deployer.signer) + const lockAsStaking = new Contract(tokenLock.address, IStakingArtifact.abi, deployer.signer) await lockAsStaking.connect(beneficiary.signer).stake(amount) // Check that the staking contract received the tokens diff --git a/packages/token-distribution/test/tokenLockWallet.test.ts b/packages/token-distribution/test/tokenLockWallet.test.ts index 00146a03f..20e0e331e 100644 --- a/packages/token-distribution/test/tokenLockWallet.test.ts +++ b/packages/token-distribution/test/tokenLockWallet.test.ts @@ -1,9 +1,9 @@ import '@nomiclabs/hardhat-ethers' import 'hardhat-deploy' -import { Staking__factory } from '@graphprotocol/contracts/types' +import IStakingArtifact from '@graphprotocol/interfaces/artifacts/contracts/contracts/staking/IStaking.sol/IStaking.json' import { expect } from 'chai' -import { BigNumber, constants, Wallet } from 'ethers' +import { BigNumber, constants, Contract, Wallet } from 'ethers' import { deployments, ethers } from 'hardhat' import { DeployOptions } from 'hardhat-deploy/types' @@ -216,7 +216,7 @@ describe('GraphTokenLockWallet', () => { beforeEach(async () => { // Use the tokenLock contract as if it were the Staking contract - lockAsStaking = Staking__factory.connect(tokenLock.address, deployer.signer) + lockAsStaking = new Contract(tokenLock.address, IStakingArtifact.abi, deployer.signer) // Add the staking contract as token destination await tokenLockManager.addTokenDestination(staking.address) @@ -268,7 +268,7 @@ describe('GraphTokenLockWallet', () => { tokenLock = await initWithArgs({ ...initArgs, periods: 6, revocable: Revocability.Enabled }) // Use the tokenLock contract as if it were the Staking contract - lockAsStaking = Staking__factory.connect(tokenLock.address, deployer.signer) + lockAsStaking = new Contract(tokenLock.address, IStakingArtifact.abi, deployer.signer) // Add the staking contract as token destination await tokenLockManager.addTokenDestination(staking.address) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 635854dbe..7dab5ef99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1214,9 +1214,6 @@ importers: '@graphprotocol/client-cli': specifier: ^2.2.22 version: 2.2.22(@babel/core@7.28.4)(@envelop/core@5.3.1)(@graphql-mesh/cross-helpers@0.3.4(@graphql-tools/utils@10.9.1(graphql@16.11.0))(graphql@16.11.0)(react-native@0.81.4(@babel/core@7.28.4)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@graphql-mesh/store@0.93.1)(@graphql-mesh/types@0.93.2)(@graphql-mesh/utils@0.93.2)(@graphql-tools/delegate@9.0.35(graphql@16.11.0))(@graphql-tools/merge@9.1.1(graphql@16.11.0))(@graphql-tools/utils@10.9.1(graphql@16.11.0))(@graphql-tools/wrap@9.4.2(graphql@16.11.0))(@types/node@20.19.14)(bufferutil@4.0.9)(encoding@0.1.13)(graphql-tag@2.12.6(graphql@16.11.0))(graphql@16.11.0)(react-native@0.81.4(@babel/core@7.28.4)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10) - '@graphprotocol/contracts': - specifier: workspace:^ - version: link:../contracts '@graphprotocol/interfaces': specifier: workspace:^ version: link:../interfaces From 896599dbae153849d2003ab8bdca07100ef15659 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Thu, 22 Oct 2026 12:22:05 -0300 Subject: [PATCH 03/20] ci: add bytecode equivalence check for the contracts package migration --- .github/workflows/build-test.yml | 6 + package.json | 2 + ...o-contract-bytecode-excluding-metadata.mjs | 22 +--- .../contracts-migration/check-bytecode.mjs | 82 +++++++++++++ .../check-bytecode.test.mjs | 111 ++++++++++++++++++ scripts/contracts-migration/manifest.json | 28 +++++ scripts/contracts-migration/reference.json | 106 +++++++++++++++++ scripts/lib/bytecode.mjs | 12 ++ 8 files changed, 348 insertions(+), 21 deletions(-) create mode 100644 scripts/contracts-migration/check-bytecode.mjs create mode 100644 scripts/contracts-migration/check-bytecode.test.mjs create mode 100644 scripts/contracts-migration/manifest.json create mode 100644 scripts/contracts-migration/reference.json create mode 100644 scripts/lib/bytecode.mjs diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index bc35bc4f2..49a8ca77a 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -24,6 +24,12 @@ jobs: - name: Build all packages run: pnpm -r --sequential run build + - name: Test scripts + run: pnpm test:scripts + + - name: Check bytecode equivalence + run: pnpm check:bytecode + - name: Test all packages run: pnpm -r --sequential run test:self diff --git a/package.json b/package.json index a29080efa..def4914ca 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "lint:yaml": "npx yaml-lint .github/**/*.{yml,yaml}; prettier -w --cache --log-level warn 'packages/**/*.{yml,yaml}' '.github/**/*.{yml,yaml}'", "test": "pnpm build && pnpm -r run test:self", "test:prod": "FOUNDRY_PROFILE=prod pnpm test", + "test:scripts": "node --test 'scripts/**/*.test.mjs'", + "check:bytecode": "node scripts/contracts-migration/check-bytecode.mjs", "test:coverage": "pnpm build && pnpm -r run build:self:coverage && pnpm -r run test:coverage:self" }, "devDependencies": { diff --git a/scripts/compare-repo-contract-bytecode-excluding-metadata.mjs b/scripts/compare-repo-contract-bytecode-excluding-metadata.mjs index 74ca957db..adeadadca 100755 --- a/scripts/compare-repo-contract-bytecode-excluding-metadata.mjs +++ b/scripts/compare-repo-contract-bytecode-excluding-metadata.mjs @@ -18,27 +18,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'fs' import { basename, join, relative, resolve } from 'path' -/** - * Strip Solidity metadata hash from bytecode to focus on functional differences. - * - * Solidity appends CBOR-encoded metadata at the end of contract bytecode: - * - a264697066735822 = CBOR encoding of {"ipfs": bytes( - * - [68 hex chars] = 34-byte IPFS multihash (0x1220 prefix + 32-byte SHA2-256 hash) - * - 64736f6c63 = CBOR encoding of "solc" - * - [variable] = CBOR-encoded version bytes (e.g., 0x430007060033 for v0.7.6) - * - * Example: a264697066735822<1220+hash>64736f6c63430007060033 - * └─ ipfs ──┘ └─34 bytes─┘ └solc┘ └─ version ─┘ - */ -function stripMetadata(bytecode) { - if (bytecode.startsWith('0x')) { - bytecode = bytecode.slice(2) - } - - // Match and remove the complete CBOR metadata suffix - // Note: 68 hex chars = 34 bytes (multihash prefix 0x1220 + 32-byte hash) - return bytecode.replace(/a264697066735822[a-fA-F0-9]{68}64736f6c63[a-fA-F0-9]+$/, '') -} +import { stripMetadata } from './lib/bytecode.mjs' /** * Extract and process bytecode from contract artifact JSON file. diff --git a/scripts/contracts-migration/check-bytecode.mjs b/scripts/contracts-migration/check-bytecode.mjs new file mode 100644 index 000000000..7d96f02a9 --- /dev/null +++ b/scripts/contracts-migration/check-bytecode.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' + +import { bytecodeHash } from '../lib/bytecode.mjs' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const FIELDS = ['bytecode', 'deployedBytecode'] + +export function computeHashes(manifest, repoRoot) { + const hashes = {} + const missing = [] + const empty = [] + for (const [name, rel] of Object.entries(manifest)) { + const file = path.join(repoRoot, rel) + if (!fs.existsSync(file)) { + missing.push(rel) + continue + } + const artifact = JSON.parse(fs.readFileSync(file, 'utf8')) + if (FIELDS.some((f) => !artifact[f] || artifact[f] === '0x')) { + empty.push(name) + continue + } + hashes[name] = Object.fromEntries(FIELDS.map((f) => [f, bytecodeHash(artifact[f])])) + } + return { hashes, missing, empty } +} + +export function compare(reference, hashes) { + const lines = [] + for (const name of Object.keys(reference)) { + if (!(name in hashes)) { + lines.push(`${name}: not in manifest`) + continue + } + for (const f of FIELDS) { + if (reference[name][f] !== hashes[name][f]) lines.push(`${name}: ${f} differs`) + } + } + for (const name of Object.keys(hashes)) { + if (!(name in reference)) lines.push(`${name}: not in reference`) + } + return lines +} + +function main() { + const { values } = parseArgs({ + options: { + root: { type: 'string', default: path.resolve(here, '../..') }, + manifest: { type: 'string', default: path.join(here, 'manifest.json') }, + reference: { type: 'string', default: path.join(here, 'reference.json') }, + write: { type: 'boolean', default: false }, + }, + }) + const manifest = JSON.parse(fs.readFileSync(values.manifest, 'utf8')) + const { hashes, missing, empty } = computeHashes(manifest, values.root) + + if (missing.length || empty.length) { + for (const m of missing) console.error(`missing artifact: ${m}`) + for (const e of empty) console.error(`empty bytecode: ${e}`) + process.exit(1) + } + + if (values.write) { + const sorted = Object.fromEntries(Object.entries(hashes).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) + fs.writeFileSync(values.reference, JSON.stringify(sorted, null, 2) + '\n') + return + } + + const reference = JSON.parse(fs.readFileSync(values.reference, 'utf8')) + const diffs = compare(reference, hashes) + if (diffs.length) { + for (const d of diffs) console.error(d) + process.exit(1) + } + console.log(`OK: ${Object.keys(hashes).length} contracts match`) +} + +if (fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main() diff --git a/scripts/contracts-migration/check-bytecode.test.mjs b/scripts/contracts-migration/check-bytecode.test.mjs new file mode 100644 index 000000000..e53b0423f --- /dev/null +++ b/scripts/contracts-migration/check-bytecode.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +import { stripMetadata } from '../lib/bytecode.mjs' +import { compare, computeHashes } from './check-bytecode.mjs' + +const script = path.join(path.dirname(fileURLToPath(import.meta.url)), 'check-bytecode.mjs') +const META_A = 'a264697066735822' + '1220' + 'ab'.repeat(32) + '64736f6c63430007060033' +const META_B = 'a264697066735822' + '1220' + 'cd'.repeat(32) + '64736f6c63430007060033' + +function fixture(artifacts) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bytecode-check-')) + for (const [name, content] of Object.entries(artifacts)) { + fs.writeFileSync(path.join(dir, name), JSON.stringify(content)) + } + return dir +} + +test('stripMetadata removes the CBOR blob', () => { + assert.equal(stripMetadata('0x6080' + META_A), '6080') +}) + +test('stripMetadata keeps bytes after the blob', () => { + assert.equal(stripMetadata('0x6080' + META_A + 'ff'.repeat(32)), '6080' + 'ff'.repeat(32)) +}) + +test('stripMetadata leaves code without metadata unchanged', () => { + assert.equal(stripMetadata('0x6080'), '6080') +}) + +test('ignores metadata-only differences', () => { + const dir = fixture({ + 'a.json': { bytecode: '0x6080' + META_A, deployedBytecode: '0x60aa' + META_A }, + 'b.json': { bytecode: '0x6080' + META_B, deployedBytecode: '0x60aa' + META_B }, + }) + const a = computeHashes({ X: 'a.json' }, dir) + const b = computeHashes({ X: 'b.json' }, dir) + assert.deepEqual(compare(a.hashes, b.hashes), []) +}) + +test('detects code differences', () => { + const dir = fixture({ + 'a.json': { bytecode: '0x6080' + META_A, deployedBytecode: '0x60aa' + META_A }, + 'b.json': { bytecode: '0x6080' + META_B, deployedBytecode: '0x60ab' + META_B }, + }) + const a = computeHashes({ X: 'a.json' }, dir) + const b = computeHashes({ X: 'b.json' }, dir) + assert.deepEqual(compare(a.hashes, b.hashes), ['X: deployedBytecode differs']) +}) + +test('detects differences after the metadata', () => { + const dir = fixture({ + 'a.json': { bytecode: '0x6080' + META_A + '00', deployedBytecode: '0x60aa' + META_A }, + 'b.json': { bytecode: '0x6080' + META_B + '01', deployedBytecode: '0x60aa' + META_B }, + }) + const a = computeHashes({ X: 'a.json' }, dir) + const b = computeHashes({ X: 'b.json' }, dir) + assert.deepEqual(compare(a.hashes, b.hashes), ['X: bytecode differs']) +}) + +test('rejects empty bytecode', () => { + const dir = fixture({ 'a.json': { bytecode: '0x', deployedBytecode: '0x60aa' + META_A } }) + assert.deepEqual(computeHashes({ X: 'a.json' }, dir).empty, ['X']) +}) + +test('fails when an artifact is missing', () => { + const dir = fixture({}) + assert.deepEqual(computeHashes({ X: 'nope.json' }, dir).missing, ['nope.json']) + const m = path.join(dir, 'manifest.json') + const r = path.join(dir, 'reference.json') + fs.writeFileSync(m, JSON.stringify({ X: 'nope.json' })) + fs.writeFileSync(r, '{}\n') + const res = spawnSync(process.execPath, [script, '--root', dir, '--manifest', m, '--reference', r], { + encoding: 'utf8', + }) + assert.equal(res.status, 1) + assert.match(res.stderr, /nope\.json/) +}) + +test('write refuses to produce a partial reference', () => { + const dir = fixture({}) + const m = path.join(dir, 'manifest.json') + const r = path.join(dir, 'reference.json') + fs.writeFileSync(m, JSON.stringify({ X: 'nope.json' })) + fs.writeFileSync(r, '{"keep":true}\n') + const res = spawnSync(process.execPath, [script, '--write', '--root', dir, '--manifest', m, '--reference', r], { + encoding: 'utf8', + }) + assert.equal(res.status, 1) + assert.equal(fs.readFileSync(r, 'utf8'), '{"keep":true}\n') +}) + +test('runs when invoked through a symlink', () => { + const dir = fixture({}) + const link = path.join(dir, 'link.mjs') + fs.symlinkSync(script, link) + const m = path.join(dir, 'manifest.json') + const r = path.join(dir, 'reference.json') + fs.writeFileSync(m, JSON.stringify({ X: 'nope.json' })) + fs.writeFileSync(r, '{}\n') + const res = spawnSync(process.execPath, [link, '--root', dir, '--manifest', m, '--reference', r], { + encoding: 'utf8', + }) + assert.equal(res.status, 1) + assert.match(res.stderr, /nope\.json/) +}) diff --git a/scripts/contracts-migration/manifest.json b/scripts/contracts-migration/manifest.json new file mode 100644 index 000000000..bb54725a9 --- /dev/null +++ b/scripts/contracts-migration/manifest.json @@ -0,0 +1,28 @@ +{ + "Controller": "packages/contracts/artifacts/contracts/governance/Controller.sol/Controller.json", + "GraphProxy": "packages/contracts/artifacts/contracts/upgrades/GraphProxy.sol/GraphProxy.json", + "GraphProxyAdmin": "packages/contracts/artifacts/contracts/upgrades/GraphProxyAdmin.sol/GraphProxyAdmin.json", + "GraphToken": "packages/contracts/artifacts/contracts/token/GraphToken.sol/GraphToken.json", + "L2GraphToken": "packages/contracts/artifacts/contracts/l2/token/L2GraphToken.sol/L2GraphToken.json", + "L1GraphTokenGateway": "packages/contracts/artifacts/contracts/gateway/L1GraphTokenGateway.sol/L1GraphTokenGateway.json", + "L2GraphTokenGateway": "packages/contracts/artifacts/contracts/l2/gateway/L2GraphTokenGateway.sol/L2GraphTokenGateway.json", + "BridgeEscrow": "packages/contracts/artifacts/contracts/gateway/BridgeEscrow.sol/BridgeEscrow.json", + "L2GNS": "packages/contracts/artifacts/contracts/l2/discovery/L2GNS.sol/L2GNS.json", + "L2Curation": "packages/contracts/artifacts/contracts/l2/curation/L2Curation.sol/L2Curation.json", + "GraphCurationToken": "packages/contracts/artifacts/contracts/curation/GraphCurationToken.sol/GraphCurationToken.json", + "SubgraphNFT": "packages/contracts/artifacts/contracts/discovery/SubgraphNFT.sol/SubgraphNFT.json", + "SubgraphNFTDescriptor": "packages/contracts/artifacts/contracts/discovery/SubgraphNFTDescriptor.sol/SubgraphNFTDescriptor.json", + "EpochManager": "packages/contracts/artifacts/contracts/epochs/EpochManager.sol/EpochManager.json", + "RewardsManager": "packages/contracts/artifacts/contracts/rewards/RewardsManager.sol/RewardsManager.json", + "L1GNS": "packages/contracts/artifacts/contracts/discovery/L1GNS.sol/L1GNS.json", + "Curation": "packages/contracts/artifacts/contracts/curation/Curation.sol/Curation.json", + "L1Staking": "packages/contracts/artifacts/contracts/staking/L1Staking.sol/L1Staking.json", + "StakingExtension": "packages/contracts/artifacts/contracts/staking/StakingExtension.sol/StakingExtension.json", + "LibExponential": "packages/contracts/artifacts/contracts/staking/libs/Exponential.sol/LibExponential.json", + "HorizonStaking": "packages/horizon/build/contracts/contracts/staking/HorizonStaking.sol/HorizonStaking.json", + "GraphPayments": "packages/horizon/build/contracts/contracts/payments/GraphPayments.sol/GraphPayments.json", + "PaymentsEscrow": "packages/horizon/build/contracts/contracts/payments/PaymentsEscrow.sol/PaymentsEscrow.json", + "SubgraphService": "packages/subgraph-service/build/contracts/contracts/SubgraphService.sol/SubgraphService.json", + "DisputeManager": "packages/subgraph-service/build/contracts/contracts/DisputeManager.sol/DisputeManager.json", + "AllocationHandler": "packages/subgraph-service/build/contracts/contracts/libraries/AllocationHandler.sol/AllocationHandler.json" +} diff --git a/scripts/contracts-migration/reference.json b/scripts/contracts-migration/reference.json new file mode 100644 index 000000000..bba5632c0 --- /dev/null +++ b/scripts/contracts-migration/reference.json @@ -0,0 +1,106 @@ +{ + "AllocationHandler": { + "bytecode": "32c496212c35507775978c944588ba0fc72bf48edfad06b207b02cad2a4b4308", + "deployedBytecode": "5e1aae4006573c7e7780a8b32abdc442cc12559b788be8461edc81508a4fb034" + }, + "BridgeEscrow": { + "bytecode": "5c106df052caf18fcabdacbef3255435a7e561053b528bf30233088d3a2caba0", + "deployedBytecode": "e2eaccad0ebf55b4e40026baeb6a72e612180d49d745f6a43154f8d2477a8b74" + }, + "Controller": { + "bytecode": "00e6f46e306ed4b56bee2d3d3dba75404e939d56213f9a1720fb733b3697f4f8", + "deployedBytecode": "b31676ab0ab9ae7c5c21a3e29e58d1c05f80994a2462a852df1707e9cec68a3b" + }, + "Curation": { + "bytecode": "467680b598f1c37cf0f9ce30ca421168531ec22d4f2e6dea1b230efe5e4f91a3", + "deployedBytecode": "c8b0bdcab52a661ff3a8615f5bf29336615832f4f0b4b3cf4e2506c6b22a87b6" + }, + "DisputeManager": { + "bytecode": "1c35b2800868b36ed27d6dad5e812ef97cccc05ad1883240ac1b1efb1443d192", + "deployedBytecode": "1d2a0f2ae36930e31bab47c7a00e3eec3d6c92320d9d9d89b614f20f2ea2fa34" + }, + "EpochManager": { + "bytecode": "fb46ad844725360c5048cd427bd4f2c230b62ce22673c1b8edd3574fb2f90eac", + "deployedBytecode": "bfd994416a46234114f88f2d28a0d40d60441ff305a105908bb1d1089b2dac91" + }, + "GraphCurationToken": { + "bytecode": "c88b7af1a3200b97603a7c30352662b80882ef467bcee3991a0b3c40981bb874", + "deployedBytecode": "7eef834bd6fb58360c7349aa645e5a41fcbdd5c7a2783eb85556e93b51ceda3a" + }, + "GraphPayments": { + "bytecode": "2bc8929c8824603d845276c9d8b221c0fe09c76752b0b0cdb886cb3abd342ffb", + "deployedBytecode": "d0b923b04d4394e28d2b268fd628d78c512bfcc63eae92cf0a3bbfa470b045a1" + }, + "GraphProxy": { + "bytecode": "6009c7ac0658fd0d2d596d99921cdff3a12f663010d705fb48f55f610e33af18", + "deployedBytecode": "d1b20d2a07e71364d9296d932bb2a4b085baf91410b977207d033cbd21e6e1c0" + }, + "GraphProxyAdmin": { + "bytecode": "cb2f3134b2d8784f5eaa9d571e2b534bb35f2220c0d58eb54f911231fb1a1e5b", + "deployedBytecode": "f382404b1ac32e98d46478e6b4d4b99dec7dbda06a45b74523e0d3a643bb6f85" + }, + "GraphToken": { + "bytecode": "e7cd08c68085627675767bd24a51b40f1fc20ec43d88e20cc10f37e1722a61e3", + "deployedBytecode": "e51ca8222a25f984292ab955aacbe8f3cfc5d2266fa28b6492dc2ac52b308e52" + }, + "HorizonStaking": { + "bytecode": "8385b1f97086b46ce0eb3b93d42d5922595bafb8f1eed14588dd3712855ea44d", + "deployedBytecode": "6d7255530d2e65918aa05965b6f88051c0f4ae5c69a388b61764cab3fcfd2d80" + }, + "L1GNS": { + "bytecode": "1e441ceeaa5098b613b6666e1d0624c3e71425dbb3910b4223595ef9bafe43bc", + "deployedBytecode": "064db8dfa22c6533327307135ef15d5f98e90d462c350a50dd6d9bc75aefe737" + }, + "L1GraphTokenGateway": { + "bytecode": "25293735575b568a928cf162c13f5bb4ca848833be86a98ad2c4ef7d3970aada", + "deployedBytecode": "0b35cd9f9a56eff56b0c97eae2ca8be472cfffecdf83151deae47bda2135bc54" + }, + "L1Staking": { + "bytecode": "4b4d19ef9a2a48fc0214593215d24e8f18681951efa9041443d30497c08f718b", + "deployedBytecode": "aeefa8093800d94ff5244470d26851af2b64711bb73a6c8701b59dfb7132377e" + }, + "L2Curation": { + "bytecode": "8d160a02518b02079b6c6fcdb9be6d356eed976f37adbf40fc9e06db369bd171", + "deployedBytecode": "38285a1736db0d3632fb160cdaa0a4d6d9e66d412c6223a6184716caa1f683ec" + }, + "L2GNS": { + "bytecode": "9991ff5db9d7410bb3e9561fef43f05cf6618830fd324cca4223673f710c03ff", + "deployedBytecode": "0e1b063478c7beebe749534bc1f74e64bb0dc274cb8499f2415e887f6a692abe" + }, + "L2GraphToken": { + "bytecode": "2509f7ce90438223a5abf9fa58175ceccca7925f3c097a950671a524507d1010", + "deployedBytecode": "82e740658e3328bb1637d060f2f2eafd6d4e0a663f1eaa00ef8a86f36e67e7cf" + }, + "L2GraphTokenGateway": { + "bytecode": "3960ebe0a49a7b2dadb1f866434704f15d1618e8f23f22b24b9791fc5edd172c", + "deployedBytecode": "9f500273f04af6e593aba64049e912196f61c912b5ff4a885579b8af6150425d" + }, + "LibExponential": { + "bytecode": "cf8e0f70123c2a525e61ab51fcfa8549fd1dc364ffb0f62890504323c3ea1b92", + "deployedBytecode": "d4a129118f42b08e5ed9dd7e29b76fe18767d64ec62e778447a013a25e722ec8" + }, + "PaymentsEscrow": { + "bytecode": "c5ee87225cd0c023d298d810457736f2a6fcf011d8e9be8bfeea96c4c34936f5", + "deployedBytecode": "07e1e1055195ed396e9ac8df7d84d98a37644bea8b48f9ccab0f31f677d53d99" + }, + "RewardsManager": { + "bytecode": "0dea68140e59bec61d669269c2097630bd0a9da9b2a241fe887073f741df30ac", + "deployedBytecode": "f361c19bcc1553e923fef7a16d423766cf3a0ea2577223f61b84fe41cf1a77da" + }, + "StakingExtension": { + "bytecode": "aa3a270946cab805c889d9ef1fd6d3541d8f736fb2ec85b51543836a5c38b23f", + "deployedBytecode": "55758d8712cd9eda9b96bc3922a9d82c8ea3f31be1772d4ea52e9118af611107" + }, + "SubgraphNFT": { + "bytecode": "830d22ac49214e1f6ba157c5cc7794d69277e96d5c93a773272ab3055a13426a", + "deployedBytecode": "9718d61570d90867068568f9b0f33a9633e55bb3cf8e8cea1f11ab5e8298f36a" + }, + "SubgraphNFTDescriptor": { + "bytecode": "05b3383d413f08c7fcac78905b05e814cd3f48353288b6c261d9d646bd2b9415", + "deployedBytecode": "a5afe887c814da0ad8c8cc8d95337cb0c3b548d767b06baa558a199ee6da5d91" + }, + "SubgraphService": { + "bytecode": "5e4db6627b195d893fa23aed782d1bb0d7bd048da473c8bc0b83c29188d192b0", + "deployedBytecode": "72a35d0372425f3e5eaa06362cefd39cb2889a938a7d6641c1104dc2f427f323" + } +} diff --git a/scripts/lib/bytecode.mjs b/scripts/lib/bytecode.mjs new file mode 100644 index 000000000..0ee15ca9f --- /dev/null +++ b/scripts/lib/bytecode.mjs @@ -0,0 +1,12 @@ +import { createHash } from 'node:crypto' + +// CBOR metadata blob: {"ipfs": <34-byte multihash>, "solc": <3-byte version>} + 2-byte length (0x0033) +const METADATA = /a264697066735822[0-9a-f]{68}64736f6c6343[0-9a-f]{6}0033/g + +export function stripMetadata(bytecode) { + return bytecode.toLowerCase().replace(/^0x/, '').replace(METADATA, '') +} + +export function bytecodeHash(bytecode) { + return createHash('sha256').update(stripMetadata(bytecode)).digest('hex') +} From 1ded9c92a36028e0af04ebc0a999917cb633e58b Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Thu, 22 Oct 2026 12:16:02 -0300 Subject: [PATCH 04/20] test: record contracts-test line coverage baseline for the Foundry ports --- .../coverage-baseline.json | 262 ++++++++++++++++++ .../summarize-coverage.mjs | 51 ++++ .../summarize-coverage.test.mjs | 42 +++ 3 files changed, 355 insertions(+) create mode 100644 scripts/contracts-migration/coverage-baseline.json create mode 100644 scripts/contracts-migration/summarize-coverage.mjs create mode 100644 scripts/contracts-migration/summarize-coverage.test.mjs diff --git a/scripts/contracts-migration/coverage-baseline.json b/scripts/contracts-migration/coverage-baseline.json new file mode 100644 index 000000000..c8e3de6dd --- /dev/null +++ b/scripts/contracts-migration/coverage-baseline.json @@ -0,0 +1,262 @@ +{ + "contracts/base/Multicall.sol": { + "linesFound": 8, + "linesHit": 6, + "pct": 75 + }, + "contracts/curation/Curation.sol": { + "linesFound": 86, + "linesHit": 86, + "pct": 100 + }, + "contracts/curation/CurationStorage.sol": { + "linesFound": 0, + "linesHit": 0, + "pct": null + }, + "contracts/curation/GraphCurationToken.sol": { + "linesFound": 4, + "linesHit": 4, + "pct": 100 + }, + "contracts/discovery/GNS.sol": { + "linesFound": 159, + "linesHit": 109, + "pct": 68.55 + }, + "contracts/discovery/GNSStorage.sol": { + "linesFound": 0, + "linesHit": 0, + "pct": null + }, + "contracts/discovery/L1GNS.sol": { + "linesFound": 40, + "linesHit": 0, + "pct": 0 + }, + "contracts/discovery/L1GNSStorage.sol": { + "linesFound": 0, + "linesHit": 0, + "pct": null + }, + "contracts/discovery/ServiceRegistry.sol": { + "linesFound": 15, + "linesHit": 15, + "pct": 100 + }, + "contracts/discovery/ServiceRegistryStorage.sol": { + "linesFound": 0, + "linesHit": 0, + "pct": null + }, + "contracts/discovery/SubgraphNFT.sol": { + "linesFound": 27, + "linesHit": 15, + "pct": 55.56 + }, + "contracts/discovery/SubgraphNFTDescriptor.sol": { + "linesFound": 4, + "linesHit": 0, + "pct": 0 + }, + "contracts/discovery/erc1056/EthereumDIDRegistry.sol": { + "linesFound": 40, + "linesHit": 0, + "pct": 0 + }, + "contracts/disputes/DisputeManager.sol": { + "linesFound": 124, + "linesHit": 123, + "pct": 99.19 + }, + "contracts/disputes/DisputeManagerStorage.sol": { + "linesFound": 0, + "linesHit": 0, + "pct": null + }, + "contracts/epochs/EpochManager.sol": { + "linesFound": 27, + "linesHit": 17, + "pct": 62.96 + }, + "contracts/epochs/EpochManagerStorage.sol": { + "linesFound": 0, + "linesHit": 0, + "pct": null + }, + "contracts/gateway/BridgeEscrow.sol": { + "linesFound": 3, + "linesHit": 3, + "pct": 100 + }, + "contracts/gateway/GraphTokenGateway.sol": { + "linesFound": 9, + "linesHit": 9, + "pct": 100 + }, + "contracts/gateway/L1GraphTokenGateway.sol": { + "linesFound": 102, + "linesHit": 102, + "pct": 100 + }, + "contracts/governance/Controller.sol": { + "linesFound": 19, + "linesHit": 19, + "pct": 100 + }, + "contracts/governance/Governed.sol": { + "linesFound": 14, + "linesHit": 14, + "pct": 100 + }, + "contracts/governance/Managed.sol": { + "linesFound": 40, + "linesHit": 40, + "pct": 100 + }, + "contracts/governance/Pausable.sol": { + "linesFound": 15, + "linesHit": 13, + "pct": 86.67 + }, + "contracts/l2/curation/L2Curation.sol": { + "linesFound": 107, + "linesHit": 107, + "pct": 100 + }, + "contracts/l2/discovery/L2GNS.sol": { + "linesFound": 81, + "linesHit": 77, + "pct": 95.06 + }, + "contracts/l2/discovery/L2GNSStorage.sol": { + "linesFound": 0, + "linesHit": 0, + "pct": null + }, + "contracts/l2/gateway/L2GraphTokenGateway.sol": { + "linesFound": 46, + "linesHit": 41, + "pct": 89.13 + }, + "contracts/l2/staking/L2Staking.sol": { + "linesFound": 26, + "linesHit": 26, + "pct": 100 + }, + "contracts/l2/token/GraphTokenUpgradeable.sol": { + "linesFound": 28, + "linesHit": 28, + "pct": 100 + }, + "contracts/l2/token/L2GraphToken.sol": { + "linesFound": 14, + "linesHit": 14, + "pct": 100 + }, + "contracts/libraries/Base58Encoder.sol": { + "linesFound": 27, + "linesHit": 0, + "pct": 0 + }, + "contracts/libraries/HexStrings.sol": { + "linesFound": 16, + "linesHit": 0, + "pct": 0 + }, + "contracts/payments/AllocationExchange.sol": { + "linesFound": 27, + "linesHit": 25, + "pct": 92.59 + }, + "contracts/rewards/RewardsManager.sol": { + "linesFound": 165, + "linesHit": 165, + "pct": 100 + }, + "contracts/rewards/RewardsManagerStorage.sol": { + "linesFound": 0, + "linesHit": 0, + "pct": null + }, + "contracts/rewards/SubgraphAvailabilityManager.sol": { + "linesFound": 46, + "linesHit": 13, + "pct": 28.26 + }, + "contracts/staking/L1Staking.sol": { + "linesFound": 69, + "linesHit": 69, + "pct": 100 + }, + "contracts/staking/L1StakingStorage.sol": { + "linesFound": 0, + "linesHit": 0, + "pct": null + }, + "contracts/staking/Staking.sol": { + "linesFound": 220, + "linesHit": 215, + "pct": 97.73 + }, + "contracts/staking/StakingExtension.sol": { + "linesFound": 114, + "linesHit": 113, + "pct": 99.12 + }, + "contracts/staking/StakingStorage.sol": { + "linesFound": 0, + "linesHit": 0, + "pct": null + }, + "contracts/staking/libs/Exponential.sol": { + "linesFound": 11, + "linesHit": 11, + "pct": 100 + }, + "contracts/staking/libs/LibFixedMath.sol": { + "linesFound": 166, + "linesHit": 83, + "pct": 50 + }, + "contracts/staking/libs/MathUtils.sol": { + "linesFound": 3, + "linesHit": 3, + "pct": 100 + }, + "contracts/staking/libs/Stakes.sol": { + "linesFound": 28, + "linesHit": 28, + "pct": 100 + }, + "contracts/token/GraphToken.sol": { + "linesFound": 24, + "linesHit": 24, + "pct": 100 + }, + "contracts/upgrades/GraphProxy.sol": { + "linesFound": 30, + "linesHit": 30, + "pct": 100 + }, + "contracts/upgrades/GraphProxyAdmin.sol": { + "linesFound": 14, + "linesHit": 14, + "pct": 100 + }, + "contracts/upgrades/GraphProxyStorage.sol": { + "linesFound": 20, + "linesHit": 18, + "pct": 90 + }, + "contracts/upgrades/GraphUpgradeable.sol": { + "linesFound": 8, + "linesHit": 8, + "pct": 100 + }, + "contracts/utils/TokenUtils.sol": { + "linesFound": 6, + "linesHit": 6, + "pct": 100 + } +} diff --git a/scripts/contracts-migration/summarize-coverage.mjs b/scripts/contracts-migration/summarize-coverage.mjs new file mode 100644 index 000000000..afd854c26 --- /dev/null +++ b/scripts/contracts-migration/summarize-coverage.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node + +import fs from 'node:fs' +import { fileURLToPath } from 'node:url' + +function normalizePath(sf) { + const parts = sf.split('/') + const idx = parts.lastIndexOf('contracts') + return idx === -1 ? sf : parts.slice(idx).join('/') +} + +export function parseLcov(text) { + const result = {} + let current = null + for (const line of text.split('\n')) { + if (line.startsWith('SF:')) { + current = { key: normalizePath(line.slice(3).trim()), linesFound: 0, linesHit: 0 } + } else if (!current) { + continue + } else if (line.startsWith('LF:')) { + current.linesFound = Number(line.slice(3)) + } else if (line.startsWith('LH:')) { + current.linesHit = Number(line.slice(3)) + } else if (line.startsWith('end_of_record')) { + const { key, linesFound, linesHit } = current + const pct = linesFound === 0 ? null : Math.round((linesHit / linesFound) * 10000) / 100 + result[key] = { linesFound, linesHit, pct } + current = null + } + } + return result +} + +function main() { + const [lcovPath, outPath] = process.argv.slice(2) + if (!lcovPath || !outPath) { + console.error('Usage: summarize-coverage.mjs ') + process.exit(1) + } + const parsed = parseLcov(fs.readFileSync(lcovPath, 'utf8')) + const sorted = Object.fromEntries( + Object.keys(parsed) + .sort() + .map((k) => [k, parsed[k]]), + ) + fs.writeFileSync(outPath, JSON.stringify(sorted, null, 2) + '\n') +} + +if (fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + main() +} diff --git a/scripts/contracts-migration/summarize-coverage.test.mjs b/scripts/contracts-migration/summarize-coverage.test.mjs new file mode 100644 index 000000000..981b4637d --- /dev/null +++ b/scripts/contracts-migration/summarize-coverage.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { parseLcov } from './summarize-coverage.mjs' + +test('parses one record with an absolute CI path', () => { + const text = [ + 'SF:/home/runner/work/contracts/contracts/packages/contracts-test/contracts/rewards/RewardsManager.sol', + 'LF:200', + 'LH:150', + 'end_of_record', + ].join('\n') + assert.deepEqual(parseLcov(text), { + 'contracts/rewards/RewardsManager.sol': { linesFound: 200, linesHit: 150, pct: 75 }, + }) +}) + +test('keeps relative contracts paths', () => { + const text = 'SF:contracts/upgrades/GraphProxy.sol\nLF:1\nLH:1\nend_of_record\n' + assert.deepEqual(Object.keys(parseLcov(text)), ['contracts/upgrades/GraphProxy.sol']) +}) + +test('keeps records separate', () => { + const text = [ + 'SF:contracts/A.sol', + 'LF:10', + 'LH:10', + 'end_of_record', + 'SF:contracts/B.sol', + 'LF:4', + 'LH:1', + 'end_of_record', + ].join('\n') + const result = parseLcov(text) + assert.equal(result['contracts/A.sol'].pct, 100) + assert.equal(result['contracts/B.sol'].pct, 25) +}) + +test('zero lines yields null pct', () => { + const result = parseLcov('SF:contracts/Z.sol\nLF:0\nLH:0\nend_of_record\n') + assert.equal(result['contracts/Z.sol'].pct, null) +}) From aae4615b98fa6e4e8e060f170d2737041f63201a Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Fri, 23 Oct 2026 16:00:39 -0300 Subject: [PATCH 05/20] test: record covered functions in the contracts-test coverage baseline --- .../coverage-baseline.json | 577 ++++++++++++++++-- .../summarize-coverage.mjs | 27 +- .../summarize-coverage.test.mjs | 64 +- 3 files changed, 598 insertions(+), 70 deletions(-) diff --git a/scripts/contracts-migration/coverage-baseline.json b/scripts/contracts-migration/coverage-baseline.json index c8e3de6dd..240212f17 100644 --- a/scripts/contracts-migration/coverage-baseline.json +++ b/scripts/contracts-migration/coverage-baseline.json @@ -2,261 +2,734 @@ "contracts/base/Multicall.sol": { "linesFound": 8, "linesHit": 6, - "pct": 75 + "pct": 75, + "functionsHit": ["multicall"] }, "contracts/curation/Curation.sol": { "linesFound": 86, "linesHit": 86, - "pct": 100 + "pct": 100, + "functionsHit": [ + "_setCurationTaxPercentage", + "_setCurationTokenMaster", + "_setDefaultReserveRatio", + "_setMinimumCurationDeposit", + "_tokensToSignal", + "_updateRewards", + "burn", + "collect", + "getCurationPoolSignal", + "getCurationPoolTokens", + "getCuratorSignal", + "initialize", + "isCurated", + "mint", + "setCurationTaxPercentage", + "setCurationTokenMaster", + "setDefaultReserveRatio", + "setMinimumCurationDeposit", + "signalToTokens", + "tokensToSignal" + ] }, "contracts/curation/CurationStorage.sol": { "linesFound": 0, "linesHit": 0, - "pct": null + "pct": null, + "functionsHit": [] }, "contracts/curation/GraphCurationToken.sol": { "linesFound": 4, "linesHit": 4, - "pct": 100 + "pct": 100, + "functionsHit": ["burnFrom", "initialize", "mint"] }, "contracts/discovery/GNS.sol": { "linesFound": 159, "linesHit": 109, - "pct": 68.55 + "pct": 68.55, + "functionsHit": [ + "_buildSubgraphID", + "_burnNFT", + "_chargeOwnerTax", + "_getSubgraphOrRevert", + "_isPublished", + "_mintNFT", + "_nextAccountSeqID", + "_nextSubgraphID", + "_setOwnerTaxPercentage", + "_setSubgraphMetadata", + "_setSubgraphNFT", + "approveAll", + "burnSignal", + "deprecateSubgraph", + "getCuratorSignal", + "initialize", + "mintSignal", + "nSignalToTokens", + "nSignalToVSignal", + "onlySubgraphAuth", + "ownerOf", + "publishNewSubgraph", + "publishNewVersion", + "setCounterpartGNSAddress", + "setOwnerTaxPercentage", + "tokensToNSignal", + "vSignalToNSignal" + ] }, "contracts/discovery/GNSStorage.sol": { "linesFound": 0, "linesHit": 0, - "pct": null + "pct": null, + "functionsHit": [] }, "contracts/discovery/L1GNS.sol": { "linesFound": 40, "linesHit": 0, - "pct": 0 + "pct": 0, + "functionsHit": [] }, "contracts/discovery/L1GNSStorage.sol": { "linesFound": 0, "linesHit": 0, - "pct": null + "pct": null, + "functionsHit": [] }, "contracts/discovery/ServiceRegistry.sol": { "linesFound": 15, "linesHit": 15, - "pct": 100 + "pct": 100, + "functionsHit": [ + "_isAuth", + "_register", + "_unregister", + "initialize", + "isRegistered", + "register", + "registerFor", + "unregister", + "unregisterFor" + ] }, "contracts/discovery/ServiceRegistryStorage.sol": { "linesFound": 0, "linesHit": 0, - "pct": null + "pct": null, + "functionsHit": [] }, "contracts/discovery/SubgraphNFT.sol": { "linesFound": 27, "linesHit": 15, - "pct": 55.56 + "pct": 55.56, + "functionsHit": [ + "_setMinter", + "_setTokenDescriptor", + "burn", + "constructor", + "mint", + "onlyMinter", + "setMinter", + "setSubgraphMetadata", + "setTokenDescriptor" + ] }, "contracts/discovery/SubgraphNFTDescriptor.sol": { "linesFound": 4, "linesHit": 0, - "pct": 0 + "pct": 0, + "functionsHit": [] }, "contracts/discovery/erc1056/EthereumDIDRegistry.sol": { "linesFound": 40, "linesHit": 0, - "pct": 0 + "pct": 0, + "functionsHit": [] }, "contracts/disputes/DisputeManager.sol": { "linesFound": 124, "linesHit": 123, - "pct": 99.19 + "pct": 99.19, + "functionsHit": [ + "_createIndexingDisputeWithAllocation", + "_createQueryDisputeWithAttestation", + "_drawDisputeInConflict", + "_getChainID", + "_getSlashingPercentageForDisputeType", + "_isDisputeInConflict", + "_onlyArbitrator", + "_parseAttestation", + "_pullSubmitterDeposit", + "_recoverAttestationSigner", + "_setArbitrator", + "_setFishermanRewardPercentage", + "_setMinimumDeposit", + "_setSlashingPercentage", + "_slashIndexer", + "_toBytes32", + "_toUint8", + "acceptDispute", + "areConflictingAttestations", + "createIndexingDispute", + "createQueryDispute", + "createQueryDisputeConflict", + "drawDispute", + "encodeHashReceipt", + "getAttestationIndexer", + "initialize", + "isDisputeCreated", + "onlyArbitrator", + "onlyPendingDispute", + "rejectDispute", + "setArbitrator", + "setFishermanRewardPercentage", + "setMinimumDeposit", + "setSlashingPercentage" + ] }, "contracts/disputes/DisputeManagerStorage.sol": { "linesFound": 0, "linesHit": 0, - "pct": null + "pct": null, + "functionsHit": [] }, "contracts/epochs/EpochManager.sol": { "linesFound": 27, "linesHit": 17, - "pct": 62.96 + "pct": 62.96, + "functionsHit": [ + "blockNum", + "currentEpoch", + "currentEpochBlock", + "currentEpochBlockSinceStart", + "epochsSinceUpdate", + "initialize", + "setEpochLength" + ] }, "contracts/epochs/EpochManagerStorage.sol": { "linesFound": 0, "linesHit": 0, - "pct": null + "pct": null, + "functionsHit": [] }, "contracts/gateway/BridgeEscrow.sol": { "linesFound": 3, "linesHit": 3, - "pct": 100 + "pct": 100, + "functionsHit": ["approveAll", "initialize", "revokeAll"] }, "contracts/gateway/GraphTokenGateway.sol": { "linesFound": 9, "linesHit": 9, - "pct": 100 + "pct": 100, + "functionsHit": ["_notPaused", "onlyGovernorOrGuardian", "paused", "setPauseGuardian", "setPaused"] }, "contracts/gateway/L1GraphTokenGateway.sol": { "linesFound": 102, "linesHit": 102, - "pct": 100 + "pct": 100, + "functionsHit": [ + "_checksBeforeUnpause", + "_l2MintAmountAllowed", + "_mintFromL2", + "_parseOutboundData", + "accumulatedL2MintAllowanceAtBlock", + "addToCallhookAllowlist", + "calculateL2TokenAddress", + "counterpartGateway", + "finalizeInboundTransfer", + "getOutboundCalldata", + "initialize", + "onlyL2Counterpart", + "outboundTransfer", + "removeFromCallhookAllowlist", + "setArbitrumAddresses", + "setEscrowAddress", + "setL2CounterpartAddress", + "setL2MintAllowanceParametersManual", + "setL2TokenAddress", + "updateL2MintAllowance" + ] }, "contracts/governance/Controller.sol": { "linesFound": 19, "linesHit": 19, - "pct": 100 + "pct": 100, + "functionsHit": [ + "constructor", + "getContractProxy", + "getGovernor", + "onlyGovernorOrGuardian", + "partialPaused", + "paused", + "setContractProxy", + "setPartialPaused", + "setPauseGuardian", + "setPaused", + "unsetContractProxy", + "updateController" + ] }, "contracts/governance/Governed.sol": { "linesFound": 14, "linesHit": 14, - "pct": 100 + "pct": 100, + "functionsHit": ["_initialize", "acceptOwnership", "onlyGovernor", "transferOwnership"] }, "contracts/governance/Managed.sol": { "linesFound": 40, "linesHit": 40, - "pct": 100 + "pct": 100, + "functionsHit": [ + "_initialize", + "_notPartialPaused", + "_notPaused", + "_onlyController", + "_onlyGovernor", + "_resolveContract", + "_setController", + "_syncContract", + "curation", + "epochManager", + "gns", + "graphToken", + "graphTokenGateway", + "notPartialPaused", + "notPaused", + "onlyController", + "onlyGovernor", + "rewardsManager", + "setController", + "staking", + "syncAllContracts" + ] }, "contracts/governance/Pausable.sol": { "linesFound": 15, "linesHit": 13, - "pct": 86.67 + "pct": 86.67, + "functionsHit": ["_setPartialPaused", "_setPauseGuardian", "_setPaused"] }, "contracts/l2/curation/L2Curation.sol": { "linesFound": 107, "linesHit": 107, - "pct": 100 + "pct": 100, + "functionsHit": [ + "_setCurationTaxPercentage", + "_setCurationTokenMaster", + "_setMinimumCurationDeposit", + "_tokensToSignal", + "_updateRewards", + "burn", + "collect", + "getCurationPoolSignal", + "getCurationPoolTokens", + "getCuratorSignal", + "initialize", + "isCurated", + "mint", + "mintTaxFree", + "onlyGNS", + "setCurationTaxPercentage", + "setCurationTokenMaster", + "setDefaultReserveRatio", + "setMinimumCurationDeposit", + "setSubgraphService", + "signalToTokens", + "tokensToSignal", + "tokensToSignalNoTax", + "tokensToSignalToTokensNoTax" + ] }, "contracts/l2/discovery/L2GNS.sol": { "linesFound": 81, "linesHit": 77, - "pct": 95.06 + "pct": 95.06, + "functionsHit": [ + "_getSubgraphData", + "_mintSignalFromL1", + "_receiveSubgraphFromL1", + "finishSubgraphTransferFromL1", + "getAliasedL2SubgraphID", + "onTokenTransfer", + "onlyL2Gateway", + "publishNewVersion" + ] }, "contracts/l2/discovery/L2GNSStorage.sol": { "linesFound": 0, "linesHit": 0, - "pct": null + "pct": null, + "functionsHit": [] }, "contracts/l2/gateway/L2GraphTokenGateway.sol": { "linesFound": 46, "linesHit": 41, - "pct": 89.13 + "pct": 89.13, + "functionsHit": [ + "_checksBeforeUnpause", + "_parseOutboundData", + "calculateL2TokenAddress", + "finalizeInboundTransfer", + "initialize", + "onlyL1Counterpart", + "outboundTransfer", + "setL1CounterpartAddress", + "setL1TokenAddress", + "setL2Router" + ] }, "contracts/l2/staking/L2Staking.sol": { "linesFound": 26, "linesHit": 26, - "pct": 100 + "pct": 100, + "functionsHit": ["_receiveDelegation", "_receiveIndexerStake", "onTokenTransfer", "onlyL2Gateway"] }, "contracts/l2/token/GraphTokenUpgradeable.sol": { "linesFound": 28, "linesHit": 28, - "pct": 100 + "pct": 100, + "functionsHit": [ + "_addMinter", + "_getChainID", + "_initialize", + "_removeMinter", + "addMinter", + "isMinter", + "mint", + "onlyMinter", + "permit", + "removeMinter", + "renounceMinter" + ] }, "contracts/l2/token/L2GraphToken.sol": { "linesFound": 14, "linesHit": 14, - "pct": 100 + "pct": 100, + "functionsHit": ["bridgeBurn", "bridgeMint", "initialize", "onlyGateway", "setGateway", "setL1Address"] }, "contracts/libraries/Base58Encoder.sol": { "linesFound": 27, "linesHit": 0, - "pct": 0 + "pct": 0, + "functionsHit": [] }, "contracts/libraries/HexStrings.sol": { "linesFound": 16, "linesHit": 0, - "pct": 0 + "pct": 0, + "functionsHit": [] }, "contracts/payments/AllocationExchange.sol": { "linesFound": 27, "linesHit": 25, - "pct": 92.59 + "pct": 92.59, + "functionsHit": ["_redeem", "_setAuthority", "approveAll", "constructor", "redeem", "setAuthority", "withdraw"] }, "contracts/rewards/RewardsManager.sol": { "linesFound": 165, "linesHit": 165, - "pct": 100 + "pct": 100, + "functionsHit": [ + "_calcAllocationRewards", + "_calcRewards", + "_deniedRewards", + "_getNewRewardsPerSignal", + "_getSubgraphAllocatedTokens", + "_getSubgraphRewardsState", + "_reclaimRewards", + "_setDenied", + "_setIssuancePerBlock", + "_updateSubgraphRewards", + "beforeIssuanceAllocationChange", + "calcRewards", + "getAccRewardsForSubgraph", + "getAccRewardsPerAllocatedToken", + "getAccRewardsPerSignal", + "getAllocatedIssuancePerBlock", + "getDefaultReclaimAddress", + "getIssuanceAllocator", + "getNewRewardsPerSignal", + "getProviderEligibilityOracle", + "getRawIssuancePerBlock", + "getReclaimAddress", + "getRevertOnIneligible", + "getRewards", + "initialize", + "isDenied", + "onSubgraphAllocationUpdate", + "onSubgraphSignalUpdate", + "onlySubgraphAvailabilityOracle", + "reclaimRewards", + "setDefaultReclaimAddress", + "setDenied", + "setIssuanceAllocator", + "setIssuancePerBlock", + "setMinimumSubgraphSignal", + "setProviderEligibilityOracle", + "setReclaimAddress", + "setRevertOnIneligible", + "setSubgraphAvailabilityOracle", + "setSubgraphService", + "supportsInterface", + "takeRewards", + "updateAccRewardsPerSignal" + ] }, "contracts/rewards/RewardsManagerStorage.sol": { "linesFound": 0, "linesHit": 0, - "pct": null + "pct": null, + "functionsHit": [] }, "contracts/rewards/SubgraphAvailabilityManager.sol": { "linesFound": 46, "linesHit": 13, - "pct": 28.26 + "pct": 28.26, + "functionsHit": ["constructor"] }, "contracts/staking/L1Staking.sol": { "linesFound": 69, "linesHit": 69, - "pct": 100 + "pct": 100, + "functionsHit": [ + "_sendTokensAndMessageToL2Staking", + "_transferDelegationToL2", + "_transferStakeToL2", + "setL1GraphTokenLockTransferTool", + "transferDelegationToL2", + "transferLockedDelegationToL2", + "transferLockedStakeToL2", + "transferStakeToL2", + "unlockDelegationToTransferredIndexer" + ] }, "contracts/staking/L1StakingStorage.sol": { "linesFound": 0, "linesHit": 0, - "pct": null + "pct": null, + "functionsHit": [] }, "contracts/staking/Staking.sol": { "linesFound": 220, "linesHit": 215, - "pct": 97.73 + "pct": 97.73, + "functionsHit": [ + "_allocate", + "_closeAllocation", + "_collectCurationFees", + "_collectDelegationIndexingRewards", + "_collectDelegationQueryRewards", + "_collectTax", + "_distributeRewards", + "_getAllocationState", + "_isAuth", + "_sendRewards", + "_setCurationPercentage", + "_setDelegationParameters", + "_setMaxAllocationEpochs", + "_setMinimumIndexerStake", + "_setProtocolPercentage", + "_setRebateParameters", + "_setThawingPeriod", + "_stake", + "_updateRewards", + "_withdraw", + "allocate", + "allocateFrom", + "closeAllocation", + "collect", + "getAllocation", + "getAllocationData", + "getAllocationState", + "getIndexerCapacity", + "getIndexerStakedTokens", + "getSubgraphAllocatedTokens", + "hasStake", + "initialize", + "isOperator", + "setCounterpartStakingAddress", + "setCurationPercentage", + "setDelegationParameters", + "setExtensionImpl", + "setMaxAllocationEpochs", + "setMinimumIndexerStake", + "setOperator", + "setProtocolPercentage", + "setRebateParameters", + "setRewardsDestination", + "setThawingPeriod", + "stake", + "stakeTo", + "unstake", + "withdraw" + ] }, "contracts/staking/StakingExtension.sol": { "linesFound": 114, "linesHit": 113, - "pct": 99.12 + "pct": 99.12, + "functionsHit": [ + "_collectTax", + "_delegate", + "_setDelegationRatio", + "_setDelegationTaxPercentage", + "_setDelegationUnbondingPeriod", + "_undelegate", + "_withdrawDelegated", + "allocations", + "alphaDenominator", + "alphaNumerator", + "curationPercentage", + "delegate", + "delegationPools", + "delegationRatio", + "delegationTaxPercentage", + "delegationUnbondingPeriod", + "getDelegation", + "getWithdraweableDelegatedTokens", + "initialize", + "isDelegator", + "lambdaDenominator", + "lambdaNumerator", + "maxAllocationEpochs", + "minimumIndexerStake", + "onlySlasher", + "operatorAuth", + "protocolPercentage", + "rewardsDestination", + "setDelegationRatio", + "setDelegationTaxPercentage", + "setDelegationUnbondingPeriod", + "setSlasher", + "slash", + "slashers", + "stakes", + "thawingPeriod", + "undelegate", + "withdrawDelegated" + ] }, "contracts/staking/StakingStorage.sol": { "linesFound": 0, "linesHit": 0, - "pct": null + "pct": null, + "functionsHit": [] }, "contracts/staking/libs/Exponential.sol": { "linesFound": 11, "linesHit": 11, - "pct": 100 + "pct": 100, + "functionsHit": ["exponentialRebates"] }, "contracts/staking/libs/LibFixedMath.sol": { "linesFound": 166, "linesHit": 83, - "pct": 50 + "pct": 50, + "functionsHit": ["_add", "_div", "_mul", "exp", "mul", "mulDiv", "one", "sub", "toFixed", "toInteger", "uintMul"] }, "contracts/staking/libs/MathUtils.sol": { "linesFound": 3, "linesHit": 3, - "pct": 100 + "pct": 100, + "functionsHit": ["diffOrZero", "min", "weightedAverageRoundingUp"] }, "contracts/staking/libs/Stakes.sol": { "linesFound": 28, "linesHit": 28, - "pct": 100 + "pct": 100, + "functionsHit": [ + "allocate", + "deposit", + "lockTokens", + "release", + "tokensAvailable", + "tokensAvailableWithDelegation", + "tokensSecureStake", + "tokensUsed", + "tokensWithdrawable", + "unallocate", + "unlockTokens", + "withdrawTokens" + ] }, "contracts/token/GraphToken.sol": { "linesFound": 24, "linesHit": 24, - "pct": 100 + "pct": 100, + "functionsHit": [ + "_addMinter", + "_getChainID", + "_removeMinter", + "addMinter", + "constructor", + "isMinter", + "mint", + "onlyMinter", + "permit", + "removeMinter", + "renounceMinter" + ] }, "contracts/upgrades/GraphProxy.sol": { "linesFound": 30, "linesHit": 30, - "pct": 100 + "pct": 100, + "functionsHit": [ + "_acceptUpgrade", + "_fallback", + "acceptUpgrade", + "acceptUpgradeAndCall", + "admin", + "constructor", + "ifAdmin", + "ifAdminOrPendingImpl", + "implementation", + "pendingImplementation", + "setAdmin", + "upgradeTo" + ] }, "contracts/upgrades/GraphProxyAdmin.sol": { "linesFound": 14, "linesHit": 14, - "pct": 100 + "pct": 100, + "functionsHit": [ + "acceptProxy", + "acceptProxyAndCall", + "changeProxyAdmin", + "constructor", + "getProxyAdmin", + "getProxyImplementation", + "getProxyPendingImplementation", + "upgrade" + ] }, "contracts/upgrades/GraphProxyStorage.sol": { "linesFound": 20, "linesHit": 18, - "pct": 90 + "pct": 90, + "functionsHit": [ + "_getAdmin", + "_getImplementation", + "_getPendingImplementation", + "_setAdmin", + "_setImplementation", + "_setPendingImplementation" + ] }, "contracts/upgrades/GraphUpgradeable.sol": { "linesFound": 8, "linesHit": 8, - "pct": 100 + "pct": 100, + "functionsHit": ["_implementation", "acceptProxy", "acceptProxyAndCall", "onlyImpl", "onlyProxyAdmin"] }, "contracts/utils/TokenUtils.sol": { "linesFound": 6, "linesHit": 6, - "pct": 100 + "pct": 100, + "functionsHit": ["burnTokens", "pullTokens", "pushTokens"] } } diff --git a/scripts/contracts-migration/summarize-coverage.mjs b/scripts/contracts-migration/summarize-coverage.mjs index afd854c26..afd67973f 100644 --- a/scripts/contracts-migration/summarize-coverage.mjs +++ b/scripts/contracts-migration/summarize-coverage.mjs @@ -10,24 +10,33 @@ function normalizePath(sf) { } export function parseLcov(text) { - const result = {} + const files = {} let current = null for (const line of text.split('\n')) { if (line.startsWith('SF:')) { - current = { key: normalizePath(line.slice(3).trim()), linesFound: 0, linesHit: 0 } + const key = normalizePath(line.slice(3).trim()) + current = files[key] ??= { lines: new Map(), functions: new Set() } } else if (!current) { continue - } else if (line.startsWith('LF:')) { - current.linesFound = Number(line.slice(3)) - } else if (line.startsWith('LH:')) { - current.linesHit = Number(line.slice(3)) + } else if (line.startsWith('DA:')) { + const [num, hits] = line.slice(3).split(',') + current.lines.set(num, (current.lines.get(num) ?? false) || Number(hits) > 0) + } else if (line.startsWith('FNDA:')) { + const rest = line.slice(5) + const comma = rest.indexOf(',') + const name = rest.slice(comma + 1).trim() + if (Number(rest.slice(0, comma)) > 0 && name) current.functions.add(name) } else if (line.startsWith('end_of_record')) { - const { key, linesFound, linesHit } = current - const pct = linesFound === 0 ? null : Math.round((linesHit / linesFound) * 10000) / 100 - result[key] = { linesFound, linesHit, pct } current = null } } + const result = {} + for (const [key, { lines, functions }] of Object.entries(files)) { + const linesFound = lines.size + const linesHit = [...lines.values()].filter(Boolean).length + const pct = linesFound === 0 ? null : Math.round((linesHit / linesFound) * 10000) / 100 + result[key] = { linesFound, linesHit, pct, functionsHit: [...functions].sort() } + } return result } diff --git a/scripts/contracts-migration/summarize-coverage.test.mjs b/scripts/contracts-migration/summarize-coverage.test.mjs index 981b4637d..665eda894 100644 --- a/scripts/contracts-migration/summarize-coverage.test.mjs +++ b/scripts/contracts-migration/summarize-coverage.test.mjs @@ -3,32 +3,33 @@ import { test } from 'node:test' import { parseLcov } from './summarize-coverage.mjs' +function da(found, hit) { + return Array.from({ length: found }, (_, i) => `DA:${i + 1},${i < hit ? 1 : 0}`) +} + test('parses one record with an absolute CI path', () => { const text = [ 'SF:/home/runner/work/contracts/contracts/packages/contracts-test/contracts/rewards/RewardsManager.sol', - 'LF:200', - 'LH:150', + ...da(200, 150), 'end_of_record', ].join('\n') assert.deepEqual(parseLcov(text), { - 'contracts/rewards/RewardsManager.sol': { linesFound: 200, linesHit: 150, pct: 75 }, + 'contracts/rewards/RewardsManager.sol': { linesFound: 200, linesHit: 150, pct: 75, functionsHit: [] }, }) }) test('keeps relative contracts paths', () => { - const text = 'SF:contracts/upgrades/GraphProxy.sol\nLF:1\nLH:1\nend_of_record\n' + const text = 'SF:contracts/upgrades/GraphProxy.sol\nDA:1,1\nend_of_record\n' assert.deepEqual(Object.keys(parseLcov(text)), ['contracts/upgrades/GraphProxy.sol']) }) test('keeps records separate', () => { const text = [ 'SF:contracts/A.sol', - 'LF:10', - 'LH:10', + ...da(10, 10), 'end_of_record', 'SF:contracts/B.sol', - 'LF:4', - 'LH:1', + ...da(4, 1), 'end_of_record', ].join('\n') const result = parseLcov(text) @@ -37,6 +38,51 @@ test('keeps records separate', () => { }) test('zero lines yields null pct', () => { - const result = parseLcov('SF:contracts/Z.sol\nLF:0\nLH:0\nend_of_record\n') + const result = parseLcov('SF:contracts/Z.sol\nend_of_record\n') assert.equal(result['contracts/Z.sol'].pct, null) }) + +test('computes lines from DA records', () => { + const text = 'SF:contracts/A.sol\nDA:1,3\nDA:2,0\nDA:3,1\nLF:99\nLH:99\nend_of_record\n' + assert.deepEqual(parseLcov(text)['contracts/A.sol'], { linesFound: 3, linesHit: 2, pct: 66.67, functionsHit: [] }) +}) + +test('merges duplicate SF records', () => { + const text = [ + 'SF:contracts/A.sol', + 'DA:1,0', + 'DA:2,1', + 'end_of_record', + 'SF:contracts/A.sol', + 'DA:1,2', + 'DA:3,0', + 'end_of_record', + ].join('\n') + const result = parseLcov(text)['contracts/A.sol'] + assert.equal(result.linesFound, 3) + assert.equal(result.linesHit, 2) +}) + +test('lists hit functions sorted and unique', () => { + const text = [ + 'SF:contracts/A.sol', + 'FNDA:2,b', + 'FNDA:0,c', + 'FNDA:1,a', + 'end_of_record', + 'SF:contracts/A.sol', + 'FNDA:1,b', + 'end_of_record', + ].join('\n') + assert.deepEqual(parseLcov(text)['contracts/A.sol'].functionsHit, ['a', 'b']) +}) + +test('drops empty function names', () => { + const result = parseLcov('SF:contracts/A.sol\nFNDA:1,\nend_of_record\n') + assert.deepEqual(result['contracts/A.sol'].functionsHit, []) +}) + +test('file without functions has empty functionsHit', () => { + const result = parseLcov('SF:contracts/A.sol\nDA:1,1\nend_of_record\n') + assert.deepEqual(result['contracts/A.sol'].functionsHit, []) +}) From 80d26fdc95aec0725ff841c70b26e5ae29bca3f5 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Fri, 23 Oct 2026 16:25:08 -0300 Subject: [PATCH 06/20] test: add ManagedMock for the graph-core Foundry tests --- .../contracts/contracts/tests/ManagedMock.sol | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 packages/contracts/contracts/tests/ManagedMock.sol diff --git a/packages/contracts/contracts/tests/ManagedMock.sol b/packages/contracts/contracts/tests/ManagedMock.sol new file mode 100644 index 000000000..3520236cd --- /dev/null +++ b/packages/contracts/contracts/tests/ManagedMock.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; + +// solhint-disable use-natspec + +import { Managed } from "../governance/Managed.sol"; +import { GraphUpgradeable } from "../upgrades/GraphUpgradeable.sol"; + +contract ManagedMock is Managed, GraphUpgradeable { + function initialize(address _controller) external onlyImpl { + Managed._initialize(_controller); + } + + function checkNotPartialPaused() external view notPartialPaused {} + + function checkNotPaused() external view notPaused {} + + function checkOnlyGovernor() external view onlyGovernor {} + + /// curation, epochManager, rewardsManager, staking, graphToken, graphTokenGateway, gns — in that order + function resolveAll() external view returns (address[7] memory) { + return + [ + address(curation()), + address(epochManager()), + address(rewardsManager()), + address(staking()), + address(graphToken()), + address(graphTokenGateway()), + address(gns()) + ]; + } +} From e7d309a199e69dc108b3ecdfc9cf20cd524b5288 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Fri, 23 Oct 2026 16:25:08 -0300 Subject: [PATCH 07/20] test(graph-core): port governance and proxy tests to Foundry --- .../tests/unit/governance/controller.test.ts | 134 ------------- .../tests/unit/governance/governed.test.ts | 45 ----- .../tests/unit/governance/pausing.test.ts | 110 ----------- .../tests/unit/upgrade/admin.test.ts | 180 ------------------ packages/graph-core/foundry.toml | 22 +++ packages/graph-core/package.json | 19 ++ .../test/unit/governance/Controller.t.sol | 119 ++++++++++++ .../test/unit/governance/Governed.t.sol | 53 ++++++ .../test/unit/governance/Managed.t.sol | 74 +++++++ .../test/unit/governance/Pausing.t.sol | 99 ++++++++++ .../test/unit/interfaces/IControllerLike.sol | 9 + .../test/unit/interfaces/IManagedMock.sol | 22 +++ .../graph-core/test/unit/shared/Fixture.t.sol | 39 ++++ .../test/unit/shared/GraphCoreTest.t.sol | 23 +++ .../test/unit/shared/ProtocolFixture.t.sol | 66 +++++++ .../test/unit/upgrades/GraphProxy.t.sol | 52 +++++ .../test/unit/upgrades/GraphProxyAdmin.t.sol | 142 ++++++++++++++ pnpm-lock.yaml | 12 ++ .../contracts-migration/test-mapping/t1.md | 46 +++++ 19 files changed, 797 insertions(+), 469 deletions(-) delete mode 100644 packages/contracts-test/tests/unit/governance/controller.test.ts delete mode 100644 packages/contracts-test/tests/unit/governance/governed.test.ts delete mode 100644 packages/contracts-test/tests/unit/governance/pausing.test.ts delete mode 100644 packages/contracts-test/tests/unit/upgrade/admin.test.ts create mode 100644 packages/graph-core/foundry.toml create mode 100644 packages/graph-core/package.json create mode 100644 packages/graph-core/test/unit/governance/Controller.t.sol create mode 100644 packages/graph-core/test/unit/governance/Governed.t.sol create mode 100644 packages/graph-core/test/unit/governance/Managed.t.sol create mode 100644 packages/graph-core/test/unit/governance/Pausing.t.sol create mode 100644 packages/graph-core/test/unit/interfaces/IControllerLike.sol create mode 100644 packages/graph-core/test/unit/interfaces/IManagedMock.sol create mode 100644 packages/graph-core/test/unit/shared/Fixture.t.sol create mode 100644 packages/graph-core/test/unit/shared/GraphCoreTest.t.sol create mode 100644 packages/graph-core/test/unit/shared/ProtocolFixture.t.sol create mode 100644 packages/graph-core/test/unit/upgrades/GraphProxy.t.sol create mode 100644 packages/graph-core/test/unit/upgrades/GraphProxyAdmin.t.sol create mode 100644 scripts/contracts-migration/test-mapping/t1.md diff --git a/packages/contracts-test/tests/unit/governance/controller.test.ts b/packages/contracts-test/tests/unit/governance/controller.test.ts deleted file mode 100644 index 06d5b1563..000000000 --- a/packages/contracts-test/tests/unit/governance/controller.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Controller } from '@graphprotocol/contracts' -import { EpochManager } from '@graphprotocol/contracts' -import { GraphNetworkContracts } from '@graphprotocol/sdk' -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { constants, utils } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const { AddressZero } = constants - -describe('Managed', () => { - const graph = hre.graph() - let me: SignerWithAddress - let governor: SignerWithAddress - let mockController: SignerWithAddress - let newMockEpochManager: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let epochManager: EpochManager - let controller: Controller - - before(async function () { - ;[me, mockController, newMockEpochManager] = await graph.getTestAccounts() - ;({ governor } = await graph.getNamedAccounts()) - // We just run the fixures to set up a contract with Managed, as this - // is cleaner and easier for us to test - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - epochManager = contracts.EpochManager as EpochManager - controller = contracts.Controller as Controller - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('setContractProxy()', function () { - it('should set contract proxy and test get contract proxy', async function () { - // Set right in the constructor - expect(await epochManager.controller()).eq(controller.address) - - // Test the controller - const id = utils.id('EpochManager') - const tx = controller.connect(governor).setContractProxy(id, newMockEpochManager.address) - await expect(tx).emit(controller, 'SetContractProxy').withArgs(id, newMockEpochManager.address) - expect(await controller.getContractProxy(id)).eq(newMockEpochManager.address) - }) - - it('reject set contract proxy to address zero', async function () { - const id = utils.id('EpochManager') - const tx = controller.connect(governor).setContractProxy(id, AddressZero) - await expect(tx).revertedWith('Contract address must be set') - }) - - it('reject set contract proxy if not governor', async function () { - const id = utils.id('EpochManager') - const tx = controller.connect(me).setContractProxy(id, newMockEpochManager.address) - await expect(tx).revertedWith('Only Governor can call') - }) - }) - - describe('unsetContractProxy()', function () { - it('should unset contract proxy', async function () { - // Set contract - const id = utils.id('EpochManager') - await controller.connect(governor).setContractProxy(id, newMockEpochManager.address) - expect(await controller.getContractProxy(id)).eq(newMockEpochManager.address) - - // Unset contract - const tx = controller.connect(governor).unsetContractProxy(id) - await expect(tx).emit(controller, 'SetContractProxy').withArgs(id, AddressZero) - expect(await controller.getContractProxy(id)).eq(AddressZero) - }) - - it('reject to call if not authorized caller', async function () { - // Unset contract - const id = utils.id('EpochManager') - const tx = controller.connect(me).unsetContractProxy(id) - await expect(tx).revertedWith('Only Governor can call') - }) - }) - - describe('updateController()', function () { - it('should update controller on a manager', async function () { - const tx = controller.connect(governor).updateController(utils.id('EpochManager'), mockController.address) - await expect(tx).emit(epochManager, 'SetController').withArgs(mockController.address) - expect(await epochManager.controller()).eq(mockController.address) - }) - - it('should fail updating controller when not called by governor', async function () { - const tx = controller.connect(me).updateController(utils.id('EpochManager'), mockController.address) - await expect(tx).revertedWith('Only Governor can call') - }) - - it('reject update controller to address zero', async function () { - const tx = controller.connect(governor).updateController(utils.id('EpochManager'), AddressZero) - await expect(tx).revertedWith('Controller must be set') - }) - }) - - describe('setController()', function () { - it('should fail setting controller when not called from Controller', async function () { - const tx = epochManager.connect(me).setController(mockController.address) - await expect(tx).revertedWith('Caller must be Controller') - }) - }) - - describe('setPauseGuardian()', function () { - it('should set the pause guardian', async function () { - const tx = controller.connect(governor).setPauseGuardian(me.address) - const currentPauseGuardian = await controller.pauseGuardian() - await expect(tx).emit(controller, 'NewPauseGuardian').withArgs(currentPauseGuardian, me.address) - expect(await controller.pauseGuardian()).eq(me.address) - }) - - it('reject to call if not authorized caller', async function () { - const tx = controller.connect(me).setPauseGuardian(me.address) - await expect(tx).revertedWith('Only Governor can call') - }) - - it('reject set pause guardian to address zero', async function () { - const tx = controller.connect(governor).setPauseGuardian(AddressZero) - await expect(tx).revertedWith('PauseGuardian must be set') - }) - }) -}) diff --git a/packages/contracts-test/tests/unit/governance/governed.test.ts b/packages/contracts-test/tests/unit/governance/governed.test.ts deleted file mode 100644 index 0fd617b1e..000000000 --- a/packages/contracts-test/tests/unit/governance/governed.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import '@nomiclabs/hardhat-ethers' - -import { Governed } from '@graphprotocol/contracts' -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import hre from 'hardhat' - -const { ethers } = hre -const { AddressZero } = ethers.constants - -describe('Governed', () => { - const graph = hre.graph() - let me: SignerWithAddress - let governor: SignerWithAddress - - let governed: Governed - - beforeEach(async function () { - ;[me, governor] = await graph.getTestAccounts() - - const factory = await ethers.getContractFactory('contracts/tests/GovernedMock.sol:GovernedMock') - governed = (await factory.connect(governor).deploy()) as Governed - }) - - it('should reject transfer if not allowed', async function () { - const tx = governed.connect(me).transferOwnership(me.address) - await expect(tx).revertedWith('Only Governor can call') - }) - - it('should transfer and accept', async function () { - // Transfer ownership - const tx1 = governed.connect(governor).transferOwnership(me.address) - await expect(tx1).emit(governed, 'NewPendingOwnership').withArgs(AddressZero, me.address) - - // Reject accept if not the pending governor - await expect(governed.connect(governor).acceptOwnership()).revertedWith('Caller must be pending governor') - - // Accept ownership - const tx2 = governed.connect(me).acceptOwnership() - await expect(tx2).emit(governed, 'NewOwnership').withArgs(governor.address, me.address) - - // Clean pending governor - expect(await governed.pendingGovernor()).eq(AddressZero) - }) -}) diff --git a/packages/contracts-test/tests/unit/governance/pausing.test.ts b/packages/contracts-test/tests/unit/governance/pausing.test.ts deleted file mode 100644 index 0e2f0d912..000000000 --- a/packages/contracts-test/tests/unit/governance/pausing.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { Controller } from '@graphprotocol/contracts' -import { IStaking } from '@graphprotocol/contracts' -import { GraphNetworkContracts, toGRT } from '@graphprotocol/sdk' -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -describe('Pausing', () => { - const graph = hre.graph() - let me: SignerWithAddress - let governor: SignerWithAddress - let guardian: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let staking: IStaking - let controller: Controller - - const setPartialPause = async (account: SignerWithAddress, setValue: boolean) => { - const tx = controller.connect(account).setPartialPaused(setValue) - await expect(tx).emit(controller, 'PartialPauseChanged').withArgs(setValue) - expect(await controller.partialPaused()).eq(setValue) - } - const setPause = async (account: SignerWithAddress, setValue: boolean) => { - const tx = controller.connect(account).setPaused(setValue) - await expect(tx).emit(controller, 'PauseChanged').withArgs(setValue) - expect(await controller.paused()).eq(setValue) - } - before(async function () { - ;[me] = await graph.getTestAccounts() - ;({ governor, pauseGuardian: guardian } = await graph.getNamedAccounts()) - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - staking = contracts.Staking as IStaking - controller = contracts.Controller as Controller - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - it('should set pause guardian', async function () { - const currentGuardian = await controller.pauseGuardian() - expect(await controller.pauseGuardian()).eq(currentGuardian) - const tx = controller.connect(governor).setPauseGuardian(guardian.address) - await expect(tx).emit(controller, 'NewPauseGuardian').withArgs(currentGuardian, guardian.address) - expect(await controller.pauseGuardian()).eq(guardian.address) - }) - it('should fail pause guardian when not governor', async function () { - const tx = controller.connect(me).setPauseGuardian(guardian.address) - await expect(tx).revertedWith('Only Governor can call') - }) - it('should set partialPaused and unset from governor and guardian', async function () { - expect(await controller.partialPaused()).eq(false) - // Governor set - await setPartialPause(governor, true) - // Governor unset - await setPartialPause(governor, false) - - await controller.connect(governor).setPauseGuardian(guardian.address) - // Guardian set - await setPartialPause(guardian, true) - // Guardian unset - await setPartialPause(guardian, false) - }) - it('should fail partial pause if not guardian or governor', async function () { - const tx = controller.connect(me).setPartialPaused(true) - await expect(tx).revertedWith('Only Governor or Guardian can call') - }) - it('should check that a function fails when partialPause is set', async function () { - await setPartialPause(governor, true) - - const tokensToStake = toGRT('100') - const tx = staking.connect(me).stake(tokensToStake) - await expect(tx).revertedWith('Partial-paused') - }) - it('should set pause and unset from governor and guardian', async function () { - expect(await controller.paused()).eq(false) - // Governor set - await setPause(governor, true) - // Governor unset - await setPause(governor, false) - - await controller.connect(governor).setPauseGuardian(guardian.address) - // Guardian set - await setPause(guardian, true) - // Guardian unset - await setPause(guardian, false) - }) - it('should fail pause if not guardian or governor', async function () { - const tx = controller.connect(me).setPaused(true) - await expect(tx).revertedWith('Only Governor or Guardian can call') - }) - it('should check that a function fails when pause is set', async function () { - await setPause(governor, true) - - const tokensToStake = toGRT('100') - const tx1 = staking.connect(me).stake(tokensToStake) - await expect(tx1).revertedWith('Paused') - - const tx2 = staking.connect(me).withdraw() - await expect(tx2).revertedWith('Paused') - }) -}) diff --git a/packages/contracts-test/tests/unit/upgrade/admin.test.ts b/packages/contracts-test/tests/unit/upgrade/admin.test.ts deleted file mode 100644 index bfe5c9e01..000000000 --- a/packages/contracts-test/tests/unit/upgrade/admin.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import '@nomiclabs/hardhat-ethers' - -import { GraphProxy } from '@graphprotocol/contracts' -import { Curation } from '@graphprotocol/contracts' -import { GraphProxyAdmin } from '@graphprotocol/contracts' -import { IStaking } from '@graphprotocol/contracts' -import { deploy, DeployType, GraphNetworkContracts, loadContractAt } from '@graphprotocol/sdk' -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const { ethers } = hre -const { AddressZero } = ethers.constants - -describe('Upgrades', () => { - const graph = hre.graph() - let me: SignerWithAddress - let governor: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let proxyAdmin: GraphProxyAdmin - let curation: Curation - let staking: IStaking - let stakingProxy: GraphProxy - - before(async function () { - ;[me, governor] = await graph.getTestAccounts() - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - staking = contracts.Staking as IStaking - proxyAdmin = contracts.GraphProxyAdmin as GraphProxyAdmin - curation = contracts.Curation as Curation - stakingProxy = loadContractAt('GraphProxy', staking.address, undefined, governor) as GraphProxy - - // Give some funds to the indexer and approve staking contract to use funds on indexer behalf - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('GraphProxyAdmin & GraphProxy', function () { - describe('getters', function () { - describe('admin()', function () { - it('should get the proxy admin of a proxy contract', async function () { - const proxyAdminAddress = await proxyAdmin.getProxyAdmin(staking.address) - expect(proxyAdmin.address).eq(proxyAdminAddress) - }) - - it('reject get admin from other than the ProxyAdmin', async function () { - await expect(stakingProxy.connect(governor).admin()).to.be.reverted - }) - }) - - describe('implementation()', function () { - it('should get implementation only from ProxyAdmin', async function () { - const implementationAddress = await proxyAdmin.getProxyImplementation(staking.address) - expect(implementationAddress).not.eq(AddressZero) - }) - - it('reject get implementation from other than the ProxyAdmin', async function () { - await expect(stakingProxy.implementation()).to.be.reverted - }) - }) - - describe('pendingImplementation()', function () { - it('should get pending implementation only from ProxyAdmin', async function () { - const pendingImplementationAddress = await proxyAdmin.getProxyPendingImplementation(staking.address) - expect(pendingImplementationAddress).eq(AddressZero) - }) - - it('reject get pending implementation from other than the ProxyAdmin', async function () { - await expect(stakingProxy.connect(governor).pendingImplementation()).to.be.reverted - }) - }) - }) - - describe('upgrade', function () { - it('should be able to upgrade contract', async function () { - // Get some other implementation to use just for the purpose of the test - const oldImplementationAddress = await proxyAdmin.getProxyImplementation(staking.address) - const newImplementationAddress = await proxyAdmin.getProxyImplementation(curation.address) - - const stakingProxy = loadContractAt('GraphProxy', staking.address, undefined, governor) - - // Upgrade the Staking:Proxy to a new implementation - const tx1 = proxyAdmin.connect(governor).upgrade(staking.address, newImplementationAddress) - await expect(tx1) - .emit(stakingProxy, 'PendingImplementationUpdated') - .withArgs(AddressZero, newImplementationAddress) - - const tx2 = proxyAdmin.connect(governor).acceptProxy(newImplementationAddress, staking.address) - await expect(tx2) - .emit(stakingProxy, 'ImplementationUpdated') - .withArgs(oldImplementationAddress, newImplementationAddress) - - // Implementation should be the new one - expect(await proxyAdmin.getProxyImplementation(curation.address)).eq(newImplementationAddress) - }) - - it('reject upgrade if not the governor of the ProxyAdmin', async function () { - const newImplementationAddress = await proxyAdmin.getProxyImplementation(curation.address) - - // Upgrade the Staking:Proxy to a new implementation - const tx = proxyAdmin.connect(me).upgrade(staking.address, newImplementationAddress) - await expect(tx).revertedWith('Only Governor can call') - }) - - it('reject upgrade if not using the ProxyAdmin', async function () { - const newImplementationAddress = await proxyAdmin.getProxyImplementation(curation.address) - - // Due to the transparent proxy we should not be able to upgrade from other than the proxy admin - const tx = stakingProxy.connect(governor).upgradeTo(newImplementationAddress) - await expect(tx).to.be.reverted - }) - }) - - describe('acceptUpgrade', function () { - it('reject accept upgrade if not using the ProxyAdmin', async function () { - // Due to the transparent proxy we should not be able to accept upgrades from other than the proxy admin - const tx = stakingProxy.connect(governor).acceptUpgrade() - await expect(tx).to.be.reverted - }) - }) - - describe('acceptProxy', function () { - it('reject accept proxy if not using the ProxyAdmin', async function () { - const newImplementationAddress = await proxyAdmin.getProxyImplementation(curation.address) - const implementation = loadContractAt('Curation', newImplementationAddress, undefined, governor) - - // Start an upgrade to a new implementation - await proxyAdmin.connect(governor).upgrade(staking.address, newImplementationAddress) - - // Try to accept the proxy directly from the implementation, this should not work, only from the ProxyAdmin - const tx = implementation.connect(governor).acceptProxy(staking.address) - await expect(tx).revertedWith('Caller must be the proxy admin') - }) - }) - - describe('changeProxyAdmin', function () { - it('should set the proxy admin of a proxy', async function () { - const { contract: otherProxyAdmin } = await deploy(DeployType.Deploy, governor, { - name: 'GraphProxyAdmin', - }) - - await proxyAdmin.connect(governor).changeProxyAdmin(staking.address, otherProxyAdmin.address) - expect(await otherProxyAdmin.getProxyAdmin(staking.address)).eq(otherProxyAdmin.address) - - // Should not find the change admin function in the proxy due to transparent proxy - // as this ProxyAdmin is not longer the owner - const tx = proxyAdmin.connect(governor).changeProxyAdmin(staking.address, otherProxyAdmin.address) - await expect(tx).to.be.reverted - }) - - it('reject change admin if not the governor of the ProxyAdmin', async function () { - const { contract: otherProxyAdmin } = await deploy(DeployType.Deploy, governor, { - name: 'GraphProxyAdmin', - }) - - const tx = proxyAdmin.connect(me).changeProxyAdmin(staking.address, otherProxyAdmin.address) - await expect(tx).revertedWith('Only Governor can call') - }) - - it('reject change admin if not using the ProxyAdmin', async function () { - // Due to the transparent proxy we should not be able to set admin from other than the proxy admin - const tx = stakingProxy.connect(governor).setAdmin(me.address) - await expect(tx).to.be.reverted - }) - }) - }) -}) diff --git a/packages/graph-core/foundry.toml b/packages/graph-core/foundry.toml new file mode 100644 index 000000000..35b9b51af --- /dev/null +++ b/packages/graph-core/foundry.toml @@ -0,0 +1,22 @@ +[profile.default] +src = 'test' +out = 'forge-artifacts' +test = 'test' +libs = ["node_modules"] +cache_path = 'cache_forge' +auto_detect_remappings = false +remappings = [ + "@graphprotocol/=node_modules/@graphprotocol/", + "forge-std/=node_modules/forge-std/src/", +] +solc_version = '0.8.35' +evm_version = 'cancun' +fs_permissions = [{ access = "read", path = "node_modules/@graphprotocol/contracts/artifacts" }] +ignored_warnings_from = ["node_modules"] + +[lint] +# Disable lint-on-build: forge's lint walker follows symlinks without cycle detection and +# infinite-loops on this package's pnpm-hoisted node_modules. +lint_on_build = false +exclude_lints = ["mixed-case-function", "mixed-case-variable", "block-timestamp"] +ignore = ["node_modules/**", "**/node_modules/**"] diff --git a/packages/graph-core/package.json b/packages/graph-core/package.json new file mode 100644 index 000000000..bcb87b7cc --- /dev/null +++ b/packages/graph-core/package.json @@ -0,0 +1,19 @@ +{ + "name": "@graphprotocol/graph-core", + "version": "0.0.0", + "private": true, + "description": "The Graph governance and proxy contracts", + "license": "GPL-2.0-or-later", + "scripts": { + "build": "pnpm build:dep", + "build:dep": "pnpm --filter '@graphprotocol/graph-core^...' run build:self", + "clean": "rm -rf forge-artifacts/ cache_forge/", + "test": "pnpm build && pnpm test:self", + "test:self": "forge test" + }, + "devDependencies": { + "@graphprotocol/contracts": "workspace:^", + "@graphprotocol/interfaces": "workspace:^", + "forge-std": "catalog:" + } +} diff --git a/packages/graph-core/test/unit/governance/Controller.t.sol b/packages/graph-core/test/unit/governance/Controller.t.sol new file mode 100644 index 000000000..b9411d0b7 --- /dev/null +++ b/packages/graph-core/test/unit/governance/Controller.t.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IControllerLike } from "../interfaces/IControllerLike.sol"; +import { GraphCoreTest } from "../shared/GraphCoreTest.t.sol"; + +contract ControllerTest is GraphCoreTest { + event SetContractProxy(bytes32 indexed id, address contractAddress); + event SetController(address controller); + event NewPauseGuardian(address indexed oldPauseGuardian, address indexed pauseGuardian); + + bytes32 internal constant EPOCH_MANAGER_ID = keccak256("EpochManager"); + + address internal me = makeAddr("me"); + address internal mockController = makeAddr("mockController"); + address internal newMockEpochManager = makeAddr("newMockEpochManager"); + + function setUp() public override { + super.setUp(); + _registerContract("EpochManager", address(managed)); + } + + // -- setContractProxy() -- + + function test_SetContractProxy_SetsAndGets() public { + assertEq(address(managed.controller()), address(controller)); + + vm.expectEmit(address(controller)); + emit SetContractProxy(EPOCH_MANAGER_ID, newMockEpochManager); + vm.prank(governor); + controller.setContractProxy(EPOCH_MANAGER_ID, newMockEpochManager); + assertEq(controller.getContractProxy(EPOCH_MANAGER_ID), newMockEpochManager); + } + + function test_SetContractProxy_RevertWhen_AddressZero() public { + vm.expectRevert(bytes("Contract address must be set")); + vm.prank(governor); + controller.setContractProxy(EPOCH_MANAGER_ID, address(0)); + } + + function test_SetContractProxy_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Governor can call")); + vm.prank(me); + controller.setContractProxy(EPOCH_MANAGER_ID, newMockEpochManager); + } + + // -- unsetContractProxy() -- + + function test_UnsetContractProxy_Unsets() public { + vm.prank(governor); + controller.setContractProxy(EPOCH_MANAGER_ID, newMockEpochManager); + assertEq(controller.getContractProxy(EPOCH_MANAGER_ID), newMockEpochManager); + + vm.expectEmit(address(controller)); + emit SetContractProxy(EPOCH_MANAGER_ID, address(0)); + vm.prank(governor); + controller.unsetContractProxy(EPOCH_MANAGER_ID); + assertEq(controller.getContractProxy(EPOCH_MANAGER_ID), address(0)); + } + + function test_UnsetContractProxy_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Governor can call")); + vm.prank(me); + controller.unsetContractProxy(EPOCH_MANAGER_ID); + } + + // -- updateController() -- + + function test_UpdateController_UpdatesManaged() public { + vm.expectEmit(address(managed)); + emit SetController(mockController); + vm.prank(governor); + controller.updateController(EPOCH_MANAGER_ID, mockController); + assertEq(address(managed.controller()), mockController); + } + + function test_UpdateController_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Governor can call")); + vm.prank(me); + controller.updateController(EPOCH_MANAGER_ID, mockController); + } + + function test_UpdateController_RevertWhen_ControllerZero() public { + vm.expectRevert(bytes("Controller must be set")); + vm.prank(governor); + controller.updateController(EPOCH_MANAGER_ID, address(0)); + } + + // -- setController() -- + + function test_SetController_RevertWhen_NotController() public { + vm.expectRevert(bytes("Caller must be Controller")); + vm.prank(me); + managed.setController(mockController); + } + + // -- setPauseGuardian() -- + + function test_SetPauseGuardian_Sets() public { + address currentPauseGuardian = IControllerLike(address(controller)).pauseGuardian(); + vm.expectEmit(address(controller)); + emit NewPauseGuardian(currentPauseGuardian, me); + vm.prank(governor); + controller.setPauseGuardian(me); + assertEq(IControllerLike(address(controller)).pauseGuardian(), me); + } + + function test_SetPauseGuardian_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Governor can call")); + vm.prank(me); + controller.setPauseGuardian(me); + } + + function test_SetPauseGuardian_RevertWhen_GuardianZero() public { + vm.expectRevert(bytes("PauseGuardian must be set")); + vm.prank(governor); + controller.setPauseGuardian(address(0)); + } +} diff --git a/packages/graph-core/test/unit/governance/Governed.t.sol b/packages/graph-core/test/unit/governance/Governed.t.sol new file mode 100644 index 000000000..70eb63803 --- /dev/null +++ b/packages/graph-core/test/unit/governance/Governed.t.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IGoverned } from "@graphprotocol/interfaces/contracts/contracts/governance/IGoverned.sol"; +import { ProtocolFixture } from "../shared/ProtocolFixture.t.sol"; + +contract GovernedTest is ProtocolFixture { + event NewPendingOwnership(address indexed from, address indexed to); + event NewOwnership(address indexed from, address indexed to); + + IGoverned internal governed; + address internal me = makeAddr("me"); + + function setUp() public { + vm.warp(1_700_000_000); + governor = makeAddr("governor"); + vm.prank(governor); + governed = IGoverned( + _deployArtifact(string.concat(PKG_ARTIFACTS, "tests/GovernedMock.sol/GovernedMock.json"), "") + ); + assertEq(governed.governor(), governor); + } + + function test_Governed_RevertWhen_TransferNotGovernor() public { + vm.expectRevert(bytes("Only Governor can call")); + vm.prank(me); + governed.transferOwnership(me); + } + + function test_Governed_TransferAndAccept() public { + vm.expectEmit(address(governed)); + emit NewPendingOwnership(address(0), me); + vm.prank(governor); + governed.transferOwnership(me); + + vm.expectRevert(bytes("Caller must be pending governor")); + vm.prank(governor); + governed.acceptOwnership(); + + vm.expectEmit(address(governed)); + emit NewOwnership(governor, me); + vm.prank(me); + governed.acceptOwnership(); + + assertEq(governed.pendingGovernor(), address(0)); + } + + function test_Governed_RevertWhen_TransferToZero() public { + vm.expectRevert(bytes("Governor must be set")); + vm.prank(governor); + governed.transferOwnership(address(0)); + } +} diff --git a/packages/graph-core/test/unit/governance/Managed.t.sol b/packages/graph-core/test/unit/governance/Managed.t.sol new file mode 100644 index 000000000..d45a93307 --- /dev/null +++ b/packages/graph-core/test/unit/governance/Managed.t.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Vm } from "forge-std/Vm.sol"; +import { GraphCoreTest } from "../shared/GraphCoreTest.t.sol"; + +contract ManagedTest is GraphCoreTest { + event ContractSynced(bytes32 indexed nameHash, address contractAddress); + + address internal me = makeAddr("me"); + + /// In the order ManagedMock.resolveAll and Managed.syncAllContracts use + function _names() internal pure returns (string[7] memory) { + return ["Curation", "EpochManager", "RewardsManager", "Staking", "GraphToken", "GraphTokenGateway", "GNS"]; + } + + function _registerAll(string memory prefix) internal returns (address[7] memory addrs) { + string[7] memory names = _names(); + for (uint256 i = 0; i < 7; i++) { + addrs[i] = makeAddr(string.concat(prefix, names[i])); + _registerContract(names[i], addrs[i]); + } + } + + function test_Managed_SyncAllContractsEmitsPerChangedId() public { + address curation = makeAddr("curation"); + address staking = makeAddr("staking"); + address gns = makeAddr("gns"); + _registerContract("Curation", curation); + _registerContract("Staking", staking); + _registerContract("GNS", gns); + + vm.expectEmit(address(managed)); + emit ContractSynced(keccak256("Curation"), curation); + vm.expectEmit(address(managed)); + emit ContractSynced(keccak256("Staking"), staking); + vm.expectEmit(address(managed)); + emit ContractSynced(keccak256("GNS"), gns); + managed.syncAllContracts(); + + vm.recordLogs(); + managed.syncAllContracts(); + Vm.Log[] memory logs = vm.getRecordedLogs(); + for (uint256 i = 0; i < logs.length; i++) { + assertNotEq(logs[i].topics[0], ContractSynced.selector); + } + } + + function test_Managed_ResolveAllUsesControllerThenCache() public { + address[7] memory registered = _registerAll("first"); + address[7] memory resolved = managed.resolveAll(); + for (uint256 i = 0; i < 7; i++) { + assertEq(resolved[i], registered[i]); + } + + managed.syncAllContracts(); + _registerAll("second"); + + resolved = managed.resolveAll(); + for (uint256 i = 0; i < 7; i++) { + assertEq(resolved[i], registered[i]); + } + } + + function test_Managed_RevertWhen_CheckOnlyGovernorNotGovernor() public { + assertEq(controller.getGovernor(), governor); + vm.prank(governor); + managed.checkOnlyGovernor(); + + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(me); + managed.checkOnlyGovernor(); + } +} diff --git a/packages/graph-core/test/unit/governance/Pausing.t.sol b/packages/graph-core/test/unit/governance/Pausing.t.sol new file mode 100644 index 000000000..1571eb903 --- /dev/null +++ b/packages/graph-core/test/unit/governance/Pausing.t.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IControllerLike } from "../interfaces/IControllerLike.sol"; +import { GraphCoreTest } from "../shared/GraphCoreTest.t.sol"; + +contract PausingTest is GraphCoreTest { + event PartialPauseChanged(bool isPaused); + event PauseChanged(bool isPaused); + event NewPauseGuardian(address indexed oldPauseGuardian, address indexed pauseGuardian); + + address internal me = makeAddr("me"); + address internal guardian = makeAddr("guardian"); + + function _setPartialPause(address account, bool value) internal { + vm.expectEmit(address(controller)); + emit PartialPauseChanged(value); + vm.prank(account); + controller.setPartialPaused(value); + assertEq(controller.partialPaused(), value); + } + + function _setPause(address account, bool value) internal { + vm.expectEmit(address(controller)); + emit PauseChanged(value); + vm.prank(account); + controller.setPaused(value); + assertEq(controller.paused(), value); + } + + function test_Pausing_SetPauseGuardian() public { + address currentGuardian = IControllerLike(address(controller)).pauseGuardian(); + vm.expectEmit(address(controller)); + emit NewPauseGuardian(currentGuardian, guardian); + vm.prank(governor); + controller.setPauseGuardian(guardian); + assertEq(IControllerLike(address(controller)).pauseGuardian(), guardian); + } + + function test_Pausing_RevertWhen_SetPauseGuardianNotGovernor() public { + vm.expectRevert(bytes("Only Governor can call")); + vm.prank(me); + controller.setPauseGuardian(guardian); + } + + function test_Pausing_SetAndUnsetPartialPaused() public { + assertFalse(controller.partialPaused()); + _setPartialPause(governor, true); + _setPartialPause(governor, false); + + vm.prank(governor); + controller.setPauseGuardian(guardian); + _setPartialPause(guardian, true); + _setPartialPause(guardian, false); + } + + function test_Pausing_RevertWhen_PartialPauseNotGovernorOrGuardian() public { + vm.expectRevert(bytes("Only Governor or Guardian can call")); + vm.prank(me); + controller.setPartialPaused(true); + } + + function test_Pausing_RevertWhen_PartialPaused() public { + _setPartialPause(governor, true); + + vm.expectRevert(bytes("Partial-paused")); + vm.prank(me); + managed.checkNotPartialPaused(); + } + + function test_Pausing_SetAndUnsetPaused() public { + assertFalse(controller.paused()); + _setPause(governor, true); + _setPause(governor, false); + + vm.prank(governor); + controller.setPauseGuardian(guardian); + _setPause(guardian, true); + _setPause(guardian, false); + } + + function test_Pausing_RevertWhen_PauseNotGovernorOrGuardian() public { + vm.expectRevert(bytes("Only Governor or Guardian can call")); + vm.prank(me); + controller.setPaused(true); + } + + function test_Pausing_RevertWhen_Paused() public { + _setPause(governor, true); + + vm.expectRevert(bytes("Paused")); + vm.prank(me); + managed.checkNotPartialPaused(); + + vm.expectRevert(bytes("Paused")); + vm.prank(me); + managed.checkNotPaused(); + } +} diff --git a/packages/graph-core/test/unit/interfaces/IControllerLike.sol b/packages/graph-core/test/unit/interfaces/IControllerLike.sol new file mode 100644 index 000000000..735f5d1da --- /dev/null +++ b/packages/graph-core/test/unit/interfaces/IControllerLike.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IController } from "@graphprotocol/interfaces/contracts/contracts/governance/IController.sol"; + +/// IController plus the `pauseGuardian` getter that Pausable exposes but IController does not. +interface IControllerLike is IController { + function pauseGuardian() external view returns (address); +} diff --git a/packages/graph-core/test/unit/interfaces/IManagedMock.sol b/packages/graph-core/test/unit/interfaces/IManagedMock.sol new file mode 100644 index 000000000..ea83053e7 --- /dev/null +++ b/packages/graph-core/test/unit/interfaces/IManagedMock.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IManaged } from "@graphprotocol/interfaces/contracts/contracts/governance/IManaged.sol"; +import { IGraphProxy } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxy.sol"; + +interface IManagedMock is IManaged { + function initialize(address controller) external; + + function checkNotPartialPaused() external view; + + function checkNotPaused() external view; + + function checkOnlyGovernor() external view; + + /// curation, epochManager, rewardsManager, staking, graphToken, graphTokenGateway, gns — in that order + function resolveAll() external view returns (address[7] memory); + + function acceptProxy(IGraphProxy proxy) external; + + function acceptProxyAndCall(IGraphProxy proxy, bytes calldata data) external; +} diff --git a/packages/graph-core/test/unit/shared/Fixture.t.sol b/packages/graph-core/test/unit/shared/Fixture.t.sol new file mode 100644 index 000000000..1b03aa050 --- /dev/null +++ b/packages/graph-core/test/unit/shared/Fixture.t.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IGraphProxy } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxy.sol"; +import { GraphCoreTest } from "./GraphCoreTest.t.sol"; + +contract FixtureTest is GraphCoreTest { + string internal constant CONTROLLER_ARTIFACT = + string.concat(CORE_ARTIFACTS, "governance/Controller.sol/Controller.json"); + string internal constant MISSING_ARTIFACT = string.concat(CORE_ARTIFACTS, "governance/Missing.sol/Missing.json"); + + function deployArtifact(string memory path) external returns (address) { + return _deployArtifact(path, ""); + } + + function test_Fixture_ArtifactReadable() public view { + assertTrue(vm.isFile(CONTROLLER_ARTIFACT)); + } + + function test_Fixture_RevertWhen_ArtifactMissing() public { + assertFalse(vm.isFile(MISSING_ARTIFACT)); + vm.expectRevert(); + this.deployArtifact(MISSING_ARTIFACT); + } + + function test_Fixture_ProxyWiredToImplementation() public view { + IGraphProxy proxy = IGraphProxy(address(managed)); + assertEq(proxyAdmin.getProxyAdmin(proxy), address(proxyAdmin)); + assertEq(proxyAdmin.getProxyImplementation(proxy), implA); + assertEq(proxyAdmin.getProxyPendingImplementation(proxy), address(0)); + assertEq(address(managed.controller()), address(controller)); + } + + function test_Fixture_ControllerUnpaused() public view { + assertFalse(controller.paused()); + assertFalse(controller.partialPaused()); + managed.checkNotPartialPaused(); + } +} diff --git a/packages/graph-core/test/unit/shared/GraphCoreTest.t.sol b/packages/graph-core/test/unit/shared/GraphCoreTest.t.sol new file mode 100644 index 000000000..85573389c --- /dev/null +++ b/packages/graph-core/test/unit/shared/GraphCoreTest.t.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { ProtocolFixture } from "./ProtocolFixture.t.sol"; +import { IManagedMock } from "../interfaces/IManagedMock.sol"; + +abstract contract GraphCoreTest is ProtocolFixture { + string internal constant MANAGED_MOCK = string.concat(PKG_ARTIFACTS, "tests/ManagedMock.sol/ManagedMock.json"); + + address internal implA; + address internal implB; + IManagedMock internal managed; + + function setUp() public virtual { + vm.warp(1_700_000_000); + _deployProtocolBase(); + implA = _deployArtifact(MANAGED_MOCK, ""); + implB = _deployArtifact(MANAGED_MOCK, ""); + managed = IManagedMock( + _deployBehindProxy(implA, abi.encodeCall(IManagedMock.initialize, (address(controller)))) + ); + } +} diff --git a/packages/graph-core/test/unit/shared/ProtocolFixture.t.sol b/packages/graph-core/test/unit/shared/ProtocolFixture.t.sol new file mode 100644 index 000000000..f38962c8e --- /dev/null +++ b/packages/graph-core/test/unit/shared/ProtocolFixture.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Test } from "forge-std/Test.sol"; +import { IController } from "@graphprotocol/interfaces/contracts/contracts/governance/IController.sol"; +import { IGraphProxy } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxy.sol"; +import { IGraphProxyAdmin } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxyAdmin.sol"; + +abstract contract ProtocolFixture is Test { + string internal constant CORE_ARTIFACTS = "node_modules/@graphprotocol/contracts/artifacts/contracts/"; + string internal constant PKG_ARTIFACTS = "node_modules/@graphprotocol/contracts/artifacts/contracts/"; + + address internal governor; // makeAddr("governor"), set in _deployProtocolBase + IController internal controller; + IGraphProxyAdmin internal proxyAdmin; + + /// path is a full artifact path, e.g. string.concat(CORE_ARTIFACTS, "governance/Controller.sol/Controller.json"). + /// Deploys `.bytecode ++ args` with CREATE (honours an active vm.prank). + /// Reverts "artifact needs linking" if `.linkReferences` has any key (vm.parseJsonKeys), + /// and "artifact deploy failed" if CREATE returns 0. + function _deployArtifact(string memory path, bytes memory args) internal returns (address deployed) { + string memory json = vm.readFile(path); + require(vm.parseJsonKeys(json, ".linkReferences").length == 0, "artifact needs linking"); + bytes memory code = bytes.concat(vm.parseJsonBytes(json, ".bytecode"), args); + // solhint-disable-next-line no-inline-assembly + assembly { + deployed := create(0, add(code, 0x20), mload(code)) + } + require(deployed != address(0), "artifact deploy failed"); + } + + /// As governor: deploys Controller and GraphProxyAdmin from CORE_ARTIFACTS, then controller.setPaused(false). + /// Must be called with no active prank. + function _deployProtocolBase() internal { + governor = makeAddr("governor"); + vm.startPrank(governor); + controller = IController( + _deployArtifact(string.concat(CORE_ARTIFACTS, "governance/Controller.sol/Controller.json"), "") + ); + proxyAdmin = IGraphProxyAdmin( + _deployArtifact(string.concat(CORE_ARTIFACTS, "upgrades/GraphProxyAdmin.sol/GraphProxyAdmin.json"), "") + ); + controller.setPaused(false); + vm.stopPrank(); + } + + /// As governor: deploys GraphProxy(address(0), proxyAdmin) from CORE_ARTIFACTS, + /// proxyAdmin.upgrade(proxy, impl), proxyAdmin.acceptProxyAndCall(impl, proxy, initData). + /// Must be called with no active prank. + function _deployBehindProxy(address impl, bytes memory initData) internal returns (address proxy) { + vm.startPrank(governor); + proxy = _deployArtifact( + string.concat(CORE_ARTIFACTS, "upgrades/GraphProxy.sol/GraphProxy.json"), + abi.encode(address(0), address(proxyAdmin)) + ); + proxyAdmin.upgrade(IGraphProxy(proxy), impl); + proxyAdmin.acceptProxyAndCall(impl, IGraphProxy(proxy), initData); + vm.stopPrank(); + } + + /// As governor: controller.setContractProxy(keccak256(bytes(name)), addr). + function _registerContract(string memory name, address addr) internal { + vm.prank(governor); + controller.setContractProxy(keccak256(bytes(name)), addr); + } +} diff --git a/packages/graph-core/test/unit/upgrades/GraphProxy.t.sol b/packages/graph-core/test/unit/upgrades/GraphProxy.t.sol new file mode 100644 index 000000000..cd4a16a79 --- /dev/null +++ b/packages/graph-core/test/unit/upgrades/GraphProxy.t.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IGraphProxy } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxy.sol"; +import { IManagedMock } from "../interfaces/IManagedMock.sol"; +import { GraphCoreTest } from "../shared/GraphCoreTest.t.sol"; + +contract GraphProxyTest is GraphCoreTest { + IGraphProxy internal proxy; + + function setUp() public override { + super.setUp(); + proxy = IGraphProxy(address(managed)); + } + + function test_GraphProxy_RevertWhen_AcceptUpgradeAndCallFails() public { + vm.prank(governor); + proxyAdmin.upgrade(proxy, implB); + + // The delegatecall runs with msg.sender == implB, which is not the governor + vm.expectRevert(bytes("Impl call failed")); + vm.prank(implB); + proxy.acceptUpgradeAndCall(abi.encodeCall(IManagedMock.checkOnlyGovernor, ())); + } + + function test_GraphProxy_RevertWhen_AdminCallsFallback() public { + vm.expectRevert(bytes("Cannot fallback to proxy target")); + vm.prank(address(proxyAdmin)); + managed.checkNotPaused(); + } + + function test_GraphProxy_RevertWhen_SetAdminZero() public { + vm.expectRevert(bytes("Admin cant be the zero address")); + vm.prank(address(proxyAdmin)); + proxy.setAdmin(address(0)); + } + + function test_GraphProxy_RevertWhen_AcceptUpgradeWithoutPendingImplementation() public { + vm.expectRevert(bytes("Impl cannot be zero address")); + vm.prank(address(proxyAdmin)); + proxy.acceptUpgrade(); + } + + function test_GraphProxy_RevertWhen_AcceptUpgradeNotPendingImplementation() public { + vm.prank(governor); + proxyAdmin.upgrade(proxy, implB); + + vm.expectRevert(bytes("Only pending implementation")); + vm.prank(address(proxyAdmin)); + proxy.acceptUpgrade(); + } +} diff --git a/packages/graph-core/test/unit/upgrades/GraphProxyAdmin.t.sol b/packages/graph-core/test/unit/upgrades/GraphProxyAdmin.t.sol new file mode 100644 index 000000000..2cdfb4c9f --- /dev/null +++ b/packages/graph-core/test/unit/upgrades/GraphProxyAdmin.t.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IGraphProxy } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxy.sol"; +import { IGraphProxyAdmin } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxyAdmin.sol"; +import { IManagedMock } from "../interfaces/IManagedMock.sol"; +import { GraphCoreTest } from "../shared/GraphCoreTest.t.sol"; + +contract GraphProxyAdminTest is GraphCoreTest { + event PendingImplementationUpdated( + address indexed oldPendingImplementation, + address indexed newPendingImplementation + ); + event ImplementationUpdated(address indexed oldImplementation, address indexed newImplementation); + + address internal me = makeAddr("me"); + IGraphProxy internal proxy; + + function setUp() public override { + super.setUp(); + proxy = IGraphProxy(address(managed)); + } + + function _deployOtherProxyAdmin() internal returns (IGraphProxyAdmin) { + vm.prank(governor); + return + IGraphProxyAdmin( + _deployArtifact(string.concat(CORE_ARTIFACTS, "upgrades/GraphProxyAdmin.sol/GraphProxyAdmin.json"), "") + ); + } + + // -- admin() -- + + function test_Admin_GetsProxyAdmin() public view { + assertEq(proxyAdmin.getProxyAdmin(proxy), address(proxyAdmin)); + } + + function test_Admin_RevertWhen_NotProxyAdmin() public { + vm.expectRevert(); + vm.prank(governor); + proxy.admin(); + } + + // -- implementation() -- + + function test_Implementation_GetsFromProxyAdmin() public view { + assertEq(proxyAdmin.getProxyImplementation(proxy), implA); + } + + function test_Implementation_RevertWhen_NotProxyAdmin() public { + vm.expectRevert(); + vm.prank(governor); + proxy.implementation(); + } + + // -- pendingImplementation() -- + + function test_PendingImplementation_GetsFromProxyAdmin() public view { + assertEq(proxyAdmin.getProxyPendingImplementation(proxy), address(0)); + } + + function test_PendingImplementation_RevertWhen_NotProxyAdmin() public { + vm.expectRevert(); + vm.prank(governor); + proxy.pendingImplementation(); + } + + // -- upgrade -- + + function test_Upgrade_UpgradesContract() public { + vm.expectEmit(address(proxy)); + emit PendingImplementationUpdated(address(0), implB); + vm.prank(governor); + proxyAdmin.upgrade(proxy, implB); + + vm.expectEmit(address(proxy)); + emit ImplementationUpdated(implA, implB); + vm.prank(governor); + proxyAdmin.acceptProxy(implB, proxy); + + assertEq(proxyAdmin.getProxyImplementation(proxy), implB); + } + + function test_Upgrade_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Governor can call")); + vm.prank(me); + proxyAdmin.upgrade(proxy, implB); + } + + function test_Upgrade_RevertWhen_NotProxyAdmin() public { + vm.expectRevert(); + vm.prank(governor); + proxy.upgradeTo(implB); + } + + // -- acceptUpgrade -- + + function test_AcceptUpgrade_RevertWhen_NotProxyAdmin() public { + vm.expectRevert(); + vm.prank(governor); + proxy.acceptUpgrade(); + } + + // -- acceptProxy -- + + function test_AcceptProxy_RevertWhen_NotProxyAdmin() public { + vm.prank(governor); + proxyAdmin.upgrade(proxy, implB); + + vm.expectRevert(bytes("Caller must be the proxy admin")); + vm.prank(governor); + IManagedMock(implB).acceptProxy(proxy); + } + + // -- changeProxyAdmin -- + + function test_ChangeProxyAdmin_SetsProxyAdmin() public { + IGraphProxyAdmin otherProxyAdmin = _deployOtherProxyAdmin(); + + vm.prank(governor); + proxyAdmin.changeProxyAdmin(proxy, address(otherProxyAdmin)); + assertEq(otherProxyAdmin.getProxyAdmin(proxy), address(otherProxyAdmin)); + + vm.expectRevert(); + vm.prank(governor); + proxyAdmin.changeProxyAdmin(proxy, address(otherProxyAdmin)); + } + + function test_ChangeProxyAdmin_RevertWhen_NotGovernor() public { + IGraphProxyAdmin otherProxyAdmin = _deployOtherProxyAdmin(); + + vm.expectRevert(bytes("Only Governor can call")); + vm.prank(me); + proxyAdmin.changeProxyAdmin(proxy, address(otherProxyAdmin)); + } + + function test_ChangeProxyAdmin_RevertWhen_NotProxyAdmin() public { + vm.expectRevert(); + vm.prank(governor); + proxy.setAdmin(me); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7dab5ef99..c5bf6f5c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -711,6 +711,18 @@ importers: specifier: ^5.5.0 version: 5.9.3 + packages/graph-core: + devDependencies: + '@graphprotocol/contracts': + specifier: workspace:^ + version: link:../contracts + '@graphprotocol/interfaces': + specifier: workspace:^ + version: link:../interfaces + forge-std: + specifier: 'catalog:' + version: https://github.com/foundry-rs/forge-std/tarball/v1.14.0 + packages/hardhat-graph-protocol: dependencies: '@graphprotocol/toolshed': diff --git a/scripts/contracts-migration/test-mapping/t1.md b/scripts/contracts-migration/test-mapping/t1.md new file mode 100644 index 000000000..02e0d750c --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t1.md @@ -0,0 +1,46 @@ +# T1 test mapping: governance and proxy tests + +New tests are in `packages/graph-core`. The `ManagedMock` proxy stands in for the EpochManager, Staking and Curation contracts the old tests used as "some Managed / GraphUpgradeable contract"; `implB` stands in for the Curation implementation. + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| packages/contracts-test/tests/unit/governance/governed.test.ts | Governed › should reject transfer if not allowed | test/unit/governance/Governed.t.sol::test_Governed_RevertWhen_TransferNotGovernor | | +| packages/contracts-test/tests/unit/governance/governed.test.ts | Governed › should transfer and accept | test/unit/governance/Governed.t.sol::test_Governed_TransferAndAccept | | +| packages/contracts-test/tests/unit/governance/controller.test.ts | Managed › setContractProxy() › should set contract proxy and test get contract proxy | test/unit/governance/Controller.t.sol::test_SetContractProxy_SetsAndGets | ManagedMock proxy registered as EpochManager | +| packages/contracts-test/tests/unit/governance/controller.test.ts | Managed › setContractProxy() › reject set contract proxy to address zero | test/unit/governance/Controller.t.sol::test_SetContractProxy_RevertWhen_AddressZero | | +| packages/contracts-test/tests/unit/governance/controller.test.ts | Managed › setContractProxy() › reject set contract proxy if not governor | test/unit/governance/Controller.t.sol::test_SetContractProxy_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/governance/controller.test.ts | Managed › unsetContractProxy() › should unset contract proxy | test/unit/governance/Controller.t.sol::test_UnsetContractProxy_Unsets | | +| packages/contracts-test/tests/unit/governance/controller.test.ts | Managed › unsetContractProxy() › reject to call if not authorized caller | test/unit/governance/Controller.t.sol::test_UnsetContractProxy_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/governance/controller.test.ts | Managed › updateController() › should update controller on a manager | test/unit/governance/Controller.t.sol::test_UpdateController_UpdatesManaged | | +| packages/contracts-test/tests/unit/governance/controller.test.ts | Managed › updateController() › should fail updating controller when not called by governor | test/unit/governance/Controller.t.sol::test_UpdateController_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/governance/controller.test.ts | Managed › updateController() › reject update controller to address zero | test/unit/governance/Controller.t.sol::test_UpdateController_RevertWhen_ControllerZero | | +| packages/contracts-test/tests/unit/governance/controller.test.ts | Managed › setController() › should fail setting controller when not called from Controller | test/unit/governance/Controller.t.sol::test_SetController_RevertWhen_NotController | | +| packages/contracts-test/tests/unit/governance/controller.test.ts | Managed › setPauseGuardian() › should set the pause guardian | test/unit/governance/Controller.t.sol::test_SetPauseGuardian_Sets | | +| packages/contracts-test/tests/unit/governance/controller.test.ts | Managed › setPauseGuardian() › reject to call if not authorized caller | test/unit/governance/Controller.t.sol::test_SetPauseGuardian_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/governance/controller.test.ts | Managed › setPauseGuardian() › reject set pause guardian to address zero | test/unit/governance/Controller.t.sol::test_SetPauseGuardian_RevertWhen_GuardianZero | | +| packages/contracts-test/tests/unit/governance/pausing.test.ts | Pausing › should set pause guardian | test/unit/governance/Pausing.t.sol::test_Pausing_SetPauseGuardian | | +| packages/contracts-test/tests/unit/governance/pausing.test.ts | Pausing › should fail pause guardian when not governor | test/unit/governance/Pausing.t.sol::test_Pausing_RevertWhen_SetPauseGuardianNotGovernor | | +| packages/contracts-test/tests/unit/governance/pausing.test.ts | Pausing › should set partialPaused and unset from governor and guardian | test/unit/governance/Pausing.t.sol::test_Pausing_SetAndUnsetPartialPaused | | +| packages/contracts-test/tests/unit/governance/pausing.test.ts | Pausing › should fail partial pause if not guardian or governor | test/unit/governance/Pausing.t.sol::test_Pausing_RevertWhen_PartialPauseNotGovernorOrGuardian | | +| packages/contracts-test/tests/unit/governance/pausing.test.ts | Pausing › should check that a function fails when partialPause is set | test/unit/governance/Pausing.t.sol::test_Pausing_RevertWhen_PartialPaused | `ManagedMock.checkNotPartialPaused` replaces `Staking.stake` | +| packages/contracts-test/tests/unit/governance/pausing.test.ts | Pausing › should set pause and unset from governor and guardian | test/unit/governance/Pausing.t.sol::test_Pausing_SetAndUnsetPaused | | +| packages/contracts-test/tests/unit/governance/pausing.test.ts | Pausing › should fail pause if not guardian or governor | test/unit/governance/Pausing.t.sol::test_Pausing_RevertWhen_PauseNotGovernorOrGuardian | | +| packages/contracts-test/tests/unit/governance/pausing.test.ts | Pausing › should check that a function fails when pause is set | test/unit/governance/Pausing.t.sol::test_Pausing_RevertWhen_Paused | `checkNotPartialPaused` / `checkNotPaused` replace `Staking.stake` / `withdraw` | +| packages/contracts-test/tests/unit/upgrade/admin.test.ts | Upgrades › GraphProxyAdmin & GraphProxy › getters › admin() › should get the proxy admin of a proxy contract | test/unit/upgrades/GraphProxyAdmin.t.sol::test_Admin_GetsProxyAdmin | | +| packages/contracts-test/tests/unit/upgrade/admin.test.ts | Upgrades › GraphProxyAdmin & GraphProxy › getters › admin() › reject get admin from other than the ProxyAdmin | test/unit/upgrades/GraphProxyAdmin.t.sol::test_Admin_RevertWhen_NotProxyAdmin | | +| packages/contracts-test/tests/unit/upgrade/admin.test.ts | Upgrades › GraphProxyAdmin & GraphProxy › getters › implementation() › should get implementation only from ProxyAdmin | test/unit/upgrades/GraphProxyAdmin.t.sol::test_Implementation_GetsFromProxyAdmin | asserts `== implA` instead of `!= 0` | +| packages/contracts-test/tests/unit/upgrade/admin.test.ts | Upgrades › GraphProxyAdmin & GraphProxy › getters › implementation() › reject get implementation from other than the ProxyAdmin | test/unit/upgrades/GraphProxyAdmin.t.sol::test_Implementation_RevertWhen_NotProxyAdmin | | +| packages/contracts-test/tests/unit/upgrade/admin.test.ts | Upgrades › GraphProxyAdmin & GraphProxy › getters › pendingImplementation() › should get pending implementation only from ProxyAdmin | test/unit/upgrades/GraphProxyAdmin.t.sol::test_PendingImplementation_GetsFromProxyAdmin | | +| packages/contracts-test/tests/unit/upgrade/admin.test.ts | Upgrades › GraphProxyAdmin & GraphProxy › getters › pendingImplementation() › reject get pending implementation from other than the ProxyAdmin | test/unit/upgrades/GraphProxyAdmin.t.sol::test_PendingImplementation_RevertWhen_NotProxyAdmin | | +| packages/contracts-test/tests/unit/upgrade/admin.test.ts | Upgrades › GraphProxyAdmin & GraphProxy › upgrade › should be able to upgrade contract | test/unit/upgrades/GraphProxyAdmin.t.sol::test_Upgrade_UpgradesContract | final check reads the upgraded proxy; the old test read the Curation proxy, whose implementation it had just looked up | +| packages/contracts-test/tests/unit/upgrade/admin.test.ts | Upgrades › GraphProxyAdmin & GraphProxy › upgrade › reject upgrade if not the governor of the ProxyAdmin | test/unit/upgrades/GraphProxyAdmin.t.sol::test_Upgrade_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/upgrade/admin.test.ts | Upgrades › GraphProxyAdmin & GraphProxy › upgrade › reject upgrade if not using the ProxyAdmin | test/unit/upgrades/GraphProxyAdmin.t.sol::test_Upgrade_RevertWhen_NotProxyAdmin | | +| packages/contracts-test/tests/unit/upgrade/admin.test.ts | Upgrades › GraphProxyAdmin & GraphProxy › acceptUpgrade › reject accept upgrade if not using the ProxyAdmin | test/unit/upgrades/GraphProxyAdmin.t.sol::test_AcceptUpgrade_RevertWhen_NotProxyAdmin | | +| packages/contracts-test/tests/unit/upgrade/admin.test.ts | Upgrades › GraphProxyAdmin & GraphProxy › acceptProxy › reject accept proxy if not using the ProxyAdmin | test/unit/upgrades/GraphProxyAdmin.t.sol::test_AcceptProxy_RevertWhen_NotProxyAdmin | | +| packages/contracts-test/tests/unit/upgrade/admin.test.ts | Upgrades › GraphProxyAdmin & GraphProxy › changeProxyAdmin › should set the proxy admin of a proxy | test/unit/upgrades/GraphProxyAdmin.t.sol::test_ChangeProxyAdmin_SetsProxyAdmin | | +| packages/contracts-test/tests/unit/upgrade/admin.test.ts | Upgrades › GraphProxyAdmin & GraphProxy › changeProxyAdmin › reject change admin if not the governor of the ProxyAdmin | test/unit/upgrades/GraphProxyAdmin.t.sol::test_ChangeProxyAdmin_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/upgrade/admin.test.ts | Upgrades › GraphProxyAdmin & GraphProxy › changeProxyAdmin › reject change admin if not using the ProxyAdmin | test/unit/upgrades/GraphProxyAdmin.t.sol::test_ChangeProxyAdmin_RevertWhen_NotProxyAdmin | | + +## Coverage + +- `constructor` (Controller, GraphProxy, GraphProxyAdmin): not a callable function; every test runs it by deploying the artifact (`_deployProtocolBase`, `_deployBehindProxy`). From 889cc5e9b51657c40bd77914d3d2eea0f18e1d24 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 26 Oct 2026 09:55:05 -0300 Subject: [PATCH 08/20] test: add TokenUtilsMock for the graph-token Foundry tests --- .../contracts/tests/TokenUtilsMock.sol | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 packages/contracts/contracts/tests/TokenUtilsMock.sol diff --git a/packages/contracts/contracts/tests/TokenUtilsMock.sol b/packages/contracts/contracts/tests/TokenUtilsMock.sol new file mode 100644 index 000000000..eeb9f20af --- /dev/null +++ b/packages/contracts/contracts/tests/TokenUtilsMock.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6; + +// solhint-disable use-natspec + +import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol"; +import { TokenUtils } from "../utils/TokenUtils.sol"; + +contract TokenUtilsMock { + function pullTokens(IGraphToken token, address from, uint256 amount) external { + TokenUtils.pullTokens(token, from, amount); + } + + function pushTokens(IGraphToken token, address to, uint256 amount) external { + TokenUtils.pushTokens(token, to, amount); + } + + function burnTokens(IGraphToken token, uint256 amount) external { + TokenUtils.burnTokens(token, amount); + } +} From 1b79322fcdb1dfcaf81efb34c4cb40bd5c5cd1bb Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 26 Oct 2026 09:55:06 -0300 Subject: [PATCH 09/20] test(graph-token): port token, escrow and token utils tests to Foundry --- .../tests/unit/gateway/bridgeEscrow.test.ts | 77 ------ .../tests/unit/graphToken.test.ts | 5 - .../tests/unit/l2/l2GraphToken.test.ts | 110 -------- .../tests/unit/lib/graphTokenTests.ts | 238 ----------------- packages/graph-token/foundry.toml | 22 ++ packages/graph-token/package.json | 19 ++ .../test/unit/gateway/BridgeEscrow.t.sol | 80 ++++++ .../unit/interfaces/IBridgeEscrowLike.sol | 10 + .../test/unit/interfaces/IGraphTokenLike.sol | 53 ++++ .../unit/interfaces/IL2GraphTokenLike.sol | 25 ++ .../test/unit/interfaces/ITokenUtilsMock.sol | 12 + .../test/unit/shared/ProtocolFixture.t.sol | 66 +++++ .../test/unit/token/GraphToken.t.sol | 27 ++ .../test/unit/token/GraphTokenShared.t.sol | 252 ++++++++++++++++++ .../test/unit/token/L2GraphToken.t.sol | 149 +++++++++++ .../test/unit/utils/TokenUtils.t.sol | 75 ++++++ pnpm-lock.yaml | 12 + .../contracts-migration/test-mapping/t2a.md | 42 +++ 18 files changed, 844 insertions(+), 430 deletions(-) delete mode 100644 packages/contracts-test/tests/unit/gateway/bridgeEscrow.test.ts delete mode 100644 packages/contracts-test/tests/unit/graphToken.test.ts delete mode 100644 packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts delete mode 100644 packages/contracts-test/tests/unit/lib/graphTokenTests.ts create mode 100644 packages/graph-token/foundry.toml create mode 100644 packages/graph-token/package.json create mode 100644 packages/graph-token/test/unit/gateway/BridgeEscrow.t.sol create mode 100644 packages/graph-token/test/unit/interfaces/IBridgeEscrowLike.sol create mode 100644 packages/graph-token/test/unit/interfaces/IGraphTokenLike.sol create mode 100644 packages/graph-token/test/unit/interfaces/IL2GraphTokenLike.sol create mode 100644 packages/graph-token/test/unit/interfaces/ITokenUtilsMock.sol create mode 100644 packages/graph-token/test/unit/shared/ProtocolFixture.t.sol create mode 100644 packages/graph-token/test/unit/token/GraphToken.t.sol create mode 100644 packages/graph-token/test/unit/token/GraphTokenShared.t.sol create mode 100644 packages/graph-token/test/unit/token/L2GraphToken.t.sol create mode 100644 packages/graph-token/test/unit/utils/TokenUtils.t.sol create mode 100644 scripts/contracts-migration/test-mapping/t2a.md diff --git a/packages/contracts-test/tests/unit/gateway/bridgeEscrow.test.ts b/packages/contracts-test/tests/unit/gateway/bridgeEscrow.test.ts deleted file mode 100644 index abeb21e39..000000000 --- a/packages/contracts-test/tests/unit/gateway/bridgeEscrow.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { GraphToken } from '@graphprotocol/contracts' -import { BridgeEscrow } from '@graphprotocol/contracts' -import { GraphNetworkContracts, toGRT } from '@graphprotocol/sdk' -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -describe('BridgeEscrow', () => { - const graph = hre.graph() - let governor: SignerWithAddress - let tokenReceiver: SignerWithAddress - let spender: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let grt: GraphToken - let bridgeEscrow: BridgeEscrow - - const nTokens = toGRT('1000') - - before(async function () { - ;[tokenReceiver, spender] = await graph.getTestAccounts() - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - grt = contracts.GraphToken as GraphToken - bridgeEscrow = contracts.BridgeEscrow as BridgeEscrow - - // Give some funds to the Escrow - await grt.connect(governor).mint(bridgeEscrow.address, nTokens) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('approveAll', function () { - it('cannot be called by someone other than the governor', async function () { - const tx = bridgeEscrow.connect(tokenReceiver).approveAll(spender.address) - await expect(tx).revertedWith('Only Controller governor') - }) - it('allows a spender to transfer GRT held by the contract', async function () { - expect(await grt.allowance(bridgeEscrow.address, spender.address)).eq(0) - const tx = grt.connect(spender).transferFrom(bridgeEscrow.address, tokenReceiver.address, nTokens) - await expect(tx).revertedWith('ERC20: transfer amount exceeds allowance') - await bridgeEscrow.connect(governor).approveAll(spender.address) - await expect(grt.connect(spender).transferFrom(bridgeEscrow.address, tokenReceiver.address, nTokens)).to.emit( - grt, - 'Transfer', - ) - expect(await grt.balanceOf(tokenReceiver.address)).to.eq(nTokens) - }) - }) - - describe('revokeAll', function () { - it('cannot be called by someone other than the governor', async function () { - const tx = bridgeEscrow.connect(tokenReceiver).revokeAll(spender.address) - await expect(tx).revertedWith('Only Controller governor') - }) - it("revokes a spender's permission to transfer GRT held by the contract", async function () { - await bridgeEscrow.connect(governor).approveAll(spender.address) - await bridgeEscrow.connect(governor).revokeAll(spender.address) - // We shouldn't be able to transfer _anything_ - const tx = grt.connect(spender).transferFrom(bridgeEscrow.address, tokenReceiver.address, BigNumber.from('1')) - await expect(tx).revertedWith('ERC20: transfer amount exceeds allowance') - }) - }) -}) diff --git a/packages/contracts-test/tests/unit/graphToken.test.ts b/packages/contracts-test/tests/unit/graphToken.test.ts deleted file mode 100644 index b42274ac4..000000000 --- a/packages/contracts-test/tests/unit/graphToken.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { grtTests } from './lib/graphTokenTests' - -describe('GraphToken', () => { - grtTests.bind(this)(false) -}) diff --git a/packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts b/packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts deleted file mode 100644 index 5b45c7cd8..000000000 --- a/packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { L2GraphToken } from '@graphprotocol/contracts' -import { GraphNetworkContracts, toGRT } from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' -import { grtTests } from '../lib/graphTokenTests' - -describe('L2GraphToken', () => { - describe('Base GRT behavior', () => { - grtTests.bind(this)(true) - }) - describe('Extended L2 behavior', () => { - const graph = hre.graph() - let mockL2Gateway: SignerWithAddress - let mockL1GRT: SignerWithAddress - let governor: SignerWithAddress - let user: SignerWithAddress - - let contracts: GraphNetworkContracts - let fixture: NetworkFixture - let grt: L2GraphToken - - before(async function () { - ;[mockL1GRT, mockL2Gateway, user] = await graph.getTestAccounts() - ;({ governor } = await graph.getNamedAccounts()) - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor, true) - grt = contracts.L2GraphToken as L2GraphToken - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('setGateway', function () { - it('cannot be called by someone who is not the governor', async function () { - const tx = grt.connect(mockL2Gateway).setGateway(mockL2Gateway.address) - await expect(tx).revertedWith('Only Governor can call') - }) - it('sets the L2 Gateway address when called by the governor', async function () { - const tx = grt.connect(governor).setGateway(mockL2Gateway.address) - await expect(tx).emit(grt, 'GatewaySet').withArgs(mockL2Gateway.address) - expect(await grt.gateway()).eq(mockL2Gateway.address) - }) - }) - describe('setL1Address', function () { - it('cannot be called by someone who is not the governor', async function () { - const tx = grt.connect(mockL2Gateway).setL1Address(mockL1GRT.address) - await expect(tx).revertedWith('Only Governor can call') - }) - it('sets the L1 GRT address when called by the governor', async function () { - const tx = grt.connect(governor).setL1Address(mockL1GRT.address) - await expect(tx).emit(grt, 'L1AddressSet').withArgs(mockL1GRT.address) - expect(await grt.l1Address()).eq(mockL1GRT.address) - }) - }) - describe('bridge minting and burning', function () { - beforeEach(async function () { - // Configure the l1Address and gateway - await grt.connect(governor).setL1Address(mockL1GRT.address) - await grt.connect(governor).setGateway(mockL2Gateway.address) - }) - describe('bridgeMint', function () { - it('cannot be called by someone who is not the gateway', async function () { - const tx = grt.connect(governor).bridgeMint(user.address, toGRT('100')) - await expect(tx).revertedWith('NOT_GATEWAY') - }) - it('mints GRT into a destination account', async function () { - const tx = grt.connect(mockL2Gateway).bridgeMint(user.address, toGRT('100')) - await expect(tx).emit(grt, 'BridgeMinted').withArgs(user.address, toGRT('100')) - expect(await grt.balanceOf(user.address)).eq(toGRT('100')) - }) - }) - describe('bridgeBurn', function () { - it('cannot be called by someone who is not the gateway', async function () { - const tx = grt.connect(governor).bridgeBurn(user.address, toGRT('100')) - await expect(tx).revertedWith('NOT_GATEWAY') - }) - it('requires approval for burning', async function () { - await grt.connect(mockL2Gateway).bridgeMint(user.address, toGRT('100')) - const tx = grt.connect(mockL2Gateway).bridgeBurn(user.address, toGRT('20')) - await expect(tx).revertedWith('ERC20: burn amount exceeds allowance') - }) - it('fails if the user does not have enough funds', async function () { - await grt.connect(mockL2Gateway).bridgeMint(user.address, toGRT('10')) - await grt.connect(user).approve(mockL2Gateway.address, toGRT('20')) - const tx = grt.connect(mockL2Gateway).bridgeBurn(user.address, toGRT('20')) - await expect(tx).revertedWith('ERC20: burn amount exceeds balance') - }) - it('burns GRT from an account when approved', async function () { - await grt.connect(mockL2Gateway).bridgeMint(user.address, toGRT('100')) - await grt.connect(user).approve(mockL2Gateway.address, toGRT('20')) - const tx = grt.connect(mockL2Gateway).bridgeBurn(user.address, toGRT('20')) - await expect(tx).emit(grt, 'BridgeBurned').withArgs(user.address, toGRT('20')) - expect(await grt.balanceOf(user.address)).eq(toGRT('80')) - }) - }) - it('does not allow the bridge to mint as a regular minter', async function () { - const tx = grt.connect(mockL2Gateway).mint(user.address, toGRT('100')) - await expect(tx).revertedWith('Only minter can call') - }) - }) - }) -}) diff --git a/packages/contracts-test/tests/unit/lib/graphTokenTests.ts b/packages/contracts-test/tests/unit/lib/graphTokenTests.ts deleted file mode 100644 index dadab32f4..000000000 --- a/packages/contracts-test/tests/unit/lib/graphTokenTests.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { L2GraphToken } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { GraphNetworkContracts, Permit, signPermit, toBN, toGRT } from '@graphprotocol/sdk' -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber, constants, ethers, Signature, Wallet } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from './fixtures' - -const { AddressZero, MaxUint256 } = constants - -const L1SALT = '0x51f3d585afe6dfeb2af01bba0889a36c1db03beec88c6a4d0c53817069026afa' -const L2SALT = '0xe33842a7acd1d5a1d28f25a931703e5605152dc48d64dc4716efdae1f5659591' - -export function grtTests(isL2: boolean): void { - let me: Wallet - let other: Wallet - let governor: SignerWithAddress - let salt: string - let fixture: NetworkFixture - let fixtureContracts: GraphNetworkContracts - - const graph = hre.graph() - - const mePrivateKey = '0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d' - const otherPrivateKey = '0x6cbed15c793ce57650b9877cf6fa156fbef513c4e6134f022a85b1ffdd59b2a1' - - let grt: GraphToken | L2GraphToken - - async function permitMaxOK(): Promise { - return permitOK(MaxUint256) - } - - async function permitOK(value: BigNumber): Promise { - const nonce = await grt.nonces(me.address) - return { - owner: me.address, - spender: other.address, - value: value, - nonce: nonce, - deadline: toBN('0'), - } - } - - async function permitExpired(): Promise { - const permit = await permitMaxOK() - permit.deadline = toBN('1') - return permit - } - - async function permitDeny(): Promise { - const permit = await permitMaxOK() - permit.value = toBN('0') - return permit - } - - function createPermitTransaction(permit: Permit, signer: string, salt: string) { - const signature: Signature = signPermit(signer, graph.chainId, grt.address, permit, salt) - const wallet = new ethers.Wallet(signer, graph.provider) - return grt - .connect(wallet) - .permit(permit.owner, permit.spender, permit.value, permit.deadline, signature.v, signature.r, signature.s) - } - - before(async function () { - ;({ governor } = await graph.getNamedAccounts()) - me = new ethers.Wallet(mePrivateKey, graph.provider) - other = new ethers.Wallet(otherPrivateKey, graph.provider) - - fixture = new NetworkFixture(graph.provider) - fixtureContracts = await fixture.load(governor, isL2) - grt = fixtureContracts.GraphToken as GraphToken - salt = isL2 ? L2SALT : L1SALT - - // Mint some tokens - const tokens = toGRT('10000') - await grt.connect(governor).mint(me.address, tokens) - }) - - describe('permit', function () { - it('should permit max token allowance', async function () { - // Allow to transfer tokens - const tokensToApprove = toGRT('1000') - const permit = await permitOK(tokensToApprove) - const tx = createPermitTransaction(permit, mePrivateKey, salt) - await expect(tx).emit(grt, 'Approval').withArgs(permit.owner, permit.spender, tokensToApprove) - - // Allowance updated - const allowance = await grt.allowance(me.address, other.address) - expect(allowance).eq(tokensToApprove) - - // Transfer tokens should work - const tokens = toGRT('100') - await grt.connect(other).transferFrom(me.address, other.address, tokens) - }) - - it('should permit max token allowance', async function () { - // Allow to transfer tokens - const permit = await permitMaxOK() - const tx = createPermitTransaction(permit, mePrivateKey, salt) - await expect(tx).emit(grt, 'Approval').withArgs(permit.owner, permit.spender, MaxUint256) - - // Allowance updated - const allowance = await grt.allowance(me.address, other.address) - expect(allowance).eq(MaxUint256) - - // Transfer tokens should work - const tokens = toGRT('100') - await grt.connect(other).transferFrom(me.address, other.address, tokens) - }) - - it('reject to transfer more tokens than approved by permit', async function () { - // Allow to transfer tokens - const tokensToApprove = toGRT('1000') - const permit = await permitOK(tokensToApprove) - await createPermitTransaction(permit, mePrivateKey, salt) - - // Should not transfer more than approved - const tooManyTokens = toGRT('1001') - const tx = grt.connect(other).transferFrom(me.address, other.address, tooManyTokens) - await expect(tx).revertedWith('ERC20: transfer amount exceeds allowance') - - // Should transfer up to the approved amount - await grt.connect(other).transferFrom(me.address, other.address, tokensToApprove) - }) - - it('reject use two permits with same nonce', async function () { - // Allow to transfer tokens - const permit = await permitMaxOK() - await createPermitTransaction(permit, mePrivateKey, salt) - - // Try to re-use the permit - const tx = createPermitTransaction(permit, mePrivateKey, salt) - await expect(tx).revertedWith('GRT: invalid permit') - }) - - it('reject use expired permit', async function () { - const permit = await permitExpired() - const tx = createPermitTransaction(permit, mePrivateKey, salt) - await expect(tx).revertedWith('GRT: expired permit') - }) - - it('reject permit if holder address does not match', async function () { - const permit = await permitMaxOK() - const tx = createPermitTransaction(permit, otherPrivateKey, salt) - await expect(tx).revertedWith('GRT: invalid permit') - }) - - it('should deny transfer from if permit was denied', async function () { - // Allow to transfer tokens - const permit1 = await permitMaxOK() - await createPermitTransaction(permit1, mePrivateKey, salt) - - // Deny transfer tokens - const permit2 = await permitDeny() - await createPermitTransaction(permit2, mePrivateKey, salt) - - // Allowance updated - const allowance = await grt.allowance(me.address, other.address) - expect(allowance).eq(toBN('0')) - - // Try to transfer without permit should fail - const tokens = toGRT('100') - const tx = grt.connect(other).transferFrom(me.address, other.address, tokens) - await expect(tx).revertedWith('ERC20: transfer amount exceeds allowance') - }) - }) - - describe('mint', function () { - describe('mint', function () { - it('reject mint if not minter', async function () { - const tx = grt.connect(me).mint(me.address, toGRT('100')) - await expect(tx).revertedWith('Only minter can call') - }) - }) - - describe('addMinter', function () { - it('reject add a new minter if not allowed', async function () { - const tx = grt.connect(me).addMinter(me.address) - await expect(tx).revertedWith('Only Governor can call') - }) - - it('should add a new minter', async function () { - expect(await grt.isMinter(me.address)).eq(false) - const tx = grt.connect(governor).addMinter(me.address) - await expect(tx).emit(grt, 'MinterAdded').withArgs(me.address) - expect(await grt.isMinter(me.address)).eq(true) - }) - }) - - context('> when is minter', function () { - beforeEach(async function () { - await grt.connect(governor).addMinter(me.address) - expect(await grt.isMinter(me.address)).eq(true) - }) - - describe('mint', function () { - it('should mint', async function () { - const beforeTokens = await grt.balanceOf(me.address) - - const tokensToMint = toGRT('100') - const tx = grt.connect(me).mint(me.address, tokensToMint) - await expect(tx).emit(grt, 'Transfer').withArgs(AddressZero, me.address, tokensToMint) - - const afterTokens = await grt.balanceOf(me.address) - expect(afterTokens).eq(beforeTokens.add(tokensToMint)) - }) - - it('should mint if governor', async function () { - const tokensToMint = toGRT('100') - await grt.connect(governor).mint(me.address, tokensToMint) - }) - }) - - describe('removeMinter', function () { - it('reject remove a minter if not allowed', async function () { - const tx = grt.connect(me).removeMinter(me.address) - await expect(tx).revertedWith('Only Governor can call') - }) - - it('should remove a minter', async function () { - const tx = grt.connect(governor).removeMinter(me.address) - await expect(tx).emit(grt, 'MinterRemoved').withArgs(me.address) - expect(await grt.isMinter(me.address)).eq(false) - }) - }) - - describe('renounceMinter', function () { - it('should renounce to be a minter', async function () { - const tx = grt.connect(me).renounceMinter() - await expect(tx).emit(grt, 'MinterRemoved').withArgs(me.address) - expect(await grt.isMinter(me.address)).eq(false) - }) - }) - }) - }) -} diff --git a/packages/graph-token/foundry.toml b/packages/graph-token/foundry.toml new file mode 100644 index 000000000..35b9b51af --- /dev/null +++ b/packages/graph-token/foundry.toml @@ -0,0 +1,22 @@ +[profile.default] +src = 'test' +out = 'forge-artifacts' +test = 'test' +libs = ["node_modules"] +cache_path = 'cache_forge' +auto_detect_remappings = false +remappings = [ + "@graphprotocol/=node_modules/@graphprotocol/", + "forge-std/=node_modules/forge-std/src/", +] +solc_version = '0.8.35' +evm_version = 'cancun' +fs_permissions = [{ access = "read", path = "node_modules/@graphprotocol/contracts/artifacts" }] +ignored_warnings_from = ["node_modules"] + +[lint] +# Disable lint-on-build: forge's lint walker follows symlinks without cycle detection and +# infinite-loops on this package's pnpm-hoisted node_modules. +lint_on_build = false +exclude_lints = ["mixed-case-function", "mixed-case-variable", "block-timestamp"] +ignore = ["node_modules/**", "**/node_modules/**"] diff --git a/packages/graph-token/package.json b/packages/graph-token/package.json new file mode 100644 index 000000000..964a91c74 --- /dev/null +++ b/packages/graph-token/package.json @@ -0,0 +1,19 @@ +{ + "name": "@graphprotocol/graph-token", + "version": "0.0.0", + "private": true, + "description": "The Graph token and bridge escrow contracts", + "license": "GPL-2.0-or-later", + "scripts": { + "build": "pnpm build:dep", + "build:dep": "pnpm --filter '@graphprotocol/graph-token^...' run build:self", + "clean": "rm -rf forge-artifacts/ cache_forge/", + "test": "pnpm build && pnpm test:self", + "test:self": "forge test" + }, + "devDependencies": { + "@graphprotocol/contracts": "workspace:^", + "@graphprotocol/interfaces": "workspace:^", + "forge-std": "catalog:" + } +} diff --git a/packages/graph-token/test/unit/gateway/BridgeEscrow.t.sol b/packages/graph-token/test/unit/gateway/BridgeEscrow.t.sol new file mode 100644 index 000000000..6c6cd3607 --- /dev/null +++ b/packages/graph-token/test/unit/gateway/BridgeEscrow.t.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IManaged } from "@graphprotocol/interfaces/contracts/contracts/governance/IManaged.sol"; +import { ProtocolFixture } from "../shared/ProtocolFixture.t.sol"; +import { IGraphTokenLike } from "../interfaces/IGraphTokenLike.sol"; +import { IBridgeEscrowLike } from "../interfaces/IBridgeEscrowLike.sol"; + +contract BridgeEscrowTest is ProtocolFixture { + string internal constant GRAPH_TOKEN = string.concat(PKG_ARTIFACTS, "token/GraphToken.sol/GraphToken.json"); + string internal constant BRIDGE_ESCROW = string.concat(PKG_ARTIFACTS, "gateway/BridgeEscrow.sol/BridgeEscrow.json"); + + uint256 internal constant N_TOKENS = 1000 ether; + + IGraphTokenLike internal grt; + IBridgeEscrowLike internal bridgeEscrow; + address internal tokenReceiver = makeAddr("tokenReceiver"); + address internal spender = makeAddr("spender"); + + function setUp() public { + vm.warp(1_700_000_000); + _deployProtocolBase(); + + vm.prank(governor); + grt = IGraphTokenLike(_deployArtifact(GRAPH_TOKEN, abi.encode(10_000_000_000 ether))); + _registerContract("GraphToken", address(grt)); + + address impl = _deployArtifact(BRIDGE_ESCROW, ""); + bridgeEscrow = IBridgeEscrowLike( + _deployBehindProxy(impl, abi.encodeCall(IBridgeEscrowLike.initialize, (address(controller)))) + ); + + // Cache GraphToken in the escrow + IManaged(address(bridgeEscrow)).syncAllContracts(); + + vm.prank(governor); + grt.mint(address(bridgeEscrow), N_TOKENS); + } + + function test_ApproveAll_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(tokenReceiver); + bridgeEscrow.approveAll(spender); + } + + function test_ApproveAll_AllowsSpenderToTransfer() public { + assertEq(grt.allowance(address(bridgeEscrow), spender), 0); + + vm.expectRevert(bytes("ERC20: transfer amount exceeds allowance")); + vm.prank(spender); + grt.transferFrom(address(bridgeEscrow), tokenReceiver, N_TOKENS); + + vm.prank(governor); + bridgeEscrow.approveAll(spender); + + vm.expectEmit(address(grt)); + emit IGraphTokenLike.Transfer(address(bridgeEscrow), tokenReceiver, N_TOKENS); + vm.prank(spender); + grt.transferFrom(address(bridgeEscrow), tokenReceiver, N_TOKENS); + + assertEq(grt.balanceOf(tokenReceiver), N_TOKENS); + } + + function test_RevokeAll_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(tokenReceiver); + bridgeEscrow.revokeAll(spender); + } + + function test_RevokeAll_RevokesSpender() public { + vm.startPrank(governor); + bridgeEscrow.approveAll(spender); + bridgeEscrow.revokeAll(spender); + vm.stopPrank(); + + vm.expectRevert(bytes("ERC20: transfer amount exceeds allowance")); + vm.prank(spender); + grt.transferFrom(address(bridgeEscrow), tokenReceiver, 1); + } +} diff --git a/packages/graph-token/test/unit/interfaces/IBridgeEscrowLike.sol b/packages/graph-token/test/unit/interfaces/IBridgeEscrowLike.sol new file mode 100644 index 000000000..a7e9c6be9 --- /dev/null +++ b/packages/graph-token/test/unit/interfaces/IBridgeEscrowLike.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +interface IBridgeEscrowLike { + function initialize(address controller) external; + + function approveAll(address spender) external; + + function revokeAll(address spender) external; +} diff --git a/packages/graph-token/test/unit/interfaces/IGraphTokenLike.sol b/packages/graph-token/test/unit/interfaces/IGraphTokenLike.sol new file mode 100644 index 000000000..f6ffd171f --- /dev/null +++ b/packages/graph-token/test/unit/interfaces/IGraphTokenLike.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +interface IGraphTokenLike { + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + event MinterAdded(address indexed account); + event MinterRemoved(address indexed account); + + function totalSupply() external view returns (uint256); + + function balanceOf(address account) external view returns (uint256); + + function transfer(address to, uint256 amount) external returns (bool); + + function transferFrom(address from, address to, uint256 amount) external returns (bool); + + function approve(address spender, uint256 amount) external returns (bool); + + function allowance(address owner, address spender) external view returns (uint256); + + function burn(uint256 amount) external; + + function burnFrom(address account, uint256 amount) external; + + function mint(address to, uint256 amount) external; + + function addMinter(address account) external; + + function removeMinter(address account) external; + + function renounceMinter() external; + + function isMinter(address account) external view returns (bool); + + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external; + + function nonces(address owner) external view returns (uint256); + + function governor() external view returns (address); + + function transferOwnership(address newGovernor) external; + + function acceptOwnership() external; +} diff --git a/packages/graph-token/test/unit/interfaces/IL2GraphTokenLike.sol b/packages/graph-token/test/unit/interfaces/IL2GraphTokenLike.sol new file mode 100644 index 000000000..47ccb039c --- /dev/null +++ b/packages/graph-token/test/unit/interfaces/IL2GraphTokenLike.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IGraphTokenLike } from "./IGraphTokenLike.sol"; + +interface IL2GraphTokenLike is IGraphTokenLike { + event BridgeMinted(address indexed account, uint256 amount); + event BridgeBurned(address indexed account, uint256 amount); + event GatewaySet(address gateway); + event L1AddressSet(address l1Address); + + function initialize(address owner) external; + + function gateway() external view returns (address); + + function l1Address() external view returns (address); + + function setGateway(address gw) external; + + function setL1Address(address addr) external; + + function bridgeMint(address account, uint256 amount) external; + + function bridgeBurn(address account, uint256 amount) external; +} diff --git a/packages/graph-token/test/unit/interfaces/ITokenUtilsMock.sol b/packages/graph-token/test/unit/interfaces/ITokenUtilsMock.sol new file mode 100644 index 000000000..601a381f9 --- /dev/null +++ b/packages/graph-token/test/unit/interfaces/ITokenUtilsMock.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IGraphTokenLike } from "./IGraphTokenLike.sol"; + +interface ITokenUtilsMock { + function pullTokens(IGraphTokenLike token, address from, uint256 amount) external; + + function pushTokens(IGraphTokenLike token, address to, uint256 amount) external; + + function burnTokens(IGraphTokenLike token, uint256 amount) external; +} diff --git a/packages/graph-token/test/unit/shared/ProtocolFixture.t.sol b/packages/graph-token/test/unit/shared/ProtocolFixture.t.sol new file mode 100644 index 000000000..f38962c8e --- /dev/null +++ b/packages/graph-token/test/unit/shared/ProtocolFixture.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Test } from "forge-std/Test.sol"; +import { IController } from "@graphprotocol/interfaces/contracts/contracts/governance/IController.sol"; +import { IGraphProxy } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxy.sol"; +import { IGraphProxyAdmin } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxyAdmin.sol"; + +abstract contract ProtocolFixture is Test { + string internal constant CORE_ARTIFACTS = "node_modules/@graphprotocol/contracts/artifacts/contracts/"; + string internal constant PKG_ARTIFACTS = "node_modules/@graphprotocol/contracts/artifacts/contracts/"; + + address internal governor; // makeAddr("governor"), set in _deployProtocolBase + IController internal controller; + IGraphProxyAdmin internal proxyAdmin; + + /// path is a full artifact path, e.g. string.concat(CORE_ARTIFACTS, "governance/Controller.sol/Controller.json"). + /// Deploys `.bytecode ++ args` with CREATE (honours an active vm.prank). + /// Reverts "artifact needs linking" if `.linkReferences` has any key (vm.parseJsonKeys), + /// and "artifact deploy failed" if CREATE returns 0. + function _deployArtifact(string memory path, bytes memory args) internal returns (address deployed) { + string memory json = vm.readFile(path); + require(vm.parseJsonKeys(json, ".linkReferences").length == 0, "artifact needs linking"); + bytes memory code = bytes.concat(vm.parseJsonBytes(json, ".bytecode"), args); + // solhint-disable-next-line no-inline-assembly + assembly { + deployed := create(0, add(code, 0x20), mload(code)) + } + require(deployed != address(0), "artifact deploy failed"); + } + + /// As governor: deploys Controller and GraphProxyAdmin from CORE_ARTIFACTS, then controller.setPaused(false). + /// Must be called with no active prank. + function _deployProtocolBase() internal { + governor = makeAddr("governor"); + vm.startPrank(governor); + controller = IController( + _deployArtifact(string.concat(CORE_ARTIFACTS, "governance/Controller.sol/Controller.json"), "") + ); + proxyAdmin = IGraphProxyAdmin( + _deployArtifact(string.concat(CORE_ARTIFACTS, "upgrades/GraphProxyAdmin.sol/GraphProxyAdmin.json"), "") + ); + controller.setPaused(false); + vm.stopPrank(); + } + + /// As governor: deploys GraphProxy(address(0), proxyAdmin) from CORE_ARTIFACTS, + /// proxyAdmin.upgrade(proxy, impl), proxyAdmin.acceptProxyAndCall(impl, proxy, initData). + /// Must be called with no active prank. + function _deployBehindProxy(address impl, bytes memory initData) internal returns (address proxy) { + vm.startPrank(governor); + proxy = _deployArtifact( + string.concat(CORE_ARTIFACTS, "upgrades/GraphProxy.sol/GraphProxy.json"), + abi.encode(address(0), address(proxyAdmin)) + ); + proxyAdmin.upgrade(IGraphProxy(proxy), impl); + proxyAdmin.acceptProxyAndCall(impl, IGraphProxy(proxy), initData); + vm.stopPrank(); + } + + /// As governor: controller.setContractProxy(keccak256(bytes(name)), addr). + function _registerContract(string memory name, address addr) internal { + vm.prank(governor); + controller.setContractProxy(keccak256(bytes(name)), addr); + } +} diff --git a/packages/graph-token/test/unit/token/GraphToken.t.sol b/packages/graph-token/test/unit/token/GraphToken.t.sol new file mode 100644 index 000000000..51ebdef02 --- /dev/null +++ b/packages/graph-token/test/unit/token/GraphToken.t.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { GraphTokenSharedTest } from "./GraphTokenShared.t.sol"; +import { IGraphTokenLike } from "../interfaces/IGraphTokenLike.sol"; + +contract GraphTokenTest is GraphTokenSharedTest { + string internal constant GRAPH_TOKEN = string.concat(PKG_ARTIFACTS, "token/GraphToken.sol/GraphToken.json"); + + IGraphTokenLike internal grt; + + function setUp() public { + vm.warp(1_700_000_000); + _deployProtocolBase(); + vm.prank(governor); + grt = IGraphTokenLike(_deployArtifact(GRAPH_TOKEN, abi.encode(10_000_000_000 ether))); + _setUpShared(); + } + + function _token() internal view override returns (IGraphTokenLike) { + return grt; + } + + function _salt() internal pure override returns (bytes32) { + return L1_SALT; + } +} diff --git a/packages/graph-token/test/unit/token/GraphTokenShared.t.sol b/packages/graph-token/test/unit/token/GraphTokenShared.t.sol new file mode 100644 index 000000000..9b9bd1bdc --- /dev/null +++ b/packages/graph-token/test/unit/token/GraphTokenShared.t.sol @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { ProtocolFixture } from "../shared/ProtocolFixture.t.sol"; +import { IGraphTokenLike } from "../interfaces/IGraphTokenLike.sol"; + +/// Cases shared by GraphToken (L1) and L2GraphToken; only the EIP-712 salt differs +abstract contract GraphTokenSharedTest is ProtocolFixture { + bytes32 internal constant L1_SALT = 0x51f3d585afe6dfeb2af01bba0889a36c1db03beec88c6a4d0c53817069026afa; + bytes32 internal constant L2_SALT = 0xe33842a7acd1d5a1d28f25a931703e5605152dc48d64dc4716efdae1f5659591; + + address internal me; + uint256 internal mePk; + address internal other; + uint256 internal otherPk; + + function _token() internal view virtual returns (IGraphTokenLike); + + function _salt() internal pure virtual returns (bytes32); + + /// Call after the token is deployed + function _setUpShared() internal { + (me, mePk) = makeAddrAndKey("me"); + (other, otherPk) = makeAddrAndKey("other"); + + vm.prank(governor); + _token().mint(me, 10_000 ether); + } + + function _domainSeparator() internal view returns (bytes32) { + return + keccak256( + abi.encode( + keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)" + ), + keccak256("Graph Token"), + keccak256("0"), + block.chainid, + address(_token()), + _salt() + ) + ); + } + + function _permitDigest( + address owner, + address spender, + uint256 value, + uint256 nonce, + uint256 deadline + ) internal view returns (bytes32) { + return + keccak256( + abi.encodePacked( + "\x19\x01", + _domainSeparator(), + keccak256( + abi.encode( + keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ), + owner, + spender, + value, + nonce, + deadline + ) + ) + ) + ); + } + + struct Permit { + address owner; + address spender; + uint256 value; + uint256 nonce; + uint256 deadline; + } + + function _permit(uint256 value, uint256 deadline) internal view returns (Permit memory) { + return Permit(me, other, value, _token().nonces(me), deadline); + } + + /// Signs `p` with `pk` and submits it + function _submitPermit(Permit memory p, uint256 pk) internal { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _permitDigest(p.owner, p.spender, p.value, p.nonce, p.deadline)); + _token().permit(p.owner, p.spender, p.value, p.deadline, v, r, s); + } + + // -- permit -- + + function test_Permit_PermitsTokenAllowance() public { + uint256 tokensToApprove = 1000 ether; + Permit memory p = _permit(tokensToApprove, 0); + + vm.expectEmit(address(_token())); + emit IGraphTokenLike.Approval(p.owner, p.spender, tokensToApprove); + _submitPermit(p, mePk); + + assertEq(_token().allowance(me, other), tokensToApprove); + + vm.prank(other); + _token().transferFrom(me, other, 100 ether); + } + + function test_Permit_PermitsMaxTokenAllowance() public { + Permit memory p = _permit(type(uint256).max, 0); + + vm.expectEmit(address(_token())); + emit IGraphTokenLike.Approval(p.owner, p.spender, type(uint256).max); + _submitPermit(p, mePk); + + assertEq(_token().allowance(me, other), type(uint256).max); + + vm.prank(other); + _token().transferFrom(me, other, 100 ether); + } + + function test_Permit_RevertWhen_TransferExceedsPermit() public { + uint256 tokensToApprove = 1000 ether; + _submitPermit(_permit(tokensToApprove, 0), mePk); + + vm.expectRevert(bytes("ERC20: transfer amount exceeds allowance")); + vm.prank(other); + _token().transferFrom(me, other, 1001 ether); + + vm.prank(other); + _token().transferFrom(me, other, tokensToApprove); + } + + function test_Permit_RevertWhen_NonceReused() public { + Permit memory p = _permit(type(uint256).max, 0); + _submitPermit(p, mePk); + + vm.expectRevert(bytes("GRT: invalid permit")); + this.submitPermit(p, mePk); + } + + function test_Permit_RevertWhen_Expired() public { + vm.warp(1_700_000_000); + Permit memory p = _permit(type(uint256).max, block.timestamp - 1); + + vm.expectRevert(bytes("GRT: expired permit")); + this.submitPermit(p, mePk); + } + + function test_Permit_RevertWhen_HolderDoesNotMatch() public { + Permit memory p = _permit(type(uint256).max, 0); + + vm.expectRevert(bytes("GRT: invalid permit")); + this.submitPermit(p, otherPk); + } + + function test_Permit_DeniesTransferFromAfterPermitDenied() public { + _submitPermit(_permit(type(uint256).max, 0), mePk); + _submitPermit(_permit(0, 0), mePk); + + assertEq(_token().allowance(me, other), 0); + + vm.expectRevert(bytes("ERC20: transfer amount exceeds allowance")); + vm.prank(other); + _token().transferFrom(me, other, 100 ether); + } + + /// External wrapper so vm.expectRevert targets the permit call and not the signing cheatcode + function submitPermit(Permit memory p, uint256 pk) external { + _submitPermit(p, pk); + } + + // -- mint -- + + function test_Mint_RevertWhen_NotMinter() public { + vm.expectRevert(bytes("Only minter can call")); + vm.prank(me); + _token().mint(me, 100 ether); + } + + function test_AddMinter_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Governor can call")); + vm.prank(me); + _token().addMinter(me); + } + + function test_AddMinter_AddsMinter() public { + assertFalse(_token().isMinter(me)); + + vm.expectEmit(address(_token())); + emit IGraphTokenLike.MinterAdded(me); + vm.prank(governor); + _token().addMinter(me); + + assertTrue(_token().isMinter(me)); + } + + function test_Mint_MintsWhenMinter() public { + vm.prank(governor); + _token().addMinter(me); + uint256 before = _token().balanceOf(me); + uint256 tokensToMint = 100 ether; + + vm.expectEmit(address(_token())); + emit IGraphTokenLike.Transfer(address(0), me, tokensToMint); + vm.prank(me); + _token().mint(me, tokensToMint); + + assertEq(_token().balanceOf(me), before + tokensToMint); + } + + function test_Mint_MintsWhenGovernor() public { + uint256 before = _token().balanceOf(me); + + vm.prank(governor); + _token().mint(me, 100 ether); + + assertEq(_token().balanceOf(me), before + 100 ether); + } + + function test_RemoveMinter_RevertWhen_NotGovernor() public { + vm.prank(governor); + _token().addMinter(me); + + vm.expectRevert(bytes("Only Governor can call")); + vm.prank(me); + _token().removeMinter(me); + } + + function test_RemoveMinter_RemovesMinter() public { + vm.prank(governor); + _token().addMinter(me); + + vm.expectEmit(address(_token())); + emit IGraphTokenLike.MinterRemoved(me); + vm.prank(governor); + _token().removeMinter(me); + + assertFalse(_token().isMinter(me)); + } + + function test_RenounceMinter_RenouncesMinter() public { + vm.prank(governor); + _token().addMinter(me); + + vm.expectEmit(address(_token())); + emit IGraphTokenLike.MinterRemoved(me); + vm.prank(me); + _token().renounceMinter(); + + assertFalse(_token().isMinter(me)); + } +} diff --git a/packages/graph-token/test/unit/token/L2GraphToken.t.sol b/packages/graph-token/test/unit/token/L2GraphToken.t.sol new file mode 100644 index 000000000..071cfbaa4 --- /dev/null +++ b/packages/graph-token/test/unit/token/L2GraphToken.t.sol @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { GraphTokenSharedTest } from "./GraphTokenShared.t.sol"; +import { IGraphTokenLike } from "../interfaces/IGraphTokenLike.sol"; +import { IL2GraphTokenLike } from "../interfaces/IL2GraphTokenLike.sol"; + +contract L2GraphTokenTest is GraphTokenSharedTest { + string internal constant L2_GRAPH_TOKEN = + string.concat(PKG_ARTIFACTS, "l2/token/L2GraphToken.sol/L2GraphToken.json"); + + IL2GraphTokenLike internal grt; + address internal mockL2Gateway = makeAddr("mockL2Gateway"); + address internal mockL1GRT = makeAddr("mockL1GRT"); + address internal user = makeAddr("user"); + + function setUp() public { + vm.warp(1_700_000_000); + _deployProtocolBase(); + address impl = _deployArtifact(L2_GRAPH_TOKEN, ""); + grt = IL2GraphTokenLike(_deployBehindProxy(impl, abi.encodeCall(IL2GraphTokenLike.initialize, (governor)))); + _setUpShared(); + } + + function _token() internal view override returns (IGraphTokenLike) { + return grt; + } + + function _salt() internal pure override returns (bytes32) { + return L2_SALT; + } + + function _configureBridge() internal { + vm.startPrank(governor); + grt.setL1Address(mockL1GRT); + grt.setGateway(mockL2Gateway); + vm.stopPrank(); + } + + // -- setGateway -- + + function test_SetGateway_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Governor can call")); + vm.prank(mockL2Gateway); + grt.setGateway(mockL2Gateway); + } + + function test_SetGateway_SetsGateway() public { + vm.expectEmit(address(grt)); + emit IL2GraphTokenLike.GatewaySet(mockL2Gateway); + vm.prank(governor); + grt.setGateway(mockL2Gateway); + + assertEq(grt.gateway(), mockL2Gateway); + } + + // -- setL1Address -- + + function test_SetL1Address_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Governor can call")); + vm.prank(mockL2Gateway); + grt.setL1Address(mockL1GRT); + } + + function test_SetL1Address_SetsL1Address() public { + vm.expectEmit(address(grt)); + emit IL2GraphTokenLike.L1AddressSet(mockL1GRT); + vm.prank(governor); + grt.setL1Address(mockL1GRT); + + assertEq(grt.l1Address(), mockL1GRT); + } + + // -- bridgeMint -- + + function test_BridgeMint_RevertWhen_NotGateway() public { + _configureBridge(); + + vm.expectRevert(bytes("NOT_GATEWAY")); + vm.prank(governor); + grt.bridgeMint(user, 100 ether); + } + + function test_BridgeMint_MintsToAccount() public { + _configureBridge(); + + vm.expectEmit(address(grt)); + emit IL2GraphTokenLike.BridgeMinted(user, 100 ether); + vm.prank(mockL2Gateway); + grt.bridgeMint(user, 100 ether); + + assertEq(grt.balanceOf(user), 100 ether); + } + + // -- bridgeBurn -- + + function test_BridgeBurn_RevertWhen_NotGateway() public { + _configureBridge(); + + vm.expectRevert(bytes("NOT_GATEWAY")); + vm.prank(governor); + grt.bridgeBurn(user, 100 ether); + } + + function test_BridgeBurn_RevertWhen_NotApproved() public { + _configureBridge(); + vm.prank(mockL2Gateway); + grt.bridgeMint(user, 100 ether); + + vm.expectRevert(bytes("ERC20: burn amount exceeds allowance")); + vm.prank(mockL2Gateway); + grt.bridgeBurn(user, 20 ether); + } + + function test_BridgeBurn_RevertWhen_InsufficientBalance() public { + _configureBridge(); + vm.prank(mockL2Gateway); + grt.bridgeMint(user, 10 ether); + vm.prank(user); + grt.approve(mockL2Gateway, 20 ether); + + vm.expectRevert(bytes("ERC20: burn amount exceeds balance")); + vm.prank(mockL2Gateway); + grt.bridgeBurn(user, 20 ether); + } + + function test_BridgeBurn_BurnsWhenApproved() public { + _configureBridge(); + vm.prank(mockL2Gateway); + grt.bridgeMint(user, 100 ether); + vm.prank(user); + grt.approve(mockL2Gateway, 20 ether); + + vm.expectEmit(address(grt)); + emit IL2GraphTokenLike.BridgeBurned(user, 20 ether); + vm.prank(mockL2Gateway); + grt.bridgeBurn(user, 20 ether); + + assertEq(grt.balanceOf(user), 80 ether); + } + + function test_BridgeMintingAndBurning_RevertWhen_GatewayMintsAsRegularMinter() public { + _configureBridge(); + + vm.expectRevert(bytes("Only minter can call")); + vm.prank(mockL2Gateway); + grt.mint(user, 100 ether); + } +} diff --git a/packages/graph-token/test/unit/utils/TokenUtils.t.sol b/packages/graph-token/test/unit/utils/TokenUtils.t.sol new file mode 100644 index 000000000..9a7eb25de --- /dev/null +++ b/packages/graph-token/test/unit/utils/TokenUtils.t.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { ProtocolFixture } from "../shared/ProtocolFixture.t.sol"; +import { IGraphTokenLike } from "../interfaces/IGraphTokenLike.sol"; +import { ITokenUtilsMock } from "../interfaces/ITokenUtilsMock.sol"; + +contract TokenUtilsTest is ProtocolFixture { + string internal constant GRAPH_TOKEN = string.concat(PKG_ARTIFACTS, "token/GraphToken.sol/GraphToken.json"); + string internal constant TOKEN_UTILS_MOCK = + string.concat(PKG_ARTIFACTS, "tests/TokenUtilsMock.sol/TokenUtilsMock.json"); + + IGraphTokenLike internal grt; + ITokenUtilsMock internal utils; + address internal holder = makeAddr("holder"); + + function setUp() public { + vm.warp(1_700_000_000); + _deployProtocolBase(); + + vm.prank(governor); + grt = IGraphTokenLike(_deployArtifact(GRAPH_TOKEN, abi.encode(10_000_000_000 ether))); + utils = ITokenUtilsMock(_deployArtifact(TOKEN_UTILS_MOCK, "")); + + vm.prank(governor); + grt.mint(holder, 1000 ether); + } + + function test_TokenUtils_PullTokensMovesTokensToCaller() public { + vm.prank(holder); + grt.approve(address(utils), 400 ether); + + utils.pullTokens(grt, holder, 400 ether); + + assertEq(grt.balanceOf(holder), 600 ether); + assertEq(grt.balanceOf(address(utils)), 400 ether); + } + + function test_TokenUtils_PullTokensZeroDoesNothing() public { + // No approval: a nonzero pull would revert + utils.pullTokens(grt, holder, 0); + + assertEq(grt.balanceOf(holder), 1000 ether); + assertEq(grt.balanceOf(address(utils)), 0); + } + + function test_TokenUtils_PushTokensMovesTokensFromCaller() public { + vm.prank(holder); + grt.transfer(address(utils), 500 ether); + + utils.pushTokens(grt, holder, 300 ether); + + assertEq(grt.balanceOf(holder), 800 ether); + assertEq(grt.balanceOf(address(utils)), 200 ether); + } + + function test_TokenUtils_PushTokensZeroDoesNothing() public { + // Mock holds nothing: a nonzero push would revert + utils.pushTokens(grt, holder, 0); + + assertEq(grt.balanceOf(holder), 1000 ether); + assertEq(grt.balanceOf(address(utils)), 0); + } + + function test_TokenUtils_BurnTokensLowersTotalSupply() public { + vm.prank(holder); + grt.transfer(address(utils), 500 ether); + uint256 supplyBefore = grt.totalSupply(); + + utils.burnTokens(grt, 200 ether); + + assertEq(grt.totalSupply(), supplyBefore - 200 ether); + assertEq(grt.balanceOf(address(utils)), 300 ether); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5bf6f5c1..8e0e89ae6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -723,6 +723,18 @@ importers: specifier: 'catalog:' version: https://github.com/foundry-rs/forge-std/tarball/v1.14.0 + packages/graph-token: + devDependencies: + '@graphprotocol/contracts': + specifier: workspace:^ + version: link:../contracts + '@graphprotocol/interfaces': + specifier: workspace:^ + version: link:../interfaces + forge-std: + specifier: 'catalog:' + version: https://github.com/foundry-rs/forge-std/tarball/v1.14.0 + packages/hardhat-graph-protocol: dependencies: '@graphprotocol/toolshed': diff --git a/scripts/contracts-migration/test-mapping/t2a.md b/scripts/contracts-migration/test-mapping/t2a.md new file mode 100644 index 000000000..4a60b37e8 --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t2a.md @@ -0,0 +1,42 @@ +# T2a test mapping: tokens, BridgeEscrow and TokenUtils + +New tests are in `packages/graph-token`. The cases of `lib/graphTokenTests.ts` run once per token in the old suite (`graphToken.test.ts` and `l2GraphToken.test.ts` both bind it); they are written once in `GraphTokenSharedTest` and inherited by `GraphTokenTest` and `L2GraphTokenTest`. `graphToken.test.ts` has no cases of its own. + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| packages/contracts-test/tests/unit/lib/graphTokenTests.ts | permit › should permit max token allowance (1000 GRT) | test/unit/token/GraphTokenShared.t.sol::test_Permit_PermitsTokenAllowance | Runs for GraphToken and L2GraphToken; identically titled in the old file | +| packages/contracts-test/tests/unit/lib/graphTokenTests.ts | permit › should permit max token allowance (MaxUint256) | test/unit/token/GraphTokenShared.t.sol::test_Permit_PermitsMaxTokenAllowance | Runs for GraphToken and L2GraphToken; identically titled in the old file | +| packages/contracts-test/tests/unit/lib/graphTokenTests.ts | permit › reject to transfer more tokens than approved by permit | test/unit/token/GraphTokenShared.t.sol::test_Permit_RevertWhen_TransferExceedsPermit | Runs for GraphToken and L2GraphToken | +| packages/contracts-test/tests/unit/lib/graphTokenTests.ts | permit › reject use two permits with same nonce | test/unit/token/GraphTokenShared.t.sol::test_Permit_RevertWhen_NonceReused | Runs for GraphToken and L2GraphToken | +| packages/contracts-test/tests/unit/lib/graphTokenTests.ts | permit › reject use expired permit | test/unit/token/GraphTokenShared.t.sol::test_Permit_RevertWhen_Expired | Runs for GraphToken and L2GraphToken; warps to 1_700_000_000, deadline = block.timestamp - 1 | +| packages/contracts-test/tests/unit/lib/graphTokenTests.ts | permit › reject permit if holder address does not match | test/unit/token/GraphTokenShared.t.sol::test_Permit_RevertWhen_HolderDoesNotMatch | Runs for GraphToken and L2GraphToken | +| packages/contracts-test/tests/unit/lib/graphTokenTests.ts | permit › should deny transfer from if permit was denied | test/unit/token/GraphTokenShared.t.sol::test_Permit_DeniesTransferFromAfterPermitDenied | Runs for GraphToken and L2GraphToken | +| packages/contracts-test/tests/unit/lib/graphTokenTests.ts | mint › mint › reject mint if not minter | test/unit/token/GraphTokenShared.t.sol::test_Mint_RevertWhen_NotMinter | Runs for GraphToken and L2GraphToken | +| packages/contracts-test/tests/unit/lib/graphTokenTests.ts | mint › addMinter › reject add a new minter if not allowed | test/unit/token/GraphTokenShared.t.sol::test_AddMinter_RevertWhen_NotGovernor | Runs for GraphToken and L2GraphToken | +| packages/contracts-test/tests/unit/lib/graphTokenTests.ts | mint › addMinter › should add a new minter | test/unit/token/GraphTokenShared.t.sol::test_AddMinter_AddsMinter | Runs for GraphToken and L2GraphToken | +| packages/contracts-test/tests/unit/lib/graphTokenTests.ts | mint › when is minter › mint › should mint | test/unit/token/GraphTokenShared.t.sol::test_Mint_MintsWhenMinter | Runs for GraphToken and L2GraphToken | +| packages/contracts-test/tests/unit/lib/graphTokenTests.ts | mint › when is minter › mint › should mint if governor | test/unit/token/GraphTokenShared.t.sol::test_Mint_MintsWhenGovernor | Runs for GraphToken and L2GraphToken | +| packages/contracts-test/tests/unit/lib/graphTokenTests.ts | mint › when is minter › removeMinter › reject remove a minter if not allowed | test/unit/token/GraphTokenShared.t.sol::test_RemoveMinter_RevertWhen_NotGovernor | Runs for GraphToken and L2GraphToken | +| packages/contracts-test/tests/unit/lib/graphTokenTests.ts | mint › when is minter › removeMinter › should remove a minter | test/unit/token/GraphTokenShared.t.sol::test_RemoveMinter_RemovesMinter | Runs for GraphToken and L2GraphToken | +| packages/contracts-test/tests/unit/lib/graphTokenTests.ts | mint › when is minter › renounceMinter › should renounce to be a minter | test/unit/token/GraphTokenShared.t.sol::test_RenounceMinter_RenouncesMinter | Runs for GraphToken and L2GraphToken | +| packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts | Extended L2 behavior › setGateway › cannot be called by someone who is not the governor | test/unit/token/L2GraphToken.t.sol::test_SetGateway_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts | Extended L2 behavior › setGateway › sets the L2 Gateway address when called by the governor | test/unit/token/L2GraphToken.t.sol::test_SetGateway_SetsGateway | | +| packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts | Extended L2 behavior › setL1Address › cannot be called by someone who is not the governor | test/unit/token/L2GraphToken.t.sol::test_SetL1Address_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts | Extended L2 behavior › setL1Address › sets the L1 GRT address when called by the governor | test/unit/token/L2GraphToken.t.sol::test_SetL1Address_SetsL1Address | | +| packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts | Extended L2 behavior › bridge minting and burning › bridgeMint › cannot be called by someone who is not the gateway | test/unit/token/L2GraphToken.t.sol::test_BridgeMint_RevertWhen_NotGateway | | +| packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts | Extended L2 behavior › bridge minting and burning › bridgeMint › mints GRT into a destination account | test/unit/token/L2GraphToken.t.sol::test_BridgeMint_MintsToAccount | | +| packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts | Extended L2 behavior › bridge minting and burning › bridgeBurn › cannot be called by someone who is not the gateway | test/unit/token/L2GraphToken.t.sol::test_BridgeBurn_RevertWhen_NotGateway | | +| packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts | Extended L2 behavior › bridge minting and burning › bridgeBurn › requires approval for burning | test/unit/token/L2GraphToken.t.sol::test_BridgeBurn_RevertWhen_NotApproved | | +| packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts | Extended L2 behavior › bridge minting and burning › bridgeBurn › fails if the user does not have enough funds | test/unit/token/L2GraphToken.t.sol::test_BridgeBurn_RevertWhen_InsufficientBalance | | +| packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts | Extended L2 behavior › bridge minting and burning › bridgeBurn › burns GRT from an account when approved | test/unit/token/L2GraphToken.t.sol::test_BridgeBurn_BurnsWhenApproved | | +| packages/contracts-test/tests/unit/l2/l2GraphToken.test.ts | Extended L2 behavior › bridge minting and burning › does not allow the bridge to mint as a regular minter | test/unit/token/L2GraphToken.t.sol::test_BridgeMintingAndBurning_RevertWhen_GatewayMintsAsRegularMinter | | +| packages/contracts-test/tests/unit/gateway/bridgeEscrow.test.ts | BridgeEscrow › approveAll › cannot be called by someone other than the governor | test/unit/gateway/BridgeEscrow.t.sol::test_ApproveAll_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/gateway/bridgeEscrow.test.ts | BridgeEscrow › approveAll › allows a spender to transfer GRT held by the contract | test/unit/gateway/BridgeEscrow.t.sol::test_ApproveAll_AllowsSpenderToTransfer | | +| packages/contracts-test/tests/unit/gateway/bridgeEscrow.test.ts | BridgeEscrow › revokeAll › cannot be called by someone other than the governor | test/unit/gateway/BridgeEscrow.t.sol::test_RevokeAll_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/gateway/bridgeEscrow.test.ts | BridgeEscrow › revokeAll › revokes a spender's permission to transfer GRT held by the contract | test/unit/gateway/BridgeEscrow.t.sol::test_RevokeAll_RevokesSpender | | + +## Coverage + +MISSING initialize: `L2GraphToken.initialize` and `BridgeEscrow.initialize` are called by the fixture through `abi.encodeCall` in the proxy init data (`acceptProxyAndCall`), not by a `.initialize(` call. Every test runs it, and it must succeed for the proxy to work. The other listed external/public functions are called directly. + +Additions with no old test: `test/unit/utils/TokenUtils.t.sol` (`TokenUtils` was only exercised through GNS and L2Curation, which are not in graph-token), through `TokenUtilsMock` in `packages/contracts/contracts/tests/`. From 325aa9e6f009fa36223b01f51dabd17e96f6a369 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 26 Oct 2026 10:27:07 -0300 Subject: [PATCH 10/20] test(graph-token): port gateway tests to Foundry --- .../unit/gateway/l1GraphTokenGateway.test.ts | 788 ------------------ .../tests/unit/l2/l2ArbitrumMessengerMock.ts | 49 -- .../tests/unit/l2/l2GraphTokenGateway.test.ts | 395 --------- .../gateway/L1GraphTokenGatewayConfig.t.sol | 245 ++++++ .../gateway/L1GraphTokenGatewayShared.t.sol | 102 +++ .../L1GraphTokenGatewayTransfers.t.sol | 465 +++++++++++ .../gateway/L2GraphTokenGatewayConfig.t.sol | 159 ++++ .../gateway/L2GraphTokenGatewayShared.t.sol | 80 ++ .../L2GraphTokenGatewayTransfers.t.sol | 159 ++++ .../unit/interfaces/IArbitrumMocksLike.sol | 35 + .../interfaces/IL1GraphTokenGatewayLike.sol | 98 +++ .../interfaces/IL2GraphTokenGatewayLike.sol | 59 ++ .../test/unit/mocks/NoCallhookReceiver.sol | 5 + .../contracts-migration/test-mapping/t2b.md | 86 ++ 14 files changed, 1493 insertions(+), 1232 deletions(-) delete mode 100644 packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts delete mode 100644 packages/contracts-test/tests/unit/l2/l2ArbitrumMessengerMock.ts delete mode 100644 packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts create mode 100644 packages/graph-token/test/unit/gateway/L1GraphTokenGatewayConfig.t.sol create mode 100644 packages/graph-token/test/unit/gateway/L1GraphTokenGatewayShared.t.sol create mode 100644 packages/graph-token/test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol create mode 100644 packages/graph-token/test/unit/gateway/L2GraphTokenGatewayConfig.t.sol create mode 100644 packages/graph-token/test/unit/gateway/L2GraphTokenGatewayShared.t.sol create mode 100644 packages/graph-token/test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol create mode 100644 packages/graph-token/test/unit/interfaces/IArbitrumMocksLike.sol create mode 100644 packages/graph-token/test/unit/interfaces/IL1GraphTokenGatewayLike.sol create mode 100644 packages/graph-token/test/unit/interfaces/IL2GraphTokenGatewayLike.sol create mode 100644 packages/graph-token/test/unit/mocks/NoCallhookReceiver.sol create mode 100644 scripts/contracts-migration/test-mapping/t2b.md diff --git a/packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts b/packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts deleted file mode 100644 index 7c87f3b9d..000000000 --- a/packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts +++ /dev/null @@ -1,788 +0,0 @@ -import { GraphToken } from '@graphprotocol/contracts' -import { BridgeMock } from '@graphprotocol/contracts' -import { InboxMock } from '@graphprotocol/contracts' -import { OutboxMock } from '@graphprotocol/contracts' -import { L1GraphTokenGateway } from '@graphprotocol/contracts' -import { L2GraphToken, L2GraphTokenGateway } from '@graphprotocol/contracts' -import { BridgeEscrow } from '@graphprotocol/contracts' -import { applyL1ToL2Alias, GraphNetworkContracts, helpers, toBN, toGRT } from '@graphprotocol/sdk' -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { constants, Signer, utils, Wallet } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const { AddressZero } = constants - -describe('L1GraphTokenGateway', () => { - const graph = hre.graph() - let governor: SignerWithAddress - let tokenSender: SignerWithAddress - let l2Receiver: SignerWithAddress - let pauseGuardian: SignerWithAddress - - let fixture: NetworkFixture - let grt: GraphToken - let l1GraphTokenGateway: L1GraphTokenGateway - let bridgeEscrow: BridgeEscrow - - let bridgeMock: BridgeMock - let inboxMock: InboxMock - let outboxMock: OutboxMock - let routerMock: Wallet - let l2GRTMock: L2GraphToken - let l2GRTGatewayMock: L2GraphTokenGateway - - let fixtureContracts: GraphNetworkContracts - let l2MockContracts: GraphNetworkContracts - - const senderTokens = toGRT('1000') - const maxGas = toBN('1000000') - const maxSubmissionCost = toBN('7') - const gasPriceBid = toBN('2') - const defaultEthValue = maxSubmissionCost.add(maxGas.mul(gasPriceBid)) - const emptyCallHookData = '0x' - const defaultData = utils.defaultAbiCoder.encode(['uint256', 'bytes'], [maxSubmissionCost, emptyCallHookData]) - const defaultDataNoSubmissionCost = utils.defaultAbiCoder.encode(['uint256', 'bytes'], [toBN(0), emptyCallHookData]) - const notEmptyCallHookData = '0x12' - const defaultDataWithNotEmptyCallHookData = utils.defaultAbiCoder.encode( - ['uint256', 'bytes'], - [maxSubmissionCost, notEmptyCallHookData], - ) - - before(async function () { - ;[tokenSender, l2Receiver] = await graph.getTestAccounts() - ;({ governor, pauseGuardian } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - - // Deploy L1 - fixtureContracts = await fixture.load(governor) - grt = fixtureContracts.GraphToken as GraphToken - l1GraphTokenGateway = fixtureContracts.L1GraphTokenGateway as L1GraphTokenGateway - bridgeEscrow = fixtureContracts.BridgeEscrow as BridgeEscrow - - // Deploy L1 arbitrum bridge - // @ts-expect-error sdk deprecation - ;({ bridgeMock, inboxMock, outboxMock, routerMock } = await fixture.loadL1ArbitrumBridge(governor)) - - // Deploy L2 mock - l2MockContracts = await fixture.loadMock(true) - l2GRTMock = l2MockContracts.L2GraphToken as L2GraphToken - l2GRTGatewayMock = l2MockContracts.L2GraphTokenGateway as L2GraphTokenGateway - - // Give some funds to the token sender/router mock - await grt.connect(governor).mint(tokenSender.address, senderTokens) - await helpers.setBalance(routerMock.address, utils.parseEther('1')) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - context('> immediately after deploy', function () { - describe('calculateL2TokenAddress', function () { - it('should return address zero as it was not set', async function () { - expect(await l1GraphTokenGateway.calculateL2TokenAddress(grt.address)).eq(AddressZero) - }) - }) - - describe('outboundTransfer', function () { - it('reverts because it is paused', async function () { - const tx = l1GraphTokenGateway - .connect(tokenSender) - .outboundTransfer(grt.address, l2Receiver.address, toGRT('10'), maxGas, gasPriceBid, defaultData, { - value: defaultEthValue, - }) - await expect(tx).revertedWith('Paused (contract)') - }) - }) - - describe('finalizeInboundTransfer', function () { - it('revert because it is paused', async function () { - const tx = l1GraphTokenGateway - .connect(tokenSender) - .finalizeInboundTransfer(grt.address, l2Receiver.address, tokenSender.address, toGRT('10'), defaultData) - await expect(tx).revertedWith('Paused (contract)') - }) - }) - - describe('setArbitrumAddresses', function () { - it('is not callable by addreses that are not the governor', async function () { - const tx = l1GraphTokenGateway.connect(tokenSender).setArbitrumAddresses(inboxMock.address, routerMock.address) - await expect(tx).revertedWith('Only Controller governor') - }) - it('rejects setting an EOA as router or inbox', async function () { - let tx = l1GraphTokenGateway.connect(governor).setArbitrumAddresses(tokenSender.address, routerMock.address) - await expect(tx).revertedWith('INBOX_MUST_BE_CONTRACT') - tx = l1GraphTokenGateway.connect(governor).setArbitrumAddresses(inboxMock.address, tokenSender.address) - await expect(tx).revertedWith('ROUTER_MUST_BE_CONTRACT') - }) - it('sets inbox and router address', async function () { - const tx = l1GraphTokenGateway.connect(governor).setArbitrumAddresses(inboxMock.address, routerMock.address) - await expect(tx) - .emit(l1GraphTokenGateway, 'ArbitrumAddressesSet') - .withArgs(inboxMock.address, routerMock.address) - expect(await l1GraphTokenGateway.l1Router()).eq(routerMock.address) - expect(await l1GraphTokenGateway.inbox()).eq(inboxMock.address) - }) - }) - - describe('setL2TokenAddress', function () { - it('is not callable by addreses that are not the governor', async function () { - const tx = l1GraphTokenGateway.connect(tokenSender).setL2TokenAddress(l2GRTMock.address) - await expect(tx).revertedWith('Only Controller governor') - }) - it('sets l2GRT', async function () { - const tx = l1GraphTokenGateway.connect(governor).setL2TokenAddress(l2GRTMock.address) - await expect(tx).emit(l1GraphTokenGateway, 'L2TokenAddressSet').withArgs(l2GRTMock.address) - expect(await l1GraphTokenGateway.l2GRT()).eq(l2GRTMock.address) - }) - }) - - describe('setL2CounterpartAddress', function () { - it('is not callable by addreses that are not the governor', async function () { - const tx = l1GraphTokenGateway.connect(tokenSender).setL2CounterpartAddress(l2GRTGatewayMock.address) - await expect(tx).revertedWith('Only Controller governor') - }) - it('sets l2Counterpart which can be queried with counterpartGateway()', async function () { - const tx = l1GraphTokenGateway.connect(governor).setL2CounterpartAddress(l2GRTGatewayMock.address) - await expect(tx).emit(l1GraphTokenGateway, 'L2CounterpartAddressSet').withArgs(l2GRTGatewayMock.address) - expect(await l1GraphTokenGateway.l2Counterpart()).eq(l2GRTGatewayMock.address) - expect(await l1GraphTokenGateway.counterpartGateway()).eq(l2GRTGatewayMock.address) - }) - }) - describe('setEscrowAddress', function () { - it('is not callable by addreses that are not the governor', async function () { - const tx = l1GraphTokenGateway.connect(tokenSender).setEscrowAddress(bridgeEscrow.address) - await expect(tx).revertedWith('Only Controller governor') - }) - it('sets escrow', async function () { - const tx = l1GraphTokenGateway.connect(governor).setEscrowAddress(bridgeEscrow.address) - await expect(tx).emit(l1GraphTokenGateway, 'EscrowAddressSet').withArgs(bridgeEscrow.address) - expect(await l1GraphTokenGateway.escrow()).eq(bridgeEscrow.address) - }) - }) - describe('addToCallhookAllowlist', function () { - it('is not callable by addreses that are not the governor', async function () { - const tx = l1GraphTokenGateway - .connect(tokenSender) - .addToCallhookAllowlist(fixtureContracts.RewardsManager.address) - await expect(tx).revertedWith('Only Controller governor') - expect(await l1GraphTokenGateway.callhookAllowlist(fixtureContracts.RewardsManager.address)).eq(false) - }) - it('rejects adding an EOA to the callhook allowlist', async function () { - const tx = l1GraphTokenGateway.connect(governor).addToCallhookAllowlist(tokenSender.address) - await expect(tx).revertedWith('MUST_BE_CONTRACT') - }) - it('adds an address to the callhook allowlist', async function () { - const tx = l1GraphTokenGateway.connect(governor).addToCallhookAllowlist(fixtureContracts.RewardsManager.address) - await expect(tx) - .emit(l1GraphTokenGateway, 'AddedToCallhookAllowlist') - .withArgs(fixtureContracts.RewardsManager.address) - expect(await l1GraphTokenGateway.callhookAllowlist(fixtureContracts.RewardsManager.address)).eq(true) - }) - }) - describe('removeFromCallhookAllowlist', function () { - it('is not callable by addreses that are not the governor', async function () { - await l1GraphTokenGateway.connect(governor).addToCallhookAllowlist(fixtureContracts.RewardsManager.address) - const tx = l1GraphTokenGateway - .connect(tokenSender) - .removeFromCallhookAllowlist(fixtureContracts.RewardsManager.address) - await expect(tx).revertedWith('Only Controller governor') - expect(await l1GraphTokenGateway.callhookAllowlist(fixtureContracts.RewardsManager.address)).eq(true) - }) - it('removes an address from the callhook allowlist', async function () { - await l1GraphTokenGateway.connect(governor).addToCallhookAllowlist(fixtureContracts.RewardsManager.address) - const tx = l1GraphTokenGateway - .connect(governor) - .removeFromCallhookAllowlist(fixtureContracts.RewardsManager.address) - await expect(tx) - .emit(l1GraphTokenGateway, 'RemovedFromCallhookAllowlist') - .withArgs(fixtureContracts.RewardsManager.address) - expect(await l1GraphTokenGateway.callhookAllowlist(fixtureContracts.RewardsManager.address)).eq(false) - }) - }) - describe('Pausable behavior', () => { - it('cannot be paused or unpaused by someone other than governor or pauseGuardian', async () => { - let tx = l1GraphTokenGateway.connect(tokenSender).setPaused(false) - await expect(tx).revertedWith('Only Governor or Guardian') - tx = l1GraphTokenGateway.connect(tokenSender).setPaused(true) - await expect(tx).revertedWith('Only Governor or Guardian') - }) - it('cannot be unpaused if some state variables are not set', async function () { - let tx = l1GraphTokenGateway.connect(governor).setPaused(false) - await expect(tx).revertedWith('INBOX_NOT_SET') - await l1GraphTokenGateway.connect(governor).setArbitrumAddresses(inboxMock.address, routerMock.address) - tx = l1GraphTokenGateway.connect(governor).setPaused(false) - await expect(tx).revertedWith('L2_COUNTERPART_NOT_SET') - await l1GraphTokenGateway.connect(governor).setL2CounterpartAddress(l2GRTGatewayMock.address) - tx = l1GraphTokenGateway.connect(governor).setPaused(false) - await expect(tx).revertedWith('ESCROW_NOT_SET') - }) - it('can be paused and unpaused by the governor', async function () { - await fixture.configureL1Bridge(governor, fixtureContracts, l2MockContracts) - let tx = l1GraphTokenGateway.connect(governor).setPaused(true) - await expect(tx).emit(l1GraphTokenGateway, 'PauseChanged').withArgs(true) - expect(await l1GraphTokenGateway.paused()).eq(true) - tx = l1GraphTokenGateway.connect(governor).setPaused(false) - await expect(tx).emit(l1GraphTokenGateway, 'PauseChanged').withArgs(false) - expect(await l1GraphTokenGateway.paused()).eq(false) - }) - describe('setPauseGuardian', function () { - it('cannot be called by someone other than governor', async function () { - const tx = l1GraphTokenGateway.connect(tokenSender).setPauseGuardian(pauseGuardian.address) - await expect(tx).revertedWith('Only Controller governor') - }) - it('sets a new pause guardian', async function () { - const currentPauseGuardian = await l1GraphTokenGateway.pauseGuardian() - const tx = l1GraphTokenGateway.connect(governor).setPauseGuardian(pauseGuardian.address) - await expect(tx) - .emit(l1GraphTokenGateway, 'NewPauseGuardian') - .withArgs(currentPauseGuardian, pauseGuardian.address) - }) - it('allows a pause guardian to pause and unpause', async function () { - await fixture.configureL1Bridge(governor, fixtureContracts, l2MockContracts) - await l1GraphTokenGateway.connect(governor).setPauseGuardian(pauseGuardian.address) - let tx = l1GraphTokenGateway.connect(pauseGuardian).setPaused(true) - await expect(tx).emit(l1GraphTokenGateway, 'PauseChanged').withArgs(true) - expect(await l1GraphTokenGateway.paused()).eq(true) - tx = l1GraphTokenGateway.connect(pauseGuardian).setPaused(false) - await expect(tx).emit(l1GraphTokenGateway, 'PauseChanged').withArgs(false) - expect(await l1GraphTokenGateway.paused()).eq(false) - }) - }) - }) - }) - - context('> after configuring and unpausing', function () { - const createMsgData = function (callHookData: string) { - const selector = l1GraphTokenGateway.interface.getSighash('finalizeInboundTransfer') - const params = utils.defaultAbiCoder.encode( - ['address', 'address', 'address', 'uint256', 'bytes'], - [grt.address, tokenSender.address, l2Receiver.address, toGRT('10'), callHookData], - ) - const outboundData = utils.hexlify(utils.concat([selector, params])) - - const msgData = utils.solidityPack( - ['uint256', 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', 'bytes'], - [ - toBN(l2GRTGatewayMock.address), - toBN('0'), - defaultEthValue, - maxSubmissionCost, - applyL1ToL2Alias(tokenSender.address), - applyL1ToL2Alias(tokenSender.address), - maxGas, - gasPriceBid, - utils.hexDataLength(outboundData), - outboundData, - ], - ) - return msgData - } - const createInboxAccsEntry = function (msgDataHash: string) { - // The real bridge would emit the InboxAccs entry that came before this one, but our mock - // emits this, making it easier for us to validate here that all the parameters we sent are correct - const expectedInboxAccsEntry = utils.keccak256( - utils.solidityPack( - ['address', 'uint8', 'address', 'bytes32'], - [inboxMock.address, 9, l1GraphTokenGateway.address, msgDataHash], - ), - ) - return expectedInboxAccsEntry - } - const testValidOutboundTransfer = async function (signer: Signer, data: string, callHookData: string) { - const tx = l1GraphTokenGateway - .connect(signer) - .outboundTransfer(grt.address, l2Receiver.address, toGRT('10'), maxGas, gasPriceBid, data, { - value: defaultEthValue, - }) - // Our bridge mock returns an incrementing seqNum starting at 1 - const expectedSeqNum = 1 - await expect(tx) - .emit(l1GraphTokenGateway, 'DepositInitiated') - .withArgs(grt.address, tokenSender.address, l2Receiver.address, expectedSeqNum, toGRT('10')) - - const msgData = createMsgData(callHookData) - const msgDataHash = utils.keccak256(msgData) - const expectedInboxAccsEntry = createInboxAccsEntry(msgDataHash) - - await expect(tx).emit(inboxMock, 'InboxMessageDelivered').withArgs(1, msgData) - await expect(tx) - .emit(bridgeMock, 'MessageDelivered') - .withArgs( - expectedSeqNum, - expectedInboxAccsEntry, - inboxMock.address, - 9, - l1GraphTokenGateway.address, - msgDataHash, - ) - const escrowBalance = await grt.balanceOf(bridgeEscrow.address) - const senderBalance = await grt.balanceOf(tokenSender.address) - expect(escrowBalance).eq(toGRT('10')) - expect(senderBalance).eq(toGRT('990')) - } - before(async function () { - await fixture.configureL1Bridge(governor, fixtureContracts, l2MockContracts) - }) - - describe('updateL2MintAllowance', function () { - it('rejects calls that are not from the governor', async function () { - const tx = l1GraphTokenGateway - .connect(pauseGuardian.address) - .updateL2MintAllowance(toGRT('1'), await helpers.latestBlock()) - await expect(tx).revertedWith('Only Controller governor') - }) - it('does not allow using a future or current block number', async function () { - const issuancePerBlock = toGRT('120') - let issuanceUpdatedAtBlock = (await helpers.latestBlock()) + 2 - const tx1 = l1GraphTokenGateway - .connect(governor) - .updateL2MintAllowance(issuancePerBlock, issuanceUpdatedAtBlock) - await expect(tx1).revertedWith('BLOCK_MUST_BE_PAST') - issuanceUpdatedAtBlock = (await helpers.latestBlock()) + 1 // This will be block.number in our next tx - const tx2 = l1GraphTokenGateway - .connect(governor) - .updateL2MintAllowance(issuancePerBlock, issuanceUpdatedAtBlock) - await expect(tx2).revertedWith('BLOCK_MUST_BE_PAST') - issuanceUpdatedAtBlock = await helpers.latestBlock() // This will be block.number-1 in our next tx - const tx3 = l1GraphTokenGateway - .connect(governor) - .updateL2MintAllowance(issuancePerBlock, issuanceUpdatedAtBlock) - await expect(tx3) - .emit(l1GraphTokenGateway, 'L2MintAllowanceUpdated') - .withArgs(toGRT('0'), issuancePerBlock, issuanceUpdatedAtBlock) - }) - it('does not allow using a block number lower than or equal to the previous one', async function () { - const issuancePerBlock = toGRT('120') - const issuanceUpdatedAtBlock = await helpers.latestBlock() - const tx1 = l1GraphTokenGateway - .connect(governor) - .updateL2MintAllowance(issuancePerBlock, issuanceUpdatedAtBlock) - await expect(tx1) - .emit(l1GraphTokenGateway, 'L2MintAllowanceUpdated') - .withArgs(toGRT('0'), issuancePerBlock, issuanceUpdatedAtBlock) - const tx2 = l1GraphTokenGateway - .connect(governor) - .updateL2MintAllowance(issuancePerBlock, issuanceUpdatedAtBlock) - await expect(tx2).revertedWith('BLOCK_MUST_BE_INCREMENTING') - const tx3 = l1GraphTokenGateway - .connect(governor) - .updateL2MintAllowance(issuancePerBlock, issuanceUpdatedAtBlock - 1) - await expect(tx3).revertedWith('BLOCK_MUST_BE_INCREMENTING') - const tx4 = l1GraphTokenGateway - .connect(governor) - .updateL2MintAllowance(issuancePerBlock, issuanceUpdatedAtBlock + 1) - await expect(tx4) - .emit(l1GraphTokenGateway, 'L2MintAllowanceUpdated') - .withArgs(issuancePerBlock, issuancePerBlock, issuanceUpdatedAtBlock + 1) - }) - it('updates the snapshot and issuance to follow a new linear function, accumulating up to the specified block', async function () { - const issuancePerBlock = toGRT('120') - const issuanceUpdatedAtBlock = (await helpers.latestBlock()) - 2 - const tx1 = l1GraphTokenGateway - .connect(governor) - .updateL2MintAllowance(issuancePerBlock, issuanceUpdatedAtBlock) - await expect(tx1) - .emit(l1GraphTokenGateway, 'L2MintAllowanceUpdated') - .withArgs(toGRT('0'), issuancePerBlock, issuanceUpdatedAtBlock) - // Now the mint allowance should be issuancePerBlock * 3 - expect(await l1GraphTokenGateway.accumulatedL2MintAllowanceAtBlock(await helpers.latestBlock())).to.eq( - issuancePerBlock.mul(3), - ) - expect(await l1GraphTokenGateway.accumulatedL2MintAllowanceSnapshot()).to.eq(0) - expect(await l1GraphTokenGateway.l2MintAllowancePerBlock()).to.eq(issuancePerBlock) - expect(await l1GraphTokenGateway.lastL2MintAllowanceUpdateBlock()).to.eq(issuanceUpdatedAtBlock) - - await helpers.mine(10) - - const newIssuancePerBlock = toGRT('200') - const newIssuanceUpdatedAtBlock = (await helpers.latestBlock()) - 1 - - const expectedAccumulatedSnapshot = issuancePerBlock.mul(newIssuanceUpdatedAtBlock - issuanceUpdatedAtBlock) - const tx2 = l1GraphTokenGateway - .connect(governor) - .updateL2MintAllowance(newIssuancePerBlock, newIssuanceUpdatedAtBlock) - await expect(tx2) - .emit(l1GraphTokenGateway, 'L2MintAllowanceUpdated') - .withArgs(expectedAccumulatedSnapshot, newIssuancePerBlock, newIssuanceUpdatedAtBlock) - - expect(await l1GraphTokenGateway.accumulatedL2MintAllowanceAtBlock(await helpers.latestBlock())).to.eq( - expectedAccumulatedSnapshot.add(newIssuancePerBlock.mul(2)), - ) - expect(await l1GraphTokenGateway.accumulatedL2MintAllowanceSnapshot()).to.eq(expectedAccumulatedSnapshot) - expect(await l1GraphTokenGateway.l2MintAllowancePerBlock()).to.eq(newIssuancePerBlock) - expect(await l1GraphTokenGateway.lastL2MintAllowanceUpdateBlock()).to.eq(newIssuanceUpdatedAtBlock) - }) - }) - describe('setL2MintAllowanceParametersManual', function () { - it('rejects calls that are not from the governor', async function () { - const tx = l1GraphTokenGateway - .connect(pauseGuardian.address) - .setL2MintAllowanceParametersManual(toGRT('0'), toGRT('1'), await helpers.latestBlock()) - await expect(tx).revertedWith('Only Controller governor') - }) - it('does not allow using a future or current block number', async function () { - const issuancePerBlock = toGRT('120') - let issuanceUpdatedAtBlock = (await helpers.latestBlock()) + 2 - const tx1 = l1GraphTokenGateway - .connect(governor) - .setL2MintAllowanceParametersManual(toGRT('0'), issuancePerBlock, issuanceUpdatedAtBlock) - await expect(tx1).revertedWith('BLOCK_MUST_BE_PAST') - issuanceUpdatedAtBlock = (await helpers.latestBlock()) + 1 // This will be block.number in our next tx - const tx2 = l1GraphTokenGateway - .connect(governor) - .setL2MintAllowanceParametersManual(toGRT('0'), issuancePerBlock, issuanceUpdatedAtBlock) - await expect(tx2).revertedWith('BLOCK_MUST_BE_PAST') - issuanceUpdatedAtBlock = await helpers.latestBlock() // This will be block.number-1 in our next tx - const tx3 = l1GraphTokenGateway - .connect(governor) - .setL2MintAllowanceParametersManual(toGRT('0'), issuancePerBlock, issuanceUpdatedAtBlock) - await expect(tx3) - .emit(l1GraphTokenGateway, 'L2MintAllowanceUpdated') - .withArgs(toGRT('0'), issuancePerBlock, issuanceUpdatedAtBlock) - }) - it('updates the snapshot and issuance to follow a new linear function, manually setting the snapshot value', async function () { - const issuancePerBlock = toGRT('120') - const issuanceUpdatedAtBlock = (await helpers.latestBlock()) - 2 - const snapshotValue = toGRT('10') - const tx1 = l1GraphTokenGateway - .connect(governor) - .setL2MintAllowanceParametersManual(snapshotValue, issuancePerBlock, issuanceUpdatedAtBlock) - await expect(tx1) - .emit(l1GraphTokenGateway, 'L2MintAllowanceUpdated') - .withArgs(snapshotValue, issuancePerBlock, issuanceUpdatedAtBlock) - // Now the mint allowance should be 10 + issuancePerBlock * 3 - expect(await l1GraphTokenGateway.accumulatedL2MintAllowanceAtBlock(await helpers.latestBlock())).to.eq( - snapshotValue.add(issuancePerBlock.mul(3)), - ) - expect(await l1GraphTokenGateway.accumulatedL2MintAllowanceSnapshot()).to.eq(snapshotValue) - expect(await l1GraphTokenGateway.l2MintAllowancePerBlock()).to.eq(issuancePerBlock) - expect(await l1GraphTokenGateway.lastL2MintAllowanceUpdateBlock()).to.eq(issuanceUpdatedAtBlock) - - await helpers.mine(10) - - const newIssuancePerBlock = toGRT('200') - const newIssuanceUpdatedAtBlock = (await helpers.latestBlock()) - 1 - const newSnapshotValue = toGRT('10') - - const tx2 = l1GraphTokenGateway - .connect(governor) - .setL2MintAllowanceParametersManual(newSnapshotValue, newIssuancePerBlock, newIssuanceUpdatedAtBlock) - await expect(tx2) - .emit(l1GraphTokenGateway, 'L2MintAllowanceUpdated') - .withArgs(newSnapshotValue, newIssuancePerBlock, newIssuanceUpdatedAtBlock) - - expect(await l1GraphTokenGateway.accumulatedL2MintAllowanceAtBlock(await helpers.latestBlock())).to.eq( - newSnapshotValue.add(newIssuancePerBlock.mul(2)), - ) - expect(await l1GraphTokenGateway.accumulatedL2MintAllowanceSnapshot()).to.eq(newSnapshotValue) - expect(await l1GraphTokenGateway.l2MintAllowancePerBlock()).to.eq(newIssuancePerBlock) - expect(await l1GraphTokenGateway.lastL2MintAllowanceUpdateBlock()).to.eq(newIssuanceUpdatedAtBlock) - }) - }) - describe('calculateL2TokenAddress', function () { - it('returns the L2 token address', async function () { - expect(await l1GraphTokenGateway.calculateL2TokenAddress(grt.address)).eq(l2GRTMock.address) - }) - it('returns the zero address if the input is any other address', async function () { - expect(await l1GraphTokenGateway.calculateL2TokenAddress(tokenSender.address)).eq(AddressZero) - }) - }) - - describe('outboundTransfer', function () { - it('reverts when called with the wrong token address', async function () { - const tx = l1GraphTokenGateway - .connect(tokenSender) - .outboundTransfer(tokenSender.address, l2Receiver.address, toGRT('10'), maxGas, gasPriceBid, defaultData, { - value: defaultEthValue, - }) - await expect(tx).revertedWith('TOKEN_NOT_GRT') - }) - it('puts tokens in escrow and creates a retryable ticket', async function () { - await grt.connect(tokenSender).approve(l1GraphTokenGateway.address, toGRT('10')) - await testValidOutboundTransfer(tokenSender, defaultData, emptyCallHookData) - }) - it('decodes the sender address from messages sent by the router', async function () { - await grt.connect(tokenSender).approve(l1GraphTokenGateway.address, toGRT('10')) - const routerEncodedData = utils.defaultAbiCoder.encode(['address', 'bytes'], [tokenSender.address, defaultData]) - await testValidOutboundTransfer(routerMock, routerEncodedData, emptyCallHookData) - }) - it('reverts when called with no submission cost', async function () { - await grt.connect(tokenSender).approve(l1GraphTokenGateway.address, toGRT('10')) - const tx = l1GraphTokenGateway - .connect(tokenSender) - .outboundTransfer( - grt.address, - l2Receiver.address, - toGRT('10'), - maxGas, - gasPriceBid, - defaultDataNoSubmissionCost, - { - value: defaultEthValue, - }, - ) - await expect(tx).revertedWith('NO_SUBMISSION_COST') - }) - it('reverts when called with nonempty calldata, if the sender is not allowlisted', async function () { - await grt.connect(tokenSender).approve(l1GraphTokenGateway.address, toGRT('10')) - const tx = l1GraphTokenGateway - .connect(tokenSender) - .outboundTransfer( - grt.address, - l2Receiver.address, - toGRT('10'), - maxGas, - gasPriceBid, - defaultDataWithNotEmptyCallHookData, - { - value: defaultEthValue, - }, - ) - await expect(tx).revertedWith('CALL_HOOK_DATA_NOT_ALLOWED') - }) - it('allows sending nonempty calldata, if the sender is allowlisted', async function () { - // Make the sender a contract so that it can be allowed to send callhooks - await helpers.setCode(tokenSender.address, '0x1234') - await l1GraphTokenGateway.connect(governor).addToCallhookAllowlist(tokenSender.address) - await grt.connect(tokenSender).approve(l1GraphTokenGateway.address, toGRT('10')) - await testValidOutboundTransfer(tokenSender, defaultDataWithNotEmptyCallHookData, notEmptyCallHookData) - }) - it('reverts when the sender does not have enough GRT', async function () { - await grt.connect(tokenSender).approve(l1GraphTokenGateway.address, toGRT('1001')) - const tx = l1GraphTokenGateway - .connect(tokenSender) - .outboundTransfer(grt.address, l2Receiver.address, toGRT('1001'), maxGas, gasPriceBid, defaultData, { - value: defaultEthValue, - }) - await expect(tx).revertedWith('ERC20: transfer amount exceeds balance') - }) - }) - - describe('finalizeInboundTransfer', function () { - it('reverts when called by an account that is not the bridge', async function () { - const tx = l1GraphTokenGateway - .connect(tokenSender) - .finalizeInboundTransfer(grt.address, l2Receiver.address, tokenSender.address, toGRT('10'), defaultData) - await expect(tx).revertedWith('NOT_FROM_BRIDGE') - }) - it('reverts when called by the bridge, but the tx was not started by the L2 gateway', async function () { - const encodedCalldata = l1GraphTokenGateway.interface.encodeFunctionData('finalizeInboundTransfer', [ - grt.address, - l2Receiver.address, - tokenSender.address, - toGRT('10'), - utils.defaultAbiCoder.encode(['uint256', 'bytes'], [0, []]), - ]) - // The real outbox would require a proof, which would - // validate that the tx was initiated by the L2 gateway but our mock - // just executes unconditionally - const tx = outboxMock.connect(tokenSender).executeTransaction( - toBN('0'), - [], - toBN('0'), - l2Receiver.address, // Note this is not l2GRTGatewayMock - l1GraphTokenGateway.address, - toBN('1337'), - await helpers.latestBlock(), - toBN('133701337'), - toBN('0'), - encodedCalldata, - ) - await expect(tx).revertedWith('ONLY_COUNTERPART_GATEWAY') - }) - it('reverts if the gateway does not have tokens or allowance', async function () { - // This scenario should never really happen, but we still - // test that the gateway reverts in this case - const encodedCalldata = l1GraphTokenGateway.interface.encodeFunctionData('finalizeInboundTransfer', [ - grt.address, - l2Receiver.address, - tokenSender.address, - toGRT('10'), - utils.defaultAbiCoder.encode(['uint256', 'bytes'], [0, []]), - ]) - // The real outbox would require a proof, which would - // validate that the tx was initiated by the L2 gateway but our mock - // just executes unconditionally - const tx = outboxMock - .connect(tokenSender) - .executeTransaction( - toBN('0'), - [], - toBN('0'), - l2GRTGatewayMock.address, - l1GraphTokenGateway.address, - toBN('1337'), - await helpers.latestBlock(), - toBN('133701337'), - toBN('0'), - encodedCalldata, - ) - await expect(tx).revertedWith('INVALID_L2_MINT_AMOUNT') - }) - it('reverts if the gateway is revoked from escrow', async function () { - await grt.connect(tokenSender).approve(l1GraphTokenGateway.address, toGRT('10')) - await testValidOutboundTransfer(tokenSender, defaultData, emptyCallHookData) - // At this point, the gateway holds 10 GRT in escrow - // But we revoke the gateway's permission to move the funds: - await bridgeEscrow.connect(governor).revokeAll(l1GraphTokenGateway.address) - const encodedCalldata = l1GraphTokenGateway.interface.encodeFunctionData('finalizeInboundTransfer', [ - grt.address, - l2Receiver.address, - tokenSender.address, - toGRT('8'), - utils.defaultAbiCoder.encode(['uint256', 'bytes'], [0, []]), - ]) - // The real outbox would require a proof, which would - // validate that the tx was initiated by the L2 gateway but our mock - // just executes unconditionally - const tx = outboxMock - .connect(tokenSender) - .executeTransaction( - toBN('0'), - [], - toBN('0'), - l2GRTGatewayMock.address, - l1GraphTokenGateway.address, - toBN('1337'), - await helpers.latestBlock(), - toBN('133701337'), - toBN('0'), - encodedCalldata, - ) - await expect(tx).revertedWith('ERC20: transfer amount exceeds allowance') - }) - it('sends tokens out of escrow', async function () { - await grt.connect(tokenSender).approve(l1GraphTokenGateway.address, toGRT('10')) - await testValidOutboundTransfer(tokenSender, defaultData, emptyCallHookData) - // At this point, the gateway holds 10 GRT in escrow - const encodedCalldata = l1GraphTokenGateway.interface.encodeFunctionData('finalizeInboundTransfer', [ - grt.address, - l2Receiver.address, - tokenSender.address, - toGRT('8'), - utils.defaultAbiCoder.encode(['uint256', 'bytes'], [0, []]), - ]) - // The real outbox would require a proof, which would - // validate that the tx was initiated by the L2 gateway but our mock - // just executes unconditionally - const tx = outboxMock - .connect(tokenSender) - .executeTransaction( - toBN('0'), - [], - toBN('0'), - l2GRTGatewayMock.address, - l1GraphTokenGateway.address, - toBN('1337'), - await helpers.latestBlock(), - toBN('133701337'), - toBN('0'), - encodedCalldata, - ) - await expect(tx) - .emit(l1GraphTokenGateway, 'WithdrawalFinalized') - .withArgs(grt.address, l2Receiver.address, tokenSender.address, toBN('0'), toGRT('8')) - const escrowBalance = await grt.balanceOf(bridgeEscrow.address) - const senderBalance = await grt.balanceOf(tokenSender.address) - expect(escrowBalance).eq(toGRT('2')) - expect(senderBalance).eq(toGRT('998')) - }) - it('mints tokens up to the L2 mint allowance', async function () { - await grt.connect(tokenSender).approve(l1GraphTokenGateway.address, toGRT('10')) - await testValidOutboundTransfer(tokenSender, defaultData, emptyCallHookData) - - // Start accruing L2 mint allowance at 2 GRT per block - await l1GraphTokenGateway.connect(governor).updateL2MintAllowance(toGRT('2'), await helpers.latestBlock()) - await helpers.mine(2) - // Now it's been three blocks since the lastL2MintAllowanceUpdateBlock, so - // there should be 8 GRT allowed to be minted from L2 in the next block. - - // At this point, the gateway holds 10 GRT in escrow - const encodedCalldata = l1GraphTokenGateway.interface.encodeFunctionData('finalizeInboundTransfer', [ - grt.address, - l2Receiver.address, - tokenSender.address, - toGRT('18'), - utils.defaultAbiCoder.encode(['uint256', 'bytes'], [0, []]), - ]) - // The real outbox would require a proof, which would - // validate that the tx was initiated by the L2 gateway but our mock - // just executes unconditionally - const tx = outboxMock - .connect(tokenSender) - .executeTransaction( - toBN('0'), - [], - toBN('0'), - l2GRTGatewayMock.address, - l1GraphTokenGateway.address, - toBN('1337'), - await helpers.latestBlock(), - toBN('133701337'), - toBN('0'), - encodedCalldata, - ) - await expect(tx) - .emit(l1GraphTokenGateway, 'WithdrawalFinalized') - .withArgs(grt.address, l2Receiver.address, tokenSender.address, toBN('0'), toGRT('18')) - .emit(l1GraphTokenGateway, 'TokensMintedFromL2') - .withArgs(toGRT('8')) - expect(await l1GraphTokenGateway.totalMintedFromL2()).to.eq(toGRT('8')) - expect(await l1GraphTokenGateway.accumulatedL2MintAllowanceAtBlock(await helpers.latestBlock())).to.eq( - toGRT('8'), - ) - - const escrowBalance = await grt.balanceOf(bridgeEscrow.address) - const senderBalance = await grt.balanceOf(tokenSender.address) - expect(escrowBalance).eq(toGRT('0')) - expect(senderBalance).eq(toGRT('1008')) - }) - it('reverts if the amount to mint is over the allowance', async function () { - await grt.connect(tokenSender).approve(l1GraphTokenGateway.address, toGRT('10')) - await testValidOutboundTransfer(tokenSender, defaultData, emptyCallHookData) - - // Start accruing L2 mint allowance at 2 GRT per block - await l1GraphTokenGateway.connect(governor).updateL2MintAllowance(toGRT('2'), await helpers.latestBlock()) - await helpers.mine(2) - // Now it's been three blocks since the lastL2MintAllowanceUpdateBlock, so - // there should be 8 GRT allowed to be minted from L2 in the next block. - - // At this point, the gateway holds 10 GRT in escrow - const encodedCalldata = l1GraphTokenGateway.interface.encodeFunctionData('finalizeInboundTransfer', [ - grt.address, - l2Receiver.address, - tokenSender.address, - toGRT('18.001'), - utils.defaultAbiCoder.encode(['uint256', 'bytes'], [0, []]), - ]) - // The real outbox would require a proof, which would - // validate that the tx was initiated by the L2 gateway but our mock - // just executes unconditionally - const tx = outboxMock - .connect(tokenSender) - .executeTransaction( - toBN('0'), - [], - toBN('0'), - l2GRTGatewayMock.address, - l1GraphTokenGateway.address, - toBN('1337'), - await helpers.latestBlock(), - toBN('133701337'), - toBN('0'), - encodedCalldata, - ) - await expect(tx).revertedWith('INVALID_L2_MINT_AMOUNT') - }) - }) - }) -}) diff --git a/packages/contracts-test/tests/unit/l2/l2ArbitrumMessengerMock.ts b/packages/contracts-test/tests/unit/l2/l2ArbitrumMessengerMock.ts deleted file mode 100644 index 51d93bf12..000000000 --- a/packages/contracts-test/tests/unit/l2/l2ArbitrumMessengerMock.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Mock implementation of the L2ArbitrumMessenger contract - * This is used to override the sendTxToL1 function in the L2GraphTokenGateway contract - */ -export class L2ArbitrumMessengerMock { - private static _calls: Array<{ - l1CallValue: number - from: string - to: string - data: string - }> = [] - - /** - * Mock implementation of sendTxToL1 function - * @param l1CallValue The call value to send to L1 - * @param from The sender address - * @param to The destination address on L1 - * @param data The calldata to send to L1 - * @returns A transaction ID (always returns 1) - */ - public static sendTxToL1(l1CallValue: number, from: string, to: string, data: string): number { - this._calls.push({ l1CallValue, from, to, data }) - return 1 // Always return 1 as the transaction ID - } - - /** - * Check if sendTxToL1 was called with specific arguments - * @param to The expected destination address - * @param data The expected calldata - * @returns true if the function was called with the specified arguments - */ - public static calledWith(to: string, data: string): boolean { - return this._calls.some((call) => call.to === to && call.data === data) - } - - /** - * Reset all recorded calls - */ - public static reset(): void { - this._calls = [] - } - - /** - * Get the number of times sendTxToL1 was called - */ - public static get callCount(): number { - return this._calls.length - } -} diff --git a/packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts b/packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts deleted file mode 100644 index 45e6b0fc1..000000000 --- a/packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { FakeContract, smock } from '@defi-wonderland/smock' -import { CallhookReceiverMock } from '@graphprotocol/contracts' -import { L2GraphToken } from '@graphprotocol/contracts' -import { L2GraphTokenGateway } from '@graphprotocol/contracts' -import { expect, use } from 'chai' -import { constants, ContractTransaction, Signer, utils, Wallet } from 'ethers' -import hre from 'hardhat' - -import { isRunningUnderCoverage } from '../../../utils/coverage' -import { NetworkFixture } from '../lib/fixtures' - -use(smock.matchers) - -import { GraphToken, L1GraphTokenGateway } from '@graphprotocol/contracts' -import { RewardsManager } from '@graphprotocol/contracts' -import { deploy, DeployType, GraphNetworkContracts, helpers, toBN, toGRT } from '@graphprotocol/sdk' -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' - -const { AddressZero } = constants - -describe('L2GraphTokenGateway', () => { - const graph = hre.graph() - let me: SignerWithAddress - let governor: SignerWithAddress - let tokenSender: SignerWithAddress - let l1Receiver: SignerWithAddress - let l2Receiver: SignerWithAddress - let pauseGuardian: SignerWithAddress - let fixture: NetworkFixture - let arbSysMock: FakeContract - - let fixtureContracts: GraphNetworkContracts - let l1MockContracts: GraphNetworkContracts - let l1GRTMock: GraphToken - let l1GRTGatewayMock: L1GraphTokenGateway - let routerMock: Wallet - - let grt: L2GraphToken - let l2GraphTokenGateway: L2GraphTokenGateway - let callhookReceiverMock: CallhookReceiverMock - let rewardsManager: RewardsManager - - const senderTokens = toGRT('1000') - const defaultData = '0x' - const defaultDataWithNotEmptyCallHookData = utils.defaultAbiCoder.encode( - ['uint256', 'uint256'], - [toBN('1337'), toBN('42')], - ) - - before(async function () { - ;[me, governor, tokenSender, l1Receiver, l2Receiver, pauseGuardian] = await graph.getTestAccounts() - - fixture = new NetworkFixture(graph.provider) - - // Deploy L2 - fixtureContracts = await fixture.load(governor, true) - grt = fixtureContracts.GraphToken as L2GraphToken - l2GraphTokenGateway = fixtureContracts.L2GraphTokenGateway as L2GraphTokenGateway - rewardsManager = fixtureContracts.RewardsManager as RewardsManager - - // Deploy L2 arbitrum bridge - ;({ routerMock } = await fixture.loadL2ArbitrumBridge(governor)) - - // Deploy L1 mock - l1MockContracts = await fixture.loadMock(false) - l1GRTMock = l1MockContracts.GraphToken as GraphToken - l1GRTGatewayMock = l1MockContracts.L1GraphTokenGateway as L1GraphTokenGateway - - callhookReceiverMock = ( - await deploy(DeployType.Deploy, governor, { - name: 'CallhookReceiverMock', - }) - ).contract as CallhookReceiverMock - - // Give some funds to the token sender and router mock - await grt.connect(governor).mint(tokenSender.address, senderTokens) - await helpers.setBalance(routerMock.address, utils.parseEther('1')) - }) - - beforeEach(async function () { - await fixture.setUp() - // Thanks to Livepeer: https://github.com/livepeer/arbitrum-lpt-bridge/blob/main/test/unit/L2/l2LPTGateway.test.ts#L86 - // Skip smock setup when running under coverage due to provider compatibility issues - if (!isRunningUnderCoverage()) { - arbSysMock = await smock.fake('ArbSys', { - address: '0x0000000000000000000000000000000000000064', - }) - arbSysMock.sendTxToL1.returns(1) - } - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - context('> immediately after deploy', function () { - describe('calculateL2TokenAddress', function () { - it('should return the zero address', async function () { - expect(await l2GraphTokenGateway.calculateL2TokenAddress(grt.address)).eq(AddressZero) - }) - }) - - describe('outboundTransfer', function () { - it('reverts because it is paused', async function () { - const tx = l2GraphTokenGateway - .connect(tokenSender) - ['outboundTransfer(address,address,uint256,bytes)'](grt.address, l1Receiver.address, toGRT('10'), defaultData) - await expect(tx).revertedWith('Paused (contract)') - }) - }) - - describe('finalizeInboundTransfer', function () { - it('revert because it is paused', async function () { - const tx = l2GraphTokenGateway - .connect(tokenSender) - .finalizeInboundTransfer(grt.address, tokenSender.address, l1Receiver.address, toGRT('10'), defaultData) - await expect(tx).revertedWith('Paused (contract)') - }) - }) - - describe('setL2Router', function () { - it('is not callable by addreses that are not the governor', async function () { - const tx = l2GraphTokenGateway.connect(tokenSender).setL2Router(routerMock.address) - await expect(tx).revertedWith('Only Controller governor') - }) - it('sets router address', async function () { - const tx = l2GraphTokenGateway.connect(governor).setL2Router(routerMock.address) - await expect(tx).emit(l2GraphTokenGateway, 'L2RouterSet').withArgs(routerMock.address) - expect(await l2GraphTokenGateway.l2Router()).eq(routerMock.address) - }) - }) - - describe('setL1TokenAddress', function () { - it('is not callable by addreses that are not the governor', async function () { - const tx = l2GraphTokenGateway.connect(tokenSender).setL1TokenAddress(l1GRTMock.address) - await expect(tx).revertedWith('Only Controller governor') - }) - it('sets l2GRT', async function () { - const tx = l2GraphTokenGateway.connect(governor).setL1TokenAddress(l1GRTMock.address) - await expect(tx).emit(l2GraphTokenGateway, 'L1TokenAddressSet').withArgs(l1GRTMock.address) - expect(await l2GraphTokenGateway.l1GRT()).eq(l1GRTMock.address) - }) - }) - - describe('setL1CounterpartAddress', function () { - it('is not callable by addreses that are not the governor', async function () { - const tx = l2GraphTokenGateway.connect(tokenSender).setL1CounterpartAddress(l1GRTGatewayMock.address) - await expect(tx).revertedWith('Only Controller governor') - }) - it('sets L1Counterpart', async function () { - const tx = l2GraphTokenGateway.connect(governor).setL1CounterpartAddress(l1GRTGatewayMock.address) - await expect(tx).emit(l2GraphTokenGateway, 'L1CounterpartAddressSet').withArgs(l1GRTGatewayMock.address) - expect(await l2GraphTokenGateway.l1Counterpart()).eq(l1GRTGatewayMock.address) - }) - }) - describe('Pausable behavior', () => { - it('cannot be paused or unpaused by someone other than governor or pauseGuardian', async () => { - let tx = l2GraphTokenGateway.connect(tokenSender).setPaused(false) - await expect(tx).revertedWith('Only Governor or Guardian') - tx = l2GraphTokenGateway.connect(tokenSender).setPaused(true) - await expect(tx).revertedWith('Only Governor or Guardian') - }) - it('cannot be paused if some state variables are not set', async function () { - let tx = l2GraphTokenGateway.connect(governor).setPaused(false) - await expect(tx).revertedWith('L2_ROUTER_NOT_SET') - await l2GraphTokenGateway.connect(governor).setL2Router(routerMock.address) - tx = l2GraphTokenGateway.connect(governor).setPaused(false) - await expect(tx).revertedWith('L1_COUNTERPART_NOT_SET') - await l2GraphTokenGateway.connect(governor).setL1CounterpartAddress(l1GRTGatewayMock.address) - tx = l2GraphTokenGateway.connect(governor).setPaused(false) - await expect(tx).revertedWith('L1_GRT_NOT_SET') - }) - it('can be paused and unpaused by the governor', async function () { - await fixture.configureL2Bridge(governor, fixtureContracts, l1MockContracts) - let tx = l2GraphTokenGateway.connect(governor).setPaused(true) - await expect(tx).emit(l2GraphTokenGateway, 'PauseChanged').withArgs(true) - expect(await l2GraphTokenGateway.paused()).eq(true) - tx = l2GraphTokenGateway.connect(governor).setPaused(false) - await expect(tx).emit(l2GraphTokenGateway, 'PauseChanged').withArgs(false) - expect(await l2GraphTokenGateway.paused()).eq(false) - }) - describe('setPauseGuardian', function () { - it('cannot be called by someone other than governor', async function () { - const tx = l2GraphTokenGateway.connect(tokenSender).setPauseGuardian(pauseGuardian.address) - await expect(tx).revertedWith('Only Controller governor') - }) - it('sets a new pause guardian', async function () { - const currentPauseGuardian = await l2GraphTokenGateway.pauseGuardian() - const tx = l2GraphTokenGateway.connect(governor).setPauseGuardian(pauseGuardian.address) - await expect(tx) - .emit(l2GraphTokenGateway, 'NewPauseGuardian') - .withArgs(currentPauseGuardian, pauseGuardian.address) - }) - it('allows a pause guardian to pause and unpause', async function () { - await fixture.configureL2Bridge(governor, fixtureContracts, l1MockContracts) - await l2GraphTokenGateway.connect(governor).setPauseGuardian(pauseGuardian.address) - let tx = l2GraphTokenGateway.connect(pauseGuardian).setPaused(true) - await expect(tx).emit(l2GraphTokenGateway, 'PauseChanged').withArgs(true) - expect(await l2GraphTokenGateway.paused()).eq(true) - tx = l2GraphTokenGateway.connect(pauseGuardian).setPaused(false) - await expect(tx).emit(l2GraphTokenGateway, 'PauseChanged').withArgs(false) - expect(await l2GraphTokenGateway.paused()).eq(false) - }) - }) - }) - }) - - context('> after configuring and unpausing', function () { - const testValidOutboundTransfer = async function (signer: Signer, data: string) { - const tx = l2GraphTokenGateway - .connect(signer) - ['outboundTransfer(address,address,uint256,bytes)'](l1GRTMock.address, l1Receiver.address, toGRT('10'), data) - const expectedId = 1 - await expect(tx) - .emit(l2GraphTokenGateway, 'WithdrawalInitiated') - .withArgs(l1GRTMock.address, tokenSender.address, l1Receiver.address, expectedId, 0, toGRT('10')) - - // Should use the L1 Gateway's interface, but both come from ITokenGateway - const calldata = l2GraphTokenGateway.interface.encodeFunctionData('finalizeInboundTransfer', [ - l1GRTMock.address, - tokenSender.address, - l1Receiver.address, - toGRT('10'), - utils.defaultAbiCoder.encode(['uint256', 'bytes'], [0, []]), - ]) - await expect(tx) - .emit(l2GraphTokenGateway, 'TxToL1') - .withArgs(tokenSender.address, l1GRTGatewayMock.address, 1, calldata) - - // For some reason the call count doesn't work properly, - // and each function call is counted 12 times. - // Possibly related to https://github.com/defi-wonderland/smock/issues/85 ? - // expect(arbSysMock.sendTxToL1).to.have.been.calledOnce - - // Only check smock expectations when not running under coverage - if (!isRunningUnderCoverage() && arbSysMock) { - expect(arbSysMock.sendTxToL1).to.have.been.calledWith(l1GRTGatewayMock.address, calldata) - } - const senderBalance = await grt.balanceOf(tokenSender.address) - expect(senderBalance).eq(toGRT('990')) - } - before(async function () { - await fixture.configureL2Bridge(governor, fixtureContracts, l1MockContracts) - }) - - describe('calculateL2TokenAddress', function () { - it('returns the L2 token address', async function () { - expect(await l2GraphTokenGateway.calculateL2TokenAddress(l1GRTMock.address)).eq(grt.address) - }) - it('returns the zero address if the input is any other address', async function () { - expect(await l2GraphTokenGateway.calculateL2TokenAddress(tokenSender.address)).eq(AddressZero) - }) - }) - - describe('outboundTransfer', function () { - it('reverts when called with the wrong token address', async function () { - const tx = l2GraphTokenGateway - .connect(tokenSender) - [ - 'outboundTransfer(address,address,uint256,bytes)' - ](tokenSender.address, l1Receiver.address, toGRT('10'), defaultData) - await expect(tx).revertedWith('TOKEN_NOT_GRT') - }) - it('burns tokens and triggers an L1 call', async function () { - // Skip this test under coverage due to complex instrumentation issues - if (isRunningUnderCoverage()) { - this.skip() - return - } - - await grt.connect(tokenSender).approve(l2GraphTokenGateway.address, toGRT('10')) - await testValidOutboundTransfer(tokenSender, defaultData) - }) - it('decodes the sender address from messages sent by the router', async function () { - // Skip this test under coverage due to complex instrumentation issues - if (isRunningUnderCoverage()) { - this.skip() - return - } - - await grt.connect(tokenSender).approve(l2GraphTokenGateway.address, toGRT('10')) - const routerEncodedData = utils.defaultAbiCoder.encode(['address', 'bytes'], [tokenSender.address, defaultData]) - await testValidOutboundTransfer(routerMock, routerEncodedData) - }) - it('reverts when called with nonempty calldata', async function () { - await grt.connect(tokenSender).approve(l2GraphTokenGateway.address, toGRT('10')) - const tx = l2GraphTokenGateway - .connect(tokenSender) - [ - 'outboundTransfer(address,address,uint256,bytes)' - ](l1GRTMock.address, l1Receiver.address, toGRT('10'), defaultDataWithNotEmptyCallHookData) - await expect(tx).revertedWith('CALL_HOOK_DATA_NOT_ALLOWED') - }) - it('reverts when the sender does not have enough GRT', async function () { - await grt.connect(tokenSender).approve(l2GraphTokenGateway.address, toGRT('1001')) - const tx = l2GraphTokenGateway - .connect(tokenSender) - [ - 'outboundTransfer(address,address,uint256,bytes)' - ](l1GRTMock.address, l1Receiver.address, toGRT('1001'), defaultData) - await expect(tx).revertedWith('ERC20: burn amount exceeds balance') - }) - }) - - describe('finalizeInboundTransfer', function () { - const testValidFinalizeTransfer = async function (data: string, to?: string): Promise { - to = to ?? l2Receiver.address - const l1GRTGatewayMockL2Alias = await helpers.getL2SignerFromL1(l1GRTGatewayMock.address) - await me.sendTransaction({ - to: await l1GRTGatewayMockL2Alias.getAddress(), - value: utils.parseUnits('1', 'ether'), - }) - const tx = l2GraphTokenGateway - .connect(l1GRTGatewayMockL2Alias) - .finalizeInboundTransfer(l1GRTMock.address, tokenSender.address, to, toGRT('10'), data) - await expect(tx) - .emit(l2GraphTokenGateway, 'DepositFinalized') - .withArgs(l1GRTMock.address, tokenSender.address, to, toGRT('10')) - - await expect(tx).emit(grt, 'BridgeMinted').withArgs(to, toGRT('10')) - - // Unchanged - const senderBalance = await grt.balanceOf(tokenSender.address) - expect(senderBalance).eq(toGRT('1000')) - // 10 newly minted GRT - const receiverBalance = await grt.balanceOf(to) - expect(receiverBalance).eq(toGRT('10')) - return tx - } - it('reverts when called by an account that is not the gateway', async function () { - const tx = l2GraphTokenGateway - .connect(tokenSender) - .finalizeInboundTransfer(l1GRTMock.address, tokenSender.address, l2Receiver.address, toGRT('10'), defaultData) - await expect(tx).revertedWith('ONLY_COUNTERPART_GATEWAY') - }) - it('reverts when called by an account that is the gateway but without the L2 alias', async function () { - const impersonatedGateway = await helpers.impersonateAccount(l1GRTGatewayMock.address) - const tx = l2GraphTokenGateway - .connect(impersonatedGateway) - .finalizeInboundTransfer(l1GRTMock.address, tokenSender.address, l2Receiver.address, toGRT('10'), defaultData) - await expect(tx).revertedWith('ONLY_COUNTERPART_GATEWAY') - }) - it('mints and sends tokens when called by the aliased gateway', async function () { - await testValidFinalizeTransfer(defaultData) - }) - it('calls a callhook if the transfer includes calldata', async function () { - const tx = await testValidFinalizeTransfer(defaultDataWithNotEmptyCallHookData, callhookReceiverMock.address) - // Emitted by the callhook: - await expect(tx) - .emit(callhookReceiverMock, 'TransferReceived') - .withArgs(tokenSender.address, toGRT('10'), toBN('1337'), toBN('42')) - }) - it('reverts if a callhook reverts', async function () { - // The 0 will make the callhook revert (see CallhookReceiverMock.sol) - const callHookData = utils.defaultAbiCoder.encode(['uint256', 'uint256'], [toBN('0'), toBN('42')]) - const l1GRTGatewayMockL2Alias = await helpers.getL2SignerFromL1(l1GRTGatewayMock.address) - await me.sendTransaction({ - to: await l1GRTGatewayMockL2Alias.getAddress(), - value: utils.parseUnits('1', 'ether'), - }) - const tx = l2GraphTokenGateway - .connect(l1GRTGatewayMockL2Alias) - .finalizeInboundTransfer( - l1GRTMock.address, - tokenSender.address, - callhookReceiverMock.address, - toGRT('10'), - callHookData, - ) - await expect(tx).revertedWith('FOO_IS_ZERO') - }) - it('reverts if trying to call a callhook in a contract that does not implement onTokenTransfer', async function () { - const callHookData = utils.defaultAbiCoder.encode(['uint256'], [toBN('0')]) - const l1GRTGatewayMockL2Alias = await helpers.getL2SignerFromL1(l1GRTGatewayMock.address) - await me.sendTransaction({ - to: await l1GRTGatewayMockL2Alias.getAddress(), - value: utils.parseUnits('1', 'ether'), - }) - // RewardsManager does not implement onTokenTransfer, so this will fail - const tx = l2GraphTokenGateway - .connect(l1GRTGatewayMockL2Alias) - .finalizeInboundTransfer( - l1GRTMock.address, - tokenSender.address, - rewardsManager.address, - toGRT('10'), - callHookData, - ) - - // The error message may vary depending on the environment - await expect(tx).to.be.reverted - }) - }) - }) -}) diff --git a/packages/graph-token/test/unit/gateway/L1GraphTokenGatewayConfig.t.sol b/packages/graph-token/test/unit/gateway/L1GraphTokenGatewayConfig.t.sol new file mode 100644 index 000000000..1ea1e2bb1 --- /dev/null +++ b/packages/graph-token/test/unit/gateway/L1GraphTokenGatewayConfig.t.sol @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { L1GraphTokenGatewaySharedTest } from "./L1GraphTokenGatewayShared.t.sol"; +import { IL1GraphTokenGatewayLike } from "../interfaces/IL1GraphTokenGatewayLike.sol"; + +/// Cases from the "immediately after deploy" context: the gateway is paused and unconfigured +contract L1GraphTokenGatewayConfigTest is L1GraphTokenGatewaySharedTest { + // -- calculateL2TokenAddress -- + + function test_CalculateL2TokenAddress_ReturnsZeroWhenNotSet() public view { + assertEq(l1Gateway.calculateL2TokenAddress(address(grt)), address(0)); + } + + // -- outboundTransfer -- + + function test_OutboundTransfer_RevertWhen_Paused() public { + vm.expectRevert(bytes("Paused (contract)")); + vm.prank(tokenSender); + l1Gateway.outboundTransfer{ value: DEFAULT_ETH_VALUE }( + address(grt), + l2Receiver, + 10 ether, + MAX_GAS, + GAS_PRICE_BID, + defaultData + ); + } + + // -- finalizeInboundTransfer -- + + function test_FinalizeInboundTransfer_RevertWhen_Paused() public { + vm.expectRevert(bytes("Paused (contract)")); + vm.prank(tokenSender); + l1Gateway.finalizeInboundTransfer(address(grt), l2Receiver, tokenSender, 10 ether, defaultData); + } + + // -- setArbitrumAddresses -- + + function test_SetArbitrumAddresses_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(tokenSender); + l1Gateway.setArbitrumAddresses(address(inboxMock), routerMock); + } + + function test_SetArbitrumAddresses_RevertWhen_NotContract() public { + vm.expectRevert(bytes("INBOX_MUST_BE_CONTRACT")); + vm.prank(governor); + l1Gateway.setArbitrumAddresses(tokenSender, routerMock); + + vm.expectRevert(bytes("ROUTER_MUST_BE_CONTRACT")); + vm.prank(governor); + l1Gateway.setArbitrumAddresses(address(inboxMock), tokenSender); + } + + function test_SetArbitrumAddresses_SetsInboxAndRouter() public { + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.ArbitrumAddressesSet(address(inboxMock), routerMock); + vm.prank(governor); + l1Gateway.setArbitrumAddresses(address(inboxMock), routerMock); + + assertEq(l1Gateway.l1Router(), routerMock); + assertEq(l1Gateway.inbox(), address(inboxMock)); + } + + // -- setL2TokenAddress -- + + function test_SetL2TokenAddress_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(tokenSender); + l1Gateway.setL2TokenAddress(l2GRTMock); + } + + function test_SetL2TokenAddress_SetsL2GRT() public { + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.L2TokenAddressSet(l2GRTMock); + vm.prank(governor); + l1Gateway.setL2TokenAddress(l2GRTMock); + + assertEq(l1Gateway.l2GRT(), l2GRTMock); + } + + // -- setL2CounterpartAddress -- + + function test_SetL2CounterpartAddress_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(tokenSender); + l1Gateway.setL2CounterpartAddress(l2GatewayMock); + } + + function test_SetL2CounterpartAddress_SetsL2Counterpart() public { + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.L2CounterpartAddressSet(l2GatewayMock); + vm.prank(governor); + l1Gateway.setL2CounterpartAddress(l2GatewayMock); + + assertEq(l1Gateway.l2Counterpart(), l2GatewayMock); + assertEq(l1Gateway.counterpartGateway(), l2GatewayMock); + } + + // -- setEscrowAddress -- + + function test_SetEscrowAddress_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(tokenSender); + l1Gateway.setEscrowAddress(address(bridgeEscrow)); + } + + function test_SetEscrowAddress_SetsEscrow() public { + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.EscrowAddressSet(address(bridgeEscrow)); + vm.prank(governor); + l1Gateway.setEscrowAddress(address(bridgeEscrow)); + + assertEq(l1Gateway.escrow(), address(bridgeEscrow)); + } + + // -- addToCallhookAllowlist -- + + function test_AddToCallhookAllowlist_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(tokenSender); + l1Gateway.addToCallhookAllowlist(address(controller)); + + assertFalse(l1Gateway.callhookAllowlist(address(controller))); + } + + function test_AddToCallhookAllowlist_RevertWhen_NotContract() public { + vm.expectRevert(bytes("MUST_BE_CONTRACT")); + vm.prank(governor); + l1Gateway.addToCallhookAllowlist(tokenSender); + } + + function test_AddToCallhookAllowlist_AddsAddress() public { + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.AddedToCallhookAllowlist(address(controller)); + vm.prank(governor); + l1Gateway.addToCallhookAllowlist(address(controller)); + + assertTrue(l1Gateway.callhookAllowlist(address(controller))); + } + + // -- removeFromCallhookAllowlist -- + + function test_RemoveFromCallhookAllowlist_RevertWhen_NotGovernor() public { + vm.prank(governor); + l1Gateway.addToCallhookAllowlist(address(controller)); + + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(tokenSender); + l1Gateway.removeFromCallhookAllowlist(address(controller)); + + assertTrue(l1Gateway.callhookAllowlist(address(controller))); + } + + function test_RemoveFromCallhookAllowlist_RemovesAddress() public { + vm.prank(governor); + l1Gateway.addToCallhookAllowlist(address(controller)); + + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.RemovedFromCallhookAllowlist(address(controller)); + vm.prank(governor); + l1Gateway.removeFromCallhookAllowlist(address(controller)); + + assertFalse(l1Gateway.callhookAllowlist(address(controller))); + } + + // -- Pausable behavior -- + + function test_PausableBehavior_RevertWhen_NotGovernorOrGuardian() public { + vm.expectRevert(bytes("Only Governor or Guardian")); + vm.prank(tokenSender); + l1Gateway.setPaused(false); + + vm.expectRevert(bytes("Only Governor or Guardian")); + vm.prank(tokenSender); + l1Gateway.setPaused(true); + } + + function test_PausableBehavior_RevertWhen_UnpausingWithStateNotSet() public { + vm.startPrank(governor); + vm.expectRevert(bytes("INBOX_NOT_SET")); + l1Gateway.setPaused(false); + + l1Gateway.setArbitrumAddresses(address(inboxMock), routerMock); + vm.expectRevert(bytes("L2_COUNTERPART_NOT_SET")); + l1Gateway.setPaused(false); + + l1Gateway.setL2CounterpartAddress(l2GatewayMock); + vm.expectRevert(bytes("ESCROW_NOT_SET")); + l1Gateway.setPaused(false); + vm.stopPrank(); + } + + function test_PausableBehavior_GovernorCanPauseAndUnpause() public { + _configureL1Bridge(); + + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.PauseChanged(true); + vm.prank(governor); + l1Gateway.setPaused(true); + assertTrue(l1Gateway.paused()); + + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.PauseChanged(false); + vm.prank(governor); + l1Gateway.setPaused(false); + assertFalse(l1Gateway.paused()); + } + + // -- setPauseGuardian -- + + function test_SetPauseGuardian_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(tokenSender); + l1Gateway.setPauseGuardian(pauseGuardian); + } + + function test_SetPauseGuardian_SetsPauseGuardian() public { + address currentPauseGuardian = l1Gateway.pauseGuardian(); + + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.NewPauseGuardian(currentPauseGuardian, pauseGuardian); + vm.prank(governor); + l1Gateway.setPauseGuardian(pauseGuardian); + } + + function test_SetPauseGuardian_GuardianCanPauseAndUnpause() public { + _configureL1Bridge(); + vm.prank(governor); + l1Gateway.setPauseGuardian(pauseGuardian); + + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.PauseChanged(true); + vm.prank(pauseGuardian); + l1Gateway.setPaused(true); + assertTrue(l1Gateway.paused()); + + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.PauseChanged(false); + vm.prank(pauseGuardian); + l1Gateway.setPaused(false); + assertFalse(l1Gateway.paused()); + } +} diff --git a/packages/graph-token/test/unit/gateway/L1GraphTokenGatewayShared.t.sol b/packages/graph-token/test/unit/gateway/L1GraphTokenGatewayShared.t.sol new file mode 100644 index 000000000..99acfad31 --- /dev/null +++ b/packages/graph-token/test/unit/gateway/L1GraphTokenGatewayShared.t.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IManaged } from "@graphprotocol/interfaces/contracts/contracts/governance/IManaged.sol"; +import { ProtocolFixture } from "../shared/ProtocolFixture.t.sol"; +import { IGraphTokenLike } from "../interfaces/IGraphTokenLike.sol"; +import { IBridgeEscrowLike } from "../interfaces/IBridgeEscrowLike.sol"; +import { IL1GraphTokenGatewayLike } from "../interfaces/IL1GraphTokenGatewayLike.sol"; +import { IBridgeMockLike, IInboxMockLike, IOutboxMockLike } from "../interfaces/IArbitrumMocksLike.sol"; + +abstract contract L1GraphTokenGatewaySharedTest is ProtocolFixture { + string internal constant GRAPH_TOKEN = string.concat(PKG_ARTIFACTS, "token/GraphToken.sol/GraphToken.json"); + string internal constant BRIDGE_ESCROW = string.concat(PKG_ARTIFACTS, "gateway/BridgeEscrow.sol/BridgeEscrow.json"); + string internal constant L1_GATEWAY = + string.concat(PKG_ARTIFACTS, "gateway/L1GraphTokenGateway.sol/L1GraphTokenGateway.json"); + string internal constant BRIDGE_MOCK = + string.concat(PKG_ARTIFACTS, "tests/arbitrum/BridgeMock.sol/BridgeMock.json"); + string internal constant INBOX_MOCK = string.concat(PKG_ARTIFACTS, "tests/arbitrum/InboxMock.sol/InboxMock.json"); + string internal constant OUTBOX_MOCK = + string.concat(PKG_ARTIFACTS, "tests/arbitrum/OutboxMock.sol/OutboxMock.json"); + + uint256 internal constant SENDER_TOKENS = 1000 ether; + uint256 internal constant MAX_GAS = 1_000_000; + uint256 internal constant MAX_SUBMISSION_COST = 7; + uint256 internal constant GAS_PRICE_BID = 2; + uint256 internal constant DEFAULT_ETH_VALUE = MAX_SUBMISSION_COST + MAX_GAS * GAS_PRICE_BID; + + IGraphTokenLike internal grt; + IBridgeEscrowLike internal bridgeEscrow; + IL1GraphTokenGatewayLike internal l1Gateway; + + IBridgeMockLike internal bridgeMock; + IInboxMockLike internal inboxMock; + IOutboxMockLike internal outboxMock; + + address internal tokenSender = makeAddr("tokenSender"); + address internal l2Receiver = makeAddr("l2Receiver"); + address internal pauseGuardian = makeAddr("pauseGuardian"); + address internal routerMock = makeAddr("routerMock"); + address internal l2GRTMock = makeAddr("l2GRTMock"); + address internal l2GatewayMock = makeAddr("l2GatewayMock"); + + bytes internal defaultData = abi.encode(MAX_SUBMISSION_COST, bytes("")); + + function setUp() public virtual { + vm.warp(1_700_000_000); + _deployProtocolBase(); + + vm.prank(governor); + grt = IGraphTokenLike(_deployArtifact(GRAPH_TOKEN, abi.encode(10_000_000_000 ether))); + _registerContract("GraphToken", address(grt)); + + bridgeEscrow = IBridgeEscrowLike( + _deployBehindProxy( + _deployArtifact(BRIDGE_ESCROW, ""), + abi.encodeCall(IBridgeEscrowLike.initialize, (address(controller))) + ) + ); + IManaged(address(bridgeEscrow)).syncAllContracts(); + + l1Gateway = IL1GraphTokenGatewayLike( + _deployBehindProxy( + _deployArtifact(L1_GATEWAY, ""), + abi.encodeCall(IL1GraphTokenGatewayLike.initialize, (address(controller))) + ) + ); + _registerContract("GraphTokenGateway", address(l1Gateway)); + IManaged(address(l1Gateway)).syncAllContracts(); + + bridgeMock = IBridgeMockLike(_deployArtifact(BRIDGE_MOCK, "")); + inboxMock = IInboxMockLike(_deployArtifact(INBOX_MOCK, "")); + outboxMock = IOutboxMockLike(_deployArtifact(OUTBOX_MOCK, "")); + bridgeMock.setInbox(address(inboxMock), true); + bridgeMock.setOutbox(address(outboxMock), true); + inboxMock.setBridge(address(bridgeMock)); + outboxMock.setBridge(address(bridgeMock)); + + vm.etch(routerMock, hex"1234"); + vm.etch(l2GRTMock, hex"1234"); + vm.etch(l2GatewayMock, hex"1234"); + + vm.startPrank(governor); + // As in the old fixture's graph config, the gateway mints what L2 sends beyond the escrow + grt.addMinter(address(l1Gateway)); + grt.mint(tokenSender, SENDER_TOKENS); + vm.stopPrank(); + vm.deal(tokenSender, 1 ether); + vm.deal(routerMock, 1 ether); + } + + /// Wires the gateway to the L2 mocks, escrow and Arbitrum bridge mocks, then unpauses it + function _configureL1Bridge() internal { + vm.startPrank(governor); + l1Gateway.setL2TokenAddress(l2GRTMock); + l1Gateway.setL2CounterpartAddress(l2GatewayMock); + l1Gateway.setEscrowAddress(address(bridgeEscrow)); + bridgeEscrow.approveAll(address(l1Gateway)); + l1Gateway.setArbitrumAddresses(address(inboxMock), routerMock); + l1Gateway.setPaused(false); + vm.stopPrank(); + } +} diff --git a/packages/graph-token/test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol b/packages/graph-token/test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol new file mode 100644 index 000000000..8da02f1ed --- /dev/null +++ b/packages/graph-token/test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol @@ -0,0 +1,465 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IBridge } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/IBridge.sol"; +import { ITokenGateway } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/ITokenGateway.sol"; +import { L1GraphTokenGatewaySharedTest } from "./L1GraphTokenGatewayShared.t.sol"; +import { IL1GraphTokenGatewayLike } from "../interfaces/IL1GraphTokenGatewayLike.sol"; +import { IInboxMockLike } from "../interfaces/IArbitrumMocksLike.sol"; + +/// Cases from the "after configuring and unpausing" context +contract L1GraphTokenGatewayTransfersTest is L1GraphTokenGatewaySharedTest { + uint8 internal constant L1_MESSAGE_TYPE_SUBMIT_RETRYABLE_TX = 9; + uint160 internal constant L1_TO_L2_ALIAS_OFFSET = uint160(0x1111000000000000000000000000000000001111); + + bytes internal notEmptyCallHookData = hex"12"; + + function setUp() public override { + super.setUp(); + _configureL1Bridge(); + } + + function _applyL1ToL2Alias(address l1Address) internal pure returns (address) { + unchecked { + return address(uint160(l1Address) + L1_TO_L2_ALIAS_OFFSET); + } + } + + /// Message data the InboxMock builds for the retryable ticket + function _createMsgData(bytes memory callHookData) internal view returns (bytes memory) { + bytes memory outboundData = abi.encodeCall( + ITokenGateway.finalizeInboundTransfer, + (address(grt), tokenSender, l2Receiver, 10 ether, callHookData) + ); + assertEq( + l1Gateway.getOutboundCalldata(address(grt), tokenSender, l2Receiver, 10 ether, callHookData), + outboundData + ); + return + abi.encodePacked( + uint256(uint160(l2GatewayMock)), + uint256(0), + DEFAULT_ETH_VALUE, + MAX_SUBMISSION_COST, + uint256(uint160(_applyL1ToL2Alias(tokenSender))), + uint256(uint160(_applyL1ToL2Alias(tokenSender))), + MAX_GAS, + GAS_PRICE_BID, + outboundData.length, + outboundData + ); + } + + function _testValidOutboundTransfer(address signer, bytes memory data, bytes memory callHookData) internal { + bytes memory msgData = _createMsgData(callHookData); + bytes32 msgDataHash = keccak256(msgData); + // The real bridge would emit the InboxAccs entry that came before this one, but the mock + // emits this one, which lets us validate all the parameters we sent + bytes32 expectedInboxAccsEntry = keccak256( + abi.encodePacked(address(inboxMock), L1_MESSAGE_TYPE_SUBMIT_RETRYABLE_TX, address(l1Gateway), msgDataHash) + ); + // The bridge mock returns an incrementing seqNum starting at 1 + uint256 expectedSeqNum = 1; + + vm.expectEmit(address(bridgeMock)); + emit IBridge.MessageDelivered( + expectedSeqNum, + expectedInboxAccsEntry, + address(inboxMock), + L1_MESSAGE_TYPE_SUBMIT_RETRYABLE_TX, + address(l1Gateway), + msgDataHash + ); + vm.expectEmit(address(inboxMock)); + emit IInboxMockLike.InboxMessageDelivered(1, msgData); + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.DepositInitiated(address(grt), tokenSender, l2Receiver, expectedSeqNum, 10 ether); + vm.prank(signer); + l1Gateway.outboundTransfer{ value: DEFAULT_ETH_VALUE }( + address(grt), + l2Receiver, + 10 ether, + MAX_GAS, + GAS_PRICE_BID, + data + ); + + assertEq(grt.balanceOf(address(bridgeEscrow)), 10 ether); + assertEq(grt.balanceOf(tokenSender), 990 ether); + } + + /// The real outbox would require a proof validating that the tx was initiated by + /// the L2 gateway, but the mock just executes unconditionally + function _executeInbound(address l2Sender, uint256 amount) internal { + bytes memory encodedCalldata = abi.encodeCall( + ITokenGateway.finalizeInboundTransfer, + (address(grt), l2Receiver, tokenSender, amount, abi.encode(uint256(0), bytes(""))) + ); + vm.prank(tokenSender); + outboxMock.executeTransaction( + 0, + new bytes32[](0), + 0, + l2Sender, + address(l1Gateway), + 1337, + block.number, + 133701337, + 0, + encodedCalldata + ); + } + + function _approveAndTransfer() internal { + vm.prank(tokenSender); + grt.approve(address(l1Gateway), 10 ether); + _testValidOutboundTransfer(tokenSender, defaultData, ""); + } + + // -- updateL2MintAllowance -- + + function test_UpdateL2MintAllowance_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(pauseGuardian); + l1Gateway.updateL2MintAllowance(1 ether, block.number); + } + + function test_UpdateL2MintAllowance_RevertWhen_FutureOrCurrentBlock() public { + vm.roll(100); + uint256 issuancePerBlock = 120 ether; + + vm.startPrank(governor); + vm.expectRevert(bytes("BLOCK_MUST_BE_PAST")); + l1Gateway.updateL2MintAllowance(issuancePerBlock, block.number + 1); + + vm.expectRevert(bytes("BLOCK_MUST_BE_PAST")); + l1Gateway.updateL2MintAllowance(issuancePerBlock, block.number); + + uint256 issuanceUpdatedAtBlock = block.number - 1; + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.L2MintAllowanceUpdated(0, issuancePerBlock, issuanceUpdatedAtBlock); + l1Gateway.updateL2MintAllowance(issuancePerBlock, issuanceUpdatedAtBlock); + vm.stopPrank(); + } + + function test_UpdateL2MintAllowance_RevertWhen_BlockNotIncrementing() public { + vm.roll(100); + uint256 issuancePerBlock = 120 ether; + uint256 issuanceUpdatedAtBlock = block.number - 1; + + vm.startPrank(governor); + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.L2MintAllowanceUpdated(0, issuancePerBlock, issuanceUpdatedAtBlock); + l1Gateway.updateL2MintAllowance(issuancePerBlock, issuanceUpdatedAtBlock); + + vm.expectRevert(bytes("BLOCK_MUST_BE_INCREMENTING")); + l1Gateway.updateL2MintAllowance(issuancePerBlock, issuanceUpdatedAtBlock); + + vm.expectRevert(bytes("BLOCK_MUST_BE_INCREMENTING")); + l1Gateway.updateL2MintAllowance(issuancePerBlock, issuanceUpdatedAtBlock - 1); + + vm.roll(block.number + 1); + uint256 nextUpdateBlock = issuanceUpdatedAtBlock + 1; + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.L2MintAllowanceUpdated( + issuancePerBlock * (nextUpdateBlock - issuanceUpdatedAtBlock), + issuancePerBlock, + nextUpdateBlock + ); + l1Gateway.updateL2MintAllowance(issuancePerBlock, nextUpdateBlock); + vm.stopPrank(); + } + + function test_UpdateL2MintAllowance_AccumulatesUpToSpecifiedBlock() public { + vm.roll(100); + uint256 issuancePerBlock = 120 ether; + uint256 issuanceUpdatedAtBlock = block.number - 3; + + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.L2MintAllowanceUpdated(0, issuancePerBlock, issuanceUpdatedAtBlock); + vm.prank(governor); + l1Gateway.updateL2MintAllowance(issuancePerBlock, issuanceUpdatedAtBlock); + + assertEq( + l1Gateway.accumulatedL2MintAllowanceAtBlock(block.number), + issuancePerBlock * (block.number - issuanceUpdatedAtBlock) + ); + assertEq(l1Gateway.accumulatedL2MintAllowanceSnapshot(), 0); + assertEq(l1Gateway.l2MintAllowancePerBlock(), issuancePerBlock); + assertEq(l1Gateway.lastL2MintAllowanceUpdateBlock(), issuanceUpdatedAtBlock); + + vm.roll(block.number + 10); + + uint256 newIssuancePerBlock = 200 ether; + uint256 newIssuanceUpdatedAtBlock = block.number - 2; + uint256 expectedAccumulatedSnapshot = issuancePerBlock * (newIssuanceUpdatedAtBlock - issuanceUpdatedAtBlock); + + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.L2MintAllowanceUpdated( + expectedAccumulatedSnapshot, + newIssuancePerBlock, + newIssuanceUpdatedAtBlock + ); + vm.prank(governor); + l1Gateway.updateL2MintAllowance(newIssuancePerBlock, newIssuanceUpdatedAtBlock); + + assertEq( + l1Gateway.accumulatedL2MintAllowanceAtBlock(block.number), + expectedAccumulatedSnapshot + newIssuancePerBlock * (block.number - newIssuanceUpdatedAtBlock) + ); + assertEq(l1Gateway.accumulatedL2MintAllowanceSnapshot(), expectedAccumulatedSnapshot); + assertEq(l1Gateway.l2MintAllowancePerBlock(), newIssuancePerBlock); + assertEq(l1Gateway.lastL2MintAllowanceUpdateBlock(), newIssuanceUpdatedAtBlock); + } + + // -- setL2MintAllowanceParametersManual -- + + function test_SetL2MintAllowanceParametersManual_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(pauseGuardian); + l1Gateway.setL2MintAllowanceParametersManual(0, 1 ether, block.number); + } + + function test_SetL2MintAllowanceParametersManual_RevertWhen_FutureOrCurrentBlock() public { + vm.roll(100); + uint256 issuancePerBlock = 120 ether; + + vm.startPrank(governor); + vm.expectRevert(bytes("BLOCK_MUST_BE_PAST")); + l1Gateway.setL2MintAllowanceParametersManual(0, issuancePerBlock, block.number + 1); + + vm.expectRevert(bytes("BLOCK_MUST_BE_PAST")); + l1Gateway.setL2MintAllowanceParametersManual(0, issuancePerBlock, block.number); + + uint256 issuanceUpdatedAtBlock = block.number - 1; + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.L2MintAllowanceUpdated(0, issuancePerBlock, issuanceUpdatedAtBlock); + l1Gateway.setL2MintAllowanceParametersManual(0, issuancePerBlock, issuanceUpdatedAtBlock); + vm.stopPrank(); + } + + function test_SetL2MintAllowanceParametersManual_SetsSnapshotManually() public { + vm.roll(100); + uint256 issuancePerBlock = 120 ether; + uint256 issuanceUpdatedAtBlock = block.number - 3; + uint256 snapshotValue = 10 ether; + + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.L2MintAllowanceUpdated(snapshotValue, issuancePerBlock, issuanceUpdatedAtBlock); + vm.prank(governor); + l1Gateway.setL2MintAllowanceParametersManual(snapshotValue, issuancePerBlock, issuanceUpdatedAtBlock); + + assertEq( + l1Gateway.accumulatedL2MintAllowanceAtBlock(block.number), + snapshotValue + issuancePerBlock * (block.number - issuanceUpdatedAtBlock) + ); + assertEq(l1Gateway.accumulatedL2MintAllowanceSnapshot(), snapshotValue); + assertEq(l1Gateway.l2MintAllowancePerBlock(), issuancePerBlock); + assertEq(l1Gateway.lastL2MintAllowanceUpdateBlock(), issuanceUpdatedAtBlock); + + vm.roll(block.number + 10); + + uint256 newIssuancePerBlock = 200 ether; + uint256 newIssuanceUpdatedAtBlock = block.number - 2; + uint256 newSnapshotValue = 10 ether; + + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.L2MintAllowanceUpdated( + newSnapshotValue, + newIssuancePerBlock, + newIssuanceUpdatedAtBlock + ); + vm.prank(governor); + l1Gateway.setL2MintAllowanceParametersManual(newSnapshotValue, newIssuancePerBlock, newIssuanceUpdatedAtBlock); + + assertEq( + l1Gateway.accumulatedL2MintAllowanceAtBlock(block.number), + newSnapshotValue + newIssuancePerBlock * (block.number - newIssuanceUpdatedAtBlock) + ); + assertEq(l1Gateway.accumulatedL2MintAllowanceSnapshot(), newSnapshotValue); + assertEq(l1Gateway.l2MintAllowancePerBlock(), newIssuancePerBlock); + assertEq(l1Gateway.lastL2MintAllowanceUpdateBlock(), newIssuanceUpdatedAtBlock); + } + + // -- calculateL2TokenAddress -- + + function test_CalculateL2TokenAddress_ReturnsL2TokenAddress() public view { + assertEq(l1Gateway.calculateL2TokenAddress(address(grt)), l2GRTMock); + } + + function test_CalculateL2TokenAddress_ReturnsZeroForOtherAddress() public view { + assertEq(l1Gateway.calculateL2TokenAddress(tokenSender), address(0)); + } + + // -- outboundTransfer -- + + function test_OutboundTransfer_RevertWhen_WrongToken() public { + vm.expectRevert(bytes("TOKEN_NOT_GRT")); + vm.prank(tokenSender); + l1Gateway.outboundTransfer{ value: DEFAULT_ETH_VALUE }( + tokenSender, + l2Receiver, + 10 ether, + MAX_GAS, + GAS_PRICE_BID, + defaultData + ); + } + + function test_OutboundTransfer_EscrowsTokensAndCreatesRetryableTicket() public { + _approveAndTransfer(); + } + + function test_OutboundTransfer_DecodesSenderFromRouter() public { + vm.prank(tokenSender); + grt.approve(address(l1Gateway), 10 ether); + bytes memory routerEncodedData = abi.encode(tokenSender, defaultData); + _testValidOutboundTransfer(routerMock, routerEncodedData, ""); + } + + function test_OutboundTransfer_RevertWhen_NoSubmissionCost() public { + vm.prank(tokenSender); + grt.approve(address(l1Gateway), 10 ether); + + vm.expectRevert(bytes("NO_SUBMISSION_COST")); + vm.prank(tokenSender); + l1Gateway.outboundTransfer{ value: DEFAULT_ETH_VALUE }( + address(grt), + l2Receiver, + 10 ether, + MAX_GAS, + GAS_PRICE_BID, + abi.encode(uint256(0), bytes("")) + ); + } + + function test_OutboundTransfer_RevertWhen_CallhookSenderNotAllowlisted() public { + vm.prank(tokenSender); + grt.approve(address(l1Gateway), 10 ether); + + vm.expectRevert(bytes("CALL_HOOK_DATA_NOT_ALLOWED")); + vm.prank(tokenSender); + l1Gateway.outboundTransfer{ value: DEFAULT_ETH_VALUE }( + address(grt), + l2Receiver, + 10 ether, + MAX_GAS, + GAS_PRICE_BID, + abi.encode(MAX_SUBMISSION_COST, notEmptyCallHookData) + ); + } + + function test_OutboundTransfer_AllowsCallhookFromAllowlistedSender() public { + // Make the sender a contract so that it can be allowed to send callhooks + vm.etch(tokenSender, hex"1234"); + vm.prank(governor); + l1Gateway.addToCallhookAllowlist(tokenSender); + vm.prank(tokenSender); + grt.approve(address(l1Gateway), 10 ether); + + _testValidOutboundTransfer( + tokenSender, + abi.encode(MAX_SUBMISSION_COST, notEmptyCallHookData), + notEmptyCallHookData + ); + } + + function test_OutboundTransfer_RevertWhen_InsufficientBalance() public { + vm.prank(tokenSender); + grt.approve(address(l1Gateway), 1001 ether); + + vm.expectRevert(bytes("ERC20: transfer amount exceeds balance")); + vm.prank(tokenSender); + l1Gateway.outboundTransfer{ value: DEFAULT_ETH_VALUE }( + address(grt), + l2Receiver, + 1001 ether, + MAX_GAS, + GAS_PRICE_BID, + defaultData + ); + } + + // -- finalizeInboundTransfer -- + + function test_FinalizeInboundTransfer_RevertWhen_NotBridge() public { + vm.expectRevert(bytes("NOT_FROM_BRIDGE")); + vm.prank(tokenSender); + l1Gateway.finalizeInboundTransfer(address(grt), l2Receiver, tokenSender, 10 ether, defaultData); + } + + function test_FinalizeInboundTransfer_RevertWhen_NotFromL2Gateway() public { + vm.expectRevert(bytes("ONLY_COUNTERPART_GATEWAY")); + _executeInbound(l2Receiver, 10 ether); // l2Receiver is not the L2 gateway + } + + function test_FinalizeInboundTransfer_RevertWhen_NoTokensOrAllowance() public { + // Should never happen, but the gateway must still revert + vm.expectRevert(bytes("INVALID_L2_MINT_AMOUNT")); + _executeInbound(l2GatewayMock, 10 ether); + } + + function test_FinalizeInboundTransfer_RevertWhen_RevokedFromEscrow() public { + _approveAndTransfer(); + // The escrow holds 10 GRT, but the gateway can no longer move them + vm.prank(governor); + bridgeEscrow.revokeAll(address(l1Gateway)); + + vm.expectRevert(bytes("ERC20: transfer amount exceeds allowance")); + _executeInbound(l2GatewayMock, 8 ether); + } + + function test_FinalizeInboundTransfer_SendsTokensOutOfEscrow() public { + _approveAndTransfer(); + + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.WithdrawalFinalized(address(grt), l2Receiver, tokenSender, 0, 8 ether); + _executeInbound(l2GatewayMock, 8 ether); + + assertEq(grt.balanceOf(address(bridgeEscrow)), 2 ether); + assertEq(grt.balanceOf(tokenSender), 998 ether); + } + + function test_FinalizeInboundTransfer_MintsUpToL2MintAllowance() public { + _approveAndTransfer(); + + // Start accruing L2 mint allowance at 2 GRT per block + uint256 updateBlock = block.number; + vm.roll(updateBlock + 1); + vm.prank(governor); + l1Gateway.updateL2MintAllowance(2 ether, updateBlock); + vm.roll(updateBlock + 4); + uint256 allowance = 2 ether * (block.number - updateBlock); + assertEq(allowance, 8 ether); + + // The escrow holds 10 GRT + uint256 amount = 10 ether + allowance; + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.TokensMintedFromL2(allowance); + vm.expectEmit(address(l1Gateway)); + emit IL1GraphTokenGatewayLike.WithdrawalFinalized(address(grt), l2Receiver, tokenSender, 0, amount); + _executeInbound(l2GatewayMock, amount); + + assertEq(l1Gateway.totalMintedFromL2(), allowance); + assertEq(l1Gateway.accumulatedL2MintAllowanceAtBlock(block.number), allowance); + assertEq(grt.balanceOf(address(bridgeEscrow)), 0); + assertEq(grt.balanceOf(tokenSender), 990 ether + amount); + } + + function test_FinalizeInboundTransfer_RevertWhen_OverL2MintAllowance() public { + _approveAndTransfer(); + + // Start accruing L2 mint allowance at 2 GRT per block + uint256 updateBlock = block.number; + vm.roll(updateBlock + 1); + vm.prank(governor); + l1Gateway.updateL2MintAllowance(2 ether, updateBlock); + vm.roll(updateBlock + 4); + uint256 allowance = 2 ether * (block.number - updateBlock); + assertEq(allowance, 8 ether); + + // The escrow holds 10 GRT + vm.expectRevert(bytes("INVALID_L2_MINT_AMOUNT")); + _executeInbound(l2GatewayMock, 10 ether + allowance + 0.001 ether); + } +} diff --git a/packages/graph-token/test/unit/gateway/L2GraphTokenGatewayConfig.t.sol b/packages/graph-token/test/unit/gateway/L2GraphTokenGatewayConfig.t.sol new file mode 100644 index 000000000..d5e45bf45 --- /dev/null +++ b/packages/graph-token/test/unit/gateway/L2GraphTokenGatewayConfig.t.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { L2GraphTokenGatewaySharedTest } from "./L2GraphTokenGatewayShared.t.sol"; +import { IL2GraphTokenGatewayLike } from "../interfaces/IL2GraphTokenGatewayLike.sol"; + +/// Cases from the "immediately after deploy" context: the gateway is paused and unconfigured +contract L2GraphTokenGatewayConfigTest is L2GraphTokenGatewaySharedTest { + // -- calculateL2TokenAddress -- + + function test_CalculateL2TokenAddress_ReturnsZeroWhenNotSet() public view { + assertEq(l2Gateway.calculateL2TokenAddress(address(grt)), address(0)); + } + + // -- outboundTransfer -- + + function test_OutboundTransfer_RevertWhen_Paused() public { + vm.expectRevert(bytes("Paused (contract)")); + vm.prank(tokenSender); + l2Gateway.outboundTransfer(address(grt), l1Receiver, 10 ether, defaultData); + } + + // -- finalizeInboundTransfer -- + + function test_FinalizeInboundTransfer_RevertWhen_Paused() public { + vm.expectRevert(bytes("Paused (contract)")); + vm.prank(tokenSender); + l2Gateway.finalizeInboundTransfer(address(grt), tokenSender, l1Receiver, 10 ether, defaultData); + } + + // -- setL2Router -- + + function test_SetL2Router_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(tokenSender); + l2Gateway.setL2Router(routerMock); + } + + function test_SetL2Router_SetsRouter() public { + vm.expectEmit(address(l2Gateway)); + emit IL2GraphTokenGatewayLike.L2RouterSet(routerMock); + vm.prank(governor); + l2Gateway.setL2Router(routerMock); + + assertEq(l2Gateway.l2Router(), routerMock); + } + + // -- setL1TokenAddress -- + + function test_SetL1TokenAddress_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(tokenSender); + l2Gateway.setL1TokenAddress(l1GRTMock); + } + + function test_SetL1TokenAddress_SetsL1GRT() public { + vm.expectEmit(address(l2Gateway)); + emit IL2GraphTokenGatewayLike.L1TokenAddressSet(l1GRTMock); + vm.prank(governor); + l2Gateway.setL1TokenAddress(l1GRTMock); + + assertEq(l2Gateway.l1GRT(), l1GRTMock); + } + + // -- setL1CounterpartAddress -- + + function test_SetL1CounterpartAddress_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(tokenSender); + l2Gateway.setL1CounterpartAddress(l1GatewayMock); + } + + function test_SetL1CounterpartAddress_SetsL1Counterpart() public { + vm.expectEmit(address(l2Gateway)); + emit IL2GraphTokenGatewayLike.L1CounterpartAddressSet(l1GatewayMock); + vm.prank(governor); + l2Gateway.setL1CounterpartAddress(l1GatewayMock); + + assertEq(l2Gateway.l1Counterpart(), l1GatewayMock); + } + + // -- Pausable behavior -- + + function test_PausableBehavior_RevertWhen_NotGovernorOrGuardian() public { + vm.expectRevert(bytes("Only Governor or Guardian")); + vm.prank(tokenSender); + l2Gateway.setPaused(false); + + vm.expectRevert(bytes("Only Governor or Guardian")); + vm.prank(tokenSender); + l2Gateway.setPaused(true); + } + + function test_PausableBehavior_RevertWhen_UnpausingWithStateNotSet() public { + vm.startPrank(governor); + vm.expectRevert(bytes("L2_ROUTER_NOT_SET")); + l2Gateway.setPaused(false); + + l2Gateway.setL2Router(routerMock); + vm.expectRevert(bytes("L1_COUNTERPART_NOT_SET")); + l2Gateway.setPaused(false); + + l2Gateway.setL1CounterpartAddress(l1GatewayMock); + vm.expectRevert(bytes("L1_GRT_NOT_SET")); + l2Gateway.setPaused(false); + vm.stopPrank(); + } + + function test_PausableBehavior_GovernorCanPauseAndUnpause() public { + _configureL2Bridge(); + + vm.expectEmit(address(l2Gateway)); + emit IL2GraphTokenGatewayLike.PauseChanged(true); + vm.prank(governor); + l2Gateway.setPaused(true); + assertTrue(l2Gateway.paused()); + + vm.expectEmit(address(l2Gateway)); + emit IL2GraphTokenGatewayLike.PauseChanged(false); + vm.prank(governor); + l2Gateway.setPaused(false); + assertFalse(l2Gateway.paused()); + } + + // -- setPauseGuardian -- + + function test_SetPauseGuardian_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(tokenSender); + l2Gateway.setPauseGuardian(pauseGuardian); + } + + function test_SetPauseGuardian_SetsPauseGuardian() public { + address currentPauseGuardian = l2Gateway.pauseGuardian(); + + vm.expectEmit(address(l2Gateway)); + emit IL2GraphTokenGatewayLike.NewPauseGuardian(currentPauseGuardian, pauseGuardian); + vm.prank(governor); + l2Gateway.setPauseGuardian(pauseGuardian); + } + + function test_SetPauseGuardian_GuardianCanPauseAndUnpause() public { + _configureL2Bridge(); + vm.prank(governor); + l2Gateway.setPauseGuardian(pauseGuardian); + + vm.expectEmit(address(l2Gateway)); + emit IL2GraphTokenGatewayLike.PauseChanged(true); + vm.prank(pauseGuardian); + l2Gateway.setPaused(true); + assertTrue(l2Gateway.paused()); + + vm.expectEmit(address(l2Gateway)); + emit IL2GraphTokenGatewayLike.PauseChanged(false); + vm.prank(pauseGuardian); + l2Gateway.setPaused(false); + assertFalse(l2Gateway.paused()); + } +} diff --git a/packages/graph-token/test/unit/gateway/L2GraphTokenGatewayShared.t.sol b/packages/graph-token/test/unit/gateway/L2GraphTokenGatewayShared.t.sol new file mode 100644 index 000000000..2e3b67a16 --- /dev/null +++ b/packages/graph-token/test/unit/gateway/L2GraphTokenGatewayShared.t.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IManaged } from "@graphprotocol/interfaces/contracts/contracts/governance/IManaged.sol"; +import { ProtocolFixture } from "../shared/ProtocolFixture.t.sol"; +import { IL2GraphTokenLike } from "../interfaces/IL2GraphTokenLike.sol"; +import { IL2GraphTokenGatewayLike } from "../interfaces/IL2GraphTokenGatewayLike.sol"; + +abstract contract L2GraphTokenGatewaySharedTest is ProtocolFixture { + string internal constant L2_GRAPH_TOKEN = + string.concat(PKG_ARTIFACTS, "l2/token/L2GraphToken.sol/L2GraphToken.json"); + string internal constant L2_GATEWAY = + string.concat(PKG_ARTIFACTS, "l2/gateway/L2GraphTokenGateway.sol/L2GraphTokenGateway.json"); + string internal constant CALLHOOK_RECEIVER_MOCK = + string.concat(PKG_ARTIFACTS, "tests/CallhookReceiverMock.sol/CallhookReceiverMock.json"); + + address internal constant ARB_SYS = address(0x64); + uint256 internal constant SENDER_TOKENS = 1000 ether; + + IL2GraphTokenLike internal grt; + IL2GraphTokenGatewayLike internal l2Gateway; + address internal callhookReceiverMock; + + address internal tokenSender = makeAddr("tokenSender"); + address internal l1Receiver = makeAddr("l1Receiver"); + address internal l2Receiver = makeAddr("l2Receiver"); + address internal pauseGuardian = makeAddr("pauseGuardian"); + address internal routerMock = makeAddr("routerMock"); + address internal l1GRTMock = makeAddr("l1GRTMock"); + address internal l1GatewayMock = makeAddr("l1GatewayMock"); + + bytes internal defaultData = ""; + + function setUp() public virtual { + vm.warp(1_700_000_000); + _deployProtocolBase(); + + grt = IL2GraphTokenLike( + _deployBehindProxy( + _deployArtifact(L2_GRAPH_TOKEN, ""), + abi.encodeCall(IL2GraphTokenLike.initialize, (governor)) + ) + ); + _registerContract("GraphToken", address(grt)); + + l2Gateway = IL2GraphTokenGatewayLike( + _deployBehindProxy( + _deployArtifact(L2_GATEWAY, ""), + abi.encodeCall(IL2GraphTokenGatewayLike.initialize, (address(controller))) + ) + ); + _registerContract("GraphTokenGateway", address(l2Gateway)); + IManaged(address(l2Gateway)).syncAllContracts(); + + callhookReceiverMock = _deployArtifact(CALLHOOK_RECEIVER_MOCK, ""); + + vm.etch(routerMock, hex"1234"); + vm.etch(l1GRTMock, hex"1234"); + vm.etch(l1GatewayMock, hex"1234"); + + // ArbSys precompile: code so the extcodesize check passes, and sendTxToL1 returns id 1 + vm.etch(ARB_SYS, hex"00"); + vm.mockCall(ARB_SYS, abi.encodeWithSignature("sendTxToL1(address,bytes)"), abi.encode(uint256(1))); + + vm.prank(governor); + grt.mint(tokenSender, SENDER_TOKENS); + } + + /// Wires the gateway and token to the L1 mocks and router, then unpauses the gateway + function _configureL2Bridge() internal { + vm.startPrank(governor); + l2Gateway.setL1TokenAddress(l1GRTMock); + grt.setL1Address(l1GRTMock); + l2Gateway.setL1CounterpartAddress(l1GatewayMock); + l2Gateway.setL2Router(routerMock); + grt.setGateway(address(l2Gateway)); + l2Gateway.setPaused(false); + vm.stopPrank(); + } +} diff --git a/packages/graph-token/test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol b/packages/graph-token/test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol new file mode 100644 index 000000000..726d6d57c --- /dev/null +++ b/packages/graph-token/test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { ITokenGateway } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/ITokenGateway.sol"; +import { L2GraphTokenGatewaySharedTest } from "./L2GraphTokenGatewayShared.t.sol"; +import { IL2GraphTokenLike } from "../interfaces/IL2GraphTokenLike.sol"; +import { IL2GraphTokenGatewayLike } from "../interfaces/IL2GraphTokenGatewayLike.sol"; +import { ICallhookReceiverMockLike } from "../interfaces/IArbitrumMocksLike.sol"; +import { NoCallhookReceiver } from "../mocks/NoCallhookReceiver.sol"; + +/// Cases from the "after configuring and unpausing" context +contract L2GraphTokenGatewayTransfersTest is L2GraphTokenGatewaySharedTest { + uint160 internal constant L1_TO_L2_ALIAS_OFFSET = uint160(0x1111000000000000000000000000000000001111); + uint256 internal constant CALLHOOK_FOO = 1337; + uint256 internal constant CALLHOOK_BAR = 42; + + bytes internal defaultDataWithNotEmptyCallHookData = abi.encode(CALLHOOK_FOO, CALLHOOK_BAR); + + function setUp() public override { + super.setUp(); + _configureL2Bridge(); + } + + function _l1GatewayAlias() internal view returns (address) { + unchecked { + return address(uint160(l1GatewayMock) + L1_TO_L2_ALIAS_OFFSET); + } + } + + function _testValidOutboundTransfer(address signer, bytes memory data) internal { + uint256 expectedId = 1; + // Should use the L1 gateway's interface, but both come from ITokenGateway + bytes memory calldataToL1 = abi.encodeCall( + ITokenGateway.finalizeInboundTransfer, + (l1GRTMock, tokenSender, l1Receiver, 10 ether, abi.encode(uint256(0), bytes(""))) + ); + assertEq(l2Gateway.getOutboundCalldata(l1GRTMock, tokenSender, l1Receiver, 10 ether, ""), calldataToL1); + + vm.expectCall(ARB_SYS, abi.encodeWithSignature("sendTxToL1(address,bytes)", l1GatewayMock, calldataToL1)); + vm.expectEmit(address(l2Gateway)); + emit IL2GraphTokenGatewayLike.TxToL1(tokenSender, l1GatewayMock, expectedId, calldataToL1); + vm.expectEmit(address(l2Gateway)); + emit IL2GraphTokenGatewayLike.WithdrawalInitiated(l1GRTMock, tokenSender, l1Receiver, expectedId, 0, 10 ether); + vm.prank(signer); + l2Gateway.outboundTransfer(l1GRTMock, l1Receiver, 10 ether, data); + + assertEq(grt.balanceOf(tokenSender), 990 ether); + } + + /// Finalizes a 10 GRT deposit to `to` as the aliased L1 gateway. With `expectCallhook`, + /// `to` must emit the CallhookReceiverMock event for CALLHOOK_FOO and CALLHOOK_BAR. + function _testValidFinalizeTransfer(bytes memory data, address to, bool expectCallhook) internal { + vm.expectEmit(address(grt)); + emit IL2GraphTokenLike.BridgeMinted(to, 10 ether); + if (expectCallhook) { + vm.expectEmit(to); + emit ICallhookReceiverMockLike.TransferReceived(tokenSender, 10 ether, CALLHOOK_FOO, CALLHOOK_BAR); + } + vm.expectEmit(address(l2Gateway)); + emit IL2GraphTokenGatewayLike.DepositFinalized(l1GRTMock, tokenSender, to, 10 ether); + vm.prank(_l1GatewayAlias()); + l2Gateway.finalizeInboundTransfer(l1GRTMock, tokenSender, to, 10 ether, data); + + // Unchanged + assertEq(grt.balanceOf(tokenSender), 1000 ether); + // 10 newly minted GRT + assertEq(grt.balanceOf(to), 10 ether); + } + + // -- calculateL2TokenAddress -- + + function test_CalculateL2TokenAddress_ReturnsL2TokenAddress() public view { + assertEq(l2Gateway.calculateL2TokenAddress(l1GRTMock), address(grt)); + } + + function test_CalculateL2TokenAddress_ReturnsZeroForOtherAddress() public view { + assertEq(l2Gateway.calculateL2TokenAddress(tokenSender), address(0)); + } + + // -- outboundTransfer -- + + function test_OutboundTransfer_RevertWhen_WrongToken() public { + vm.expectRevert(bytes("TOKEN_NOT_GRT")); + vm.prank(tokenSender); + l2Gateway.outboundTransfer(tokenSender, l1Receiver, 10 ether, defaultData); + } + + function test_OutboundTransfer_BurnsTokensAndTriggersL1Call() public { + vm.prank(tokenSender); + grt.approve(address(l2Gateway), 10 ether); + _testValidOutboundTransfer(tokenSender, defaultData); + } + + function test_OutboundTransfer_DecodesSenderFromRouter() public { + vm.prank(tokenSender); + grt.approve(address(l2Gateway), 10 ether); + bytes memory routerEncodedData = abi.encode(tokenSender, defaultData); + _testValidOutboundTransfer(routerMock, routerEncodedData); + } + + function test_OutboundTransfer_RevertWhen_NonEmptyCallhookData() public { + vm.prank(tokenSender); + grt.approve(address(l2Gateway), 10 ether); + + vm.expectRevert(bytes("CALL_HOOK_DATA_NOT_ALLOWED")); + vm.prank(tokenSender); + l2Gateway.outboundTransfer(l1GRTMock, l1Receiver, 10 ether, defaultDataWithNotEmptyCallHookData); + } + + function test_OutboundTransfer_RevertWhen_InsufficientBalance() public { + vm.prank(tokenSender); + grt.approve(address(l2Gateway), 1001 ether); + + vm.expectRevert(bytes("ERC20: burn amount exceeds balance")); + vm.prank(tokenSender); + l2Gateway.outboundTransfer(l1GRTMock, l1Receiver, 1001 ether, defaultData); + } + + // -- finalizeInboundTransfer -- + + function test_FinalizeInboundTransfer_RevertWhen_NotGateway() public { + vm.expectRevert(bytes("ONLY_COUNTERPART_GATEWAY")); + vm.prank(tokenSender); + l2Gateway.finalizeInboundTransfer(l1GRTMock, tokenSender, l2Receiver, 10 ether, defaultData); + } + + function test_FinalizeInboundTransfer_RevertWhen_GatewayWithoutL2Alias() public { + vm.expectRevert(bytes("ONLY_COUNTERPART_GATEWAY")); + vm.prank(l1GatewayMock); + l2Gateway.finalizeInboundTransfer(l1GRTMock, tokenSender, l2Receiver, 10 ether, defaultData); + } + + function test_FinalizeInboundTransfer_MintsWhenCalledByAliasedGateway() public { + _testValidFinalizeTransfer(defaultData, l2Receiver, false); + } + + function test_FinalizeInboundTransfer_CallsCallhookWithCalldata() public { + _testValidFinalizeTransfer(defaultDataWithNotEmptyCallHookData, callhookReceiverMock, true); + } + + function test_FinalizeInboundTransfer_RevertWhen_CallhookReverts() public { + // A zero foo makes CallhookReceiverMock revert + bytes memory callHookData = abi.encode(uint256(0), CALLHOOK_BAR); + + vm.expectRevert(bytes("FOO_IS_ZERO")); + vm.prank(_l1GatewayAlias()); + l2Gateway.finalizeInboundTransfer(l1GRTMock, tokenSender, callhookReceiverMock, 10 ether, callHookData); + } + + function test_FinalizeInboundTransfer_RevertWhen_ReceiverHasNoCallhook() public { + address receiver = address(new NoCallhookReceiver()); + bytes memory callHookData = abi.encode(uint256(0)); + + // The revert reason depends on the environment + vm.expectRevert(); + vm.prank(_l1GatewayAlias()); + l2Gateway.finalizeInboundTransfer(l1GRTMock, tokenSender, receiver, 10 ether, callHookData); + } +} diff --git a/packages/graph-token/test/unit/interfaces/IArbitrumMocksLike.sol b/packages/graph-token/test/unit/interfaces/IArbitrumMocksLike.sol new file mode 100644 index 000000000..e9a81c330 --- /dev/null +++ b/packages/graph-token/test/unit/interfaces/IArbitrumMocksLike.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +interface IBridgeMockLike { + function setInbox(address inbox, bool enabled) external; + + function setOutbox(address outbox, bool enabled) external; +} + +interface IInboxMockLike { + event InboxMessageDelivered(uint256 indexed messageNum, bytes data); + + function setBridge(address bridge) external; +} + +interface IOutboxMockLike { + function setBridge(address bridge) external; + + function executeTransaction( + uint256 batchNum, + bytes32[] calldata proof, + uint256 index, + address l2Sender, + address destAddr, + uint256 l2Block, + uint256 l1Block, + uint256 l2Timestamp, + uint256 amount, + bytes calldata calldataForL1 + ) external; +} + +interface ICallhookReceiverMockLike { + event TransferReceived(address from, uint256 amount, uint256 foo, uint256 bar); +} diff --git a/packages/graph-token/test/unit/interfaces/IL1GraphTokenGatewayLike.sol b/packages/graph-token/test/unit/interfaces/IL1GraphTokenGatewayLike.sol new file mode 100644 index 000000000..659100395 --- /dev/null +++ b/packages/graph-token/test/unit/interfaces/IL1GraphTokenGatewayLike.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { ITokenGateway } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/ITokenGateway.sol"; + +interface IL1GraphTokenGatewayLike is ITokenGateway { + event DepositInitiated( + address l1Token, + address indexed from, + address indexed to, + uint256 indexed sequenceNumber, + uint256 amount + ); + event WithdrawalFinalized( + address l1Token, + address indexed from, + address indexed to, + uint256 indexed exitNum, + uint256 amount + ); + event ArbitrumAddressesSet(address inbox, address l1Router); + event L2TokenAddressSet(address l2GRT); + event L2CounterpartAddressSet(address l2Counterpart); + event EscrowAddressSet(address escrow); + event AddedToCallhookAllowlist(address newAllowlisted); + event RemovedFromCallhookAllowlist(address notAllowlisted); + event L2MintAllowanceUpdated( + uint256 accumulatedL2MintAllowanceSnapshot, + uint256 l2MintAllowancePerBlock, + uint256 lastL2MintAllowanceUpdateBlock + ); + event TokensMintedFromL2(uint256 amount); + event TxToL2(address indexed _from, address indexed _to, uint256 indexed _seqNum, bytes _data); + event PauseChanged(bool isPaused); + event NewPauseGuardian(address indexed oldPauseGuardian, address indexed pauseGuardian); + + function initialize(address controller) external; + + function setArbitrumAddresses(address inbox, address router) external; + + function setL2TokenAddress(address l2GRT) external; + + function setL2CounterpartAddress(address l2Counterpart) external; + + function setEscrowAddress(address escrow) external; + + function addToCallhookAllowlist(address newAllowlisted) external; + + function removeFromCallhookAllowlist(address notAllowlisted) external; + + function callhookAllowlist(address account) external view returns (bool); + + function updateL2MintAllowance(uint256 l2IssuancePerBlock, uint256 updateBlockNum) external; + + function setL2MintAllowanceParametersManual( + uint256 accumulatedAllowance, + uint256 l2IssuancePerBlock, + uint256 lastUpdateBlock + ) external; + + function accumulatedL2MintAllowanceAtBlock(uint256 blockNum) external view returns (uint256); + + function accumulatedL2MintAllowanceSnapshot() external view returns (uint256); + + function l2MintAllowancePerBlock() external view returns (uint256); + + function lastL2MintAllowanceUpdateBlock() external view returns (uint256); + + function totalMintedFromL2() external view returns (uint256); + + function l2GRT() external view returns (address); + + function inbox() external view returns (address); + + function l1Router() external view returns (address); + + function l2Counterpart() external view returns (address); + + function escrow() external view returns (address); + + function counterpartGateway() external view returns (address); + + function getOutboundCalldata( + address l1Token, + address from, + address to, + uint256 amount, + bytes memory data + ) external pure returns (bytes memory); + + function setPaused(bool newPaused) external; + + function paused() external view returns (bool); + + function setPauseGuardian(address newPauseGuardian) external; + + function pauseGuardian() external view returns (address); +} diff --git a/packages/graph-token/test/unit/interfaces/IL2GraphTokenGatewayLike.sol b/packages/graph-token/test/unit/interfaces/IL2GraphTokenGatewayLike.sol new file mode 100644 index 000000000..290cdb570 --- /dev/null +++ b/packages/graph-token/test/unit/interfaces/IL2GraphTokenGatewayLike.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { ITokenGateway } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/ITokenGateway.sol"; + +interface IL2GraphTokenGatewayLike is ITokenGateway { + event DepositFinalized(address indexed l1Token, address indexed from, address indexed to, uint256 amount); + event WithdrawalInitiated( + address l1Token, + address indexed from, + address indexed to, + uint256 indexed l2ToL1Id, + uint256 exitNum, + uint256 amount + ); + event L2RouterSet(address l2Router); + event L1TokenAddressSet(address l1GRT); + event L1CounterpartAddressSet(address l1Counterpart); + event TxToL1(address indexed _from, address indexed _to, uint256 indexed _id, bytes _data); + event PauseChanged(bool isPaused); + event NewPauseGuardian(address indexed oldPauseGuardian, address indexed pauseGuardian); + + function initialize(address controller) external; + + function setL2Router(address l2Router) external; + + function setL1TokenAddress(address l1GRT) external; + + function setL1CounterpartAddress(address l1Counterpart) external; + + function l2Router() external view returns (address); + + function l1GRT() external view returns (address); + + function l1Counterpart() external view returns (address); + + function outboundTransfer( + address l1Token, + address to, + uint256 amount, + bytes calldata data + ) external returns (bytes memory); + + function getOutboundCalldata( + address token, + address from, + address to, + uint256 amount, + bytes memory data + ) external pure returns (bytes memory); + + function setPaused(bool newPaused) external; + + function paused() external view returns (bool); + + function setPauseGuardian(address newPauseGuardian) external; + + function pauseGuardian() external view returns (address); +} diff --git a/packages/graph-token/test/unit/mocks/NoCallhookReceiver.sol b/packages/graph-token/test/unit/mocks/NoCallhookReceiver.sol new file mode 100644 index 000000000..7605f42a9 --- /dev/null +++ b/packages/graph-token/test/unit/mocks/NoCallhookReceiver.sol @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +/// Has no onTokenTransfer and no fallback, so any call to it reverts +contract NoCallhookReceiver {} diff --git a/scripts/contracts-migration/test-mapping/t2b.md b/scripts/contracts-migration/test-mapping/t2b.md new file mode 100644 index 000000000..5ecd25bed --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t2b.md @@ -0,0 +1,86 @@ +# T2b test mapping: L1 and L2 GraphTokenGateway + +New tests are in `packages/graph-token`. Each old file is split by its top-level context: `> immediately after deploy` goes to `*Config.t.sol` and `> after configuring and unpausing` to `*Transfers.t.sol`; shared setup is in `*Shared.t.sol`. `packages/contracts-test/tests/unit/l2/l2ArbitrumMessengerMock.ts` is also deleted: it has no cases and nothing imports it. + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › calculateL2TokenAddress › should return address zero as it was not set | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_CalculateL2TokenAddress_ReturnsZeroWhenNotSet | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › outboundTransfer › reverts because it is paused | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_OutboundTransfer_RevertWhen_Paused | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › finalizeInboundTransfer › revert because it is paused | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_FinalizeInboundTransfer_RevertWhen_Paused | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › setArbitrumAddresses › is not callable by addreses that are not the governor | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_SetArbitrumAddresses_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › setArbitrumAddresses › rejects setting an EOA as router or inbox | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_SetArbitrumAddresses_RevertWhen_NotContract | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › setArbitrumAddresses › sets inbox and router address | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_SetArbitrumAddresses_SetsInboxAndRouter | Router is `makeAddr` + `vm.etch` | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › setL2TokenAddress › is not callable by addreses that are not the governor | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_SetL2TokenAddress_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › setL2TokenAddress › sets l2GRT | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_SetL2TokenAddress_SetsL2GRT | L2 token is `makeAddr` + `vm.etch` | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › setL2CounterpartAddress › is not callable by addreses that are not the governor | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_SetL2CounterpartAddress_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › setL2CounterpartAddress › sets l2Counterpart which can be queried with counterpartGateway() | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_SetL2CounterpartAddress_SetsL2Counterpart | L2 gateway is `makeAddr` + `vm.etch` | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › setEscrowAddress › is not callable by addreses that are not the governor | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_SetEscrowAddress_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › setEscrowAddress › sets escrow | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_SetEscrowAddress_SetsEscrow | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › addToCallhookAllowlist › is not callable by addreses that are not the governor | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_AddToCallhookAllowlist_RevertWhen_NotGovernor | Controller replaces RewardsManager as the allowlisted contract | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › addToCallhookAllowlist › rejects adding an EOA to the callhook allowlist | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_AddToCallhookAllowlist_RevertWhen_NotContract | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › addToCallhookAllowlist › adds an address to the callhook allowlist | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_AddToCallhookAllowlist_AddsAddress | Controller replaces RewardsManager as the allowlisted contract | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › removeFromCallhookAllowlist › is not callable by addreses that are not the governor | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_RemoveFromCallhookAllowlist_RevertWhen_NotGovernor | Controller replaces RewardsManager as the allowlisted contract | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › removeFromCallhookAllowlist › removes an address from the callhook allowlist | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_RemoveFromCallhookAllowlist_RemovesAddress | Controller replaces RewardsManager as the allowlisted contract | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › Pausable behavior › cannot be paused or unpaused by someone other than governor or pauseGuardian | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_PausableBehavior_RevertWhen_NotGovernorOrGuardian | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › Pausable behavior › cannot be unpaused if some state variables are not set | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_PausableBehavior_RevertWhen_UnpausingWithStateNotSet | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › Pausable behavior › can be paused and unpaused by the governor | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_PausableBehavior_GovernorCanPauseAndUnpause | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › Pausable behavior › setPauseGuardian › cannot be called by someone other than governor | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_SetPauseGuardian_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › Pausable behavior › setPauseGuardian › sets a new pause guardian | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_SetPauseGuardian_SetsPauseGuardian | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > immediately after deploy › Pausable behavior › setPauseGuardian › allows a pause guardian to pause and unpause | test/unit/gateway/L1GraphTokenGatewayConfig.t.sol::test_SetPauseGuardian_GuardianCanPauseAndUnpause | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › updateL2MintAllowance › rejects calls that are not from the governor | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_UpdateL2MintAllowance_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › updateL2MintAllowance › does not allow using a future or current block number | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_UpdateL2MintAllowance_RevertWhen_FutureOrCurrentBlock | Blocks relative to `block.number` after `vm.roll(100)` | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › updateL2MintAllowance › does not allow using a block number lower than or equal to the previous one | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_UpdateL2MintAllowance_RevertWhen_BlockNotIncrementing | Explicit `vm.roll` before the final update; snapshot computed from the rolls | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › updateL2MintAllowance › updates the snapshot and issuance to follow a new linear function, accumulating up to the specified block | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_UpdateL2MintAllowance_AccumulatesUpToSpecifiedBlock | `helpers.mine(10)` becomes `vm.roll`; expected allowances computed from the rolls | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › setL2MintAllowanceParametersManual › rejects calls that are not from the governor | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_SetL2MintAllowanceParametersManual_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › setL2MintAllowanceParametersManual › does not allow using a future or current block number | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_SetL2MintAllowanceParametersManual_RevertWhen_FutureOrCurrentBlock | Blocks relative to `block.number` after `vm.roll(100)` | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › setL2MintAllowanceParametersManual › updates the snapshot and issuance to follow a new linear function, manually setting the snapshot value | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_SetL2MintAllowanceParametersManual_SetsSnapshotManually | `helpers.mine(10)` becomes `vm.roll`; expected allowances computed from the rolls | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › calculateL2TokenAddress › returns the L2 token address | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_CalculateL2TokenAddress_ReturnsL2TokenAddress | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › calculateL2TokenAddress › returns the zero address if the input is any other address | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_CalculateL2TokenAddress_ReturnsZeroForOtherAddress | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › outboundTransfer › reverts when called with the wrong token address | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_OutboundTransfer_RevertWhen_WrongToken | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › outboundTransfer › puts tokens in escrow and creates a retryable ticket | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_OutboundTransfer_EscrowsTokensAndCreatesRetryableTicket | Also asserts `getOutboundCalldata` matches the encoded L2 calldata | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › outboundTransfer › decodes the sender address from messages sent by the router | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_OutboundTransfer_DecodesSenderFromRouter | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › outboundTransfer › reverts when called with no submission cost | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_OutboundTransfer_RevertWhen_NoSubmissionCost | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › outboundTransfer › reverts when called with nonempty calldata, if the sender is not allowlisted | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_OutboundTransfer_RevertWhen_CallhookSenderNotAllowlisted | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › outboundTransfer › allows sending nonempty calldata, if the sender is allowlisted | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_OutboundTransfer_AllowsCallhookFromAllowlistedSender | `helpers.setCode` becomes `vm.etch` | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › outboundTransfer › reverts when the sender does not have enough GRT | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_OutboundTransfer_RevertWhen_InsufficientBalance | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › finalizeInboundTransfer › reverts when called by an account that is not the bridge | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_FinalizeInboundTransfer_RevertWhen_NotBridge | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › finalizeInboundTransfer › reverts when called by the bridge, but the tx was not started by the L2 gateway | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_FinalizeInboundTransfer_RevertWhen_NotFromL2Gateway | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › finalizeInboundTransfer › reverts if the gateway does not have tokens or allowance | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_FinalizeInboundTransfer_RevertWhen_NoTokensOrAllowance | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › finalizeInboundTransfer › reverts if the gateway is revoked from escrow | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_FinalizeInboundTransfer_RevertWhen_RevokedFromEscrow | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › finalizeInboundTransfer › sends tokens out of escrow | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_FinalizeInboundTransfer_SendsTokensOutOfEscrow | | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › finalizeInboundTransfer › mints tokens up to the L2 mint allowance | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_FinalizeInboundTransfer_MintsUpToL2MintAllowance | Update at B, roll to B + 4: 8 GRT allowance at 2 GRT per block, computed from the rolls | +| packages/contracts-test/tests/unit/gateway/l1GraphTokenGateway.test.ts | L1GraphTokenGateway › > after configuring and unpausing › finalizeInboundTransfer › reverts if the amount to mint is over the allowance | test/unit/gateway/L1GraphTokenGatewayTransfers.t.sol::test_FinalizeInboundTransfer_RevertWhen_OverL2MintAllowance | Same rolls as the case above | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > immediately after deploy › calculateL2TokenAddress › should return the zero address | test/unit/gateway/L2GraphTokenGatewayConfig.t.sol::test_CalculateL2TokenAddress_ReturnsZeroWhenNotSet | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > immediately after deploy › outboundTransfer › reverts because it is paused | test/unit/gateway/L2GraphTokenGatewayConfig.t.sol::test_OutboundTransfer_RevertWhen_Paused | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > immediately after deploy › finalizeInboundTransfer › revert because it is paused | test/unit/gateway/L2GraphTokenGatewayConfig.t.sol::test_FinalizeInboundTransfer_RevertWhen_Paused | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > immediately after deploy › setL2Router › is not callable by addreses that are not the governor | test/unit/gateway/L2GraphTokenGatewayConfig.t.sol::test_SetL2Router_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > immediately after deploy › setL2Router › sets router address | test/unit/gateway/L2GraphTokenGatewayConfig.t.sol::test_SetL2Router_SetsRouter | Router is `makeAddr` + `vm.etch` | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > immediately after deploy › setL1TokenAddress › is not callable by addreses that are not the governor | test/unit/gateway/L2GraphTokenGatewayConfig.t.sol::test_SetL1TokenAddress_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > immediately after deploy › setL1TokenAddress › sets l2GRT | test/unit/gateway/L2GraphTokenGatewayConfig.t.sol::test_SetL1TokenAddress_SetsL1GRT | L1 token is `makeAddr` + `vm.etch` | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > immediately after deploy › setL1CounterpartAddress › is not callable by addreses that are not the governor | test/unit/gateway/L2GraphTokenGatewayConfig.t.sol::test_SetL1CounterpartAddress_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > immediately after deploy › setL1CounterpartAddress › sets L1Counterpart | test/unit/gateway/L2GraphTokenGatewayConfig.t.sol::test_SetL1CounterpartAddress_SetsL1Counterpart | L1 gateway is `makeAddr` + `vm.etch` | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > immediately after deploy › Pausable behavior › cannot be paused or unpaused by someone other than governor or pauseGuardian | test/unit/gateway/L2GraphTokenGatewayConfig.t.sol::test_PausableBehavior_RevertWhen_NotGovernorOrGuardian | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > immediately after deploy › Pausable behavior › cannot be paused if some state variables are not set | test/unit/gateway/L2GraphTokenGatewayConfig.t.sol::test_PausableBehavior_RevertWhen_UnpausingWithStateNotSet | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > immediately after deploy › Pausable behavior › can be paused and unpaused by the governor | test/unit/gateway/L2GraphTokenGatewayConfig.t.sol::test_PausableBehavior_GovernorCanPauseAndUnpause | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > immediately after deploy › Pausable behavior › setPauseGuardian › cannot be called by someone other than governor | test/unit/gateway/L2GraphTokenGatewayConfig.t.sol::test_SetPauseGuardian_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > immediately after deploy › Pausable behavior › setPauseGuardian › sets a new pause guardian | test/unit/gateway/L2GraphTokenGatewayConfig.t.sol::test_SetPauseGuardian_SetsPauseGuardian | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > immediately after deploy › Pausable behavior › setPauseGuardian › allows a pause guardian to pause and unpause | test/unit/gateway/L2GraphTokenGatewayConfig.t.sol::test_SetPauseGuardian_GuardianCanPauseAndUnpause | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > after configuring and unpausing › calculateL2TokenAddress › returns the L2 token address | test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol::test_CalculateL2TokenAddress_ReturnsL2TokenAddress | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > after configuring and unpausing › calculateL2TokenAddress › returns the zero address if the input is any other address | test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol::test_CalculateL2TokenAddress_ReturnsZeroForOtherAddress | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > after configuring and unpausing › outboundTransfer › reverts when called with the wrong token address | test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol::test_OutboundTransfer_RevertWhen_WrongToken | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > after configuring and unpausing › outboundTransfer › burns tokens and triggers an L1 call | test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol::test_OutboundTransfer_BurnsTokensAndTriggersL1Call | ArbSys faked with `vm.etch` + `vm.mockCall`, checked with `vm.expectCall`; also asserts `getOutboundCalldata`. No longer skipped under coverage | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > after configuring and unpausing › outboundTransfer › decodes the sender address from messages sent by the router | test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol::test_OutboundTransfer_DecodesSenderFromRouter | As above | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > after configuring and unpausing › outboundTransfer › reverts when called with nonempty calldata | test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol::test_OutboundTransfer_RevertWhen_NonEmptyCallhookData | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > after configuring and unpausing › outboundTransfer › reverts when the sender does not have enough GRT | test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol::test_OutboundTransfer_RevertWhen_InsufficientBalance | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > after configuring and unpausing › finalizeInboundTransfer › reverts when called by an account that is not the gateway | test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol::test_FinalizeInboundTransfer_RevertWhen_NotGateway | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > after configuring and unpausing › finalizeInboundTransfer › reverts when called by an account that is the gateway but without the L2 alias | test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol::test_FinalizeInboundTransfer_RevertWhen_GatewayWithoutL2Alias | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > after configuring and unpausing › finalizeInboundTransfer › mints and sends tokens when called by the aliased gateway | test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol::test_FinalizeInboundTransfer_MintsWhenCalledByAliasedGateway | Aliased gateway pranked with an unchecked add of the alias offset | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > after configuring and unpausing › finalizeInboundTransfer › calls a callhook if the transfer includes calldata | test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol::test_FinalizeInboundTransfer_CallsCallhookWithCalldata | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > after configuring and unpausing › finalizeInboundTransfer › reverts if a callhook reverts | test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol::test_FinalizeInboundTransfer_RevertWhen_CallhookReverts | | +| packages/contracts-test/tests/unit/l2/l2GraphTokenGateway.test.ts | L2GraphTokenGateway › > after configuring and unpausing › finalizeInboundTransfer › reverts if trying to call a callhook in a contract that does not implement onTokenTransfer | test/unit/gateway/L2GraphTokenGatewayTransfers.t.sol::test_FinalizeInboundTransfer_RevertWhen_ReceiverHasNoCallhook | `NoCallhookReceiver` replaces RewardsManager; bare `vm.expectRevert()` | + +## Coverage + +MISSING initialize: `L1GraphTokenGateway.initialize` and `L2GraphTokenGateway.initialize` are called by the fixture through `abi.encodeCall` in the proxy init data (`acceptProxyAndCall`), not by a `.initialize(` call. Every test runs it, and it must succeed for the proxy to work. The other listed external/public functions are called directly. + +Additions with no old test: none. `L2GraphTokenGateway.getOutboundCalldata` is not in the baseline (the old outbound cases were skipped under coverage); the two valid outbound helpers (L1 and L2) assert `getOutboundCalldata` against the expected encoding, so it is called directly. From 0906961d200602ded3720d2e580cd33432808d1e Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 26 Oct 2026 10:49:56 -0300 Subject: [PATCH 11/20] test(subgraph-discovery): port L2Curation tests to Foundry --- .../tests/unit/l2/l2Curation.test.ts | 760 ------------------ packages/subgraph-discovery/foundry.toml | 22 + packages/subgraph-discovery/package.json | 19 + .../subgraph-discovery/test/unit/Setup.t.sol | 12 + .../test/unit/interfaces/IGraphTokenLike.sol | 53 ++ .../test/unit/interfaces/IL2CurationLike.sol | 76 ++ .../test/unit/interfaces/IL2GNSLike.sol | 125 +++ .../test/unit/interfaces/ISubgraphNFTLike.sol | 14 + .../test/unit/l2-curation/BondingCurve.t.sol | 49 ++ .../test/unit/l2-curation/Burn.t.sol | 74 ++ .../test/unit/l2-curation/Collect.t.sol | 63 ++ .../test/unit/l2-curation/Config.t.sol | 93 +++ .../test/unit/l2-curation/Conservation.t.sol | 44 + .../test/unit/l2-curation/Curate.t.sol | 63 ++ .../test/unit/l2-curation/CurateTaxFree.t.sol | 41 + .../unit/l2-curation/L2CurationShared.t.sol | 129 +++ .../unit/l2-curation/MultipleMinting.t.sol | 65 ++ .../test/unit/mocks/L2GatewayMock.sol | 19 + .../test/unit/shared/DiscoveryTest.t.sol | 91 +++ .../test/unit/shared/ProtocolFixture.t.sol | 66 ++ pnpm-lock.yaml | 12 + .../contracts-migration/test-mapping/t3a.md | 57 ++ 22 files changed, 1187 insertions(+), 760 deletions(-) delete mode 100644 packages/contracts-test/tests/unit/l2/l2Curation.test.ts create mode 100644 packages/subgraph-discovery/foundry.toml create mode 100644 packages/subgraph-discovery/package.json create mode 100644 packages/subgraph-discovery/test/unit/Setup.t.sol create mode 100644 packages/subgraph-discovery/test/unit/interfaces/IGraphTokenLike.sol create mode 100644 packages/subgraph-discovery/test/unit/interfaces/IL2CurationLike.sol create mode 100644 packages/subgraph-discovery/test/unit/interfaces/IL2GNSLike.sol create mode 100644 packages/subgraph-discovery/test/unit/interfaces/ISubgraphNFTLike.sol create mode 100644 packages/subgraph-discovery/test/unit/l2-curation/BondingCurve.t.sol create mode 100644 packages/subgraph-discovery/test/unit/l2-curation/Burn.t.sol create mode 100644 packages/subgraph-discovery/test/unit/l2-curation/Collect.t.sol create mode 100644 packages/subgraph-discovery/test/unit/l2-curation/Config.t.sol create mode 100644 packages/subgraph-discovery/test/unit/l2-curation/Conservation.t.sol create mode 100644 packages/subgraph-discovery/test/unit/l2-curation/Curate.t.sol create mode 100644 packages/subgraph-discovery/test/unit/l2-curation/CurateTaxFree.t.sol create mode 100644 packages/subgraph-discovery/test/unit/l2-curation/L2CurationShared.t.sol create mode 100644 packages/subgraph-discovery/test/unit/l2-curation/MultipleMinting.t.sol create mode 100644 packages/subgraph-discovery/test/unit/mocks/L2GatewayMock.sol create mode 100644 packages/subgraph-discovery/test/unit/shared/DiscoveryTest.t.sol create mode 100644 packages/subgraph-discovery/test/unit/shared/ProtocolFixture.t.sol create mode 100644 scripts/contracts-migration/test-mapping/t3a.md diff --git a/packages/contracts-test/tests/unit/l2/l2Curation.test.ts b/packages/contracts-test/tests/unit/l2/l2Curation.test.ts deleted file mode 100644 index a680ec28c..000000000 --- a/packages/contracts-test/tests/unit/l2/l2Curation.test.ts +++ /dev/null @@ -1,760 +0,0 @@ -import { L2Curation } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { Controller } from '@graphprotocol/contracts' -import { GNS } from '@graphprotocol/contracts' -import { - formatGRT, - GraphNetworkContracts, - helpers, - randomAddress, - randomHexBytes, - toBN, - toGRT, -} from '@graphprotocol/sdk' -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber, constants, Event, Signer, utils } from 'ethers' -import { parseEther } from 'ethers/lib/utils' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const { AddressZero } = constants - -const MAX_PPM = 1000000 - -const chunkify = (total: BigNumber, maxChunks = 10): Array => { - const chunks: BigNumber[] = [] - while (total.gt(0) && maxChunks > 0) { - const m = 1000000 - const p = Math.floor(Math.random() * m) - const n = total.mul(p).div(m) - chunks.push(n) - total = total.sub(n) - maxChunks-- - } - if (total.gt(0)) { - chunks.push(total) - } - return chunks -} - -const toFloat = (n: BigNumber) => parseFloat(formatGRT(n)) -const toRound = (n: number) => n.toPrecision(11) - -describe('L2Curation:Config', () => { - const graph = hre.graph() - const defaults = graph.graphConfig.defaults - let me: SignerWithAddress - let governor: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let curation: L2Curation - - before(async function () { - ;[me] = await graph.getTestAccounts() - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor, true) - curation = contracts.L2Curation as L2Curation - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('defaultReserveRatio', function () { - it('should be fixed to MAX_PPM', async function () { - // Set right in the constructor - expect(await curation.connect(me).defaultReserveRatio()).eq(MAX_PPM) - }) - it('cannot be changed because the setter is not implemented', async function () { - const tx = curation.connect(governor).setDefaultReserveRatio(10) - await expect(tx).revertedWith('Not implemented in L2') - }) - }) - - describe('minimumCurationDeposit', function () { - it('should set `minimumCurationDeposit`', async function () { - // Set right in the constructor - expect(await curation.minimumCurationDeposit()).eq(defaults.curation.l2MinimumCurationDeposit) - - // Can set if allowed - const newValue = toBN('100') - await curation.connect(governor).setMinimumCurationDeposit(newValue) - expect(await curation.minimumCurationDeposit()).eq(newValue) - }) - - it('reject set `minimumCurationDeposit` if out of bounds', async function () { - const tx = curation.connect(governor).setMinimumCurationDeposit(0) - await expect(tx).revertedWith('Minimum curation deposit cannot be 0') - }) - - it('reject set `minimumCurationDeposit` if not allowed', async function () { - const tx = curation.connect(me).setMinimumCurationDeposit(defaults.curation.minimumCurationDeposit) - await expect(tx).revertedWith('Only Controller governor') - }) - }) - - describe('curationTaxPercentage', function () { - it('should set `curationTaxPercentage`', async function () { - const curationTaxPercentage = defaults.curation.curationTaxPercentage - - // Set new value - await curation.connect(governor).setCurationTaxPercentage(0) - await curation.connect(governor).setCurationTaxPercentage(curationTaxPercentage) - }) - - it('reject set `curationTaxPercentage` if out of bounds', async function () { - const tx = curation.connect(governor).setCurationTaxPercentage(MAX_PPM + 1) - await expect(tx).revertedWith('Curation tax percentage must be below or equal to MAX_PPM') - }) - - it('reject set `curationTaxPercentage` if not allowed', async function () { - const tx = curation.connect(me).setCurationTaxPercentage(0) - await expect(tx).revertedWith('Only Controller governor') - }) - }) - - describe('curationTokenMaster', function () { - it('should set `curationTokenMaster`', async function () { - const newCurationTokenMaster = curation.address - await curation.connect(governor).setCurationTokenMaster(newCurationTokenMaster) - }) - - it('reject set `curationTokenMaster` to empty value', async function () { - const newCurationTokenMaster = AddressZero - const tx = curation.connect(governor).setCurationTokenMaster(newCurationTokenMaster) - await expect(tx).revertedWith('Token master must be non-empty') - }) - - it('reject set `curationTokenMaster` to non-contract', async function () { - const newCurationTokenMaster = randomAddress() - const tx = curation.connect(governor).setCurationTokenMaster(newCurationTokenMaster) - await expect(tx).revertedWith('Token master must be a contract') - }) - - it('reject set `curationTokenMaster` if not allowed', async function () { - const newCurationTokenMaster = curation.address - const tx = curation.connect(me).setCurationTokenMaster(newCurationTokenMaster) - await expect(tx).revertedWith('Only Controller governor') - }) - }) -}) - -describe('L2Curation', () => { - const graph = hre.graph() - let me: SignerWithAddress - let governor: SignerWithAddress - let curator: SignerWithAddress - let subgraphServiceMock: SignerWithAddress - let gnsImpersonator: Signer - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let curation: L2Curation - let grt: GraphToken - let controller: Controller - let gns: GNS - - // Test values - const signalAmountFor1000Tokens = toGRT('1000.0') - const signalAmountForMinimumCuration = toBN('1') - const subgraphDeploymentID = randomHexBytes() - const curatorTokens = toGRT('1000000000') - const tokensToDeposit = toGRT('1000') - const tokensToCollect = toGRT('2000') - - async function calcLinearBondingCurve( - supply: BigNumber, - reserveBalance: BigNumber, - depositAmount: BigNumber, - ): Promise { - // Handle the initialization of the bonding curve - if (supply.eq(0)) { - const minDeposit = await curation.minimumCurationDeposit() - if (depositAmount.lt(minDeposit)) { - throw new Error('deposit must be above minimum') - } - const minSupply = signalAmountForMinimumCuration - return (await calcLinearBondingCurve(minSupply, minDeposit, depositAmount.sub(minDeposit))) + toFloat(minSupply) - } - // Calculate bonding curve in the test - return toFloat(supply) * (toFloat(depositAmount) / toFloat(reserveBalance)) - } - - const shouldMint = async (tokensToDeposit: BigNumber, expectedSignal: BigNumber) => { - // Before state - const beforeTokenTotalSupply = await grt.totalSupply() - const beforeCuratorTokens = await grt.balanceOf(curator.address) - const beforeCuratorSignal = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - const beforePool = await curation.pools(subgraphDeploymentID) - const beforePoolSignal = await curation.getCurationPoolSignal(subgraphDeploymentID) - const beforeTotalTokens = await grt.balanceOf(curation.address) - - // Calculations - const curationTaxPercentage = await curation.curationTaxPercentage() - const curationTax = tokensToDeposit.mul(toBN(curationTaxPercentage)).div(toBN(MAX_PPM)) - - // Curate - const tx = curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - await expect(tx) - .emit(curation, 'Signalled') - .withArgs(curator.address, subgraphDeploymentID, tokensToDeposit, expectedSignal, curationTax) - - // After state - const afterTokenTotalSupply = await grt.totalSupply() - const afterCuratorTokens = await grt.balanceOf(curator.address) - const afterCuratorSignal = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - const afterPool = await curation.pools(subgraphDeploymentID) - const afterPoolSignal = await curation.getCurationPoolSignal(subgraphDeploymentID) - const afterTotalTokens = await grt.balanceOf(curation.address) - - // Curator balance updated - expect(afterCuratorTokens).eq(beforeCuratorTokens.sub(tokensToDeposit)) - expect(afterCuratorSignal).eq(beforeCuratorSignal.add(expectedSignal)) - // Allocated and balance updated - expect(afterPool.tokens).eq(beforePool.tokens.add(tokensToDeposit.sub(curationTax))) - expect(afterPoolSignal).eq(beforePoolSignal.add(expectedSignal)) - // Pool reserveRatio is deprecated and therefore always zero in L2 - expect(afterPool.reserveRatio).eq(0) - // Contract balance updated - expect(afterTotalTokens).eq(beforeTotalTokens.add(tokensToDeposit.sub(curationTax))) - // Total supply is reduced to curation tax burning - expect(afterTokenTotalSupply).eq(beforeTokenTotalSupply.sub(curationTax)) - } - - const shouldMintTaxFree = async (tokensToDeposit: BigNumber, expectedSignal: BigNumber) => { - // Before state - const beforeTokenTotalSupply = await grt.totalSupply() - const beforeCuratorTokens = await grt.balanceOf(gns.address) - const beforeCuratorSignal = await curation.getCuratorSignal(gns.address, subgraphDeploymentID) - const beforePool = await curation.pools(subgraphDeploymentID) - const beforePoolSignal = await curation.getCurationPoolSignal(subgraphDeploymentID) - const beforeTotalTokens = await grt.balanceOf(curation.address) - - // Curate - const tx = curation.connect(gnsImpersonator).mintTaxFree(subgraphDeploymentID, tokensToDeposit) - await expect(tx) - .emit(curation, 'Signalled') - .withArgs(gns.address, subgraphDeploymentID, tokensToDeposit, expectedSignal, 0) - - // After state - const afterTokenTotalSupply = await grt.totalSupply() - const afterCuratorTokens = await grt.balanceOf(gns.address) - const afterCuratorSignal = await curation.getCuratorSignal(gns.address, subgraphDeploymentID) - const afterPool = await curation.pools(subgraphDeploymentID) - const afterPoolSignal = await curation.getCurationPoolSignal(subgraphDeploymentID) - const afterTotalTokens = await grt.balanceOf(curation.address) - - // Curator balance updated - expect(afterCuratorTokens).eq(beforeCuratorTokens.sub(tokensToDeposit)) - expect(afterCuratorSignal).eq(beforeCuratorSignal.add(expectedSignal)) - // Allocated and balance updated - expect(afterPool.tokens).eq(beforePool.tokens.add(tokensToDeposit)) - expect(afterPoolSignal).eq(beforePoolSignal.add(expectedSignal)) - // Pool reserveRatio is deprecated and therefore always zero in L2 - expect(afterPool.reserveRatio).eq(0) - // Contract balance updated - expect(afterTotalTokens).eq(beforeTotalTokens.add(tokensToDeposit)) - // Total supply is reduced to curation tax burning - expect(afterTokenTotalSupply).eq(beforeTokenTotalSupply) - } - - const shouldBurn = async (signalToRedeem: BigNumber, expectedTokens: BigNumber) => { - // Before balances - const beforeTokenTotalSupply = await grt.totalSupply() - const beforeCuratorTokens = await grt.balanceOf(curator.address) - const beforeCuratorSignal = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - const beforePool = await curation.pools(subgraphDeploymentID) - const beforePoolSignal = await curation.getCurationPoolSignal(subgraphDeploymentID) - const beforeTotalTokens = await grt.balanceOf(curation.address) - - // Redeem - const tx = curation.connect(curator).burn(subgraphDeploymentID, signalToRedeem, 0) - await expect(tx) - .emit(curation, 'Burned') - .withArgs(curator.address, subgraphDeploymentID, expectedTokens, signalToRedeem) - - // After balances - const afterTokenTotalSupply = await grt.totalSupply() - const afterCuratorTokens = await grt.balanceOf(curator.address) - const afterCuratorSignal = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - const afterPool = await curation.pools(subgraphDeploymentID) - const afterPoolSignal = await curation.getCurationPoolSignal(subgraphDeploymentID) - const afterTotalTokens = await grt.balanceOf(curation.address) - - // Curator balance updated - expect(afterCuratorTokens).eq(beforeCuratorTokens.add(expectedTokens)) - expect(afterCuratorSignal).eq(beforeCuratorSignal.sub(signalToRedeem)) - // Curation balance updated - expect(afterPool.tokens).eq(beforePool.tokens.sub(expectedTokens)) - expect(afterPoolSignal).eq(beforePoolSignal.sub(signalToRedeem)) - // Contract balance updated - expect(afterTotalTokens).eq(beforeTotalTokens.sub(expectedTokens)) - // Total supply is conserved - expect(afterTokenTotalSupply).eq(beforeTokenTotalSupply) - } - - const shouldCollect = async (tokensToCollect: BigNumber) => { - // Before state - const beforePool = await curation.pools(subgraphDeploymentID) - const beforeTotalBalance = await grt.balanceOf(curation.address) - - // Source of tokens must be the staking for this to work - await grt.connect(subgraphServiceMock).transfer(curation.address, tokensToCollect) - const tx = curation.connect(subgraphServiceMock).collect(subgraphDeploymentID, tokensToCollect) - await expect(tx).emit(curation, 'Collected').withArgs(subgraphDeploymentID, tokensToCollect) - - // After state - const afterPool = await curation.pools(subgraphDeploymentID) - const afterTotalBalance = await grt.balanceOf(curation.address) - - // State updated - expect(afterPool.tokens).eq(beforePool.tokens.add(tokensToCollect)) - expect(afterTotalBalance).eq(beforeTotalBalance.add(tokensToCollect)) - } - - before(async function () { - // Use stakingMock so we can call collect - ;[me, curator, subgraphServiceMock] = await graph.getTestAccounts() - ;({ governor } = await graph.getNamedAccounts()) - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor, true) - curation = contracts.Curation as L2Curation - grt = contracts.GraphToken as GraphToken - controller = contracts.Controller - gns = contracts.GNS as GNS - - gnsImpersonator = await helpers.impersonateAccount(gns.address) - await helpers.setBalance(gns.address, parseEther('1')) - // Give some funds to the curator and GNS impersonator and approve the curation contract - await grt.connect(governor).mint(curator.address, curatorTokens) - await grt.connect(curator).approve(curation.address, curatorTokens) - await grt.connect(governor).mint(gns.address, curatorTokens) - await grt.connect(gnsImpersonator).approve(curation.address, curatorTokens) - - // Give some funds to the staking contract and approve the curation contract - await grt.connect(governor).mint(subgraphServiceMock.address, tokensToCollect) - await grt.connect(subgraphServiceMock).approve(curation.address, tokensToCollect) - - // Set the subgraph service - await curation.connect(governor).setSubgraphService(subgraphServiceMock.address) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('bonding curve', function () { - const tokensToDeposit = curatorTokens - - it('reject convert signal to tokens if subgraph deployment not initted', async function () { - const tx = curation.signalToTokens(subgraphDeploymentID, toGRT('100')) - await expect(tx).revertedWith('Subgraph deployment must be curated to perform calculations') - }) - - it('convert signal to tokens', async function () { - // Curate - await curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - - // Conversion - const signal = await curation.getCurationPoolSignal(subgraphDeploymentID) - const expectedTokens = await curation.signalToTokens(subgraphDeploymentID, signal) - expect(expectedTokens).eq(tokensToDeposit) - }) - - it('convert signal to tokens (with curation tax)', async function () { - // Set curation tax - const curationTaxPercentage = 50000 // 5% - await curation.connect(governor).setCurationTaxPercentage(curationTaxPercentage) - - // Curate - const expectedCurationTax = tokensToDeposit.mul(curationTaxPercentage).div(MAX_PPM) - const { 1: curationTax } = await curation.tokensToSignal(subgraphDeploymentID, tokensToDeposit) - await curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - - // Conversion - const signal = await curation.getCurationPoolSignal(subgraphDeploymentID) - const tokens = await curation.signalToTokens(subgraphDeploymentID, signal) - expect(tokens).eq(tokensToDeposit.sub(expectedCurationTax)) - expect(expectedCurationTax).eq(curationTax) - }) - - it('convert tokens to signal', async function () { - // Conversion - const tokens = toGRT('1000') - const { 0: signal } = await curation.tokensToSignal(subgraphDeploymentID, tokens) - expect(signal).eq(signalAmountFor1000Tokens) - }) - - it('convert tokens to signal if non-curated subgraph', async function () { - // Conversion - const nonCuratedSubgraphDeploymentID = randomHexBytes() - const tokens = toGRT('0') - const tx = curation.tokensToSignal(nonCuratedSubgraphDeploymentID, tokens) - await expect(tx).revertedWith('Curation deposit is below minimum required') - }) - }) - - describe('curate', function () { - it('reject deposit below minimum tokens required', async function () { - // Set the minimum to a value greater than 1 so that we can test - await curation.connect(governor).setMinimumCurationDeposit(toBN('2')) - const tokensToDeposit = (await curation.minimumCurationDeposit()).sub(toBN(1)) - const tx = curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - await expect(tx).revertedWith('Curation deposit is below minimum required') - }) - - it('should deposit on a subgraph deployment', async function () { - const tokensToDeposit = await curation.minimumCurationDeposit() - const expectedSignal = signalAmountForMinimumCuration // tax = 0 due to rounding - await shouldMint(tokensToDeposit, expectedSignal) - }) - - it('should get signal according to bonding curve', async function () { - const tokensToDeposit = toGRT('1000') - const expectedSignal = signalAmountFor1000Tokens - await shouldMint(tokensToDeposit, expectedSignal) - }) - - it('should get signal according to bonding curve (and account for curation tax)', async function () { - // Set curation tax - await curation.connect(governor).setCurationTaxPercentage(50000) // 5% - - // Mint - const tokensToDeposit = toGRT('1000') - const { 0: expectedSignal } = await curation.tokensToSignal(subgraphDeploymentID, tokensToDeposit) - await shouldMint(tokensToDeposit, expectedSignal) - }) - - it('should revert curate if over slippage', async function () { - const tokensToDeposit = toGRT('1000') - const expectedSignal = signalAmountFor1000Tokens - const tx = curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, expectedSignal.add(1)) - await expect(tx).revertedWith('Slippage protection') - }) - - it('should pay a minimum of 1 wei GRT in tax when depositing small amounts', async function () { - // Set minimum curation deposit - await contracts.Curation.connect(governor).setMinimumCurationDeposit('1') - - // Set curation tax to 1% - await contracts.Curation.connect(governor).setCurationTaxPercentage(10000) - - // Deposit a small amount where tax would be less than 1 wei - const tokensToDeposit = '99' - - const expectedTokens = '98' - const expectedSignal = '98' - const expectedTax = 1 - - const tx = contracts.Curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, expectedSignal) - - await expect(tx) - .emit(contracts.Curation, 'Signalled') - .withArgs(curator.address, subgraphDeploymentID, tokensToDeposit, expectedSignal, expectedTax) - - const burnTx = contracts.Curation.connect(curator).burn(subgraphDeploymentID, expectedSignal, expectedTokens) - - await expect(burnTx) - .emit(contracts.Curation, 'Burned') - .withArgs(curator.address, subgraphDeploymentID, expectedTokens, expectedSignal) - }) - }) - - describe('curate tax free (from GNS)', function () { - it('can not be called by anyone other than GNS', async function () { - const tokensToDeposit = await curation.minimumCurationDeposit() - const tx = curation.connect(curator).mintTaxFree(subgraphDeploymentID, tokensToDeposit) - await expect(tx).revertedWith('Only the GNS can call this') - }) - - it('reject deposit below minimum tokens required', async function () { - // Set the minimum to a value greater than 1 so that we can test - await curation.connect(governor).setMinimumCurationDeposit(toBN('2')) - const tokensToDeposit = (await curation.minimumCurationDeposit()).sub(toBN(1)) - const tx = curation.connect(gnsImpersonator).mintTaxFree(subgraphDeploymentID, tokensToDeposit) - await expect(tx).revertedWith('Curation deposit is below minimum required') - }) - - it('should deposit on a subgraph deployment', async function () { - const tokensToDeposit = await curation.minimumCurationDeposit() - const expectedSignal = signalAmountForMinimumCuration - await shouldMintTaxFree(tokensToDeposit, expectedSignal) - }) - - it('should get signal according to bonding curve', async function () { - const tokensToDeposit = toGRT('1000') - const expectedSignal = signalAmountFor1000Tokens - await shouldMintTaxFree(tokensToDeposit, expectedSignal) - }) - - it('should get signal according to bonding curve (and with zero tax)', async function () { - // Set curation tax - await curation.connect(governor).setCurationTaxPercentage(50000) // 5% - - // Mint - const tokensToDeposit = toGRT('1000') - const expectedSignal = await curation.tokensToSignalNoTax(subgraphDeploymentID, tokensToDeposit) - await shouldMintTaxFree(tokensToDeposit, expectedSignal) - }) - }) - - describe('collect', function () { - context('> not curated', function () { - it('reject collect tokens distributed to the curation pool', async function () { - // Source of tokens must be the staking for this to work - await controller.connect(governor).setContractProxy(utils.id('Staking'), subgraphServiceMock.address) - await curation.connect(governor).syncAllContracts() // call sync because we change the proxy for staking - - const tx = curation.connect(subgraphServiceMock).collect(subgraphDeploymentID, tokensToCollect) - await expect(tx).revertedWith('Subgraph deployment must be curated to collect fees') - }) - }) - - context('> curated', function () { - beforeEach(async function () { - await curation.connect(curator).mint(subgraphDeploymentID, toGRT('1000'), 0) - }) - - it('reject collect tokens distributed from invalid address', async function () { - const tx = curation.connect(me).collect(subgraphDeploymentID, tokensToCollect) - await expect(tx).revertedWith('Caller must be the subgraph service') - }) - - it('should collect tokens distributed to the curation pool', async function () { - await controller.connect(governor).setContractProxy(utils.id('Staking'), subgraphServiceMock.address) - await curation.connect(governor).syncAllContracts() // call sync because we change the proxy for staking - - await shouldCollect(toGRT('1')) - await shouldCollect(toGRT('10')) - await shouldCollect(toGRT('100')) - await shouldCollect(toGRT('200')) - await shouldCollect(toGRT('500.25')) - }) - - it('should collect tokens and then unsignal all', async function () { - await controller.connect(governor).setContractProxy(utils.id('Staking'), subgraphServiceMock.address) - await curation.connect(governor).syncAllContracts() // call sync because we change the proxy for staking - - // Collect increase the pool reserves - await shouldCollect(toGRT('100')) - - // When we burn signal we should get more tokens than initially curated - const signalToRedeem = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - await shouldBurn(signalToRedeem, toGRT('1100')) - }) - - it('should collect tokens and then unsignal multiple times', async function () { - await controller.connect(governor).setContractProxy(utils.id('Staking'), subgraphServiceMock.address) - await curation.connect(governor).syncAllContracts() // call sync because we change the proxy for staking - - // Collect increase the pool reserves - const tokensToCollect = toGRT('100') - await shouldCollect(tokensToCollect) - - // Unsignal partially - const signalOutRemainder = toGRT(1) - const signalOutPartial = (await curation.getCuratorSignal(curator.address, subgraphDeploymentID)).sub( - signalOutRemainder, - ) - const tx1 = await curation.connect(curator).burn(subgraphDeploymentID, signalOutPartial, 0) - const r1 = await tx1.wait() - const event1 = curation.interface.parseLog(r1.events[2]).args - const tokensOut1 = event1.tokens - - // Collect increase the pool reserves - await shouldCollect(tokensToCollect) - - // Unsignal the rest - const tx2 = await curation.connect(curator).burn(subgraphDeploymentID, signalOutRemainder, 0) - const r2 = await tx2.wait() - const event2 = curation.interface.parseLog(r2.events[2]).args - const tokensOut2 = event2.tokens - - expect(tokensOut1.add(tokensOut2)).eq(toGRT('1000').add(tokensToCollect.mul(2))) - }) - }) - }) - - describe('burn', function () { - beforeEach(async function () { - await curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - }) - - it('reject redeem more than a curator owns', async function () { - const tx = curation.connect(me).burn(subgraphDeploymentID, toGRT('1'), 0) - await expect(tx).revertedWith('Cannot burn more signal than you own') - }) - - it('reject redeem zero signal', async function () { - const tx = curation.connect(me).burn(subgraphDeploymentID, toGRT('0'), 0) - await expect(tx).revertedWith('Cannot burn zero signal') - }) - - it('should allow to redeem *partially*', async function () { - // Redeem just one signal - const signalToRedeem = toGRT('1') - const expectedTokens = toGRT('1') - await shouldBurn(signalToRedeem, expectedTokens) - }) - - it('should allow to redeem *fully*', async function () { - // Get all signal of the curator - const signalToRedeem = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - const expectedTokens = tokensToDeposit - await shouldBurn(signalToRedeem, expectedTokens) - }) - - it('should allow to redeem back below minimum deposit', async function () { - // Set the minimum to a value greater than 1 so that we can test - await curation.connect(governor).setMinimumCurationDeposit(toGRT('1')) - - // Redeem "almost" all signal - const signal = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - const signalToRedeem = signal.sub(toGRT('0.000001')) - const expectedTokens = await curation.signalToTokens(subgraphDeploymentID, signalToRedeem) - await shouldBurn(signalToRedeem, expectedTokens) - - // The pool should have less tokens that required by minimumCurationDeposit - const afterPool = await curation.pools(subgraphDeploymentID) - expect(afterPool.tokens).lt(await curation.minimumCurationDeposit()) - - // Should be able to deposit more after being under minimumCurationDeposit - const tokensToDeposit = toGRT('1') - const { 0: expectedSignal } = await curation.tokensToSignal(subgraphDeploymentID, tokensToDeposit) - await shouldMint(tokensToDeposit, expectedSignal) - }) - - it('should revert redeem if over slippage', async function () { - const signalToRedeem = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - const expectedTokens = tokensToDeposit - - const tx = curation.connect(curator).burn(subgraphDeploymentID, signalToRedeem, expectedTokens.add(1)) - await expect(tx).revertedWith('Slippage protection') - }) - - it('should not re-deploy the curation token when signal is reset', async function () { - const beforeSubgraphPool = await curation.pools(subgraphDeploymentID) - - // Burn all the signal - const signalToRedeem = await curation.getCuratorSignal(curator.address, subgraphDeploymentID) - const expectedTokens = tokensToDeposit - await shouldBurn(signalToRedeem, expectedTokens) - - // Mint again on the same subgraph - await curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - - // Check state - const afterSubgraphPool = await curation.pools(subgraphDeploymentID) - expect(afterSubgraphPool.gcs).eq(beforeSubgraphPool.gcs) - }) - }) - - describe('conservation', function () { - it('should match multiple deposits and redeems back to initial state', async function () { - this.timeout(60000) // increase timeout for test runner - - const totalDeposits = toGRT('1000000000') - - // Signal multiple times - let totalSignal = toGRT('0') - for (const tokensToDeposit of chunkify(totalDeposits, 10)) { - const tx = await curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - const receipt = await tx.wait() - const event: Event = receipt.events.pop() - const signal = event.args['signal'] - totalSignal = totalSignal.add(signal) - } - - // Redeem signal multiple times - let totalTokens = toGRT('0') - for (const signalToRedeem of chunkify(totalSignal, 10)) { - const tx = await curation.connect(curator).burn(subgraphDeploymentID, signalToRedeem, 0) - const receipt = await tx.wait() - const event: Event = receipt.events.pop() - const tokens = event.args['tokens'] - totalTokens = totalTokens.add(tokens) - // console.log('<', formatEther(signalToRedeem), '=', formatEther(tokens)) - } - - // Conservation of work - const afterPool = await curation.pools(subgraphDeploymentID) - const afterPoolSignal = await curation.getCurationPoolSignal(subgraphDeploymentID) - expect(afterPool.tokens).eq(toGRT('0')) - expect(afterPoolSignal).eq(toGRT('0')) - expect(await curation.isCurated(subgraphDeploymentID)).eq(false) - expect(totalDeposits).eq(totalTokens) - }) - }) - - describe('multiple minting', function () { - it('should mint the same signal every time due to the flat bonding curve', async function () { - const tokensToDepositMany = [ - toGRT('1000'), // should mint if we start with number above minimum deposit - toGRT('1000'), // every time it should mint the same GCS due to bonding curve! - toGRT('1000'), - toGRT('1000'), - toGRT('2000'), - toGRT('2000'), - toGRT('123'), - toGRT('1'), // should mint below minimum deposit - ] - for (const tokensToDeposit of tokensToDepositMany) { - const expectedSignal = await calcLinearBondingCurve( - await curation.getCurationPoolSignal(subgraphDeploymentID), - await curation.getCurationPoolTokens(subgraphDeploymentID), - tokensToDeposit, - ) - // SIGNAL_PER_MINIMUM_DEPOSIT should always give the same ratio - expect(tokensToDeposit.div(toGRT(expectedSignal))).eq(1) - - const tx = await curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - const receipt = await tx.wait() - const event: Event = receipt.events.pop() - const signal = event.args['signal'] - expect(toRound(toFloat(toBN(signal)))).eq(toRound(expectedSignal)) - } - }) - - it('should mint when using a different ratio between GRT and signal', async function () { - this.timeout(60000) // increase timeout for test runner - - // Setup edge case with 1 GRT = 1 wei signal - await curation.connect(governor).setMinimumCurationDeposit(toGRT('1')) - - const tokensToDepositMany = [ - toGRT('1000'), // should mint if we start with number above minimum deposit - toGRT('1000'), // every time it should mint proportionally the same GCS due to linear bonding curve... - toGRT('1000'), - toGRT('1000'), - toGRT('2000'), - toGRT('2000'), - toGRT('123'), - toGRT('1'), - ] - - // Mint multiple times - for (const tokensToDeposit of tokensToDepositMany) { - const tx = await curation.connect(curator).mint(subgraphDeploymentID, tokensToDeposit, 0) - const receipt = await tx.wait() - const event: Event = receipt.events.pop() - const signal = event.args['signal'] - expect(tokensToDeposit).eq(signal.mul(toGRT('1'))) // we compare 1 GRT : 1 wei ratio - } - }) - }) -}) diff --git a/packages/subgraph-discovery/foundry.toml b/packages/subgraph-discovery/foundry.toml new file mode 100644 index 000000000..35b9b51af --- /dev/null +++ b/packages/subgraph-discovery/foundry.toml @@ -0,0 +1,22 @@ +[profile.default] +src = 'test' +out = 'forge-artifacts' +test = 'test' +libs = ["node_modules"] +cache_path = 'cache_forge' +auto_detect_remappings = false +remappings = [ + "@graphprotocol/=node_modules/@graphprotocol/", + "forge-std/=node_modules/forge-std/src/", +] +solc_version = '0.8.35' +evm_version = 'cancun' +fs_permissions = [{ access = "read", path = "node_modules/@graphprotocol/contracts/artifacts" }] +ignored_warnings_from = ["node_modules"] + +[lint] +# Disable lint-on-build: forge's lint walker follows symlinks without cycle detection and +# infinite-loops on this package's pnpm-hoisted node_modules. +lint_on_build = false +exclude_lints = ["mixed-case-function", "mixed-case-variable", "block-timestamp"] +ignore = ["node_modules/**", "**/node_modules/**"] diff --git a/packages/subgraph-discovery/package.json b/packages/subgraph-discovery/package.json new file mode 100644 index 000000000..c3e3c899a --- /dev/null +++ b/packages/subgraph-discovery/package.json @@ -0,0 +1,19 @@ +{ + "name": "@graphprotocol/subgraph-discovery", + "version": "0.0.0", + "private": true, + "description": "The Graph subgraph discovery and curation contracts", + "license": "GPL-2.0-or-later", + "scripts": { + "build": "pnpm build:dep", + "build:dep": "pnpm --filter '@graphprotocol/subgraph-discovery^...' run build:self", + "clean": "rm -rf forge-artifacts/ cache_forge/", + "test": "pnpm build && pnpm test:self", + "test:self": "forge test" + }, + "devDependencies": { + "@graphprotocol/contracts": "workspace:^", + "@graphprotocol/interfaces": "workspace:^", + "forge-std": "catalog:" + } +} diff --git a/packages/subgraph-discovery/test/unit/Setup.t.sol b/packages/subgraph-discovery/test/unit/Setup.t.sol new file mode 100644 index 000000000..4b4f4ca7d --- /dev/null +++ b/packages/subgraph-discovery/test/unit/Setup.t.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { DiscoveryTest } from "./shared/DiscoveryTest.t.sol"; + +contract SetupTest is DiscoveryTest { + function test_Setup_Wired() public view { + assertEq(nft.minter(), address(gns)); + assertEq(curation.subgraphService(), subgraphService); + assertEq(gns.counterpartGNSAddress(), l1GNS); + } +} diff --git a/packages/subgraph-discovery/test/unit/interfaces/IGraphTokenLike.sol b/packages/subgraph-discovery/test/unit/interfaces/IGraphTokenLike.sol new file mode 100644 index 000000000..f6ffd171f --- /dev/null +++ b/packages/subgraph-discovery/test/unit/interfaces/IGraphTokenLike.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +interface IGraphTokenLike { + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + event MinterAdded(address indexed account); + event MinterRemoved(address indexed account); + + function totalSupply() external view returns (uint256); + + function balanceOf(address account) external view returns (uint256); + + function transfer(address to, uint256 amount) external returns (bool); + + function transferFrom(address from, address to, uint256 amount) external returns (bool); + + function approve(address spender, uint256 amount) external returns (bool); + + function allowance(address owner, address spender) external view returns (uint256); + + function burn(uint256 amount) external; + + function burnFrom(address account, uint256 amount) external; + + function mint(address to, uint256 amount) external; + + function addMinter(address account) external; + + function removeMinter(address account) external; + + function renounceMinter() external; + + function isMinter(address account) external view returns (bool); + + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external; + + function nonces(address owner) external view returns (uint256); + + function governor() external view returns (address); + + function transferOwnership(address newGovernor) external; + + function acceptOwnership() external; +} diff --git a/packages/subgraph-discovery/test/unit/interfaces/IL2CurationLike.sol b/packages/subgraph-discovery/test/unit/interfaces/IL2CurationLike.sol new file mode 100644 index 000000000..deba3ee0d --- /dev/null +++ b/packages/subgraph-discovery/test/unit/interfaces/IL2CurationLike.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +interface IL2CurationLike { + event Signalled( + address indexed curator, + bytes32 indexed subgraphDeploymentID, + uint256 tokens, + uint256 signal, + uint256 curationTax + ); + event Burned(address indexed curator, bytes32 indexed subgraphDeploymentID, uint256 tokens, uint256 signal); + event Collected(bytes32 indexed subgraphDeploymentID, uint256 tokens); + + function initialize( + address controller, + address curationTokenMaster, + uint32 curationTaxPercentage, + uint256 minimumCurationDeposit + ) external; + + function pools( + bytes32 subgraphDeploymentID + ) external view returns (uint256 tokens, uint32 reserveRatio, address gcs); + + function defaultReserveRatio() external view returns (uint32); + + function setDefaultReserveRatio(uint32 defaultReserveRatio) external; + + function minimumCurationDeposit() external view returns (uint256); + + function setMinimumCurationDeposit(uint256 minimumCurationDeposit) external; + + function curationTaxPercentage() external view returns (uint32); + + function setCurationTaxPercentage(uint32 percentage) external; + + function curationTokenMaster() external view returns (address); + + function setCurationTokenMaster(address curationTokenMaster) external; + + function subgraphService() external view returns (address); + + function setSubgraphService(address subgraphService) external; + + function mint( + bytes32 subgraphDeploymentID, + uint256 tokensIn, + uint256 signalOutMin + ) external returns (uint256 signalOut, uint256 curationTax); + + function mintTaxFree(bytes32 subgraphDeploymentID, uint256 tokensIn) external returns (uint256); + + function burn(bytes32 subgraphDeploymentID, uint256 signalIn, uint256 tokensOutMin) external returns (uint256); + + function collect(bytes32 subgraphDeploymentID, uint256 tokens) external; + + function isCurated(bytes32 subgraphDeploymentID) external view returns (bool); + + function getCuratorSignal(address curator, bytes32 subgraphDeploymentID) external view returns (uint256); + + function getCurationPoolSignal(bytes32 subgraphDeploymentID) external view returns (uint256); + + function getCurationPoolTokens(bytes32 subgraphDeploymentID) external view returns (uint256); + + function tokensToSignal(bytes32 subgraphDeploymentID, uint256 tokensIn) external view returns (uint256, uint256); + + function tokensToSignalNoTax(bytes32 subgraphDeploymentID, uint256 tokensIn) external view returns (uint256); + + function tokensToSignalToTokensNoTax( + bytes32 subgraphDeploymentID, + uint256 tokensIn + ) external view returns (uint256); + + function signalToTokens(bytes32 subgraphDeploymentID, uint256 signalIn) external view returns (uint256); +} diff --git a/packages/subgraph-discovery/test/unit/interfaces/IL2GNSLike.sol b/packages/subgraph-discovery/test/unit/interfaces/IL2GNSLike.sol new file mode 100644 index 000000000..dff9d4d54 --- /dev/null +++ b/packages/subgraph-discovery/test/unit/interfaces/IL2GNSLike.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +interface IL2GNSLike { + event SubgraphMetadataUpdated(uint256 indexed subgraphID, bytes32 subgraphMetadata); + event SubgraphVersionUpdated( + uint256 indexed subgraphID, + bytes32 indexed subgraphDeploymentID, + bytes32 versionMetadata + ); + event SignalMinted( + uint256 indexed subgraphID, + address indexed curator, + uint256 nSignalCreated, + uint256 vSignalCreated, + uint256 tokensDeposited + ); + event SignalBurned( + uint256 indexed subgraphID, + address indexed curator, + uint256 nSignalBurnt, + uint256 vSignalBurnt, + uint256 tokensReceived + ); + event SubgraphPublished(uint256 indexed subgraphID, bytes32 indexed subgraphDeploymentID, uint32 reserveRatio); + event SubgraphUpgraded( + uint256 indexed subgraphID, + uint256 vSignalCreated, + uint256 tokensSignalled, + bytes32 indexed subgraphDeploymentID + ); + event SubgraphDeprecated(uint256 indexed subgraphID, uint256 withdrawableGRT); + event SubgraphReceivedFromL1( + uint256 indexed _l1SubgraphID, + uint256 indexed _l2SubgraphID, + address indexed _owner, + uint256 _tokens + ); + event SubgraphL2TransferFinalized(uint256 indexed _l2SubgraphID); + event CuratorBalanceReceived( + uint256 indexed _l1SubgraphId, + uint256 indexed _l2SubgraphID, + address indexed _l2Curator, + uint256 _tokens + ); + event CuratorBalanceReturnedToBeneficiary( + uint256 indexed _l1SubgraphID, + address indexed _l2Curator, + uint256 _tokens + ); + + function initialize(address controller, address subgraphNFT) external; + + function approveAll() external; + + function setCounterpartGNSAddress(address counterpart) external; + + function counterpartGNSAddress() external view returns (address); + + function ownerTaxPercentage() external view returns (uint32); + + function setOwnerTaxPercentage(uint32 ownerTaxPercentage) external; + + function nextAccountSeqID(address account) external view returns (uint256); + + function publishNewSubgraph( + bytes32 subgraphDeploymentID, + bytes32 versionMetadata, + bytes32 subgraphMetadata + ) external; + + function publishNewVersion(uint256 subgraphID, bytes32 subgraphDeploymentID, bytes32 versionMetadata) external; + + function publishNewVersion( + uint256 subgraphID, + bytes32 subgraphDeploymentID, + bytes32 versionMetadata, + uint256 signalOutMin + ) external; + + function deprecateSubgraph(uint256 subgraphID) external; + + function mintSignal(uint256 subgraphID, uint256 tokensIn, uint256 nSignalOutMin) external; + + function burnSignal(uint256 subgraphID, uint256 nSignal, uint256 tokensOutMin) external; + + function onTokenTransfer(address from, uint256 amount, bytes calldata data) external; + + function finishSubgraphTransferFromL1( + uint256 l2SubgraphID, + bytes32 subgraphDeploymentID, + bytes32 subgraphMetadata, + bytes32 versionMetadata + ) external; + + function ownerOf(uint256 tokenID) external view returns (address); + + function subgraphs( + uint256 subgraphID + ) + external + view + returns ( + uint256 vSignal, + uint256 nSignal, + bytes32 subgraphDeploymentID, + uint32 reserveRatio, + bool disabled, + uint256 withdrawableGRT + ); + + function subgraphL2TransferData( + uint256 subgraphID + ) external view returns (uint256 tokens, bool l2Done, uint256 subgraphReceivedOnL2BlockNumber); + + function getAliasedL2SubgraphID(uint256 l1SubgraphID) external pure returns (uint256); + + function getCuratorSignal(uint256 subgraphID, address curator) external view returns (uint256); + + function tokensToNSignal(uint256 subgraphID, uint256 tokensIn) external view returns (uint256, uint256, uint256); + + function nSignalToTokens(uint256 subgraphID, uint256 nSignalIn) external view returns (uint256, uint256); + + function vSignalToNSignal(uint256 subgraphID, uint256 vSignalIn) external view returns (uint256); +} diff --git a/packages/subgraph-discovery/test/unit/interfaces/ISubgraphNFTLike.sol b/packages/subgraph-discovery/test/unit/interfaces/ISubgraphNFTLike.sol new file mode 100644 index 000000000..0e9109514 --- /dev/null +++ b/packages/subgraph-discovery/test/unit/interfaces/ISubgraphNFTLike.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +interface ISubgraphNFTLike { + function ownerOf(uint256 tokenId) external view returns (address); + + function minter() external view returns (address); + + function setMinter(address minter) external; + + function setTokenDescriptor(address tokenDescriptor) external; + + function tokenURI(uint256 tokenId) external view returns (string memory); +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/BondingCurve.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/BondingCurve.t.sol new file mode 100644 index 000000000..15e74b1df --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/BondingCurve.t.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { L2CurationSharedTest } from "./L2CurationShared.t.sol"; + +contract BondingCurveTest is L2CurationSharedTest { + function test_BondingCurve_RevertWhen_SignalToTokensNotCurated() public { + vm.expectRevert(bytes("Subgraph deployment must be curated to perform calculations")); + curation.signalToTokens(SUBGRAPH_DEPLOYMENT_ID, 100 ether); + } + + function test_BondingCurve_ConvertSignalToTokens() public { + _mint(CURATOR_TOKENS); + + uint256 signal = curation.getCurationPoolSignal(SUBGRAPH_DEPLOYMENT_ID); + assertEq(curation.signalToTokens(SUBGRAPH_DEPLOYMENT_ID, signal), CURATOR_TOKENS); + } + + function test_BondingCurve_ConvertSignalToTokensWithCurationTax() public { + uint32 curationTaxPercentage = 50_000; // 5% + vm.prank(governor); + curation.setCurationTaxPercentage(curationTaxPercentage); + + uint256 expectedCurationTax = (CURATOR_TOKENS * curationTaxPercentage) / MAX_PPM; + (, uint256 curationTax) = curation.tokensToSignal(SUBGRAPH_DEPLOYMENT_ID, CURATOR_TOKENS); + _mint(CURATOR_TOKENS); + + uint256 signal = curation.getCurationPoolSignal(SUBGRAPH_DEPLOYMENT_ID); + assertEq(curation.signalToTokens(SUBGRAPH_DEPLOYMENT_ID, signal), CURATOR_TOKENS - expectedCurationTax); + assertEq(expectedCurationTax, curationTax); + } + + function test_BondingCurve_ConvertTokensToSignal() public view { + (uint256 signal, ) = curation.tokensToSignal(SUBGRAPH_DEPLOYMENT_ID, 1000 ether); + assertEq(signal, SIGNAL_AMOUNT_FOR_1000_TOKENS); + } + + function test_BondingCurve_RevertWhen_ConvertTokensToSignalBelowMinimum() public { + vm.expectRevert(bytes("Curation deposit is below minimum required")); + curation.tokensToSignal(keccak256("nonCuratedSubgraphDeploymentID"), 0); + } + + function test_BondingCurve_TokensToSignalToTokensNoTax() public { + _mint(TOKENS_TO_DEPOSIT); + + // Flat curve: depositing tokens and redeeming the resulting signal returns the same tokens + assertEq(curation.tokensToSignalToTokensNoTax(SUBGRAPH_DEPLOYMENT_ID, TOKENS_TO_DEPOSIT), TOKENS_TO_DEPOSIT); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/Burn.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/Burn.t.sol new file mode 100644 index 000000000..896fcd66b --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/Burn.t.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { L2CurationSharedTest } from "./L2CurationShared.t.sol"; + +contract BurnTest is L2CurationSharedTest { + function setUp() public override { + super.setUp(); + _mint(TOKENS_TO_DEPOSIT); + } + + function test_Burn_RevertWhen_MoreThanOwned() public { + vm.expectRevert(bytes("Cannot burn more signal than you own")); + vm.prank(me); + curation.burn(SUBGRAPH_DEPLOYMENT_ID, 1 ether, 0); + } + + function test_Burn_RevertWhen_ZeroSignal() public { + vm.expectRevert(bytes("Cannot burn zero signal")); + vm.prank(me); + curation.burn(SUBGRAPH_DEPLOYMENT_ID, 0, 0); + } + + function test_Burn_Partially() public { + _shouldBurn(1 ether, 1 ether); + } + + function test_Burn_Fully() public { + uint256 signalToRedeem = curation.getCuratorSignal(curator, SUBGRAPH_DEPLOYMENT_ID); + _shouldBurn(signalToRedeem, TOKENS_TO_DEPOSIT); + } + + function test_Burn_BackBelowMinimumDeposit() public { + // Set the minimum to a value greater than 1 so that we can test + vm.prank(governor); + curation.setMinimumCurationDeposit(1 ether); + + // Redeem "almost" all signal + uint256 signal = curation.getCuratorSignal(curator, SUBGRAPH_DEPLOYMENT_ID); + uint256 signalToRedeem = signal - 0.000001 ether; + uint256 expectedTokens = curation.signalToTokens(SUBGRAPH_DEPLOYMENT_ID, signalToRedeem); + _shouldBurn(signalToRedeem, expectedTokens); + + // The pool should have less tokens than required by minimumCurationDeposit + (uint256 poolTokens, , ) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + assertLt(poolTokens, curation.minimumCurationDeposit()); + + // Should be able to deposit more after being under minimumCurationDeposit + (uint256 expectedSignal, ) = curation.tokensToSignal(SUBGRAPH_DEPLOYMENT_ID, 1 ether); + _shouldMint(1 ether, expectedSignal); + } + + function test_Burn_RevertWhen_OverSlippage() public { + uint256 signalToRedeem = curation.getCuratorSignal(curator, SUBGRAPH_DEPLOYMENT_ID); + + vm.expectRevert(bytes("Slippage protection")); + vm.prank(curator); + curation.burn(SUBGRAPH_DEPLOYMENT_ID, signalToRedeem, TOKENS_TO_DEPOSIT + 1); + } + + function test_Burn_DoesNotRedeployCurationTokenWhenSignalReset() public { + (, , address beforeGcs) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + + // Burn all the signal + uint256 signalToRedeem = curation.getCuratorSignal(curator, SUBGRAPH_DEPLOYMENT_ID); + _shouldBurn(signalToRedeem, TOKENS_TO_DEPOSIT); + + // Mint again on the same subgraph + _mint(TOKENS_TO_DEPOSIT); + + (, , address afterGcs) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + assertEq(afterGcs, beforeGcs); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/Collect.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/Collect.t.sol new file mode 100644 index 000000000..6f292e4af --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/Collect.t.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { L2CurationSharedTest } from "./L2CurationShared.t.sol"; + +contract CollectTest is L2CurationSharedTest { + modifier curated() { + _mint(1000 ether); + _; + } + + // -- > not curated -- + + function test_NotCurated_RevertWhen_Collect() public { + vm.expectRevert(bytes("Subgraph deployment must be curated to collect fees")); + vm.prank(subgraphService); + curation.collect(SUBGRAPH_DEPLOYMENT_ID, TOKENS_TO_COLLECT); + } + + // -- > curated -- + + function test_Curated_RevertWhen_CallerNotSubgraphService() public curated { + vm.expectRevert(bytes("Caller must be the subgraph service")); + vm.prank(me); + curation.collect(SUBGRAPH_DEPLOYMENT_ID, TOKENS_TO_COLLECT); + } + + function test_Curated_CollectTokens() public curated { + _shouldCollect(1 ether); + _shouldCollect(10 ether); + _shouldCollect(100 ether); + _shouldCollect(200 ether); + _shouldCollect(500.25 ether); + } + + function test_Curated_CollectThenBurnAll() public curated { + // Collect increases the pool reserves + _shouldCollect(100 ether); + + // When we burn signal we should get more tokens than initially curated + uint256 signalToRedeem = curation.getCuratorSignal(curator, SUBGRAPH_DEPLOYMENT_ID); + _shouldBurn(signalToRedeem, 1100 ether); + } + + function test_Curated_CollectThenBurnMultipleTimes() public curated { + uint256 tokensToCollect = 100 ether; + _shouldCollect(tokensToCollect); + + // Burn partially + uint256 signalOutRemainder = 1 ether; + uint256 signalOutPartial = curation.getCuratorSignal(curator, SUBGRAPH_DEPLOYMENT_ID) - signalOutRemainder; + vm.prank(curator); + uint256 tokensOut1 = curation.burn(SUBGRAPH_DEPLOYMENT_ID, signalOutPartial, 0); + + _shouldCollect(tokensToCollect); + + // Burn the rest + vm.prank(curator); + uint256 tokensOut2 = curation.burn(SUBGRAPH_DEPLOYMENT_ID, signalOutRemainder, 0); + + assertEq(tokensOut1 + tokensOut2, 1000 ether + tokensToCollect * 2); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/Config.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/Config.t.sol new file mode 100644 index 000000000..99a165e81 --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/Config.t.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { DiscoveryTest } from "../shared/DiscoveryTest.t.sol"; + +contract ConfigTest is DiscoveryTest { + uint32 internal constant MAX_PPM = 1_000_000; + uint256 internal constant DEFAULT_MINIMUM_CURATION_DEPOSIT = 1; + uint32 internal constant DEFAULT_CURATION_TAX_PERCENTAGE = 10_000; + + // -- defaultReserveRatio -- + + function test_DefaultReserveRatio_FixedToMaxPPM() public view { + assertEq(curation.defaultReserveRatio(), MAX_PPM); + } + + function test_DefaultReserveRatio_RevertWhen_Set() public { + vm.expectRevert(bytes("Not implemented in L2")); + vm.prank(governor); + curation.setDefaultReserveRatio(10); + } + + // -- minimumCurationDeposit -- + + function test_MinimumCurationDeposit_Set() public { + assertEq(curation.minimumCurationDeposit(), DEFAULT_MINIMUM_CURATION_DEPOSIT); + + vm.prank(governor); + curation.setMinimumCurationDeposit(100); + assertEq(curation.minimumCurationDeposit(), 100); + } + + function test_MinimumCurationDeposit_RevertWhen_Zero() public { + vm.expectRevert(bytes("Minimum curation deposit cannot be 0")); + vm.prank(governor); + curation.setMinimumCurationDeposit(0); + } + + function test_MinimumCurationDeposit_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(me); + curation.setMinimumCurationDeposit(DEFAULT_MINIMUM_CURATION_DEPOSIT); + } + + // -- curationTaxPercentage -- + + function test_CurationTaxPercentage_Set() public { + vm.startPrank(governor); + curation.setCurationTaxPercentage(0); + assertEq(curation.curationTaxPercentage(), 0); + curation.setCurationTaxPercentage(DEFAULT_CURATION_TAX_PERCENTAGE); + assertEq(curation.curationTaxPercentage(), DEFAULT_CURATION_TAX_PERCENTAGE); + vm.stopPrank(); + } + + function test_CurationTaxPercentage_RevertWhen_OutOfBounds() public { + vm.expectRevert(bytes("Curation tax percentage must be below or equal to MAX_PPM")); + vm.prank(governor); + curation.setCurationTaxPercentage(MAX_PPM + 1); + } + + function test_CurationTaxPercentage_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(me); + curation.setCurationTaxPercentage(0); + } + + // -- curationTokenMaster -- + + function test_CurationTokenMaster_Set() public { + vm.prank(governor); + curation.setCurationTokenMaster(address(curation)); + assertEq(curation.curationTokenMaster(), address(curation)); + } + + function test_CurationTokenMaster_RevertWhen_Empty() public { + vm.expectRevert(bytes("Token master must be non-empty")); + vm.prank(governor); + curation.setCurationTokenMaster(address(0)); + } + + function test_CurationTokenMaster_RevertWhen_NotContract() public { + vm.expectRevert(bytes("Token master must be a contract")); + vm.prank(governor); + curation.setCurationTokenMaster(makeAddr("notContract")); + } + + function test_CurationTokenMaster_RevertWhen_NotGovernor() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(me); + curation.setCurationTokenMaster(address(curation)); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/Conservation.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/Conservation.t.sol new file mode 100644 index 000000000..dd20afd1d --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/Conservation.t.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { L2CurationSharedTest } from "./L2CurationShared.t.sol"; + +contract ConservationTest is L2CurationSharedTest { + function testFuzz_Conservation_DepositsAndRedeemsMatch( + uint256[10] memory depositCuts, + uint256[10] memory redeemCuts + ) public { + uint256 totalDeposits = 1_000_000_000 ether; + + // Signal multiple times + uint256 totalSignal; + uint256 remaining = totalDeposits; + for (uint256 i = 0; i < 10; i++) { + uint256 chunk = i == 9 ? remaining : bound(depositCuts[i], 0, remaining); + remaining -= chunk; + if (chunk == 0) continue; + totalSignal += _mint(chunk); + } + assertEq(totalSignal, curation.getCuratorSignal(curator, SUBGRAPH_DEPLOYMENT_ID)); + + // Redeem signal multiple times + uint256 totalTokens; + remaining = totalSignal; + for (uint256 i = 0; i < 10; i++) { + uint256 chunk = i == 9 ? remaining : bound(redeemCuts[i], 0, remaining); + remaining -= chunk; + if (chunk == 0) continue; + vm.prank(curator); + totalTokens += curation.burn(SUBGRAPH_DEPLOYMENT_ID, chunk, 0); + } + + // Conservation of work + (uint256 poolTokens, , ) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + assertEq(poolTokens, 0); + assertEq(curation.getCurationPoolSignal(SUBGRAPH_DEPLOYMENT_ID), 0); + assertFalse(curation.isCurated(SUBGRAPH_DEPLOYMENT_ID)); + assertEq(totalTokens, totalDeposits); + assertEq(grt.balanceOf(curator), CURATOR_TOKENS); + assertEq(grt.balanceOf(address(curation)), 0); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/Curate.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/Curate.t.sol new file mode 100644 index 000000000..aa611dc0e --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/Curate.t.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { L2CurationSharedTest } from "./L2CurationShared.t.sol"; +import { IL2CurationLike } from "../interfaces/IL2CurationLike.sol"; + +contract CurateTest is L2CurationSharedTest { + function test_Curate_RevertWhen_BelowMinimumDeposit() public { + // Set the minimum to a value greater than 1 so that we can test + vm.prank(governor); + curation.setMinimumCurationDeposit(2); + uint256 tokensToDeposit = curation.minimumCurationDeposit() - 1; + + vm.expectRevert(bytes("Curation deposit is below minimum required")); + vm.prank(curator); + curation.mint(SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit, 0); + } + + function test_Curate_DepositOnSubgraphDeployment() public { + _shouldMint(curation.minimumCurationDeposit(), SIGNAL_AMOUNT_FOR_MINIMUM_CURATION); + } + + function test_Curate_SignalAccordingToBondingCurve() public { + _shouldMint(1000 ether, SIGNAL_AMOUNT_FOR_1000_TOKENS); + } + + function test_Curate_SignalAccordingToBondingCurveWithCurationTax() public { + vm.prank(governor); + curation.setCurationTaxPercentage(50_000); // 5% + + (uint256 expectedSignal, ) = curation.tokensToSignal(SUBGRAPH_DEPLOYMENT_ID, 1000 ether); + _shouldMint(1000 ether, expectedSignal); + } + + function test_Curate_RevertWhen_OverSlippage() public { + vm.expectRevert(bytes("Slippage protection")); + vm.prank(curator); + curation.mint(SUBGRAPH_DEPLOYMENT_ID, 1000 ether, SIGNAL_AMOUNT_FOR_1000_TOKENS + 1); + } + + function test_Curate_PaysMinimumOneWeiTaxOnSmallDeposits() public { + vm.startPrank(governor); + curation.setMinimumCurationDeposit(1); + curation.setCurationTaxPercentage(10_000); // 1% + vm.stopPrank(); + + // Deposit a small amount where tax would be less than 1 wei + uint256 tokensToDeposit = 99; + uint256 expectedTokens = 98; + uint256 expectedSignal = 98; + uint256 expectedTax = 1; + + vm.expectEmit(address(curation)); + emit IL2CurationLike.Signalled(curator, SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit, expectedSignal, expectedTax); + vm.prank(curator); + curation.mint(SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit, expectedSignal); + + vm.expectEmit(address(curation)); + emit IL2CurationLike.Burned(curator, SUBGRAPH_DEPLOYMENT_ID, expectedTokens, expectedSignal); + vm.prank(curator); + curation.burn(SUBGRAPH_DEPLOYMENT_ID, expectedSignal, expectedTokens); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/CurateTaxFree.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/CurateTaxFree.t.sol new file mode 100644 index 000000000..d8aeeb552 --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/CurateTaxFree.t.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { L2CurationSharedTest } from "./L2CurationShared.t.sol"; + +contract CurateTaxFreeTest is L2CurationSharedTest { + function test_CurateTaxFree_RevertWhen_NotGNS() public { + uint256 tokensToDeposit = curation.minimumCurationDeposit(); + + vm.expectRevert(bytes("Only the GNS can call this")); + vm.prank(curator); + curation.mintTaxFree(SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit); + } + + function test_CurateTaxFree_RevertWhen_BelowMinimumDeposit() public { + // Set the minimum to a value greater than 1 so that we can test + vm.prank(governor); + curation.setMinimumCurationDeposit(2); + uint256 tokensToDeposit = curation.minimumCurationDeposit() - 1; + + vm.expectRevert(bytes("Curation deposit is below minimum required")); + vm.prank(address(gns)); + curation.mintTaxFree(SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit); + } + + function test_CurateTaxFree_DepositOnSubgraphDeployment() public { + _shouldMintTaxFree(curation.minimumCurationDeposit(), SIGNAL_AMOUNT_FOR_MINIMUM_CURATION); + } + + function test_CurateTaxFree_SignalAccordingToBondingCurve() public { + _shouldMintTaxFree(1000 ether, SIGNAL_AMOUNT_FOR_1000_TOKENS); + } + + function test_CurateTaxFree_SignalAccordingToBondingCurveWithZeroTax() public { + vm.prank(governor); + curation.setCurationTaxPercentage(50_000); // 5% + + uint256 expectedSignal = curation.tokensToSignalNoTax(SUBGRAPH_DEPLOYMENT_ID, 1000 ether); + _shouldMintTaxFree(1000 ether, expectedSignal); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/L2CurationShared.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/L2CurationShared.t.sol new file mode 100644 index 000000000..a239703fe --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/L2CurationShared.t.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { DiscoveryTest } from "../shared/DiscoveryTest.t.sol"; +import { IL2CurationLike } from "../interfaces/IL2CurationLike.sol"; + +abstract contract L2CurationSharedTest is DiscoveryTest { + uint32 internal constant MAX_PPM = 1_000_000; + + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID = keccak256("subgraphDeploymentID"); + uint256 internal constant SIGNAL_AMOUNT_FOR_1000_TOKENS = 1000 ether; + uint256 internal constant SIGNAL_AMOUNT_FOR_MINIMUM_CURATION = 1; + uint256 internal constant CURATOR_TOKENS = 1_000_000_000 ether; + uint256 internal constant TOKENS_TO_DEPOSIT = 1000 ether; + uint256 internal constant TOKENS_TO_COLLECT = 2000 ether; + + struct State { + uint256 tokenTotalSupply; + uint256 curatorTokens; + uint256 curatorSignal; + uint256 poolTokens; + uint256 poolSignal; + uint256 totalTokens; + } + + function setUp() public virtual override { + super.setUp(); + + // Curator and GNS funds (GNS already approved curation in approveAll) + vm.startPrank(governor); + grt.mint(curator, CURATOR_TOKENS); + grt.mint(address(gns), CURATOR_TOKENS); + grt.mint(subgraphService, TOKENS_TO_COLLECT); + vm.stopPrank(); + vm.prank(curator); + grt.approve(address(curation), CURATOR_TOKENS); + vm.prank(subgraphService); + grt.approve(address(curation), TOKENS_TO_COLLECT); + } + + function _state(address account) internal view returns (State memory s) { + s.tokenTotalSupply = grt.totalSupply(); + s.curatorTokens = grt.balanceOf(account); + s.curatorSignal = curation.getCuratorSignal(account, SUBGRAPH_DEPLOYMENT_ID); + (s.poolTokens, , ) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + s.poolSignal = curation.getCurationPoolSignal(SUBGRAPH_DEPLOYMENT_ID); + s.totalTokens = grt.balanceOf(address(curation)); + } + + function _shouldMint(uint256 tokensToDeposit, uint256 expectedSignal) internal { + State memory before = _state(curator); + uint256 curationTax = (tokensToDeposit * curation.curationTaxPercentage()) / MAX_PPM; + + vm.expectEmit(address(curation)); + emit IL2CurationLike.Signalled(curator, SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit, expectedSignal, curationTax); + vm.prank(curator); + curation.mint(SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit, 0); + + State memory afterState = _state(curator); + (, uint32 reserveRatio, ) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + assertEq(afterState.curatorTokens, before.curatorTokens - tokensToDeposit); + assertEq(afterState.curatorSignal, before.curatorSignal + expectedSignal); + assertEq(afterState.poolTokens, before.poolTokens + tokensToDeposit - curationTax); + assertEq(afterState.poolSignal, before.poolSignal + expectedSignal); + // Pool reserveRatio is deprecated and therefore always zero in L2 + assertEq(reserveRatio, 0); + assertEq(afterState.totalTokens, before.totalTokens + tokensToDeposit - curationTax); + // Total supply is reduced by the curation tax burn + assertEq(afterState.tokenTotalSupply, before.tokenTotalSupply - curationTax); + } + + function _shouldMintTaxFree(uint256 tokensToDeposit, uint256 expectedSignal) internal { + State memory before = _state(address(gns)); + + vm.expectEmit(address(curation)); + emit IL2CurationLike.Signalled(address(gns), SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit, expectedSignal, 0); + vm.prank(address(gns)); + curation.mintTaxFree(SUBGRAPH_DEPLOYMENT_ID, tokensToDeposit); + + State memory afterState = _state(address(gns)); + (, uint32 reserveRatio, ) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + assertEq(afterState.curatorTokens, before.curatorTokens - tokensToDeposit); + assertEq(afterState.curatorSignal, before.curatorSignal + expectedSignal); + assertEq(afterState.poolTokens, before.poolTokens + tokensToDeposit); + assertEq(afterState.poolSignal, before.poolSignal + expectedSignal); + assertEq(reserveRatio, 0); + assertEq(afterState.totalTokens, before.totalTokens + tokensToDeposit); + assertEq(afterState.tokenTotalSupply, before.tokenTotalSupply); + } + + function _shouldBurn(uint256 signalToRedeem, uint256 expectedTokens) internal { + State memory before = _state(curator); + + vm.expectEmit(address(curation)); + emit IL2CurationLike.Burned(curator, SUBGRAPH_DEPLOYMENT_ID, expectedTokens, signalToRedeem); + vm.prank(curator); + curation.burn(SUBGRAPH_DEPLOYMENT_ID, signalToRedeem, 0); + + State memory afterState = _state(curator); + assertEq(afterState.curatorTokens, before.curatorTokens + expectedTokens); + assertEq(afterState.curatorSignal, before.curatorSignal - signalToRedeem); + assertEq(afterState.poolTokens, before.poolTokens - expectedTokens); + assertEq(afterState.poolSignal, before.poolSignal - signalToRedeem); + assertEq(afterState.totalTokens, before.totalTokens - expectedTokens); + // Total supply is conserved + assertEq(afterState.tokenTotalSupply, before.tokenTotalSupply); + } + + function _shouldCollect(uint256 tokensToCollect) internal { + (uint256 beforePoolTokens, , ) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + uint256 beforeTotalBalance = grt.balanceOf(address(curation)); + + vm.prank(subgraphService); + grt.transfer(address(curation), tokensToCollect); + vm.expectEmit(address(curation)); + emit IL2CurationLike.Collected(SUBGRAPH_DEPLOYMENT_ID, tokensToCollect); + vm.prank(subgraphService); + curation.collect(SUBGRAPH_DEPLOYMENT_ID, tokensToCollect); + + (uint256 afterPoolTokens, , ) = curation.pools(SUBGRAPH_DEPLOYMENT_ID); + assertEq(afterPoolTokens, beforePoolTokens + tokensToCollect); + assertEq(grt.balanceOf(address(curation)), beforeTotalBalance + tokensToCollect); + } + + function _mint(uint256 tokens) internal returns (uint256 signal) { + vm.prank(curator); + (signal, ) = curation.mint(SUBGRAPH_DEPLOYMENT_ID, tokens, 0); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-curation/MultipleMinting.t.sol b/packages/subgraph-discovery/test/unit/l2-curation/MultipleMinting.t.sol new file mode 100644 index 000000000..05d71f212 --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-curation/MultipleMinting.t.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { L2CurationSharedTest } from "./L2CurationShared.t.sol"; + +contract MultipleMintingTest is L2CurationSharedTest { + function _tokensToDepositMany() private pure returns (uint256[8] memory) { + return + [ + uint256(1000 ether), // should mint if we start with number above minimum deposit + 1000 ether, // every time it should mint the same GCS due to the bonding curve + 1000 ether, + 1000 ether, + 2000 ether, + 2000 ether, + 123 ether, + 1 ether // should mint below minimum deposit + ]; + } + + /// Linear bonding curve computed independently of the contract + function _calcLinearBondingCurve( + uint256 supply, + uint256 reserveBalance, + uint256 depositAmount + ) private view returns (uint256) { + // Handle the initialization of the bonding curve + if (supply == 0) { + uint256 minDeposit = curation.minimumCurationDeposit(); + require(depositAmount >= minDeposit, "deposit must be above minimum"); + uint256 minSupply = SIGNAL_AMOUNT_FOR_MINIMUM_CURATION; + return _calcLinearBondingCurve(minSupply, minDeposit, depositAmount - minDeposit) + minSupply; + } + return (supply * depositAmount) / reserveBalance; + } + + function test_MultipleMinting_SameSignalDueToFlatBondingCurve() public { + uint256[8] memory tokensToDepositMany = _tokensToDepositMany(); + for (uint256 i = 0; i < tokensToDepositMany.length; i++) { + uint256 tokensToDeposit = tokensToDepositMany[i]; + uint256 expectedSignal = _calcLinearBondingCurve( + curation.getCurationPoolSignal(SUBGRAPH_DEPLOYMENT_ID), + curation.getCurationPoolTokens(SUBGRAPH_DEPLOYMENT_ID), + tokensToDeposit + ); + // SIGNAL_PER_MINIMUM_DEPOSIT should always give the same ratio + assertEq(tokensToDeposit / expectedSignal, 1); + + assertEq(_mint(tokensToDeposit), expectedSignal); + } + } + + function test_MultipleMinting_DifferentRatioBetweenGRTAndSignal() public { + // Setup edge case with 1 GRT = 1 wei signal + vm.prank(governor); + curation.setMinimumCurationDeposit(1 ether); + + uint256[8] memory tokensToDepositMany = _tokensToDepositMany(); + for (uint256 i = 0; i < tokensToDepositMany.length; i++) { + uint256 tokensToDeposit = tokensToDepositMany[i]; + // 1 GRT : 1 wei signal ratio + assertEq(tokensToDeposit, _mint(tokensToDeposit) * 1 ether); + } + } +} diff --git a/packages/subgraph-discovery/test/unit/mocks/L2GatewayMock.sol b/packages/subgraph-discovery/test/unit/mocks/L2GatewayMock.sol new file mode 100644 index 000000000..6df628d5f --- /dev/null +++ b/packages/subgraph-discovery/test/unit/mocks/L2GatewayMock.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { ICallhookReceiver } from "@graphprotocol/interfaces/contracts/contracts/gateway/ICallhookReceiver.sol"; +import { IGraphTokenLike } from "../interfaces/IGraphTokenLike.sol"; + +/// Stands in for L2GraphTokenGateway: mints first, then forwards the callhook, like finalizeInboundTransfer +contract L2GatewayMock { + address public immutable token; + + constructor(address token_) { + token = token_; + } + + function deliver(address gns, address l1From, uint256 amount, bytes calldata data) external { + IGraphTokenLike(token).mint(gns, amount); + ICallhookReceiver(gns).onTokenTransfer(l1From, amount, data); + } +} diff --git a/packages/subgraph-discovery/test/unit/shared/DiscoveryTest.t.sol b/packages/subgraph-discovery/test/unit/shared/DiscoveryTest.t.sol new file mode 100644 index 000000000..9414df07d --- /dev/null +++ b/packages/subgraph-discovery/test/unit/shared/DiscoveryTest.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IManaged } from "@graphprotocol/interfaces/contracts/contracts/governance/IManaged.sol"; +import { ProtocolFixture } from "./ProtocolFixture.t.sol"; +import { L2GatewayMock } from "../mocks/L2GatewayMock.sol"; +import { IGraphTokenLike } from "../interfaces/IGraphTokenLike.sol"; +import { IL2CurationLike } from "../interfaces/IL2CurationLike.sol"; +import { IL2GNSLike } from "../interfaces/IL2GNSLike.sol"; +import { ISubgraphNFTLike } from "../interfaces/ISubgraphNFTLike.sol"; + +/// L2 discovery stack: L2GraphToken, L2Curation, SubgraphNFT and L2GNS, with a mock L2 gateway. +/// There is no staking, rewards manager or epoch manager. +abstract contract DiscoveryTest is ProtocolFixture { + string internal constant L2_GRAPH_TOKEN = + string.concat(PKG_ARTIFACTS, "l2/token/L2GraphToken.sol/L2GraphToken.json"); + string internal constant GRAPH_CURATION_TOKEN = + string.concat(PKG_ARTIFACTS, "curation/GraphCurationToken.sol/GraphCurationToken.json"); + string internal constant L2_CURATION = string.concat(PKG_ARTIFACTS, "l2/curation/L2Curation.sol/L2Curation.json"); + string internal constant SUBGRAPH_NFT = string.concat(PKG_ARTIFACTS, "discovery/SubgraphNFT.sol/SubgraphNFT.json"); + string internal constant SUBGRAPH_NFT_DESCRIPTOR = + string.concat(PKG_ARTIFACTS, "discovery/SubgraphNFTDescriptor.sol/SubgraphNFTDescriptor.json"); + string internal constant L2_GNS = string.concat(PKG_ARTIFACTS, "l2/discovery/L2GNS.sol/L2GNS.json"); + + IGraphTokenLike internal grt; + IL2CurationLike internal curation; + IL2GNSLike internal gns; + ISubgraphNFTLike internal nft; + L2GatewayMock internal gatewayMock; + address internal curationTokenMaster; + + address internal me = makeAddr("me"); + address internal other = makeAddr("other"); + address internal curator = makeAddr("curator"); + address internal attacker = makeAddr("attacker"); + address internal l1GNS = makeAddr("l1GNS"); + address internal subgraphService = makeAddr("subgraphService"); + + function setUp() public virtual { + vm.warp(1_700_000_000); + _deployProtocolBase(); + + grt = IGraphTokenLike( + _deployBehindProxy( + _deployArtifact(L2_GRAPH_TOKEN, ""), + abi.encodeWithSignature("initialize(address)", governor) + ) + ); + gatewayMock = new L2GatewayMock(address(grt)); + vm.startPrank(governor); + grt.addMinter(address(gatewayMock)); + grt.addMinter(governor); + vm.stopPrank(); + + curationTokenMaster = _deployArtifact(GRAPH_CURATION_TOKEN, ""); + + curation = IL2CurationLike( + _deployBehindProxy( + _deployArtifact(L2_CURATION, ""), + abi.encodeCall(IL2CurationLike.initialize, (address(controller), curationTokenMaster, 0, 1)) + ) + ); + + nft = ISubgraphNFTLike(_deployArtifact(SUBGRAPH_NFT, abi.encode(governor))); + address descriptor = _deployArtifact(SUBGRAPH_NFT_DESCRIPTOR, ""); + vm.prank(governor); + nft.setTokenDescriptor(descriptor); + + gns = IL2GNSLike( + _deployBehindProxy( + _deployArtifact(L2_GNS, ""), + abi.encodeCall(IL2GNSLike.initialize, (address(controller), address(nft))) + ) + ); + vm.prank(governor); + nft.setMinter(address(gns)); + + _registerContract("GraphToken", address(grt)); + _registerContract("Curation", address(curation)); + _registerContract("GNS", address(gns)); + _registerContract("GraphTokenGateway", address(gatewayMock)); + IManaged(address(curation)).syncAllContracts(); + IManaged(address(gns)).syncAllContracts(); + + gns.approveAll(); + vm.startPrank(governor); + gns.setCounterpartGNSAddress(l1GNS); + curation.setSubgraphService(subgraphService); + vm.stopPrank(); + } +} diff --git a/packages/subgraph-discovery/test/unit/shared/ProtocolFixture.t.sol b/packages/subgraph-discovery/test/unit/shared/ProtocolFixture.t.sol new file mode 100644 index 000000000..f38962c8e --- /dev/null +++ b/packages/subgraph-discovery/test/unit/shared/ProtocolFixture.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Test } from "forge-std/Test.sol"; +import { IController } from "@graphprotocol/interfaces/contracts/contracts/governance/IController.sol"; +import { IGraphProxy } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxy.sol"; +import { IGraphProxyAdmin } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxyAdmin.sol"; + +abstract contract ProtocolFixture is Test { + string internal constant CORE_ARTIFACTS = "node_modules/@graphprotocol/contracts/artifacts/contracts/"; + string internal constant PKG_ARTIFACTS = "node_modules/@graphprotocol/contracts/artifacts/contracts/"; + + address internal governor; // makeAddr("governor"), set in _deployProtocolBase + IController internal controller; + IGraphProxyAdmin internal proxyAdmin; + + /// path is a full artifact path, e.g. string.concat(CORE_ARTIFACTS, "governance/Controller.sol/Controller.json"). + /// Deploys `.bytecode ++ args` with CREATE (honours an active vm.prank). + /// Reverts "artifact needs linking" if `.linkReferences` has any key (vm.parseJsonKeys), + /// and "artifact deploy failed" if CREATE returns 0. + function _deployArtifact(string memory path, bytes memory args) internal returns (address deployed) { + string memory json = vm.readFile(path); + require(vm.parseJsonKeys(json, ".linkReferences").length == 0, "artifact needs linking"); + bytes memory code = bytes.concat(vm.parseJsonBytes(json, ".bytecode"), args); + // solhint-disable-next-line no-inline-assembly + assembly { + deployed := create(0, add(code, 0x20), mload(code)) + } + require(deployed != address(0), "artifact deploy failed"); + } + + /// As governor: deploys Controller and GraphProxyAdmin from CORE_ARTIFACTS, then controller.setPaused(false). + /// Must be called with no active prank. + function _deployProtocolBase() internal { + governor = makeAddr("governor"); + vm.startPrank(governor); + controller = IController( + _deployArtifact(string.concat(CORE_ARTIFACTS, "governance/Controller.sol/Controller.json"), "") + ); + proxyAdmin = IGraphProxyAdmin( + _deployArtifact(string.concat(CORE_ARTIFACTS, "upgrades/GraphProxyAdmin.sol/GraphProxyAdmin.json"), "") + ); + controller.setPaused(false); + vm.stopPrank(); + } + + /// As governor: deploys GraphProxy(address(0), proxyAdmin) from CORE_ARTIFACTS, + /// proxyAdmin.upgrade(proxy, impl), proxyAdmin.acceptProxyAndCall(impl, proxy, initData). + /// Must be called with no active prank. + function _deployBehindProxy(address impl, bytes memory initData) internal returns (address proxy) { + vm.startPrank(governor); + proxy = _deployArtifact( + string.concat(CORE_ARTIFACTS, "upgrades/GraphProxy.sol/GraphProxy.json"), + abi.encode(address(0), address(proxyAdmin)) + ); + proxyAdmin.upgrade(IGraphProxy(proxy), impl); + proxyAdmin.acceptProxyAndCall(impl, IGraphProxy(proxy), initData); + vm.stopPrank(); + } + + /// As governor: controller.setContractProxy(keccak256(bytes(name)), addr). + function _registerContract(string memory name, address addr) internal { + vm.prank(governor); + controller.setContractProxy(keccak256(bytes(name)), addr); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e0e89ae6..287d911a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1058,6 +1058,18 @@ importers: specifier: 'catalog:' version: 1.7.0 + packages/subgraph-discovery: + devDependencies: + '@graphprotocol/contracts': + specifier: workspace:^ + version: link:../contracts + '@graphprotocol/interfaces': + specifier: workspace:^ + version: link:../interfaces + forge-std: + specifier: 'catalog:' + version: https://github.com/foundry-rs/forge-std/tarball/v1.14.0 + packages/subgraph-service: devDependencies: '@graphprotocol/contracts': diff --git a/scripts/contracts-migration/test-mapping/t3a.md b/scripts/contracts-migration/test-mapping/t3a.md new file mode 100644 index 000000000..e7885b594 --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t3a.md @@ -0,0 +1,57 @@ +# T3a test mapping: L2Curation + +New tests are in `packages/subgraph-discovery`. Each `describe` of the old file has its own file under `test/unit/l2-curation/`; the shared mint/burn/collect helpers and curator funding are in `L2CurationShared.t.sol`, on top of the package's `DiscoveryTest` base. `test/unit/Setup.t.sol::test_Setup_Wired` checks the base wiring and has no old test. + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › defaultReserveRatio › should be fixed to MAX_PPM | test/unit/l2-curation/Config.t.sol::test_DefaultReserveRatio_FixedToMaxPPM | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › defaultReserveRatio › cannot be changed because the setter is not implemented | test/unit/l2-curation/Config.t.sol::test_DefaultReserveRatio_RevertWhen_Set | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › minimumCurationDeposit › should set `minimumCurationDeposit` | test/unit/l2-curation/Config.t.sol::test_MinimumCurationDeposit_Set | Initial value 1 (`l2MinimumCurationDeposit`) | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › minimumCurationDeposit › reject set `minimumCurationDeposit` if out of bounds | test/unit/l2-curation/Config.t.sol::test_MinimumCurationDeposit_RevertWhen_Zero | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › minimumCurationDeposit › reject set `minimumCurationDeposit` if not allowed | test/unit/l2-curation/Config.t.sol::test_MinimumCurationDeposit_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › curationTaxPercentage › should set `curationTaxPercentage` | test/unit/l2-curation/Config.t.sol::test_CurationTaxPercentage_Set | Also asserts the getter after each set | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › curationTaxPercentage › reject set `curationTaxPercentage` if out of bounds | test/unit/l2-curation/Config.t.sol::test_CurationTaxPercentage_RevertWhen_OutOfBounds | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › curationTaxPercentage › reject set `curationTaxPercentage` if not allowed | test/unit/l2-curation/Config.t.sol::test_CurationTaxPercentage_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › curationTokenMaster › should set `curationTokenMaster` | test/unit/l2-curation/Config.t.sol::test_CurationTokenMaster_Set | Also asserts the getter | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › curationTokenMaster › reject set `curationTokenMaster` to empty value | test/unit/l2-curation/Config.t.sol::test_CurationTokenMaster_RevertWhen_Empty | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › curationTokenMaster › reject set `curationTokenMaster` to non-contract | test/unit/l2-curation/Config.t.sol::test_CurationTokenMaster_RevertWhen_NotContract | Random address becomes `makeAddr` | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation:Config › curationTokenMaster › reject set `curationTokenMaster` if not allowed | test/unit/l2-curation/Config.t.sol::test_CurationTokenMaster_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › bonding curve › reject convert signal to tokens if subgraph deployment not initted | test/unit/l2-curation/BondingCurve.t.sol::test_BondingCurve_RevertWhen_SignalToTokensNotCurated | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › bonding curve › convert signal to tokens | test/unit/l2-curation/BondingCurve.t.sol::test_BondingCurve_ConvertSignalToTokens | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › bonding curve › convert signal to tokens (with curation tax) | test/unit/l2-curation/BondingCurve.t.sol::test_BondingCurve_ConvertSignalToTokensWithCurationTax | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › bonding curve › convert tokens to signal | test/unit/l2-curation/BondingCurve.t.sol::test_BondingCurve_ConvertTokensToSignal | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › bonding curve › convert tokens to signal if non-curated subgraph | test/unit/l2-curation/BondingCurve.t.sol::test_BondingCurve_RevertWhen_ConvertTokensToSignalBelowMinimum | Random deployment ID becomes a fixed hash | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate › reject deposit below minimum tokens required | test/unit/l2-curation/Curate.t.sol::test_Curate_RevertWhen_BelowMinimumDeposit | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate › should deposit on a subgraph deployment | test/unit/l2-curation/Curate.t.sol::test_Curate_DepositOnSubgraphDeployment | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate › should get signal according to bonding curve | test/unit/l2-curation/Curate.t.sol::test_Curate_SignalAccordingToBondingCurve | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate › should get signal according to bonding curve (and account for curation tax) | test/unit/l2-curation/Curate.t.sol::test_Curate_SignalAccordingToBondingCurveWithCurationTax | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate › should revert curate if over slippage | test/unit/l2-curation/Curate.t.sol::test_Curate_RevertWhen_OverSlippage | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate › should pay a minimum of 1 wei GRT in tax when depositing small amounts | test/unit/l2-curation/Curate.t.sol::test_Curate_PaysMinimumOneWeiTaxOnSmallDeposits | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate tax free (from GNS) › can not be called by anyone other than GNS | test/unit/l2-curation/CurateTaxFree.t.sol::test_CurateTaxFree_RevertWhen_NotGNS | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate tax free (from GNS) › reject deposit below minimum tokens required | test/unit/l2-curation/CurateTaxFree.t.sol::test_CurateTaxFree_RevertWhen_BelowMinimumDeposit | `vm.prank(address(gns))` replaces the GNS impersonator | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate tax free (from GNS) › should deposit on a subgraph deployment | test/unit/l2-curation/CurateTaxFree.t.sol::test_CurateTaxFree_DepositOnSubgraphDeployment | As above | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate tax free (from GNS) › should get signal according to bonding curve | test/unit/l2-curation/CurateTaxFree.t.sol::test_CurateTaxFree_SignalAccordingToBondingCurve | As above | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › curate tax free (from GNS) › should get signal according to bonding curve (and with zero tax) | test/unit/l2-curation/CurateTaxFree.t.sol::test_CurateTaxFree_SignalAccordingToBondingCurveWithZeroTax | As above | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › collect › > not curated › reject collect tokens distributed to the curation pool | test/unit/l2-curation/Collect.t.sol::test_NotCurated_RevertWhen_Collect | Leftover `setContractProxy("Staking")` dropped: `collect` only checks `subgraphService` | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › collect › > curated › reject collect tokens distributed from invalid address | test/unit/l2-curation/Collect.t.sol::test_Curated_RevertWhen_CallerNotSubgraphService | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › collect › > curated › should collect tokens distributed to the curation pool | test/unit/l2-curation/Collect.t.sol::test_Curated_CollectTokens | Leftover `setContractProxy("Staking")` dropped | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › collect › > curated › should collect tokens and then unsignal all | test/unit/l2-curation/Collect.t.sol::test_Curated_CollectThenBurnAll | Leftover `setContractProxy("Staking")` dropped | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › collect › > curated › should collect tokens and then unsignal multiple times | test/unit/l2-curation/Collect.t.sol::test_Curated_CollectThenBurnMultipleTimes | Leftover `setContractProxy("Staking")` dropped; tokens read from `burn`'s return value instead of the `Burned` log | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › burn › reject redeem more than a curator owns | test/unit/l2-curation/Burn.t.sol::test_Burn_RevertWhen_MoreThanOwned | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › burn › reject redeem zero signal | test/unit/l2-curation/Burn.t.sol::test_Burn_RevertWhen_ZeroSignal | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › burn › should allow to redeem _partially_ | test/unit/l2-curation/Burn.t.sol::test_Burn_Partially | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › burn › should allow to redeem _fully_ | test/unit/l2-curation/Burn.t.sol::test_Burn_Fully | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › burn › should allow to redeem back below minimum deposit | test/unit/l2-curation/Burn.t.sol::test_Burn_BackBelowMinimumDeposit | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › burn › should revert redeem if over slippage | test/unit/l2-curation/Burn.t.sol::test_Burn_RevertWhen_OverSlippage | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › burn › should not re-deploy the curation token when signal is reset | test/unit/l2-curation/Burn.t.sol::test_Burn_DoesNotRedeployCurationTokenWhenSignalReset | | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › conservation › should match multiple deposits and redeems back to initial state | test/unit/l2-curation/Conservation.t.sol::testFuzz_Conservation_DepositsAndRedeemsMatch | `Math.random` chunking becomes fuzzed cuts (10 deposit, 10 redeem chunks); signal and tokens from return values; also asserts curator and pool GRT balances return to initial | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › multiple minting › should mint the same signal every time due to the flat bonding curve | test/unit/l2-curation/MultipleMinting.t.sol::test_MultipleMinting_SameSignalDueToFlatBondingCurve | Expected signal computed in integers, so asserted exactly (was float, 11 significant digits) | +| packages/contracts-test/tests/unit/l2/l2Curation.test.ts | L2Curation › multiple minting › should mint when using a different ratio between GRT and signal | test/unit/l2-curation/MultipleMinting.t.sol::test_MultipleMinting_DifferentRatioBetweenGRTAndSignal | | + +## Coverage + +MISSING initialize: `L2Curation.initialize` is called by `DiscoveryTest` through `abi.encodeCall` in the proxy init data (`acceptProxyAndCall`), not by a `.initialize(` call; every test runs it. `GraphCurationToken.initialize` is only callable once per clone and is called by L2Curation when it creates a pool's curation token, on the first `mint`/`mintTaxFree` of every curate test. + +MISSING burnFrom: `GraphCurationToken.burnFrom` is `onlyOwner` (the owner is L2Curation) and is reached through every `curation.burn(` call. `GraphCurationToken.mint` is likewise reached only through `curation.mint` / `curation.mintTaxFree` (the grep matches `.mint(` on curation). + +Additions with no old test: `test/unit/l2-curation/BondingCurve.t.sol::test_BondingCurve_TokensToSignalToTokensNoTax` calls `tokensToSignalToTokensNoTax` directly (the old suite reached it only through L2GNS). From 38c8b813750b88eee0324a92bd09ceb098987550 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 26 Oct 2026 11:07:56 -0300 Subject: [PATCH 12/20] test(subgraph-discovery): port L2GNS tests to Foundry --- .../contracts-test/tests/unit/gns.test.ts | 1473 ----------------- .../tests/unit/l2/l2GNS.test.ts | 777 --------- .../contracts-test/tests/unit/lib/gnsUtils.ts | 276 --- .../subgraph-discovery/test/unit/Setup.t.sol | 8 +- .../test/unit/interfaces/IL2GNSLike.sol | 4 + .../test/unit/l2-gns/AliasedSubgraphId.t.sol | 29 + .../unit/l2-gns/FinishSubgraphTransfer.t.sol | 124 ++ .../test/unit/l2-gns/Multicall.t.sol | 32 + .../test/unit/l2-gns/PublishNewVersion.t.sol | 178 ++ .../unit/l2-gns/ReceiveCuratorBalance.t.sol | 132 ++ .../unit/l2-gns/ReceiveSubgraphFromL1.t.sol | 92 + .../test/unit/shared/GNSHelpers.t.sol | 240 +++ .../contracts-migration/test-mapping/t3b.md | 56 + 13 files changed, 893 insertions(+), 2528 deletions(-) delete mode 100644 packages/contracts-test/tests/unit/gns.test.ts delete mode 100644 packages/contracts-test/tests/unit/l2/l2GNS.test.ts delete mode 100644 packages/contracts-test/tests/unit/lib/gnsUtils.ts create mode 100644 packages/subgraph-discovery/test/unit/l2-gns/AliasedSubgraphId.t.sol create mode 100644 packages/subgraph-discovery/test/unit/l2-gns/FinishSubgraphTransfer.t.sol create mode 100644 packages/subgraph-discovery/test/unit/l2-gns/Multicall.t.sol create mode 100644 packages/subgraph-discovery/test/unit/l2-gns/PublishNewVersion.t.sol create mode 100644 packages/subgraph-discovery/test/unit/l2-gns/ReceiveCuratorBalance.t.sol create mode 100644 packages/subgraph-discovery/test/unit/l2-gns/ReceiveSubgraphFromL1.t.sol create mode 100644 packages/subgraph-discovery/test/unit/shared/GNSHelpers.t.sol create mode 100644 scripts/contracts-migration/test-mapping/t3b.md diff --git a/packages/contracts-test/tests/unit/gns.test.ts b/packages/contracts-test/tests/unit/gns.test.ts deleted file mode 100644 index 92e5d83e8..000000000 --- a/packages/contracts-test/tests/unit/gns.test.ts +++ /dev/null @@ -1,1473 +0,0 @@ -import { formatGRT, SubgraphDeploymentID } from '@graphprotocol/common-ts' -import { L2GNS, L2GraphTokenGateway, SubgraphNFT } from '@graphprotocol/contracts' -import { Controller } from '@graphprotocol/contracts' -import { Curation } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { L1GNS } from '@graphprotocol/contracts' -import { L1GraphTokenGateway } from '@graphprotocol/contracts' -import { LegacyGNSMock } from '@graphprotocol/contracts' -import { - buildLegacySubgraphId, - buildSubgraph, - buildSubgraphId, - deploy, - DeployType, - GraphNetworkContracts, - helpers, - loadContractAt, - PublishSubgraph, - randomHexBytes, - Subgraph, - toBN, - toGRT, -} from '@graphprotocol/sdk' -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber, ContractTransaction, ethers, Event } from 'ethers' -import { defaultAbiCoder } from 'ethers/lib/utils' -import hre from 'hardhat' - -import { isRunningUnderCoverage } from '../../utils/coverage' -import { NetworkFixture } from './lib/fixtures' -import { - AccountDefaultName, - burnSignal, - createDefaultName, - deprecateSubgraph, - mintSignal, - publishNewSubgraph, - publishNewVersion, -} from './lib/gnsUtils' - -const { AddressZero, HashZero } = ethers.constants - -// Utils -const toFloat = (n: BigNumber) => parseFloat(formatGRT(n)) -const toRound = (n: number) => n.toFixed(12) - -describe.skip('L1GNS @skip-on-coverage', () => { - const graph = hre.graph() - - let me: SignerWithAddress - let other: SignerWithAddress - let another: SignerWithAddress - let governor: SignerWithAddress - - let fixture: NetworkFixture - let l2MockContracts: GraphNetworkContracts - - let l2GNSMock: L2GNS - let l2GRTGatewayMock: L2GraphTokenGateway - - let gns: L1GNS - let legacyGNSMock: LegacyGNSMock - let grt: GraphToken - let curation: Curation - let controller: Controller - let subgraphNFT: SubgraphNFT - let l1GraphTokenGateway: L1GraphTokenGateway - - const tokens1000 = toGRT('1000') - const tokens10000 = toGRT('10000') - const tokens100000 = toGRT('100000') - const curationTaxPercentage = 50000 - - let newSubgraph0: PublishSubgraph - let newSubgraph1: PublishSubgraph - let defaultName: AccountDefaultName - - async function calcGNSBondingCurve( - gnsSupply: BigNumber, // nSignal - gnsReserveBalance: BigNumber, // vSignal - depositAmount: BigNumber, // GRT deposited - subgraphID: string, - ): Promise { - const signal = await curation.getCurationPoolSignal(subgraphID) - const curationTokens = await curation.getCurationPoolTokens(subgraphID) - const curationReserveRatio = await curation.defaultReserveRatio() - const expectedSignal = await calcCurationBondingCurve(signal, curationTokens, curationReserveRatio, depositAmount) - const expectedSignalBN = toGRT(String(expectedSignal.toFixed(18))) - - // Handle the initialization of the bonding curve - if (gnsSupply.eq(0)) { - return expectedSignal - } - // Since we known CW = 1, we can do the simplified formula of: - return (toFloat(gnsSupply) * toFloat(expectedSignalBN)) / toFloat(gnsReserveBalance) - } - - async function calcCurationBondingCurve( - supply: BigNumber, - reserveBalance: BigNumber, - reserveRatio: number, - depositAmount: BigNumber, - ): Promise { - // Handle the initialization of the bonding curve - const minSupply = toGRT('1') - if (supply.eq(0)) { - const minDeposit = await curation.minimumCurationDeposit() - if (depositAmount.lt(minDeposit)) { - throw new Error('deposit must be above minimum') - } - return ( - (await calcCurationBondingCurve(minSupply, minDeposit, reserveRatio, depositAmount.sub(minDeposit))) + - toFloat(minSupply) - ) - } - // Calculate bonding curve in the test - return toFloat(supply) * ((1 + toFloat(depositAmount) / toFloat(reserveBalance)) ** (reserveRatio / 1000000) - 1) - } - - const transferSignal = async ( - subgraphID: string, - owner: SignerWithAddress, - recipient: SignerWithAddress, - amount: BigNumber, - ): Promise => { - // Before state - const beforeOwnerNSignal = await gns.getCuratorSignal(subgraphID, owner.address) - const beforeRecipientNSignal = await gns.getCuratorSignal(subgraphID, recipient.address) - - // Transfer - const tx = gns.connect(owner).transferSignal(subgraphID, recipient.address, amount) - - await expect(tx).emit(gns, 'SignalTransferred').withArgs(subgraphID, owner.address, recipient.address, amount) - - // After state - const afterOwnerNSignal = await gns.getCuratorSignal(subgraphID, owner.address) - const afterRecipientNSignal = await gns.getCuratorSignal(subgraphID, recipient.address) - - // Check state - expect(afterOwnerNSignal).eq(beforeOwnerNSignal.sub(amount)) - expect(afterRecipientNSignal).eq(beforeRecipientNSignal.add(amount)) - - return tx - } - - const withdraw = async (account: SignerWithAddress, subgraphID: string): Promise => { - // Before state - const beforeCuratorNSignal = await gns.getCuratorSignal(subgraphID, account.address) - const beforeSubgraph = await gns.subgraphs(subgraphID) - const beforeGNSBalance = await grt.balanceOf(gns.address) - const tokensEstimate = beforeSubgraph.withdrawableGRT.mul(beforeCuratorNSignal).div(beforeSubgraph.nSignal) - - // Send tx - const tx = gns.connect(account).withdraw(subgraphID) - await expect(tx) - .emit(gns, 'GRTWithdrawn') - .withArgs(subgraphID, account.address, beforeCuratorNSignal, tokensEstimate) - - // curator nSignal should be updated - const afterCuratorNSignal = await gns.getCuratorSignal(subgraphID, account.address) - expect(afterCuratorNSignal).eq(toBN(0)) - - // overall n signal should be updated - const afterSubgraph = await gns.subgraphs(subgraphID) - expect(afterSubgraph.nSignal).eq(beforeSubgraph.nSignal.sub(beforeCuratorNSignal)) - - // Token balance should be updated - const afterGNSBalance = await grt.balanceOf(gns.address) - expect(afterGNSBalance).eq(beforeGNSBalance.sub(tokensEstimate)) - - return tx - } - const deployLegacyGNSMock = async (): Promise => { - const { contract: subgraphDescriptor } = await deploy(DeployType.Deploy, governor, { - name: 'SubgraphNFTDescriptor', - }) - const { contract: subgraphNFT } = await deploy(DeployType.Deploy, governor, { - name: 'SubgraphNFT', - args: [governor.address], - }) - - // Deploy - const deployResult = await deploy( - DeployType.DeployWithProxy, - governor, - { name: 'LegacyGNSMock', args: [controller.address, subgraphNFT.address] }, - graph.addressBook, - { - name: 'GraphProxy', - }, - ) - legacyGNSMock = deployResult.contract as LegacyGNSMock - - // Post-config - await subgraphNFT.connect(governor).setMinter(legacyGNSMock.address) - await subgraphNFT.connect(governor).setTokenDescriptor(subgraphDescriptor.address) - await legacyGNSMock.connect(governor).syncAllContracts() - await legacyGNSMock.connect(governor).approveAll() - await l1GraphTokenGateway.connect(governor).addToCallhookAllowlist(legacyGNSMock.address) - await legacyGNSMock.connect(governor).setCounterpartGNSAddress(l2GNSMock.address) - } - - before(async function () { - ;[me, other, governor, another] = await graph.getTestAccounts() - - fixture = new NetworkFixture(graph.provider) - - // Deploy L1 - const fixtureContracts = await fixture.load(governor) - grt = fixtureContracts.GraphToken as GraphToken - curation = fixtureContracts.Curation as Curation - gns = fixtureContracts.GNS as L1GNS - controller = fixtureContracts.Controller as Controller - l1GraphTokenGateway = fixtureContracts.L1GraphTokenGateway as L1GraphTokenGateway - subgraphNFT = fixtureContracts.SubgraphNFT as SubgraphNFT - - // Deploy L1 arbitrum bridge - await fixture.loadL1ArbitrumBridge(governor) - - // Deploy L2 mock - l2MockContracts = await fixture.loadMock(true) - l2GNSMock = l2MockContracts.L2GNS as L2GNS - l2GRTGatewayMock = l2MockContracts.L2GraphTokenGateway as L2GraphTokenGateway - - // Configure graph bridge - await fixture.configureL1Bridge(governor, fixtureContracts, l2MockContracts) - - newSubgraph0 = buildSubgraph() - newSubgraph1 = buildSubgraph() - defaultName = createDefaultName('graph') - // Give some funds to the signers and approve gns contract to use funds on signers behalf - await grt.connect(governor).mint(me.address, tokens100000) - await grt.connect(governor).mint(other.address, tokens100000) - await grt.connect(governor).mint(another.address, tokens100000) - await grt.connect(me).approve(gns.address, tokens100000) - await grt.connect(me).approve(curation.address, tokens100000) - await grt.connect(other).approve(gns.address, tokens100000) - await grt.connect(other).approve(curation.address, tokens100000) - await grt.connect(another).approve(gns.address, tokens100000) - await grt.connect(another).approve(curation.address, tokens100000) - // Update curation tax to test the functionality of it in disableNameSignal() - await curation.connect(governor).setCurationTaxPercentage(curationTaxPercentage) - - // Deploying a GNS mock with support for legacy subgraphs - await deployLegacyGNSMock() - await grt.connect(me).approve(legacyGNSMock.address, tokens100000) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('Configuration', function () { - describe('setOwnerTaxPercentage', function () { - const newValue = 10 - - it('should set `ownerTaxPercentage`', async function () { - // Can set if allowed - await gns.connect(governor).setOwnerTaxPercentage(newValue) - expect(await gns.ownerTaxPercentage()).eq(newValue) - }) - - it('reject set `ownerTaxPercentage` if out of bounds', async function () { - const tx = gns.connect(governor).setOwnerTaxPercentage(1000001) - await expect(tx).revertedWith('Owner tax must be MAX_PPM or less') - }) - - it('reject set `ownerTaxPercentage` if not allowed', async function () { - const tx = gns.connect(me).setOwnerTaxPercentage(newValue) - await expect(tx).revertedWith('Only Controller governor') - }) - }) - - describe('setCounterpartGNSAddress', function () { - it('should set `counterpartGNSAddress`', async function () { - // Can set if allowed - const newValue = other.address - const tx = gns.connect(governor).setCounterpartGNSAddress(newValue) - await expect(tx).emit(gns, 'CounterpartGNSAddressUpdated').withArgs(newValue) - expect(await gns.counterpartGNSAddress()).eq(newValue) - }) - - it('reject set `counterpartGNSAddress` if not allowed', async function () { - const newValue = other.address - const tx = gns.connect(me).setCounterpartGNSAddress(newValue) - await expect(tx).revertedWith('Only Controller governor') - }) - }) - - describe('setSubgraphNFT', function () { - it('should set `setSubgraphNFT`', async function () { - const newValue = gns.address // I just use any contract address - const tx = gns.connect(governor).setSubgraphNFT(newValue) - await expect(tx).emit(gns, 'SubgraphNFTUpdated').withArgs(newValue) - expect(await gns.subgraphNFT()).eq(newValue) - }) - - it('revert set to empty address', async function () { - const tx = gns.connect(governor).setSubgraphNFT(AddressZero) - await expect(tx).revertedWith('NFT address cant be zero') - }) - - it('revert set to non-contract', async function () { - const tx = gns.connect(governor).setSubgraphNFT(randomHexBytes(20)) - await expect(tx).revertedWith('NFT must be valid') - }) - }) - }) - - describe('Publishing names and versions', function () { - describe('setDefaultName', function () { - it('setDefaultName emits the event', async function () { - const tx = gns.connect(me).setDefaultName(me.address, 0, defaultName.nameIdentifier, defaultName.name) - await expect(tx) - .emit(gns, 'SetDefaultName') - .withArgs(me.address, 0, defaultName.nameIdentifier, defaultName.name) - }) - - it('setDefaultName fails if not owner', async function () { - const tx = gns.connect(other).setDefaultName(me.address, 0, defaultName.nameIdentifier, defaultName.name) - await expect(tx).revertedWith('GNS: Only you can set your name') - }) - }) - - describe('updateSubgraphMetadata', function () { - let subgraph: Subgraph - - beforeEach(async function () { - subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - }) - - it('updateSubgraphMetadata emits the event', async function () { - const tx = gns.connect(me).updateSubgraphMetadata(subgraph.id, newSubgraph0.subgraphMetadata) - await expect(tx).emit(gns, 'SubgraphMetadataUpdated').withArgs(subgraph.id, newSubgraph0.subgraphMetadata) - }) - - it('updateSubgraphMetadata fails if not owner', async function () { - const tx = gns.connect(other).updateSubgraphMetadata(subgraph.id, newSubgraph0.subgraphMetadata) - await expect(tx).revertedWith('GNS: Must be authorized') - }) - }) - - describe('isPublished', function () { - it('should return if the subgraph is published', async function () { - const subgraphID = await buildSubgraphId(me.address, toBN(0), graph.chainId) - expect(await gns.isPublished(subgraphID)).eq(false) - await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - expect(await gns.isPublished(subgraphID)).eq(true) - }) - }) - - describe('publishNewSubgraph', function () { - it('should publish a new subgraph and first version with it', async function () { - await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - }) - - it('should publish a new subgraph with an incremented value', async function () { - const subgraph1 = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - const subgraph2 = await publishNewSubgraph(me, newSubgraph1, gns, graph.chainId) - expect(subgraph1.id).not.eq(subgraph2.id) - }) - - it('should prevent subgraphDeploymentID of 0 to be used', async function () { - const tx = gns - .connect(me) - .publishNewSubgraph(HashZero, newSubgraph0.versionMetadata, newSubgraph0.subgraphMetadata) - await expect(tx).revertedWith('GNS: Cannot set deploymentID to 0 in publish') - }) - }) - - describe('publishNewVersion', function () { - let subgraph: Subgraph - - beforeEach(async () => { - subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - await mintSignal(me, subgraph.id, tokens10000, gns, curation) - }) - - it('should publish a new version on an existing subgraph', async function () { - await publishNewVersion(me, subgraph.id, newSubgraph1, gns, curation) - }) - - it('should publish a new version on an existing subgraph when owner tax is zero', async function () { - await gns.connect(governor).setOwnerTaxPercentage(0) - await publishNewVersion(me, subgraph.id, newSubgraph1, gns, curation) - }) - - it('should publish a new version on an existing subgraph when curation tax percentage is zero', async function () { - await curation.connect(governor).setCurationTaxPercentage(0) - await publishNewVersion(me, subgraph.id, newSubgraph1, gns, curation) - }) - - it('should publish a new version on an existing subgraph with no current signal', async function () { - const emptySignalSubgraph = await publishNewSubgraph(me, buildSubgraph(), gns, graph.chainId) - await publishNewVersion(me, emptySignalSubgraph.id, newSubgraph1, gns, curation) - }) - - it('should reject a new version with the same subgraph deployment ID', async function () { - const tx = gns - .connect(me) - .publishNewVersion(subgraph.id, newSubgraph0.subgraphDeploymentID, newSubgraph0.versionMetadata) - await expect(tx).revertedWith('GNS: Cannot publish a new version with the same subgraph deployment ID') - }) - - it('should reject publishing a version to a subgraph that does not exist', async function () { - const tx = gns - .connect(me) - .publishNewVersion(randomHexBytes(32), newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata) - await expect(tx).revertedWith('ERC721: owner query for nonexistent token') - }) - - it('reject if not the owner', async function () { - const tx = gns - .connect(other) - .publishNewVersion(subgraph.id, newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata) - await expect(tx).revertedWith('GNS: Must be authorized') - }) - - it('should fail when upgrade tries to point to a pre-curated', async function () { - // Curate directly to the deployment - await curation.connect(me).mint(newSubgraph1.subgraphDeploymentID, tokens1000, 0) - - // Target a pre-curated subgraph deployment - const tx = gns - .connect(me) - .publishNewVersion(subgraph.id, newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata) - await expect(tx).revertedWith('GNS: Owner cannot point to a subgraphID that has been pre-curated') - }) - - it('should upgrade version when there is no signal with no signal migration', async function () { - await burnSignal(me, subgraph.id, gns, curation) - const tx = gns - .connect(me) - .publishNewVersion(subgraph.id, newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata) - await expect(tx) - .emit(gns, 'SubgraphVersionUpdated') - .withArgs(subgraph.id, newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata) - }) - - it('should fail when subgraph is deprecated', async function () { - await deprecateSubgraph(me, subgraph.id, gns, curation, grt) - const tx = gns - .connect(me) - .publishNewVersion(subgraph.id, newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata) - // NOTE: deprecate burns the Subgraph NFT, when someone wants to publish a new version it won't find it - await expect(tx).revertedWith('ERC721: owner query for nonexistent token') - }) - }) - describe('subgraphTokens', function () { - it('should return the correct number of tokens for a subgraph', async function () { - const subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - const taxForMe = (await curation.tokensToSignal(subgraph.subgraphDeploymentID, tokens10000))[1] - await mintSignal(me, subgraph.id, tokens10000, gns, curation) - const taxForOther = (await curation.tokensToSignal(subgraph.subgraphDeploymentID, tokens1000))[1] - await mintSignal(other, subgraph.id, tokens1000, gns, curation) - expect(await gns.subgraphTokens(subgraph.id)).eq(tokens10000.add(tokens1000).sub(taxForMe).sub(taxForOther)) - }) - }) - describe('subgraphSignal', function () { - it('should return the correct amount of signal for a subgraph', async function () { - const subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - const vSignalForMe = (await curation.tokensToSignal(subgraph.subgraphDeploymentID, tokens10000))[0] - await mintSignal(me, subgraph.id, tokens10000, gns, curation) - const vSignalForOther = (await curation.tokensToSignal(subgraph.subgraphDeploymentID, tokens1000))[0] - await mintSignal(other, subgraph.id, tokens1000, gns, curation) - const expectedSignal = await gns.vSignalToNSignal(subgraph.id, vSignalForMe.add(vSignalForOther)) - expect(await gns.subgraphSignal(subgraph.id)).eq(expectedSignal) - }) - }) - describe('deprecateSubgraph', function () { - let subgraph: Subgraph - - beforeEach(async () => { - subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - await mintSignal(me, subgraph.id, tokens10000, gns, curation) - }) - - it('should deprecate a subgraph', async function () { - await deprecateSubgraph(me, subgraph.id, gns, curation, grt) - }) - - it('should prevent a deprecated subgraph from being republished', async function () { - await deprecateSubgraph(me, subgraph.id, gns, curation, grt) - const tx = gns - .connect(me) - .publishNewVersion(subgraph.id, newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata) - // NOTE: deprecate burns the Subgraph NFT, when someone wants to publish a new version it won't find it - await expect(tx).revertedWith('ERC721: owner query for nonexistent token') - }) - - it('reject if the subgraph does not exist', async function () { - const subgraphID = randomHexBytes(32) - const tx = gns.connect(me).deprecateSubgraph(subgraphID) - await expect(tx).revertedWith('ERC721: owner query for nonexistent token') - }) - - it('reject deprecate if not the owner', async function () { - const tx = gns.connect(other).deprecateSubgraph(subgraph.id) - await expect(tx).revertedWith('GNS: Must be authorized') - }) - }) - }) - - describe('Curating on names', function () { - describe('mintSignal()', function () { - it('should deposit into the name signal curve', async function () { - const subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - await mintSignal(other, subgraph.id, tokens10000, gns, curation) - }) - - it('should fail when name signal is disabled', async function () { - const subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - await deprecateSubgraph(me, subgraph.id, gns, curation, grt) - const tx = gns.connect(me).mintSignal(subgraph.id, tokens1000, 0) - await expect(tx).revertedWith('GNS: Must be active') - }) - - it('should fail if you try to deposit on a non existing name', async function () { - const subgraphID = randomHexBytes(32) - const tx = gns.connect(me).mintSignal(subgraphID, tokens1000, 0) - await expect(tx).revertedWith('GNS: Must be active') - }) - - it('reject minting if under slippage', async function () { - // First publish the subgraph - const subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - - // Set slippage to be 1 less than expected result to force reverting - const { 1: expectedNSignal } = await gns.tokensToNSignal(subgraph.id, tokens1000) - const tx = gns.connect(me).mintSignal(subgraph.id, tokens1000, expectedNSignal.add(1)) - await expect(tx).revertedWith('GNS: Slippage protection') - }) - }) - - describe('burnSignal()', function () { - let subgraph: Subgraph - - beforeEach(async () => { - subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - await mintSignal(other, subgraph.id, tokens10000, gns, curation) - }) - - it('should withdraw from the name signal curve', async function () { - await burnSignal(other, subgraph.id, gns, curation) - }) - - it('should fail when name signal is disabled', async function () { - await deprecateSubgraph(me, subgraph.id, gns, curation, grt) - // just test 1 since it will fail - const tx = gns.connect(me).burnSignal(subgraph.id, 1, 0) - await expect(tx).revertedWith('GNS: Must be active') - }) - - it('should fail when the curator tries to withdraw more nSignal than they have', async function () { - const tx = gns.connect(me).burnSignal( - subgraph.id, - // 1000000 * 10^18 nSignal is a lot, and will cause fail - toBN('1000000000000000000000000'), - 0, - ) - await expect(tx).revertedWith('GNS: Curator cannot withdraw more nSignal than they have') - }) - - it('reject burning if under slippage', async function () { - // Get current curator name signal - const curatorNSignal = await gns.getCuratorSignal(subgraph.id, other.address) - - // Withdraw - const { 1: expectedTokens } = await gns.nSignalToTokens(subgraph.id, curatorNSignal) - - // Force a revert by asking 1 more token than the function will return - const tx = gns.connect(other).burnSignal(subgraph.id, curatorNSignal, expectedTokens.add(1)) - await expect(tx).revertedWith('Slippage protection') - }) - }) - - describe('transferSignal()', function () { - let subgraph: Subgraph - let otherNSignal: BigNumber - - beforeEach(async () => { - subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - await mintSignal(other, subgraph.id, tokens10000, gns, curation) - otherNSignal = await gns.getCuratorSignal(subgraph.id, other.address) - }) - - it('should transfer signal from one curator to another', async function () { - await transferSignal(subgraph.id, other, another, otherNSignal) - }) - it('should fail when transferring to zero address', async function () { - const tx = gns.connect(other).transferSignal(subgraph.id, ethers.constants.AddressZero, otherNSignal) - await expect(tx).revertedWith('GNS: Curator cannot transfer to the zero address') - }) - it('should fail when name signal is disabled', async function () { - await deprecateSubgraph(me, subgraph.id, gns, curation, grt) - const tx = gns.connect(other).transferSignal(subgraph.id, another.address, otherNSignal) - await expect(tx).revertedWith('GNS: Must be active') - }) - it('should fail if you try to transfer on a non existing name', async function () { - const subgraphID = randomHexBytes(32) - const tx = gns.connect(other).transferSignal(subgraphID, another.address, otherNSignal) - await expect(tx).revertedWith('GNS: Must be active') - }) - it('should fail when the curator tries to transfer more signal than they have', async function () { - const tx = gns.connect(other).transferSignal(subgraph.id, another.address, otherNSignal.add(otherNSignal)) - await expect(tx).revertedWith('GNS: Curator transfer amount exceeds balance') - }) - }) - describe('withdraw()', function () { - let subgraph: Subgraph - - beforeEach(async () => { - subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - await mintSignal(other, subgraph.id, tokens10000, gns, curation) - }) - - it('should withdraw GRT from a disabled name signal', async function () { - await deprecateSubgraph(me, subgraph.id, gns, curation, grt) - await withdraw(other, subgraph.id) - }) - - it('should fail if not disabled', async function () { - const tx = gns.connect(other).withdraw(subgraph.id) - await expect(tx).revertedWith('GNS: Must be disabled first') - }) - - it('should fail when there is no more GRT to withdraw', async function () { - await deprecateSubgraph(me, subgraph.id, gns, curation, grt) - await withdraw(other, subgraph.id) - const tx = gns.connect(other).withdraw(subgraph.id) - await expect(tx).revertedWith('GNS: No more GRT to withdraw') - }) - - it('should fail if the curator has no nSignal', async function () { - await deprecateSubgraph(me, subgraph.id, gns, curation, grt) - const tx = gns.connect(me).withdraw(subgraph.id) - await expect(tx).revertedWith('GNS: No signal to withdraw GRT') - }) - }) - - describe('multiple minting', function () { - it('should mint less signal every time due to the bonding curve', async function () { - const tokensToDepositMany = [ - toGRT('1000'), // should mint if we start with number above minimum deposit - toGRT('1000'), // every time it should mint less GCS due to bonding curve... - toGRT('1.06'), // should mint minimum deposit including tax - toGRT('1000'), - toGRT('1000'), - toGRT('2000'), - toGRT('2000'), - toGRT('123'), - ] - const subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - - // State updated - const curationTaxPercentage = await curation.curationTaxPercentage() - - for (const tokensToDeposit of tokensToDepositMany) { - const beforeSubgraph = await gns.subgraphs(subgraph.id) - expect(newSubgraph0.subgraphDeploymentID).eq(beforeSubgraph.subgraphDeploymentID) - - const curationTax = toBN(curationTaxPercentage).mul(tokensToDeposit).div(toBN(1000000)) - const expectedNSignal = await calcGNSBondingCurve( - beforeSubgraph.nSignal, - beforeSubgraph.vSignal, - tokensToDeposit.sub(curationTax), - beforeSubgraph.subgraphDeploymentID, - ) - const tx = await mintSignal(me, subgraph.id, tokensToDeposit, gns, curation) - const receipt = await tx.wait() - const event: Event = receipt.events.pop() - const nSignalCreated = event.args['nSignalCreated'] - expect(toRound(expectedNSignal)).eq(toRound(toFloat(nSignalCreated))) - } - }) - - it('should mint when using the edge case of linear function', async function () { - // Setup edge case like linear function: 1 vSignal = 1 nSignal = 1 token - await curation.connect(governor).setMinimumCurationDeposit(toGRT('1')) - await curation.connect(governor).setDefaultReserveRatio(1000000) - // note - reserve ratio is already set to 1000000 in GNS - - const tokensToDepositMany = [ - toGRT('1000'), // should mint if we start with number above minimum deposit - toGRT('1000'), // every time it should mint less GCS due to bonding curve... - toGRT('1000'), - toGRT('1000'), - toGRT('2000'), - toGRT('2000'), - toGRT('123'), - toGRT('1'), // should mint below minimum deposit - ] - - const subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - - // State updated - for (const tokensToDeposit of tokensToDepositMany) { - await mintSignal(me, subgraph.id, tokensToDeposit, gns, curation) - } - }) - }) - }) - - describe('Two named subgraphs point to the same subgraph deployment ID', function () { - it('handle initialization under minimum signal values', async function () { - await curation.connect(governor).setMinimumCurationDeposit(toGRT('1')) - - // Publish a named subgraph-0 -> subgraphDeployment0 - const subgraph0 = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - // Curate on the first subgraph - await gns.connect(me).mintSignal(subgraph0.id, toGRT('90000'), 0) - - // Publish a named subgraph-1 -> subgraphDeployment0 - const subgraph1 = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - // Curate on the second subgraph should work - await gns.connect(me).mintSignal(subgraph1.id, toGRT('10'), 0) - }) - }) - - describe('batch calls', function () { - it('should publish new subgraph and mint signal in single transaction', async function () { - // Create a subgraph - const tx1 = await gns.populateTransaction.publishNewSubgraph( - newSubgraph0.subgraphDeploymentID, - newSubgraph0.versionMetadata, - newSubgraph0.subgraphMetadata, - ) - // Curate on the subgraph - const subgraphID = await buildSubgraphId(me.address, await gns.nextAccountSeqID(me.address), graph.chainId) - const tx2 = await gns.populateTransaction.mintSignal(subgraphID, toGRT('90000'), 0) - - // Batch send transaction - await gns.connect(me).multicall([tx1.data, tx2.data]) - }) - - it('should revert if batching a call to non-authorized function', async function () { - // Call a forbidden function - const tx1 = await gns.populateTransaction.setOwnerTaxPercentage(100) - - // Create a subgraph - const tx2 = await gns.populateTransaction.publishNewSubgraph( - newSubgraph0.subgraphDeploymentID, - newSubgraph0.versionMetadata, - newSubgraph0.subgraphMetadata, - ) - - // Batch send transaction - const tx = gns.connect(me).multicall([tx1.data, tx2.data]) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should revert if batching a call to initialize', async function () { - // Call a forbidden function - const tx1 = await gns.populateTransaction.initialize(me.address, me.address) - - // Create a subgraph - const tx2 = await gns.populateTransaction.publishNewSubgraph( - newSubgraph0.subgraphDeploymentID, - newSubgraph0.versionMetadata, - newSubgraph0.subgraphMetadata, - ) - - // Batch send transaction - const tx = gns.connect(me).multicall([tx1.data, tx2.data]) - await expect(tx).revertedWith('Only implementation') - }) - - it('should revert if trying to call a private function', async function () { - // Craft call a private function - const hash = ethers.utils.id('_setOwnerTaxPercentage(uint32)') - const functionHash = hash.slice(0, 10) - const calldata = ethers.utils.arrayify(ethers.utils.defaultAbiCoder.encode(['uint32'], ['100'])) - const bogusPayload = ethers.utils.concat([functionHash, calldata]) - - // Create a subgraph - const tx2 = await gns.populateTransaction.publishNewSubgraph( - newSubgraph0.subgraphDeploymentID, - newSubgraph0.versionMetadata, - newSubgraph0.subgraphMetadata, - ) - - // Batch send transaction - const tx = gns.connect(me).multicall([bogusPayload, tx2.data]) - - // Under coverage, the error message may be different due to instrumentation - if (isRunningUnderCoverage()) { - await expect(tx).to.be.reverted - } else { - await expect(tx).revertedWith("function selector was not recognized and there's no fallback function") - } - }) - }) - - describe('NFT descriptor', function () { - it('cannot be minted by an account that is not the minter (i.e. GNS)', async function () { - const tx = subgraphNFT.connect(me).mint(me.address, 1) - await expect(tx).revertedWith('Must be a minter') - }) - it('cannot be burned by an account that is not the minter (i.e. GNS)', async function () { - const tx = subgraphNFT.connect(me).burn(1) - await expect(tx).revertedWith('Must be a minter') - }) - it('with token descriptor', async function () { - const subgraph0 = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - - const tokenURI = await subgraphNFT.connect(me).tokenURI(subgraph0.id) - - const sub = new SubgraphDeploymentID(newSubgraph0.subgraphMetadata) - expect(sub.ipfsHash).eq(tokenURI) - }) - - it('with token descriptor and baseURI', async function () { - const subgraph0 = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - - await subgraphNFT.connect(governor).setBaseURI('ipfs://') - const tokenURI = await subgraphNFT.connect(me).tokenURI(subgraph0.id) - - const sub = new SubgraphDeploymentID(newSubgraph0.subgraphMetadata) - expect('ipfs://' + sub.ipfsHash).eq(tokenURI) - }) - - it('without token descriptor', async function () { - const subgraph0 = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - - await subgraphNFT.connect(governor).setTokenDescriptor(AddressZero) - const tokenURI = await subgraphNFT.connect(me).tokenURI(subgraph0.id) - - const sub = new SubgraphDeploymentID(newSubgraph0.subgraphMetadata) - expect(sub.bytes32).eq(tokenURI) - }) - - it('without token descriptor and baseURI', async function () { - const subgraph0 = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - - await subgraphNFT.connect(governor).setTokenDescriptor(AddressZero) - await subgraphNFT.connect(governor).setBaseURI('ipfs://') - const tokenURI = await subgraphNFT.connect(me).tokenURI(subgraph0.id) - - const sub = new SubgraphDeploymentID(newSubgraph0.subgraphMetadata) - expect('ipfs://' + sub.bytes32).eq(tokenURI) - }) - - it('without token descriptor and 0x0 metadata', async function () { - const newSubgraphNoMetadata = buildSubgraph() - newSubgraphNoMetadata.subgraphMetadata = HashZero - const subgraph0 = await publishNewSubgraph(me, newSubgraphNoMetadata, gns, graph.chainId) - - await subgraphNFT.connect(governor).setTokenDescriptor(AddressZero) - await subgraphNFT.connect(governor).setBaseURI('ipfs://') - const tokenURI = await subgraphNFT.connect(me).tokenURI(subgraph0.id) - expect('ipfs://' + subgraph0.id).eq(tokenURI) - }) - }) - describe('Legacy subgraph migration', function () { - it('migrates a legacy subgraph', async function () { - const seqID = toBN('2') - await legacyGNSMock.connect(me).createLegacySubgraph(seqID, newSubgraph0.subgraphDeploymentID) - const tx = legacyGNSMock.connect(me).migrateLegacySubgraph(me.address, seqID, newSubgraph0.subgraphMetadata) - await expect(tx).emit(legacyGNSMock, ' LegacySubgraphClaimed').withArgs(me.address, seqID) - const expectedSubgraphID = buildLegacySubgraphId(me.address, seqID) - const migratedSubgraphDeploymentID = await legacyGNSMock.getSubgraphDeploymentID(expectedSubgraphID) - const migratedNSignal = await legacyGNSMock.getSubgraphNSignal(expectedSubgraphID) - expect(migratedSubgraphDeploymentID).eq(newSubgraph0.subgraphDeploymentID) - expect(migratedNSignal).eq(toBN('1000')) - - const subgraphNFTAddress = await legacyGNSMock.subgraphNFT() - const subgraphNFT = loadContractAt('SubgraphNFT', subgraphNFTAddress) - const tokenURI = await subgraphNFT.connect(me).tokenURI(expectedSubgraphID) - - const sub = new SubgraphDeploymentID(newSubgraph0.subgraphMetadata) - expect(sub.ipfsHash).eq(tokenURI) - }) - it('refuses to migrate an already migrated subgraph', async function () { - const seqID = toBN('2') - await legacyGNSMock.connect(me).createLegacySubgraph(seqID, newSubgraph0.subgraphDeploymentID) - let tx = legacyGNSMock.connect(me).migrateLegacySubgraph(me.address, seqID, newSubgraph0.subgraphMetadata) - await expect(tx).emit(legacyGNSMock, ' LegacySubgraphClaimed').withArgs(me.address, seqID) - tx = legacyGNSMock.connect(me).migrateLegacySubgraph(me.address, seqID, newSubgraph0.subgraphMetadata) - await expect(tx).revertedWith('GNS: Subgraph was already claimed') - }) - }) - describe('Legacy subgraph view functions', function () { - it('isLegacySubgraph returns whether a subgraph is legacy or not', async function () { - const seqID = toBN('2') - const subgraphId = buildLegacySubgraphId(me.address, seqID) - await legacyGNSMock.connect(me).createLegacySubgraph(seqID, newSubgraph0.subgraphDeploymentID) - await legacyGNSMock.connect(me).migrateLegacySubgraph(me.address, seqID, newSubgraph0.subgraphMetadata) - - expect(await legacyGNSMock.isLegacySubgraph(subgraphId)).eq(true) - - const subgraph0 = await publishNewSubgraph(me, newSubgraph0, legacyGNSMock, graph.chainId) - expect(await legacyGNSMock.isLegacySubgraph(subgraph0.id)).eq(false) - }) - it('getLegacySubgraphKey returns the account and seqID for a legacy subgraph', async function () { - const seqID = toBN('2') - const subgraphId = buildLegacySubgraphId(me.address, seqID) - await legacyGNSMock.connect(me).createLegacySubgraph(seqID, newSubgraph0.subgraphDeploymentID) - await legacyGNSMock.connect(me).migrateLegacySubgraph(me.address, seqID, newSubgraph0.subgraphMetadata) - const [account, id] = await legacyGNSMock.getLegacySubgraphKey(subgraphId) - expect(account).eq(me.address) - expect(id).eq(seqID) - }) - it('getLegacySubgraphKey returns zero values for a non-legacy subgraph', async function () { - const subgraph0 = await publishNewSubgraph(me, newSubgraph0, legacyGNSMock, graph.chainId) - const [account, id] = await legacyGNSMock.getLegacySubgraphKey(subgraph0.id) - expect(account).eq(AddressZero) - expect(id).eq(toBN('0')) - }) - }) - describe('Subgraph transfer to L2', function () { - const publishAndCurateOnSubgraph = async function (): Promise { - // Publish a named subgraph-0 -> subgraphDeployment0 - const subgraph0 = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - // Curate on the subgraph - await gns.connect(me).mintSignal(subgraph0.id, toGRT('90000'), 0) - // Add an additional curator that is not the owner - await gns.connect(other).mintSignal(subgraph0.id, toGRT('10000'), 0) - return subgraph0 - } - - const publishCurateAndSendSubgraph = async function ( - beforeTransferCallback?: (_subgraphID: string) => Promise, - ): Promise { - const subgraph0 = await publishAndCurateOnSubgraph() - - if (beforeTransferCallback != null && subgraph0.id) { - await beforeTransferCallback(subgraph0.id) - } - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - - const subgraphBefore = await gns.subgraphs(subgraph0.id) - const curatedTokens = await gns.subgraphTokens(subgraph0.id) - const beforeOwnerSignal = await gns.getCuratorSignal(subgraph0.id, me.address) - const expectedSentToL2 = beforeOwnerSignal.mul(curatedTokens).div(subgraphBefore.nSignal) - - const tx = gns.connect(me).sendSubgraphToL2(subgraph0.id, me.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - - await expect(tx).emit(gns, 'SubgraphSentToL2').withArgs(subgraph0.id, me.address, me.address, expectedSentToL2) - return subgraph0 - } - const publishAndCurateOnLegacySubgraph = async function (seqID: BigNumber): Promise { - await legacyGNSMock.connect(me).createLegacySubgraph(seqID, newSubgraph0.subgraphDeploymentID) - // The legacy subgraph must be claimed - const migrateTx = legacyGNSMock - .connect(me) - .migrateLegacySubgraph(me.address, seqID, newSubgraph0.subgraphMetadata) - await expect(migrateTx).emit(legacyGNSMock, ' LegacySubgraphClaimed').withArgs(me.address, seqID) - const subgraphID = buildLegacySubgraphId(me.address, seqID) - - // Curate on the subgraph - await legacyGNSMock.connect(me).mintSignal(subgraphID, toGRT('10000'), 0) - - return subgraphID - } - - describe('sendSubgraphToL2', function () { - it('sends tokens and calldata to L2 through the GRT bridge, for a desired L2 owner', async function () { - const subgraph0 = await publishAndCurateOnSubgraph() - - const curatedTokens = await grt.balanceOf(curation.address) - const subgraphBefore = await gns.subgraphs(subgraph0.id) - - const beforeOwnerSignal = await gns.getCuratorSignal(subgraph0.id, me.address) - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - const tx = gns - .connect(me) - .sendSubgraphToL2(subgraph0.id, other.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - const expectedSentToL2 = beforeOwnerSignal.mul(curatedTokens).div(subgraphBefore.nSignal) - await expect(tx) - .emit(gns, 'SubgraphSentToL2') - .withArgs(subgraph0.id, me.address, other.address, expectedSentToL2) - - const expectedRemainingTokens = curatedTokens.sub(expectedSentToL2) - const subgraphAfter = await gns.subgraphs(subgraph0.id) - expect(subgraphAfter.vSignal).eq(0) - expect(await grt.balanceOf(gns.address)).eq(expectedRemainingTokens) - expect(subgraphAfter.disabled).eq(true) - expect(subgraphAfter.withdrawableGRT).eq(expectedRemainingTokens) - - const transferred = await gns.subgraphTransferredToL2(subgraph0.id) - expect(transferred).eq(true) - - const expectedCallhookData = defaultAbiCoder.encode( - ['uint8', 'uint256', 'address'], - [toBN(0), subgraph0.id, other.address], // code = 0 means RECEIVE_SUBGRAPH_CODE - ) - const expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( - grt.address, - gns.address, - l2GNSMock.address, - expectedSentToL2, - expectedCallhookData, - ) - await expect(tx) - .emit(l1GraphTokenGateway, 'TxToL2') - .withArgs(gns.address, l2GRTGatewayMock.address, toBN(1), expectedL2Data) - }) - it('sends tokens and calldata for a legacy subgraph to L2 through the GRT bridge', async function () { - const seqID = toBN('2') - const subgraphID = await publishAndCurateOnLegacySubgraph(seqID) - - const subgraphBefore = await legacyGNSMock.legacySubgraphData(me.address, seqID) - const curatedTokens = await legacyGNSMock.subgraphTokens(subgraphID) - const beforeOwnerSignal = await legacyGNSMock.getCuratorSignal(subgraphID, me.address) - const expectedSentToL2 = beforeOwnerSignal.mul(curatedTokens).div(subgraphBefore.nSignal) - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - const tx = legacyGNSMock - .connect(me) - .sendSubgraphToL2(subgraphID, other.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - await expect(tx) - .emit(legacyGNSMock, 'SubgraphSentToL2') - .withArgs(subgraphID, me.address, other.address, expectedSentToL2) - - const expectedRemainingTokens = curatedTokens.sub(expectedSentToL2) - const subgraphAfter = await legacyGNSMock.legacySubgraphData(me.address, seqID) - expect(subgraphAfter.vSignal).eq(0) - expect(await grt.balanceOf(legacyGNSMock.address)).eq(expectedRemainingTokens) - expect(subgraphAfter.disabled).eq(true) - expect(subgraphAfter.withdrawableGRT).eq(expectedRemainingTokens) - - const transferred = await legacyGNSMock.subgraphTransferredToL2(subgraphID) - expect(transferred).eq(true) - - const expectedCallhookData = defaultAbiCoder.encode( - ['uint8', 'uint256', 'address'], - [toBN(0), subgraphID, other.address], // code = 0 means RECEIVE_SUBGRAPH_CODE - ) - - const expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( - grt.address, - legacyGNSMock.address, - l2GNSMock.address, - expectedSentToL2, - expectedCallhookData, - ) - await expect(tx) - .emit(l1GraphTokenGateway, 'TxToL2') - .withArgs(legacyGNSMock.address, l2GRTGatewayMock.address, toBN(1), expectedL2Data) - }) - it('rejects calls from someone who is not the subgraph owner', async function () { - const subgraph0 = await publishAndCurateOnSubgraph() - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - const tx = gns - .connect(other) - .sendSubgraphToL2(subgraph0.id, other.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - await expect(tx).revertedWith('GNS: Must be authorized') - }) - it('rejects calls for a subgraph that was already sent', async function () { - const subgraph0 = await publishAndCurateOnSubgraph() - - const subgraphBefore = await gns.subgraphs(subgraph0.id) - const curatedTokens = await gns.subgraphTokens(subgraph0.id) - const beforeOwnerSignal = await gns.getCuratorSignal(subgraph0.id, me.address) - const expectedSentToL2 = beforeOwnerSignal.mul(curatedTokens).div(subgraphBefore.nSignal) - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - const tx = gns.connect(me).sendSubgraphToL2(subgraph0.id, me.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - await expect(tx).emit(gns, 'SubgraphSentToL2').withArgs(subgraph0.id, me.address, me.address, expectedSentToL2) - - const tx2 = gns.connect(me).sendSubgraphToL2(subgraph0.id, me.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - await expect(tx2).revertedWith('ALREADY_DONE') - }) - it('rejects a call for a subgraph that is deprecated', async function () { - const subgraph0 = await publishAndCurateOnSubgraph() - - await gns.connect(me).deprecateSubgraph(subgraph0.id) - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - const tx = gns.connect(me).sendSubgraphToL2(subgraph0.id, me.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - - await expect(tx).revertedWith('GNS: Must be active') - }) - it('rejects a call for a subgraph that does not exist', async function () { - const subgraphId = await buildSubgraphId(me.address, toBN(100), graph.chainId) - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - const tx = gns.connect(me).sendSubgraphToL2(subgraphId, me.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - - await expect(tx).revertedWith('GNS: Must be active') - }) - it('rejects calls with more ETH than maxSubmissionCost + maxGas * gasPriceBid', async function () { - const subgraph0 = await publishAndCurateOnSubgraph() - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - const tx = gns.connect(me).sendSubgraphToL2(subgraph0.id, me.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)).add(toBN('1')), - }) - await expect(tx).revertedWith('INVALID_ETH_VALUE') - }) - it('does not allow curators to burn signal after sending', async function () { - const subgraph0 = await publishAndCurateOnSubgraph() - - const subgraphBefore = await gns.subgraphs(subgraph0.id) - const curatedTokens = await gns.subgraphTokens(subgraph0.id) - const beforeOwnerSignal = await gns.getCuratorSignal(subgraph0.id, me.address) - const expectedSentToL2 = beforeOwnerSignal.mul(curatedTokens).div(subgraphBefore.nSignal) - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - const tx = gns.connect(me).sendSubgraphToL2(subgraph0.id, me.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - await expect(tx).emit(gns, 'SubgraphSentToL2').withArgs(subgraph0.id, me.address, me.address, expectedSentToL2) - - const tx2 = gns.connect(me).burnSignal(subgraph0.id, toBN(1), toGRT('0')) - await expect(tx2).revertedWith('GNS: Must be active') - const tx3 = gns.connect(other).burnSignal(subgraph0.id, toBN(1), toGRT('0')) - await expect(tx3).revertedWith('GNS: Must be active') - }) - it('does not allow curators to transfer signal after sending', async function () { - const subgraph0 = await publishAndCurateOnSubgraph() - - const subgraphBefore = await gns.subgraphs(subgraph0.id) - const curatedTokens = await gns.subgraphTokens(subgraph0.id) - const beforeOwnerSignal = await gns.getCuratorSignal(subgraph0.id, me.address) - const expectedSentToL2 = beforeOwnerSignal.mul(curatedTokens).div(subgraphBefore.nSignal) - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - const tx = gns.connect(me).sendSubgraphToL2(subgraph0.id, me.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - await expect(tx).emit(gns, 'SubgraphSentToL2').withArgs(subgraph0.id, me.address, me.address, expectedSentToL2) - - const tx2 = gns.connect(me).transferSignal(subgraph0.id, other.address, toBN(1)) - await expect(tx2).revertedWith('GNS: Must be active') - const tx3 = gns.connect(other).transferSignal(subgraph0.id, me.address, toBN(1)) - await expect(tx3).revertedWith('GNS: Must be active') - }) - it('does not allow the owner to withdraw GRT after sending', async function () { - const subgraph0 = await publishAndCurateOnSubgraph() - - const subgraphBefore = await gns.subgraphs(subgraph0.id) - const curatedTokens = await gns.subgraphTokens(subgraph0.id) - const beforeOwnerSignal = await gns.getCuratorSignal(subgraph0.id, me.address) - const expectedSentToL2 = beforeOwnerSignal.mul(curatedTokens).div(subgraphBefore.nSignal) - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - const tx = gns.connect(me).sendSubgraphToL2(subgraph0.id, me.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - await expect(tx).emit(gns, 'SubgraphSentToL2').withArgs(subgraph0.id, me.address, me.address, expectedSentToL2) - - const tx2 = gns.connect(me).withdraw(subgraph0.id) - await expect(tx2).revertedWith('GNS: No signal to withdraw GRT') - }) - it('allows a curator that is not the owner to withdraw GRT after sending', async function () { - const subgraph0 = await publishAndCurateOnSubgraph() - - const subgraphBefore = await gns.subgraphs(subgraph0.id) - const curatedTokens = await gns.subgraphTokens(subgraph0.id) - const beforeOwnerSignal = await gns.getCuratorSignal(subgraph0.id, me.address) - const expectedSentToL2 = beforeOwnerSignal.mul(curatedTokens).div(subgraphBefore.nSignal) - const beforeOtherSignal = await gns.getCuratorSignal(subgraph0.id, other.address) - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - const tx = gns.connect(me).sendSubgraphToL2(subgraph0.id, me.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - await expect(tx).emit(gns, 'SubgraphSentToL2').withArgs(subgraph0.id, me.address, me.address, expectedSentToL2) - - const remainingTokens = (await gns.subgraphs(subgraph0.id)).withdrawableGRT - const tx2 = gns.connect(other).withdraw(subgraph0.id) - await expect(tx2) - .emit(gns, 'GRTWithdrawn') - .withArgs(subgraph0.id, other.address, beforeOtherSignal, remainingTokens) - }) - }) - describe('sendCuratorBalanceToBeneficiaryOnL2', function () { - it('sends a transaction with a curator balance to the L2GNS using the L1 gateway', async function () { - const subgraph0 = await publishCurateAndSendSubgraph() - const afterSubgraph = await gns.subgraphs(subgraph0.id) - const curatorTokens = afterSubgraph.withdrawableGRT - - const expectedCallhookData = defaultAbiCoder.encode( - ['uint8', 'uint256', 'address'], - [toBN(1), subgraph0.id, another.address], // code = 1 means RECEIVE_CURATOR_BALANCE_CODE - ) - const expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( - grt.address, - gns.address, - l2GNSMock.address, - curatorTokens, - expectedCallhookData, - ) - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - - const tx = gns - .connect(other) - .sendCuratorBalanceToBeneficiaryOnL2(subgraph0.id, another.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - - // seqNum (third argument in the event) is 2, because number 1 was when the subgraph was sent to L2 - await expect(tx) - .emit(l1GraphTokenGateway, 'TxToL2') - .withArgs(gns.address, l2GRTGatewayMock.address, toBN('2'), expectedL2Data) - await expect(tx) - .emit(gns, 'CuratorBalanceSentToL2') - .withArgs(subgraph0.id, other.address, another.address, curatorTokens) - }) - it('sets the curator signal to zero so it cannot be called twice', async function () { - const subgraph0 = await publishCurateAndSendSubgraph() - const afterSubgraph = await gns.subgraphs(subgraph0.id) - const curatorTokens = afterSubgraph.withdrawableGRT - - const expectedCallhookData = defaultAbiCoder.encode( - ['uint8', 'uint256', 'address'], - [toBN(1), subgraph0.id, other.address], // code = 1 means RECEIVE_CURATOR_BALANCE_CODE - ) - const expectedL2Data = await l1GraphTokenGateway.getOutboundCalldata( - grt.address, - gns.address, - l2GNSMock.address, - curatorTokens, - expectedCallhookData, - ) - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - - const tx = gns - .connect(other) - .sendCuratorBalanceToBeneficiaryOnL2(subgraph0.id, other.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - - // seqNum (third argument in the event) is 2, because number 1 was when the subgraph was sent to L2 - await expect(tx) - .emit(l1GraphTokenGateway, 'TxToL2') - .withArgs(gns.address, l2GRTGatewayMock.address, toBN('2'), expectedL2Data) - - const tx2 = gns - .connect(other) - .sendCuratorBalanceToBeneficiaryOnL2(subgraph0.id, other.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - await expect(tx2).revertedWith('NO_SIGNAL') - }) - it('sets the curator signal to zero so they cannot withdraw', async function () { - // Under coverage, skip this test as it has issues with BigNumber values - if (isRunningUnderCoverage()) { - this.skip() - return - } - - const subgraph0 = await publishCurateAndSendSubgraph(async (_subgraphId) => { - // We add another curator before transferring, so the the subgraph doesn't - // run out of withdrawable GRT and we can test that it denies the specific curator - // because they have sent their signal to L2, not because the subgraph is out of GRT. - await gns.connect(another).mintSignal(_subgraphId, toGRT('1000'), toBN(0)) - }) - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - - await gns - .connect(other) - .sendCuratorBalanceToBeneficiaryOnL2(subgraph0.id, other.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - - const tx = gns.connect(other).withdraw(subgraph0.id) - await expect(tx).revertedWith('GNS: No signal to withdraw GRT') - }) - it('gives each curator an amount of tokens proportional to their nSignal', async function () { - // Under coverage, skip this test as it has issues with BigNumber values - if (isRunningUnderCoverage()) { - this.skip() - return - } - - let beforeOtherNSignal: BigNumber - let beforeAnotherNSignal: BigNumber - const subgraph0 = await publishCurateAndSendSubgraph(async (subgraphID: string) => { - beforeOtherNSignal = await gns.getCuratorSignal(subgraphID, other.address) - await gns.connect(another).mintSignal(subgraphID, toGRT('10000'), 0) - beforeAnotherNSignal = await gns.getCuratorSignal(subgraphID, another.address) - }) - const afterSubgraph = await gns.subgraphs(subgraph0.id) - - // Compute how much is owed to each curator - const curator1Tokens = beforeOtherNSignal.mul(afterSubgraph.withdrawableGRT).div(afterSubgraph.nSignal) - const curator2Tokens = beforeAnotherNSignal.mul(afterSubgraph.withdrawableGRT).div(afterSubgraph.nSignal) - - const expectedCallhookData1 = defaultAbiCoder.encode( - ['uint8', 'uint256', 'address'], - [toBN(1), subgraph0.id, other.address], // code = 1 means RECEIVE_CURATOR_BALANCE_CODE - ) - const expectedCallhookData2 = defaultAbiCoder.encode( - ['uint8', 'uint256', 'address'], - [toBN(1), subgraph0.id, another.address], // code = 1 means RECEIVE_CURATOR_BALANCE_CODE - ) - const expectedL2Data1 = await l1GraphTokenGateway.getOutboundCalldata( - grt.address, - gns.address, - l2GNSMock.address, - curator1Tokens, - expectedCallhookData1, - ) - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - - const tx = gns - .connect(other) - .sendCuratorBalanceToBeneficiaryOnL2(subgraph0.id, other.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - - // seqNum (third argument in the event) is 2, because number 1 was when the subgraph was sent to L2 - await expect(tx) - .emit(l1GraphTokenGateway, 'TxToL2') - .withArgs(gns.address, l2GRTGatewayMock.address, toBN('2'), expectedL2Data1) - - // Accept slight numerical errors given how we compute the amount of tokens to send - const curator2TokensUpdated = (await gns.subgraphs(subgraph0.id)).withdrawableGRT - expect(toRound(toFloat(curator2TokensUpdated))).to.equal(toRound(toFloat(curator2Tokens))) - const expectedL2Data2 = await l1GraphTokenGateway.getOutboundCalldata( - grt.address, - gns.address, - l2GNSMock.address, - curator2TokensUpdated, - expectedCallhookData2, - ) - const tx2 = gns - .connect(another) - .sendCuratorBalanceToBeneficiaryOnL2(subgraph0.id, another.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - // seqNum (third argument in the event) is 3 now - await expect(tx2) - .emit(l1GraphTokenGateway, 'TxToL2') - .withArgs(gns.address, l2GRTGatewayMock.address, toBN('3'), expectedL2Data2) - }) - it('rejects calls for a subgraph that was not sent to L2', async function () { - const subgraph0 = await publishAndCurateOnSubgraph() - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - - const tx = gns - .connect(me) - .sendCuratorBalanceToBeneficiaryOnL2(subgraph0.id, other.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - - await expect(tx).revertedWith('!TRANSFERRED') - }) - - it('rejects calls for a subgraph that was deprecated', async function () { - const subgraph0 = await publishAndCurateOnSubgraph() - - await helpers.mine(256) - await gns.connect(me).deprecateSubgraph(subgraph0.id) - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - - const tx = gns - .connect(me) - .sendCuratorBalanceToBeneficiaryOnL2(subgraph0.id, other.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - - await expect(tx).revertedWith('!TRANSFERRED') - }) - it('rejects calls with zero maxSubmissionCost', async function () { - const subgraph0 = await publishCurateAndSendSubgraph() - - const maxSubmissionCost = toBN('0') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - - const tx = gns - .connect(me) - .sendCuratorBalanceToBeneficiaryOnL2(subgraph0.id, other.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - - await expect(tx).revertedWith('NO_SUBMISSION_COST') - }) - it('rejects calls with more ETH than maxSubmissionCost + maxGas * gasPriceBid', async function () { - const subgraph0 = await publishCurateAndSendSubgraph() - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - - const tx = gns - .connect(me) - .sendCuratorBalanceToBeneficiaryOnL2(subgraph0.id, other.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)).add(toBN('1')), - }) - - await expect(tx).revertedWith('INVALID_ETH_VALUE') - }) - it('rejects calls if the curator has withdrawn the GRT', async function () { - const subgraph0 = await publishCurateAndSendSubgraph() - - await gns.connect(other).withdraw(subgraph0.id) - - const maxSubmissionCost = toBN('100') - const maxGas = toBN('10') - const gasPriceBid = toBN('20') - - const tx = gns - .connect(other) - .sendCuratorBalanceToBeneficiaryOnL2(subgraph0.id, another.address, maxGas, gasPriceBid, maxSubmissionCost, { - value: maxSubmissionCost.add(maxGas.mul(gasPriceBid)), - }) - - // seqNum (third argument in the event) is 2, because number 1 was when the subgraph was sent to L2 - await expect(tx).revertedWith('NO_SIGNAL') - }) - }) - }) -}) diff --git a/packages/contracts-test/tests/unit/l2/l2GNS.test.ts b/packages/contracts-test/tests/unit/l2/l2GNS.test.ts deleted file mode 100644 index a8b1b67a9..000000000 --- a/packages/contracts-test/tests/unit/l2/l2GNS.test.ts +++ /dev/null @@ -1,777 +0,0 @@ -import { L2GNS } from '@graphprotocol/contracts' -import { L2GraphTokenGateway } from '@graphprotocol/contracts' -import { L2Curation } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { IL2Staking } from '@graphprotocol/contracts' -import { L1GNS, L1GraphTokenGateway } from '@graphprotocol/contracts' -import { - buildSubgraph, - buildSubgraphId, - deriveChannelKey, - GraphNetworkContracts, - helpers, - PublishSubgraph, - randomHexBytes, - Subgraph, - toBN, - toGRT, -} from '@graphprotocol/sdk' -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber, ContractTransaction, ethers } from 'ethers' -import { defaultAbiCoder, parseEther } from 'ethers/lib/utils' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' -import { - burnSignal, - DEFAULT_RESERVE_RATIO, - deprecateSubgraph, - mintSignal, - publishNewSubgraph, - publishNewVersion, -} from '../lib/gnsUtils' - -const { HashZero } = ethers.constants - -interface L1SubgraphParams { - l1SubgraphId: string - curatedTokens: BigNumber - subgraphMetadata: string - versionMetadata: string -} - -describe('L2GNS', () => { - const graph = hre.graph() - let me: SignerWithAddress - let attacker: SignerWithAddress - let other: SignerWithAddress - let governor: SignerWithAddress - let fixture: NetworkFixture - - let fixtureContracts: GraphNetworkContracts - let l1MockContracts: GraphNetworkContracts - let l1GRTMock: GraphToken - let l1GNSMock: L1GNS - let l1GRTGatewayMock: L1GraphTokenGateway - let l2GraphTokenGateway: L2GraphTokenGateway - let gns: L2GNS - let curation: L2Curation - let grt: GraphToken - let staking: IL2Staking - - let newSubgraph0: PublishSubgraph - let newSubgraph1: PublishSubgraph - - const tokens1000 = toGRT('1000') - const tokens10000 = toGRT('10000') - const tokens100000 = toGRT('100000') - const curationTaxPercentage = 50000 - - const gatewayFinalizeTransfer = async function ( - from: string, - to: string, - amount: BigNumber, - callhookData: string, - ): Promise { - const l1GRTGatewayMockL2Alias = await helpers.getL2SignerFromL1(l1GRTGatewayMock.address) - // Eth for gas: - await helpers.setBalance(await l1GRTGatewayMockL2Alias.getAddress(), parseEther('1')) - - const tx = l2GraphTokenGateway - .connect(l1GRTGatewayMockL2Alias) - .finalizeInboundTransfer(l1GRTMock.address, from, to, amount, callhookData) - return tx - } - - const defaultL1SubgraphParams = async function (): Promise { - return { - l1SubgraphId: await buildSubgraphId(me.address, toBN('1'), graph.chainId), - curatedTokens: toGRT('1337'), - subgraphMetadata: randomHexBytes(), - versionMetadata: randomHexBytes(), - } - } - const transferMockSubgraphFromL1 = async function ( - l1SubgraphId: string, - curatedTokens: BigNumber, - subgraphMetadata: string, - versionMetadata: string, - ) { - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(0), l1SubgraphId, me.address]) - await gatewayFinalizeTransfer(l1GNSMock.address, gns.address, curatedTokens, callhookData) - - const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId) - await gns - .connect(me) - .finishSubgraphTransferFromL1(l2SubgraphId, newSubgraph0.subgraphDeploymentID, subgraphMetadata, versionMetadata) - } - - before(async function () { - newSubgraph0 = buildSubgraph() - ;[me, attacker, other] = await graph.getTestAccounts() - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - - // Deploy L2 - fixtureContracts = await fixture.load(governor, true) - l2GraphTokenGateway = fixtureContracts.L2GraphTokenGateway as L2GraphTokenGateway - gns = fixtureContracts.L2GNS as L2GNS - curation = fixtureContracts.L2Curation as L2Curation - grt = fixtureContracts.GraphToken as GraphToken - staking = fixtureContracts.L2Staking as unknown as IL2Staking - - // Deploy L1 mock - l1MockContracts = await fixture.loadMock(false) - l1GRTMock = l1MockContracts.GraphToken as GraphToken - l1GNSMock = l1MockContracts.L1GNS as L1GNS - l1GRTGatewayMock = l1MockContracts.L1GraphTokenGateway as L1GraphTokenGateway - - // Deploy L2 arbitrum bridge - await fixture.loadL2ArbitrumBridge(governor) - - // Configure L2 bridge - await fixture.configureL2Bridge(governor, fixtureContracts, l1MockContracts) - - await grt.connect(governor).mint(me.address, toGRT('10000')) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - // Adapted from the L1 GNS tests but allowing curating to a pre-curated subgraph deployment - describe('publishNewVersion', function () { - let subgraph: Subgraph - - beforeEach(async () => { - newSubgraph0 = buildSubgraph() - newSubgraph1 = buildSubgraph() - // Give some funds to the signers and approve gns contract to use funds on signers behalf - await grt.connect(governor).mint(me.address, tokens100000) - await grt.connect(governor).mint(other.address, tokens100000) - await grt.connect(me).approve(gns.address, tokens100000) - await grt.connect(me).approve(curation.address, tokens100000) - await grt.connect(other).approve(gns.address, tokens100000) - await grt.connect(other).approve(curation.address, tokens100000) - // Update curation tax to test the functionality of it in disableNameSignal() - await curation.connect(governor).setCurationTaxPercentage(curationTaxPercentage) - subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - await mintSignal(me, subgraph.id, tokens10000, gns, curation) - }) - - it('should publish a new version on an existing subgraph', async function () { - await publishNewVersion(me, subgraph.id, newSubgraph1, gns, curation) - }) - - it('should publish a new version on an existing subgraph when owner tax is zero', async function () { - await gns.connect(governor).setOwnerTaxPercentage(0) - await publishNewVersion(me, subgraph.id, newSubgraph1, gns, curation) - }) - - it('should publish a new version on an existing subgraph with no current signal', async function () { - const emptySignalSubgraph = await publishNewSubgraph(me, buildSubgraph(), gns, graph.chainId) - await publishNewVersion(me, emptySignalSubgraph.id, newSubgraph1, gns, curation) - }) - - it('should reject a new version with the same subgraph deployment ID', async function () { - const tx = gns - .connect(me) - [ - 'publishNewVersion(uint256,bytes32,bytes32,uint256)' - ](subgraph.id, newSubgraph0.subgraphDeploymentID, newSubgraph0.versionMetadata, 0) - await expect(tx).revertedWith('GNS: Cannot publish a new version with the same subgraph deployment ID') - }) - - it('should reject publishing a version to a subgraph that does not exist', async function () { - const tx = gns - .connect(me) - [ - 'publishNewVersion(uint256,bytes32,bytes32,uint256)' - ](randomHexBytes(32), newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata, 0) - await expect(tx).revertedWith('ERC721: owner query for nonexistent token') - }) - - it('reject if not the owner', async function () { - const tx = gns - .connect(other) - [ - 'publishNewVersion(uint256,bytes32,bytes32,uint256)' - ](subgraph.id, newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata, 0) - await expect(tx).revertedWith('GNS: Must be authorized') - }) - - it('should NOT fail when upgrade tries to point to a pre-curated', async function () { - // Curate directly to the deployment - await curation.connect(me).mint(newSubgraph1.subgraphDeploymentID, tokens1000, 0) - - await publishNewVersion(me, subgraph.id, newSubgraph1, gns, curation) - }) - - it('should upgrade version when there is no signal with no signal migration', async function () { - await burnSignal(me, subgraph.id, gns, curation) - const tx = gns - .connect(me) - [ - 'publishNewVersion(uint256,bytes32,bytes32,uint256)' - ](subgraph.id, newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata, 0) - await expect(tx) - .emit(gns, 'SubgraphVersionUpdated') - .withArgs(subgraph.id, newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata) - }) - - it('should fail when subgraph is deprecated', async function () { - await deprecateSubgraph(me, subgraph.id, gns, curation, grt) - const tx = gns - .connect(me) - [ - 'publishNewVersion(uint256,bytes32,bytes32,uint256)' - ](subgraph.id, newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata, 0) - // NOTE: deprecate burns the Subgraph NFT, when someone wants to publish a new version it won't find it - await expect(tx).revertedWith('ERC721: owner query for nonexistent token') - }) - - it('should protect against front-running with slippage protection', async function () { - // Give attacker massive funds for the attack - await grt.connect(governor).mint(attacker.address, toGRT('1000000')) - await grt.connect(attacker).approve(curation.address, toGRT('1000000')) - await grt.connect(attacker).approve(staking.address, toGRT('1000000')) - - // Step 1: Set minimum curation deposit to 1 wei and disable tax for accurate attack test - await curation.connect(governor).setMinimumCurationDeposit(1) - await curation.connect(governor).setCurationTaxPercentage(0) - await curation.connect(attacker).mint(newSubgraph1.subgraphDeploymentID, 1, 0) - - // Step 2: Attacker stakes and creates allocation on the deployment - await staking.connect(attacker).stake(toGRT('100000')) - const channelKey = deriveChannelKey() - await staking - .connect(attacker) - .allocateFrom( - attacker.address, - newSubgraph1.subgraphDeploymentID, - toGRT('10000'), - channelKey.address, - randomHexBytes(32), - await channelKey.generateProof(attacker.address), - ) - - // Calculate expected signal from burning current subgraph (before attack) - const subgraphData = await gns.subgraphs(subgraph.id) - const tokensFromOldCurve = await curation.signalToTokens(newSubgraph0.subgraphDeploymentID, subgraphData.vSignal) - const expectedSignalWithoutAttack = await curation.tokensToSignalNoTax( - newSubgraph1.subgraphDeploymentID, - tokensFromOldCurve, - ) - - // Step 3: Attacker inflates the curation pool by collecting massive query fees - // Simulate the token collection flow: TokenUtils.pushTokens + curation.collect - const collectTokens = toGRT('50000') // 50k GRT collected as fees - - // Mint tokens to curation contract (simulating TokenUtils.pushTokens from staking) - await grt.connect(governor).mint(curation.address, collectTokens) - - // Only the subgraph service may call collect, so point it at the attacker for this step - await curation.connect(governor).setSubgraphService(attacker.address) - await curation.connect(attacker).collect(newSubgraph1.subgraphDeploymentID, collectTokens) - - // Now calculate signal after the attack - should be much less due to inflated pool - const actualSignalAfterAttack = await curation.tokensToSignalNoTax( - newSubgraph1.subgraphDeploymentID, - tokensFromOldCurve, - ) - - // Verify the attack worked - victim gets much less signal - expect(actualSignalAfterAttack).to.be.lt(expectedSignalWithoutAttack.div(10)) - - // Step 4: With slippage protection, the upgrade should fail when expecting reasonable signal - const tx = gns - .connect(me) - [ - 'publishNewVersion(uint256,bytes32,bytes32,uint256)' - ](subgraph.id, newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata, expectedSignalWithoutAttack.div(2)) - await expect(tx).to.be.revertedWith('Slippage protection') - }) - - it('should succeed when slippage protection is satisfied', async function () { - // Give attacker funds and pre-curate the new deployment with some tokens - await grt.connect(governor).mint(attacker.address, toGRT('100')) - await grt.connect(attacker).approve(curation.address, toGRT('100')) - await curation.connect(attacker).mint(newSubgraph1.subgraphDeploymentID, toGRT('100'), 0) - - // Calculate the minimum signal we'll accept (allowing for some slippage) - const subgraphData = await gns.subgraphs(subgraph.id) - const tokensFromOldCurve = await curation.signalToTokens(newSubgraph0.subgraphDeploymentID, subgraphData.vSignal) - const expectedSignal = await curation.tokensToSignalNoTax(newSubgraph1.subgraphDeploymentID, tokensFromOldCurve) - const minSignalWithSlippage = expectedSignal.mul(95).div(100) // Accept 5% slippage - - // Upgrade should succeed with reasonable slippage protection - const tx = gns - .connect(me) - [ - 'publishNewVersion(uint256,bytes32,bytes32,uint256)' - ](subgraph.id, newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata, minSignalWithSlippage) - await expect(tx) - .emit(gns, 'SubgraphVersionUpdated') - .withArgs(subgraph.id, newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata) - }) - - it('should succeed using L1 version (3-param) when deployment is NOT pre-curated', async function () { - // Use the 3-parameter version (inherited from GNS) - const tx = gns - .connect(me) - [ - 'publishNewVersion(uint256,bytes32,bytes32)' - ](subgraph.id, newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata) - await expect(tx) - .emit(gns, 'SubgraphVersionUpdated') - .withArgs(subgraph.id, newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata) - }) - - it('should revert using L1 version (3-param) when deployment IS pre-curated', async function () { - // Pre-curate the new deployment - await curation.connect(me).mint(newSubgraph1.subgraphDeploymentID, tokens1000, 0) - - // Use the 3-parameter version (inherited from GNS) - should revert due to pre-curation check - const tx = gns - .connect(me) - [ - 'publishNewVersion(uint256,bytes32,bytes32)' - ](subgraph.id, newSubgraph1.subgraphDeploymentID, newSubgraph1.versionMetadata) - await expect(tx).revertedWith('GNS: Owner cannot point to a subgraphID that has been pre-curated') - }) - }) - - describe('receiving a subgraph from L1 (onTokenTransfer)', function () { - it('cannot be called by someone other than the L2GraphTokenGateway', async function () { - const { l1SubgraphId, curatedTokens } = await defaultL1SubgraphParams() - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(0), l1SubgraphId, me.address]) - const tx = gns.connect(me).onTokenTransfer(l1GNSMock.address, curatedTokens, callhookData) - await expect(tx).revertedWith('ONLY_GATEWAY') - }) - it('rejects calls if the L1 sender is not the L1GNS', async function () { - const { l1SubgraphId, curatedTokens } = await defaultL1SubgraphParams() - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(0), l1SubgraphId, me.address]) - const tx = gatewayFinalizeTransfer(me.address, gns.address, curatedTokens, callhookData) - - await expect(tx).revertedWith('ONLY_L1_GNS_THROUGH_BRIDGE') - }) - it('creates a subgraph in a disabled state', async function () { - const { l1SubgraphId, curatedTokens } = await defaultL1SubgraphParams() - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(0), l1SubgraphId, me.address]) - const tx = gatewayFinalizeTransfer(l1GNSMock.address, gns.address, curatedTokens, callhookData) - - const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId) - - await expect(tx) - .emit(l2GraphTokenGateway, 'DepositFinalized') - .withArgs(l1GRTMock.address, l1GNSMock.address, gns.address, curatedTokens) - await expect(tx) - .emit(gns, 'SubgraphReceivedFromL1') - .withArgs(l1SubgraphId, l2SubgraphId, me.address, curatedTokens) - - const transferData = await gns.subgraphL2TransferData(l2SubgraphId) - const subgraphData = await gns.subgraphs(l2SubgraphId) - - expect(transferData.tokens).eq(curatedTokens) - expect(transferData.l2Done).eq(false) - expect(transferData.subgraphReceivedOnL2BlockNumber).eq(await helpers.latestBlock()) - - expect(subgraphData.vSignal).eq(0) - expect(subgraphData.nSignal).eq(0) - expect(subgraphData.subgraphDeploymentID).eq(HashZero) - expect(subgraphData.__DEPRECATED_reserveRatio).eq(DEFAULT_RESERVE_RATIO) - expect(subgraphData.disabled).eq(true) - expect(subgraphData.withdrawableGRT).eq(0) // Important so that it's not the same as a deprecated subgraph! - - expect(await gns.ownerOf(l2SubgraphId)).eq(me.address) - }) - it('does not conflict with a locally created subgraph', async function () { - const l2Subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - - const { l1SubgraphId, curatedTokens } = await defaultL1SubgraphParams() - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(0), l1SubgraphId, me.address]) - const tx = gatewayFinalizeTransfer(l1GNSMock.address, gns.address, curatedTokens, callhookData) - - const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId) - - await expect(tx) - .emit(l2GraphTokenGateway, 'DepositFinalized') - .withArgs(l1GRTMock.address, l1GNSMock.address, gns.address, curatedTokens) - await expect(tx) - .emit(gns, 'SubgraphReceivedFromL1') - .withArgs(l1SubgraphId, l2SubgraphId, me.address, curatedTokens) - - const transferData = await gns.subgraphL2TransferData(l2SubgraphId) - const subgraphData = await gns.subgraphs(l2SubgraphId) - - expect(transferData.tokens).eq(curatedTokens) - expect(transferData.l2Done).eq(false) - expect(transferData.subgraphReceivedOnL2BlockNumber).eq(await helpers.latestBlock()) - - expect(subgraphData.vSignal).eq(0) - expect(subgraphData.nSignal).eq(0) - expect(subgraphData.subgraphDeploymentID).eq(HashZero) - expect(subgraphData.__DEPRECATED_reserveRatio).eq(DEFAULT_RESERVE_RATIO) - expect(subgraphData.disabled).eq(true) - expect(subgraphData.withdrawableGRT).eq(0) // Important so that it's not the same as a deprecated subgraph! - - expect(await gns.ownerOf(l2SubgraphId)).eq(me.address) - - expect(l2Subgraph.id).not.eq(l2SubgraphId) - const l2SubgraphData = await gns.subgraphs(l2Subgraph.id) - expect(l2SubgraphData.vSignal).eq(0) - expect(l2SubgraphData.nSignal).eq(0) - expect(l2SubgraphData.subgraphDeploymentID).eq(l2Subgraph.subgraphDeploymentID) - expect(l2SubgraphData.__DEPRECATED_reserveRatio).eq(DEFAULT_RESERVE_RATIO) - expect(l2SubgraphData.disabled).eq(false) - expect(l2SubgraphData.withdrawableGRT).eq(0) - }) - }) - - describe('finishing a subgraph transfer from L1', function () { - it('publishes the transferred subgraph and mints signal with no tax', async function () { - const { l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams() - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(0), l1SubgraphId, me.address]) - await gatewayFinalizeTransfer(l1GNSMock.address, gns.address, curatedTokens, callhookData) - // Calculate expected signal before minting - const expectedSignal = await curation.tokensToSignalNoTax(newSubgraph0.subgraphDeploymentID, curatedTokens) - const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId) - const tx = gns - .connect(me) - .finishSubgraphTransferFromL1( - l2SubgraphId, - newSubgraph0.subgraphDeploymentID, - subgraphMetadata, - versionMetadata, - ) - await expect(tx) - .emit(gns, 'SubgraphPublished') - .withArgs(l2SubgraphId, newSubgraph0.subgraphDeploymentID, DEFAULT_RESERVE_RATIO) - await expect(tx).emit(gns, 'SubgraphMetadataUpdated').withArgs(l2SubgraphId, subgraphMetadata) - await expect(tx) - .emit(gns, 'SubgraphUpgraded') - .withArgs(l2SubgraphId, expectedSignal, curatedTokens, newSubgraph0.subgraphDeploymentID) - await expect(tx) - .emit(gns, 'SubgraphVersionUpdated') - .withArgs(l2SubgraphId, newSubgraph0.subgraphDeploymentID, versionMetadata) - await expect(tx).emit(gns, 'SubgraphL2TransferFinalized').withArgs(l2SubgraphId) - - const subgraphAfter = await gns.subgraphs(l2SubgraphId) - const transferDataAfter = await gns.subgraphL2TransferData(l2SubgraphId) - expect(subgraphAfter.vSignal).eq(expectedSignal) - expect(transferDataAfter.l2Done).eq(true) - expect(subgraphAfter.disabled).eq(false) - expect(subgraphAfter.subgraphDeploymentID).eq(newSubgraph0.subgraphDeploymentID) - const expectedNSignal = await gns.vSignalToNSignal(l2SubgraphId, expectedSignal) - expect(await gns.getCuratorSignal(l2SubgraphId, me.address)).eq(expectedNSignal) - await expect(tx) - .emit(gns, 'SignalMinted') - .withArgs(l2SubgraphId, me.address, expectedNSignal, expectedSignal, curatedTokens) - }) - it('cannot be called by someone other than the subgraph owner', async function () { - const { l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams() - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(0), l1SubgraphId, me.address]) - await gatewayFinalizeTransfer(l1GNSMock.address, gns.address, curatedTokens, callhookData) - const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId) - const tx = gns - .connect(other) - .finishSubgraphTransferFromL1( - l2SubgraphId, - newSubgraph0.subgraphDeploymentID, - subgraphMetadata, - versionMetadata, - ) - await expect(tx).revertedWith('GNS: Must be authorized') - }) - it('rejects calls for a subgraph that does not exist', async function () { - const l1SubgraphId = await buildSubgraphId(me.address, toBN('1'), graph.chainId) - const metadata = randomHexBytes() - const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId) - const tx = gns - .connect(me) - .finishSubgraphTransferFromL1(l2SubgraphId, newSubgraph0.subgraphDeploymentID, metadata, metadata) - await expect(tx).revertedWith('ERC721: owner query for nonexistent token') - }) - it('rejects calls for a subgraph that was not transferred from L1', async function () { - const l2Subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - const metadata = randomHexBytes() - - const tx = gns - .connect(me) - .finishSubgraphTransferFromL1(l2Subgraph.id, newSubgraph0.subgraphDeploymentID, metadata, metadata) - await expect(tx).revertedWith('INVALID_SUBGRAPH') - }) - it('accepts calls to a pre-curated subgraph deployment', async function () { - const { l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams() - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(0), l1SubgraphId, me.address]) - await gatewayFinalizeTransfer(l1GNSMock.address, gns.address, curatedTokens, callhookData) - const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId) - - // Calculate expected signal before minting - const expectedSignal = await curation.tokensToSignalNoTax(newSubgraph0.subgraphDeploymentID, curatedTokens) - await grt.connect(me).approve(curation.address, toGRT('100')) - await curation.connect(me).mint(newSubgraph0.subgraphDeploymentID, toGRT('100'), toBN('0')) - - expect(await curation.getCurationPoolTokens(newSubgraph0.subgraphDeploymentID)).eq(toGRT('100')) - const tx = gns - .connect(me) - .finishSubgraphTransferFromL1( - l2SubgraphId, - newSubgraph0.subgraphDeploymentID, - subgraphMetadata, - versionMetadata, - ) - await expect(tx) - .emit(gns, 'SubgraphPublished') - .withArgs(l2SubgraphId, newSubgraph0.subgraphDeploymentID, DEFAULT_RESERVE_RATIO) - await expect(tx).emit(gns, 'SubgraphMetadataUpdated').withArgs(l2SubgraphId, subgraphMetadata) - await expect(tx) - .emit(gns, 'SubgraphUpgraded') - .withArgs(l2SubgraphId, expectedSignal, curatedTokens, newSubgraph0.subgraphDeploymentID) - await expect(tx) - .emit(gns, 'SubgraphVersionUpdated') - .withArgs(l2SubgraphId, newSubgraph0.subgraphDeploymentID, versionMetadata) - await expect(tx).emit(gns, 'SubgraphL2TransferFinalized').withArgs(l2SubgraphId) - - const subgraphAfter = await gns.subgraphs(l2SubgraphId) - const transferDataAfter = await gns.subgraphL2TransferData(l2SubgraphId) - expect(subgraphAfter.vSignal).eq(expectedSignal) - expect(transferDataAfter.l2Done).eq(true) - expect(subgraphAfter.disabled).eq(false) - expect(subgraphAfter.subgraphDeploymentID).eq(newSubgraph0.subgraphDeploymentID) - expect(await curation.getCurationPoolTokens(newSubgraph0.subgraphDeploymentID)).eq( - toGRT('100').add(curatedTokens), - ) - }) - it('rejects calls if the subgraph deployment ID is zero', async function () { - const metadata = randomHexBytes() - const { l1SubgraphId, curatedTokens } = await defaultL1SubgraphParams() - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(0), l1SubgraphId, me.address]) - await gatewayFinalizeTransfer(l1GNSMock.address, gns.address, curatedTokens, callhookData) - const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId) - const tx = gns.connect(me).finishSubgraphTransferFromL1(l2SubgraphId, HashZero, metadata, metadata) - await expect(tx).revertedWith('GNS: deploymentID != 0') - }) - it('rejects calls if the subgraph transfer was already finished', async function () { - const metadata = randomHexBytes() - const { l1SubgraphId, curatedTokens } = await defaultL1SubgraphParams() - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(0), l1SubgraphId, me.address]) - await gatewayFinalizeTransfer(l1GNSMock.address, gns.address, curatedTokens, callhookData) - const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId) - await gns - .connect(me) - .finishSubgraphTransferFromL1(l2SubgraphId, newSubgraph0.subgraphDeploymentID, metadata, metadata) - - const tx = gns - .connect(me) - .finishSubgraphTransferFromL1(l2SubgraphId, newSubgraph0.subgraphDeploymentID, metadata, metadata) - await expect(tx).revertedWith('ALREADY_DONE') - }) - }) - describe('claiming a curator balance with a message from L1 (onTokenTransfer)', function () { - it('assigns a curator balance to a beneficiary', async function () { - const l1GNSMockL2Alias = await helpers.getL2SignerFromL1(l1GNSMock.address) - // Eth for gas: - await helpers.setBalance(await l1GNSMockL2Alias.getAddress(), parseEther('1')) - - const { l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams() - await transferMockSubgraphFromL1(l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata) - const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId) - const l2OwnerSignalBefore = await gns.getCuratorSignal(l2SubgraphId, me.address) - - const newCuratorTokens = toGRT('10') - const callhookData = defaultAbiCoder.encode( - ['uint8', 'uint256', 'address'], - [toBN(1), l1SubgraphId, other.address], - ) - const tx = await gatewayFinalizeTransfer(l1GNSMock.address, gns.address, newCuratorTokens, callhookData) - - await expect(tx) - .emit(gns, 'CuratorBalanceReceived') - .withArgs(l1SubgraphId, l2SubgraphId, other.address, newCuratorTokens) - - const l2NewCuratorSignal = await gns.getCuratorSignal(l2SubgraphId, other.address) - const expectedNewCuratorSignal = await gns.vSignalToNSignal( - l2SubgraphId, - await curation.tokensToSignalNoTax(newSubgraph0.subgraphDeploymentID, newCuratorTokens), - ) - const l2OwnerSignalAfter = await gns.getCuratorSignal(l2SubgraphId, me.address) - expect(l2OwnerSignalAfter).eq(l2OwnerSignalBefore) - expect(l2NewCuratorSignal).eq(expectedNewCuratorSignal) - }) - it('adds the signal to any existing signal for the beneficiary', async function () { - const l1GNSMockL2Alias = await helpers.getL2SignerFromL1(l1GNSMock.address) - // Eth for gas: - await helpers.setBalance(await l1GNSMockL2Alias.getAddress(), parseEther('1')) - - const { l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams() - await transferMockSubgraphFromL1(l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata) - - const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId) - await grt.connect(governor).mint(other.address, toGRT('10')) - await grt.connect(other).approve(gns.address, toGRT('10')) - await gns.connect(other).mintSignal(l2SubgraphId, toGRT('10'), toBN(0)) - const prevSignal = await gns.getCuratorSignal(l2SubgraphId, other.address) - - const newCuratorTokens = toGRT('10') - const callhookData = defaultAbiCoder.encode( - ['uint8', 'uint256', 'address'], - [toBN(1), l1SubgraphId, other.address], - ) - const tx = await gatewayFinalizeTransfer(l1GNSMock.address, gns.address, newCuratorTokens, callhookData) - - await expect(tx) - .emit(gns, 'CuratorBalanceReceived') - .withArgs(l1SubgraphId, l2SubgraphId, other.address, newCuratorTokens) - - const expectedNewCuratorSignal = await gns.vSignalToNSignal( - l2SubgraphId, - await curation.tokensToSignalNoTax(newSubgraph0.subgraphDeploymentID, newCuratorTokens), - ) - const l2CuratorBalance = await gns.getCuratorSignal(l2SubgraphId, other.address) - expect(l2CuratorBalance).eq(prevSignal.add(expectedNewCuratorSignal)) - }) - it('cannot be called by someone other than the L2GraphTokenGateway', async function () { - const { l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams() - await transferMockSubgraphFromL1(l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata) - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(1), l1SubgraphId, me.address]) - const tx = gns.connect(me).onTokenTransfer(l1GNSMock.address, toGRT('1'), callhookData) - await expect(tx).revertedWith('ONLY_GATEWAY') - }) - it('rejects calls if the L1 sender is not the L1GNS', async function () { - const { l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams() - await transferMockSubgraphFromL1(l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata) - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(1), l1SubgraphId, me.address]) - const tx = gatewayFinalizeTransfer(me.address, gns.address, toGRT('1'), callhookData) - - await expect(tx).revertedWith('ONLY_L1_GNS_THROUGH_BRIDGE') - }) - it('if a subgraph does not exist, it returns the tokens to the beneficiary', async function () { - const l1GNSMockL2Alias = await helpers.getL2SignerFromL1(l1GNSMock.address) - // Eth for gas: - await helpers.setBalance(await l1GNSMockL2Alias.getAddress(), parseEther('1')) - - const { l1SubgraphId } = await defaultL1SubgraphParams() - - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(1), l1SubgraphId, me.address]) - const curatorTokensBefore = await grt.balanceOf(me.address) - const gnsBalanceBefore = await grt.balanceOf(gns.address) - const tx = gatewayFinalizeTransfer(l1GNSMock.address, gns.address, toGRT('1'), callhookData) - await expect(tx).emit(gns, 'CuratorBalanceReturnedToBeneficiary').withArgs(l1SubgraphId, me.address, toGRT('1')) - const curatorTokensAfter = await grt.balanceOf(me.address) - expect(curatorTokensAfter).eq(curatorTokensBefore.add(toGRT('1'))) - const gnsBalanceAfter = await grt.balanceOf(gns.address) - // gatewayFinalizeTransfer will mint the tokens that are sent to the curator, - // so the GNS balance should be the same - expect(gnsBalanceAfter).eq(gnsBalanceBefore) - }) - it('for an L2-native subgraph, it sends the tokens to the beneficiary', async function () { - // This should never really happen unless there's a clash in subgraph IDs (which should - // also never happen), but we test it anyway to ensure it's a well-defined behavior - const l1GNSMockL2Alias = await helpers.getL2SignerFromL1(l1GNSMock.address) - // Eth for gas: - await helpers.setBalance(await l1GNSMockL2Alias.getAddress(), parseEther('1')) - - const l2Subgraph = await publishNewSubgraph(me, newSubgraph0, gns, graph.chainId) - - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(1), l2Subgraph.id, me.address]) - const curatorTokensBefore = await grt.balanceOf(me.address) - const gnsBalanceBefore = await grt.balanceOf(gns.address) - const tx = gatewayFinalizeTransfer(l1GNSMock.address, gns.address, toGRT('1'), callhookData) - await expect(tx).emit(gns, 'CuratorBalanceReturnedToBeneficiary').withArgs(l2Subgraph.id, me.address, toGRT('1')) - const curatorTokensAfter = await grt.balanceOf(me.address) - expect(curatorTokensAfter).eq(curatorTokensBefore.add(toGRT('1'))) - const gnsBalanceAfter = await grt.balanceOf(gns.address) - // gatewayFinalizeTransfer will mint the tokens that are sent to the curator, - // so the GNS balance should be the same - expect(gnsBalanceAfter).eq(gnsBalanceBefore) - }) - it('if a subgraph transfer was not finished, it returns the tokens to the beneficiary', async function () { - const l1GNSMockL2Alias = await helpers.getL2SignerFromL1(l1GNSMock.address) - // Eth for gas: - await helpers.setBalance(await l1GNSMockL2Alias.getAddress(), parseEther('1')) - - const { l1SubgraphId, curatedTokens } = await defaultL1SubgraphParams() - const callhookDataSG = defaultAbiCoder.encode( - ['uint8', 'uint256', 'address'], - [toBN(0), l1SubgraphId, me.address], - ) - await gatewayFinalizeTransfer(l1GNSMock.address, gns.address, curatedTokens, callhookDataSG) - - // At this point the SG exists, but transfer is not finished - - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(1), l1SubgraphId, me.address]) - const curatorTokensBefore = await grt.balanceOf(me.address) - const gnsBalanceBefore = await grt.balanceOf(gns.address) - const tx = gatewayFinalizeTransfer(l1GNSMock.address, gns.address, toGRT('1'), callhookData) - await expect(tx).emit(gns, 'CuratorBalanceReturnedToBeneficiary').withArgs(l1SubgraphId, me.address, toGRT('1')) - const curatorTokensAfter = await grt.balanceOf(me.address) - expect(curatorTokensAfter).eq(curatorTokensBefore.add(toGRT('1'))) - const gnsBalanceAfter = await grt.balanceOf(gns.address) - // gatewayFinalizeTransfer will mint the tokens that are sent to the curator, - // so the GNS balance should be the same - expect(gnsBalanceAfter).eq(gnsBalanceBefore) - }) - - it('if a subgraph was deprecated after transfer, it returns the tokens to the beneficiary', async function () { - const l1GNSMockL2Alias = await helpers.getL2SignerFromL1(l1GNSMock.address) - // Eth for gas: - await helpers.setBalance(await l1GNSMockL2Alias.getAddress(), parseEther('1')) - - const { l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams() - await transferMockSubgraphFromL1(l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata) - const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId) - await gns.connect(me).deprecateSubgraph(l2SubgraphId) - - // SG was transferred, but is deprecated now! - - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(1), l1SubgraphId, me.address]) - const curatorTokensBefore = await grt.balanceOf(me.address) - const gnsBalanceBefore = await grt.balanceOf(gns.address) - const tx = gatewayFinalizeTransfer(l1GNSMock.address, gns.address, toGRT('1'), callhookData) - await expect(tx).emit(gns, 'CuratorBalanceReturnedToBeneficiary').withArgs(l1SubgraphId, me.address, toGRT('1')) - const curatorTokensAfter = await grt.balanceOf(me.address) - expect(curatorTokensAfter).eq(curatorTokensBefore.add(toGRT('1'))) - const gnsBalanceAfter = await grt.balanceOf(gns.address) - // gatewayFinalizeTransfer will mint the tokens that are sent to the curator, - // so the GNS balance should be the same - expect(gnsBalanceAfter).eq(gnsBalanceBefore) - }) - }) - describe('onTokenTransfer with invalid codes', function () { - it('reverts', 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', 'uint256', 'address'], [toBN(2), toBN(1337), me.address]) - const tx = gatewayFinalizeTransfer(l1GNSMock.address, gns.address, toGRT('1'), callhookData) - await expect(tx).revertedWith('INVALID_CODE') - }) - }) - describe('getAliasedL2SubgraphID', function () { - it('returns the L2 subgraph ID that is the L1 subgraph ID with an offset', async function () { - const l1SubgraphId = ethers.BigNumber.from( - '68799548758199140224151701590582019137924969401915573086349306511960790045480', - ) - const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId) - const offset = ethers.BigNumber.from('0x1111000000000000000000000000000000000000000000000000000000001111') - const base = ethers.constants.MaxUint256.add(1) - const expectedL2SubgraphId = l1SubgraphId.add(offset).mod(base) - expect(l2SubgraphId).eq(expectedL2SubgraphId) - }) - it('wraps around MAX_UINT256 in case of overflow', async function () { - const l1SubgraphId = ethers.constants.MaxUint256 - const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId) - const offset = ethers.BigNumber.from('0x1111000000000000000000000000000000000000000000000000000000001111') - const base = ethers.constants.MaxUint256.add(1) - const expectedL2SubgraphId = l1SubgraphId.add(offset).mod(base) - expect(l2SubgraphId).eq(expectedL2SubgraphId) - }) - }) -}) diff --git a/packages/contracts-test/tests/unit/lib/gnsUtils.ts b/packages/contracts-test/tests/unit/lib/gnsUtils.ts deleted file mode 100644 index 877601886..000000000 --- a/packages/contracts-test/tests/unit/lib/gnsUtils.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { Curation } from '@graphprotocol/contracts' -import { L1GNS } from '@graphprotocol/contracts' -import { L2GNS } from '@graphprotocol/contracts' -import { L2Curation } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { L2GraphToken } from '@graphprotocol/contracts' -import { buildSubgraphId, PublishSubgraph, Subgraph, toBN } from '@graphprotocol/sdk' -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber, ContractTransaction } from 'ethers' -import { namehash } from 'ethers/lib/utils' - -// Entities -export interface AccountDefaultName { - name: string - nameIdentifier: string -} - -export const DEFAULT_RESERVE_RATIO = 1000000 - -export const createDefaultName = (name: string): AccountDefaultName => { - return { - name: name, - nameIdentifier: namehash(name), - } -} - -export const getTokensAndVSignal = async ( - subgraphDeploymentID: string, - curation: Curation | L2Curation, -): Promise> => { - const curationPool = await curation.pools(subgraphDeploymentID) - const vSignal = await curation.getCurationPoolSignal(subgraphDeploymentID) - return [curationPool.tokens, vSignal] -} - -export const publishNewSubgraph = async ( - account: SignerWithAddress, - newSubgraph: PublishSubgraph, - gns: L1GNS | L2GNS, - chainId: number, -): Promise => { - const subgraphID = await buildSubgraphId(account.address, await gns.nextAccountSeqID(account.address), chainId) - - // Send tx - const tx = gns - .connect(account) - .publishNewSubgraph(newSubgraph.subgraphDeploymentID, newSubgraph.versionMetadata, newSubgraph.subgraphMetadata) - - // Check events - await expect(tx) - .emit(gns, 'SubgraphPublished') - .withArgs(subgraphID, newSubgraph.subgraphDeploymentID, DEFAULT_RESERVE_RATIO) - .emit(gns, 'SubgraphMetadataUpdated') - .withArgs(subgraphID, newSubgraph.subgraphMetadata) - .emit(gns, 'SubgraphVersionUpdated') - .withArgs(subgraphID, newSubgraph.subgraphDeploymentID, newSubgraph.versionMetadata) - - // Check state - const subgraph = await gns.subgraphs(subgraphID) - expect(subgraph.vSignal).eq(0) - expect(subgraph.nSignal).eq(0) - expect(subgraph.subgraphDeploymentID).eq(newSubgraph.subgraphDeploymentID) - expect(subgraph.__DEPRECATED_reserveRatio).eq(DEFAULT_RESERVE_RATIO) - expect(subgraph.disabled).eq(false) - expect(subgraph.withdrawableGRT).eq(0) - - // Check NFT issuance - const owner = await gns.ownerOf(subgraphID) - expect(owner).eq(account.address) - - // TODO: properly type this - return { ...subgraph, id: subgraphID } as unknown as Subgraph -} - -export const publishNewVersion = async ( - account: SignerWithAddress, - subgraphID: string, - newSubgraph: PublishSubgraph, - gns: L1GNS | L2GNS, - curation: Curation | L2Curation, -) => { - // Before state - const ownerTaxPercentage = await gns.ownerTaxPercentage() - const curationTaxPercentage = await curation.curationTaxPercentage() - const beforeSubgraph = await gns.subgraphs(subgraphID) - - // Check what selling all nSignal, which == selling all vSignal, should return for tokens - // NOTE - no tax on burning on nSignal - const tokensReceivedEstimate = beforeSubgraph.nSignal.gt(0) - ? (await gns.nSignalToTokens(subgraphID, beforeSubgraph.nSignal))[1] - : toBN(0) - // Example: - // Deposit 100, 5 is taxed, 95 GRT in curve - // Upgrade - calculate 5% tax on 95 --> 4.75 GRT - // Multiple by ownerPercentage --> 50% * 4.75 = 2.375 GRT - // Owner adds 2.375 to 90.25, we deposit 92.625 GRT into the curve - // Divide this by 0.95 to get exactly 97.5 total tokens to be deposited - - // nSignalToTokens returns the amount of tokens with tax removed - // already. So we must add in the tokens removed - const MAX_PPM = 1000000 - const taxOnOriginal = tokensReceivedEstimate.mul(curationTaxPercentage).div(MAX_PPM) - const totalWithoutOwnerTax = tokensReceivedEstimate.sub(taxOnOriginal) - const ownerTax = taxOnOriginal.mul(ownerTaxPercentage).div(MAX_PPM) - const totalWithOwnerTax = totalWithoutOwnerTax.add(ownerTax) - const totalAdjustedUp = totalWithOwnerTax.mul(MAX_PPM).div(MAX_PPM - curationTaxPercentage) - - // Re-estimate amount of signal to get considering the owner tax paid by the owner - - const { 0: newVSignalEstimate, 1: newCurationTaxEstimate } = beforeSubgraph.nSignal.gt(0) - ? await curation.tokensToSignal(newSubgraph.subgraphDeploymentID, totalAdjustedUp) - : [toBN(0), toBN(0)] - - // Check the vSignal of the new curation curve, and tokens, before upgrading - const [beforeTokensNewCurve, beforeVSignalNewCurve] = await getTokensAndVSignal( - newSubgraph.subgraphDeploymentID, - curation, - ) - // Send tx - // Check if it's L2GNS (which requires slippage parameter) or L1GNS - const isL2 = 'getAliasedL2SubgraphID' in gns - const tx = isL2 - ? (gns as L2GNS) - .connect(account) - [ - 'publishNewVersion(uint256,bytes32,bytes32,uint256)' - ](subgraphID, newSubgraph.subgraphDeploymentID, newSubgraph.versionMetadata, 0) - : (gns as L1GNS) - .connect(account) - .publishNewVersion(subgraphID, newSubgraph.subgraphDeploymentID, newSubgraph.versionMetadata) - const txResult = expect(tx) - .emit(gns, 'SubgraphVersionUpdated') - .withArgs(subgraphID, newSubgraph.subgraphDeploymentID, newSubgraph.versionMetadata) - - // Only emits this event if there was actual signal to upgrade - if (beforeSubgraph.nSignal.gt(0)) { - await txResult - .emit(gns, 'SubgraphUpgraded') - .withArgs(subgraphID, newVSignalEstimate, totalAdjustedUp, newSubgraph.subgraphDeploymentID) - } - await txResult - - // Check curation vSignal old are set to zero - const [afterTokensOldCuration, afterVSignalOldCuration] = await getTokensAndVSignal( - beforeSubgraph.subgraphDeploymentID, - curation, - ) - expect(afterTokensOldCuration).eq(0) - expect(afterVSignalOldCuration).eq(0) - - // Check the vSignal of the new curation curve, and tokens - const [afterTokensNewCurve, afterVSignalNewCurve] = await getTokensAndVSignal( - newSubgraph.subgraphDeploymentID, - curation, - ) - expect(afterTokensNewCurve).eq(beforeTokensNewCurve.add(totalAdjustedUp).sub(newCurationTaxEstimate)) - expect(afterVSignalNewCurve).eq(beforeVSignalNewCurve.add(newVSignalEstimate)) - - // Check the nSignal pool - const afterSubgraph = await gns.subgraphs(subgraphID) - expect(afterSubgraph.vSignal).eq(afterVSignalNewCurve.sub(beforeVSignalNewCurve)).eq(newVSignalEstimate) - expect(afterSubgraph.nSignal).eq(beforeSubgraph.nSignal) // should not change - expect(afterSubgraph.subgraphDeploymentID).eq(newSubgraph.subgraphDeploymentID) - - // Check NFT should not change owner - const owner = await gns.ownerOf(subgraphID) - expect(owner).eq(account.address) - - return tx -} - -export const mintSignal = async ( - account: SignerWithAddress, - subgraphID: string, - tokensIn: BigNumber, - gns: L1GNS | L2GNS, - curation: Curation | L2Curation, -): Promise => { - // Before state - const beforeSubgraph = await gns.subgraphs(subgraphID) - const [beforeTokens, beforeVSignal] = await getTokensAndVSignal(beforeSubgraph.subgraphDeploymentID, curation) - - // Deposit - const { 0: vSignalExpected, 1: nSignalExpected, 2: curationTax } = await gns.tokensToNSignal(subgraphID, tokensIn) - const tx = gns.connect(account).mintSignal(subgraphID, tokensIn, 0) - await expect(tx) - .emit(gns, 'SignalMinted') - .withArgs(subgraphID, account.address, nSignalExpected, vSignalExpected, tokensIn) - - // After state - const afterSubgraph = await gns.subgraphs(subgraphID) - const [afterTokens, afterVSignal] = await getTokensAndVSignal(afterSubgraph.subgraphDeploymentID, curation) - - // Check state - expect(afterTokens).eq(beforeTokens.add(tokensIn.sub(curationTax))) - expect(afterVSignal).eq(beforeVSignal.add(vSignalExpected)) - expect(afterSubgraph.nSignal).eq(beforeSubgraph.nSignal.add(nSignalExpected)) - expect(afterSubgraph.vSignal).eq(beforeVSignal.add(vSignalExpected)) - - return tx -} - -export const burnSignal = async ( - account: SignerWithAddress, - subgraphID: string, - gns: L1GNS | L2GNS, - curation: Curation | L2Curation, -): Promise => { - // Before state - const beforeSubgraph = await gns.subgraphs(subgraphID) - const [beforeTokens, beforeVSignal] = await getTokensAndVSignal(beforeSubgraph.subgraphDeploymentID, curation) - const beforeUsersNSignal = await gns.getCuratorSignal(subgraphID, account.address) - - // Withdraw - const { 0: vSignalExpected, 1: tokensExpected } = await gns.nSignalToTokens(subgraphID, beforeUsersNSignal) - - // Send tx - const tx = gns.connect(account).burnSignal(subgraphID, beforeUsersNSignal, 0) - await expect(tx) - .emit(gns, 'SignalBurned') - .withArgs(subgraphID, account.address, beforeUsersNSignal, vSignalExpected, tokensExpected) - - // After state - const afterSubgraph = await gns.subgraphs(subgraphID) - const [afterTokens, afterVSignalCuration] = await getTokensAndVSignal(afterSubgraph.subgraphDeploymentID, curation) - - // Check state - expect(afterTokens).eq(beforeTokens.sub(tokensExpected)) - expect(afterVSignalCuration).eq(beforeVSignal.sub(vSignalExpected)) - expect(afterSubgraph.nSignal).eq(beforeSubgraph.nSignal.sub(beforeUsersNSignal)) - - return tx -} - -export const deprecateSubgraph = async ( - account: SignerWithAddress, - subgraphID: string, - gns: L1GNS | L2GNS, - curation: Curation | L2Curation, - grt: GraphToken | L2GraphToken, -) => { - // Before state - const beforeSubgraph = await gns.subgraphs(subgraphID) - const [beforeTokens] = await getTokensAndVSignal(beforeSubgraph.subgraphDeploymentID, curation) - - // We can use the whole amount, since in this test suite all vSignal is used to be staked on nSignal - const ownerBalanceBefore = await grt.balanceOf(account.address) - - // Send tx - const tx = gns.connect(account).deprecateSubgraph(subgraphID) - await expect(tx).emit(gns, 'SubgraphDeprecated').withArgs(subgraphID, beforeTokens) - - // After state - const afterSubgraph = await gns.subgraphs(subgraphID) - // Check marked as deprecated - expect(afterSubgraph.disabled).eq(true) - // Signal for the deployment must be all burned - expect(afterSubgraph.vSignal.eq(toBN('0'))) - // Cleanup reserve ratio - expect(afterSubgraph.__DEPRECATED_reserveRatio).eq(0) - // Should be equal since owner pays curation tax - expect(afterSubgraph.withdrawableGRT).eq(beforeTokens) - - // Check balance of GNS increased by curation tax from owner being added - const afterGNSBalance = await grt.balanceOf(gns.address) - expect(afterGNSBalance).eq(afterSubgraph.withdrawableGRT) - // Check that the owner balance decreased by the curation tax - const ownerBalanceAfter = await grt.balanceOf(account.address) - expect(ownerBalanceBefore.eq(ownerBalanceAfter)) - - // Check NFT was burned - await expect(gns.ownerOf(subgraphID)).revertedWith('ERC721: owner query for nonexistent token') - - return tx -} diff --git a/packages/subgraph-discovery/test/unit/Setup.t.sol b/packages/subgraph-discovery/test/unit/Setup.t.sol index 4b4f4ca7d..d95930ff7 100644 --- a/packages/subgraph-discovery/test/unit/Setup.t.sol +++ b/packages/subgraph-discovery/test/unit/Setup.t.sol @@ -1,12 +1,16 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; -import { DiscoveryTest } from "./shared/DiscoveryTest.t.sol"; +import { GNSHelpers } from "./shared/GNSHelpers.t.sol"; -contract SetupTest is DiscoveryTest { +contract SetupTest is GNSHelpers { function test_Setup_Wired() public view { assertEq(nft.minter(), address(gns)); assertEq(curation.subgraphService(), subgraphService); assertEq(gns.counterpartGNSAddress(), l1GNS); } + + function test_Setup_PublishNewSubgraph() public { + _publishNewSubgraph(me, DEPLOYMENT_ID_0); + } } diff --git a/packages/subgraph-discovery/test/unit/interfaces/IL2GNSLike.sol b/packages/subgraph-discovery/test/unit/interfaces/IL2GNSLike.sol index dff9d4d54..6a3d0b2ee 100644 --- a/packages/subgraph-discovery/test/unit/interfaces/IL2GNSLike.sol +++ b/packages/subgraph-discovery/test/unit/interfaces/IL2GNSLike.sol @@ -122,4 +122,8 @@ interface IL2GNSLike { function nSignalToTokens(uint256 subgraphID, uint256 nSignalIn) external view returns (uint256, uint256); function vSignalToNSignal(uint256 subgraphID, uint256 vSignalIn) external view returns (uint256); + + function nSignalToVSignal(uint256 subgraphID, uint256 nSignalIn) external view returns (uint256); + + function multicall(bytes[] calldata data) external returns (bytes[] memory results); } diff --git a/packages/subgraph-discovery/test/unit/l2-gns/AliasedSubgraphId.t.sol b/packages/subgraph-discovery/test/unit/l2-gns/AliasedSubgraphId.t.sol new file mode 100644 index 000000000..e55643a93 --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-gns/AliasedSubgraphId.t.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { GNSHelpers } from "../shared/GNSHelpers.t.sol"; + +contract AliasedSubgraphIdTest is GNSHelpers { + uint256 internal constant OFFSET = 0x1111000000000000000000000000000000000000000000000000000000001111; + + // -- getAliasedL2SubgraphID -- + + function test_GetAliasedL2SubgraphID_AddsOffset() public view { + uint256 l1SubgraphId = 68799548758199140224151701590582019137924969401915573086349306511960790045480; + uint256 expectedL2SubgraphId; + unchecked { + expectedL2SubgraphId = l1SubgraphId + OFFSET; + } + assertEq(gns.getAliasedL2SubgraphID(l1SubgraphId), expectedL2SubgraphId); + } + + function test_GetAliasedL2SubgraphID_WrapsAroundMaxUint256() public view { + uint256 l1SubgraphId = type(uint256).max; + uint256 expectedL2SubgraphId; + unchecked { + expectedL2SubgraphId = l1SubgraphId + OFFSET; + } + assertEq(gns.getAliasedL2SubgraphID(l1SubgraphId), expectedL2SubgraphId); + assertEq(expectedL2SubgraphId, OFFSET - 1); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-gns/FinishSubgraphTransfer.t.sol b/packages/subgraph-discovery/test/unit/l2-gns/FinishSubgraphTransfer.t.sol new file mode 100644 index 000000000..1d52d7bdc --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-gns/FinishSubgraphTransfer.t.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { GNSHelpers } from "../shared/GNSHelpers.t.sol"; +import { IL2GNSLike } from "../interfaces/IL2GNSLike.sol"; + +contract FinishSubgraphTransferTest is GNSHelpers { + bytes32 internal constant METADATA = keccak256("metadata"); + + // -- finishing a subgraph transfer from L1 -- + + function test_FinishSubgraphTransfer_PublishesAndMintsSignalWithNoTax() public { + uint256 l1SubgraphId = _l1SubgraphId(); + uint256 l2SubgraphId = _receiveSubgraph(l1SubgraphId); + uint256 expectedSignal = curation.tokensToSignalNoTax(DEPLOYMENT_ID_0, L1_CURATED_TOKENS); + // vSignal is zero before the transfer, so nSignal is minted 1:1 + uint256 expectedNSignalEmitted = gns.vSignalToNSignal(l2SubgraphId, expectedSignal); + + vm.expectEmit(address(gns)); + emit IL2GNSLike.SignalMinted(l2SubgraphId, me, expectedNSignalEmitted, expectedSignal, L1_CURATED_TOKENS); + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphUpgraded(l2SubgraphId, expectedSignal, L1_CURATED_TOKENS, DEPLOYMENT_ID_0); + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphMetadataUpdated(l2SubgraphId, L1_SUBGRAPH_METADATA); + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphPublished(l2SubgraphId, DEPLOYMENT_ID_0, DEFAULT_RESERVE_RATIO); + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphVersionUpdated(l2SubgraphId, DEPLOYMENT_ID_0, L1_VERSION_METADATA); + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphL2TransferFinalized(l2SubgraphId); + vm.prank(me); + gns.finishSubgraphTransferFromL1(l2SubgraphId, DEPLOYMENT_ID_0, L1_SUBGRAPH_METADATA, L1_VERSION_METADATA); + + _assertFinished(l2SubgraphId, expectedSignal); + uint256 expectedNSignal = gns.vSignalToNSignal(l2SubgraphId, expectedSignal); + assertEq(gns.getCuratorSignal(l2SubgraphId, me), expectedNSignal); + assertEq(expectedNSignal, expectedNSignalEmitted); + } + + function test_FinishSubgraphTransfer_RevertWhen_NotOwner() public { + uint256 l2SubgraphId = _receiveSubgraph(_l1SubgraphId()); + vm.expectRevert(bytes("GNS: Must be authorized")); + vm.prank(other); + gns.finishSubgraphTransferFromL1(l2SubgraphId, DEPLOYMENT_ID_0, L1_SUBGRAPH_METADATA, L1_VERSION_METADATA); + } + + function test_FinishSubgraphTransfer_RevertWhen_SubgraphDoesNotExist() public { + uint256 l2SubgraphId = gns.getAliasedL2SubgraphID(_l1SubgraphId()); + vm.expectRevert(bytes("ERC721: owner query for nonexistent token")); + vm.prank(me); + gns.finishSubgraphTransferFromL1(l2SubgraphId, DEPLOYMENT_ID_0, METADATA, METADATA); + } + + function test_FinishSubgraphTransfer_RevertWhen_NotTransferredFromL1() public { + uint256 l2SubgraphId = _publishNewSubgraph(me, DEPLOYMENT_ID_0); + vm.expectRevert(bytes("INVALID_SUBGRAPH")); + vm.prank(me); + gns.finishSubgraphTransferFromL1(l2SubgraphId, DEPLOYMENT_ID_0, METADATA, METADATA); + } + + function test_FinishSubgraphTransfer_ToPreCuratedDeployment() public { + uint256 l2SubgraphId = _receiveSubgraph(_l1SubgraphId()); + + // Expected signal computed before the pre-curation + uint256 expectedSignal = curation.tokensToSignalNoTax(DEPLOYMENT_ID_0, L1_CURATED_TOKENS); + vm.startPrank(me); + grt.approve(address(curation), 100 ether); + curation.mint(DEPLOYMENT_ID_0, 100 ether, 0); + vm.stopPrank(); + assertEq(curation.getCurationPoolTokens(DEPLOYMENT_ID_0), 100 ether); + + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphUpgraded(l2SubgraphId, expectedSignal, L1_CURATED_TOKENS, DEPLOYMENT_ID_0); + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphMetadataUpdated(l2SubgraphId, L1_SUBGRAPH_METADATA); + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphPublished(l2SubgraphId, DEPLOYMENT_ID_0, DEFAULT_RESERVE_RATIO); + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphVersionUpdated(l2SubgraphId, DEPLOYMENT_ID_0, L1_VERSION_METADATA); + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphL2TransferFinalized(l2SubgraphId); + vm.prank(me); + gns.finishSubgraphTransferFromL1(l2SubgraphId, DEPLOYMENT_ID_0, L1_SUBGRAPH_METADATA, L1_VERSION_METADATA); + + _assertFinished(l2SubgraphId, expectedSignal); + assertEq(curation.getCurationPoolTokens(DEPLOYMENT_ID_0), 100 ether + L1_CURATED_TOKENS); + } + + function test_FinishSubgraphTransfer_RevertWhen_DeploymentIDZero() public { + uint256 l2SubgraphId = _receiveSubgraph(_l1SubgraphId()); + vm.expectRevert(bytes("GNS: deploymentID != 0")); + vm.prank(me); + gns.finishSubgraphTransferFromL1(l2SubgraphId, bytes32(0), METADATA, METADATA); + } + + function test_FinishSubgraphTransfer_RevertWhen_AlreadyFinished() public { + uint256 l2SubgraphId = _receiveSubgraph(_l1SubgraphId()); + vm.startPrank(me); + gns.finishSubgraphTransferFromL1(l2SubgraphId, DEPLOYMENT_ID_0, METADATA, METADATA); + + vm.expectRevert(bytes("ALREADY_DONE")); + gns.finishSubgraphTransferFromL1(l2SubgraphId, DEPLOYMENT_ID_0, METADATA, METADATA); + vm.stopPrank(); + } + + function _receiveSubgraph(uint256 l1SubgraphId) internal returns (uint256) { + gatewayMock.deliver( + address(gns), + l1GNS, + L1_CURATED_TOKENS, + _callhookData(RECEIVE_SUBGRAPH_CODE, l1SubgraphId, me) + ); + return gns.getAliasedL2SubgraphID(l1SubgraphId); + } + + function _assertFinished(uint256 l2SubgraphId, uint256 expectedSignal) internal view { + Subgraph memory s = _subgraph(l2SubgraphId); + (, bool l2Done, ) = gns.subgraphL2TransferData(l2SubgraphId); + assertEq(s.vSignal, expectedSignal); + assertTrue(l2Done); + assertFalse(s.disabled); + assertEq(s.subgraphDeploymentID, DEPLOYMENT_ID_0); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-gns/Multicall.t.sol b/packages/subgraph-discovery/test/unit/l2-gns/Multicall.t.sol new file mode 100644 index 000000000..34c568fae --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-gns/Multicall.t.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { GNSHelpers } from "../shared/GNSHelpers.t.sol"; +import { IL2GNSLike } from "../interfaces/IL2GNSLike.sol"; + +contract MulticallTest is GNSHelpers { + function test_GNS_Multicall() public { + uint256 tokens = 1000 ether; + uint256 subgraphId = uint256(keccak256(abi.encodePacked(me, gns.nextAccountSeqID(me), block.chainid))); + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeCall( + IL2GNSLike.publishNewSubgraph, + (DEPLOYMENT_ID_0, _versionMetadata(DEPLOYMENT_ID_0), _subgraphMetadata(DEPLOYMENT_ID_0)) + ); + calls[1] = abi.encodeCall(IL2GNSLike.mintSignal, (subgraphId, tokens, 0)); + + vm.startPrank(me); + grt.approve(address(gns), tokens); + bytes[] memory results = gns.multicall(calls); + vm.stopPrank(); + + assertEq(results.length, 2); + assertEq(gns.ownerOf(subgraphId), me); + assertEq(_subgraph(subgraphId).subgraphDeploymentID, DEPLOYMENT_ID_0); + uint256 nSignal = _subgraph(subgraphId).nSignal; + assertGt(nSignal, 0); + assertEq(gns.getCuratorSignal(subgraphId, me), nSignal); + // Curation tax is zero + assertEq(curation.getCurationPoolTokens(DEPLOYMENT_ID_0), tokens); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-gns/PublishNewVersion.t.sol b/packages/subgraph-discovery/test/unit/l2-gns/PublishNewVersion.t.sol new file mode 100644 index 000000000..523f1dffd --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-gns/PublishNewVersion.t.sol @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { GNSHelpers } from "../shared/GNSHelpers.t.sol"; +import { IL2GNSLike } from "../interfaces/IL2GNSLike.sol"; + +contract PublishNewVersionTest is GNSHelpers { + uint256 internal constant TOKENS_1000 = 1000 ether; + uint256 internal constant TOKENS_10000 = 10_000 ether; + uint256 internal constant TOKENS_100000 = 100_000 ether; + uint32 internal constant CURATION_TAX_PERCENTAGE = 50_000; + + uint256 internal subgraphId; + + function setUp() public override { + super.setUp(); + + vm.startPrank(governor); + grt.mint(me, TOKENS_100000); + grt.mint(other, TOKENS_100000); + curation.setCurationTaxPercentage(CURATION_TAX_PERCENTAGE); + vm.stopPrank(); + vm.startPrank(me); + grt.approve(address(gns), TOKENS_100000); + grt.approve(address(curation), TOKENS_100000); + vm.stopPrank(); + vm.startPrank(other); + grt.approve(address(gns), TOKENS_100000); + grt.approve(address(curation), TOKENS_100000); + vm.stopPrank(); + + subgraphId = _publishNewSubgraph(me, DEPLOYMENT_ID_0); + _mintSignal(me, subgraphId, TOKENS_10000); + } + + function test_PublishNewVersion_OnExistingSubgraph() public { + _publishNewVersion(me, subgraphId, DEPLOYMENT_ID_1); + } + + function test_PublishNewVersion_WhenOwnerTaxIsZero() public { + vm.prank(governor); + gns.setOwnerTaxPercentage(0); + _publishNewVersion(me, subgraphId, DEPLOYMENT_ID_1); + } + + function test_PublishNewVersion_WithNoCurrentSignal() public { + uint256 emptySignalSubgraphId = _publishNewSubgraph(me, keccak256("emptySignalDeploymentID")); + _publishNewVersion(me, emptySignalSubgraphId, DEPLOYMENT_ID_1); + } + + function test_PublishNewVersion_RevertWhen_SameDeploymentID() public { + vm.expectRevert(bytes("GNS: Cannot publish a new version with the same subgraph deployment ID")); + vm.prank(me); + gns.publishNewVersion(subgraphId, DEPLOYMENT_ID_0, _versionMetadata(DEPLOYMENT_ID_0), 0); + } + + function test_PublishNewVersion_RevertWhen_SubgraphDoesNotExist() public { + vm.expectRevert(bytes("ERC721: owner query for nonexistent token")); + vm.prank(me); + gns.publishNewVersion( + uint256(keccak256("nonexistentSubgraph")), + DEPLOYMENT_ID_1, + _versionMetadata(DEPLOYMENT_ID_1), + 0 + ); + } + + function test_PublishNewVersion_RevertWhen_NotOwner() public { + vm.expectRevert(bytes("GNS: Must be authorized")); + vm.prank(other); + gns.publishNewVersion(subgraphId, DEPLOYMENT_ID_1, _versionMetadata(DEPLOYMENT_ID_1), 0); + } + + function test_PublishNewVersion_ToPreCuratedDeployment() public { + // Curate directly to the deployment + vm.prank(me); + curation.mint(DEPLOYMENT_ID_1, TOKENS_1000, 0); + + _publishNewVersion(me, subgraphId, DEPLOYMENT_ID_1); + } + + function test_PublishNewVersion_WithNoSignalNoMigration() public { + _burnSignal(me, subgraphId); + + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphVersionUpdated(subgraphId, DEPLOYMENT_ID_1, _versionMetadata(DEPLOYMENT_ID_1)); + vm.prank(me); + gns.publishNewVersion(subgraphId, DEPLOYMENT_ID_1, _versionMetadata(DEPLOYMENT_ID_1), 0); + } + + function test_PublishNewVersion_RevertWhen_Deprecated() public { + _deprecateSubgraph(me, subgraphId); + + // Deprecate burns the subgraph NFT, so the ownership check can't find it + vm.expectRevert(bytes("ERC721: owner query for nonexistent token")); + vm.prank(me); + gns.publishNewVersion(subgraphId, DEPLOYMENT_ID_1, _versionMetadata(DEPLOYMENT_ID_1), 0); + } + + function test_PublishNewVersion_RevertWhen_FrontRunBeyondSlippage() public { + // Give attacker massive funds for the attack + vm.prank(governor); + grt.mint(attacker, 1_000_000 ether); + vm.prank(attacker); + grt.approve(address(curation), 1_000_000 ether); + + // Set minimum curation deposit to 1 wei and disable tax for an accurate attack test + vm.startPrank(governor); + curation.setMinimumCurationDeposit(1); + curation.setCurationTaxPercentage(0); + vm.stopPrank(); + vm.prank(attacker); + curation.mint(DEPLOYMENT_ID_1, 1, 0); + + // Expected signal from burning the current subgraph (before the attack) + uint256 vSignal = _subgraph(subgraphId).vSignal; + uint256 tokensFromOldCurve = curation.signalToTokens(DEPLOYMENT_ID_0, vSignal); + uint256 expectedSignalWithoutAttack = curation.tokensToSignalNoTax(DEPLOYMENT_ID_1, tokensFromOldCurve); + + // Attacker inflates the curation pool by collecting massive query fees + uint256 collectTokens = 50_000 ether; + vm.prank(governor); + grt.mint(address(curation), collectTokens); + vm.prank(subgraphService); + curation.collect(DEPLOYMENT_ID_1, collectTokens); + + // The victim now gets much less signal + uint256 actualSignalAfterAttack = curation.tokensToSignalNoTax(DEPLOYMENT_ID_1, tokensFromOldCurve); + assertLt(actualSignalAfterAttack, expectedSignalWithoutAttack / 10); + + // With slippage protection the upgrade fails when expecting reasonable signal + vm.expectRevert(bytes("Slippage protection")); + vm.prank(me); + gns.publishNewVersion( + subgraphId, + DEPLOYMENT_ID_1, + _versionMetadata(DEPLOYMENT_ID_1), + expectedSignalWithoutAttack / 2 + ); + } + + function test_PublishNewVersion_WhenSlippageProtectionSatisfied() public { + // Pre-curate the new deployment + vm.prank(governor); + grt.mint(attacker, 100 ether); + vm.startPrank(attacker); + grt.approve(address(curation), 100 ether); + curation.mint(DEPLOYMENT_ID_1, 100 ether, 0); + vm.stopPrank(); + + // Minimum signal we accept (5% slippage) + uint256 vSignal = _subgraph(subgraphId).vSignal; + uint256 tokensFromOldCurve = curation.signalToTokens(DEPLOYMENT_ID_0, vSignal); + uint256 expectedSignal = curation.tokensToSignalNoTax(DEPLOYMENT_ID_1, tokensFromOldCurve); + uint256 minSignalWithSlippage = (expectedSignal * 95) / 100; + + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphVersionUpdated(subgraphId, DEPLOYMENT_ID_1, _versionMetadata(DEPLOYMENT_ID_1)); + vm.prank(me); + gns.publishNewVersion(subgraphId, DEPLOYMENT_ID_1, _versionMetadata(DEPLOYMENT_ID_1), minSignalWithSlippage); + } + + function test_PublishNewVersion_L1VersionWhenNotPreCurated() public { + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphVersionUpdated(subgraphId, DEPLOYMENT_ID_1, _versionMetadata(DEPLOYMENT_ID_1)); + vm.prank(me); + gns.publishNewVersion(subgraphId, DEPLOYMENT_ID_1, _versionMetadata(DEPLOYMENT_ID_1)); + } + + function test_PublishNewVersion_RevertWhen_L1VersionPreCurated() public { + vm.prank(me); + curation.mint(DEPLOYMENT_ID_1, TOKENS_1000, 0); + + vm.expectRevert(bytes("GNS: Owner cannot point to a subgraphID that has been pre-curated")); + vm.prank(me); + gns.publishNewVersion(subgraphId, DEPLOYMENT_ID_1, _versionMetadata(DEPLOYMENT_ID_1)); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-gns/ReceiveCuratorBalance.t.sol b/packages/subgraph-discovery/test/unit/l2-gns/ReceiveCuratorBalance.t.sol new file mode 100644 index 000000000..c1352e6cc --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-gns/ReceiveCuratorBalance.t.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { GNSHelpers } from "../shared/GNSHelpers.t.sol"; +import { IL2GNSLike } from "../interfaces/IL2GNSLike.sol"; + +contract ReceiveCuratorBalanceTest is GNSHelpers { + // -- claiming a curator balance with a message from L1 (onTokenTransfer) -- + + function test_ReceiveCuratorBalance_AssignsBalanceToBeneficiary() public { + uint256 l1SubgraphId = _l1SubgraphId(); + _transferMockSubgraphFromL1(l1SubgraphId, L1_CURATED_TOKENS); + uint256 l2SubgraphId = gns.getAliasedL2SubgraphID(l1SubgraphId); + uint256 l2OwnerSignalBefore = gns.getCuratorSignal(l2SubgraphId, me); + + uint256 newCuratorTokens = 10 ether; + vm.expectEmit(address(gns)); + emit IL2GNSLike.CuratorBalanceReceived(l1SubgraphId, l2SubgraphId, other, newCuratorTokens); + gatewayMock.deliver( + address(gns), + l1GNS, + newCuratorTokens, + _callhookData(RECEIVE_CURATOR_BALANCE_CODE, l1SubgraphId, other) + ); + + uint256 expectedNewCuratorSignal = gns.vSignalToNSignal( + l2SubgraphId, + curation.tokensToSignalNoTax(DEPLOYMENT_ID_0, newCuratorTokens) + ); + assertEq(gns.getCuratorSignal(l2SubgraphId, me), l2OwnerSignalBefore); + assertEq(gns.getCuratorSignal(l2SubgraphId, other), expectedNewCuratorSignal); + } + + function test_ReceiveCuratorBalance_AddsToExistingSignal() public { + uint256 l1SubgraphId = _l1SubgraphId(); + _transferMockSubgraphFromL1(l1SubgraphId, L1_CURATED_TOKENS); + uint256 l2SubgraphId = gns.getAliasedL2SubgraphID(l1SubgraphId); + + vm.prank(governor); + grt.mint(other, 10 ether); + vm.startPrank(other); + grt.approve(address(gns), 10 ether); + gns.mintSignal(l2SubgraphId, 10 ether, 0); + vm.stopPrank(); + uint256 prevSignal = gns.getCuratorSignal(l2SubgraphId, other); + + uint256 newCuratorTokens = 10 ether; + vm.expectEmit(address(gns)); + emit IL2GNSLike.CuratorBalanceReceived(l1SubgraphId, l2SubgraphId, other, newCuratorTokens); + gatewayMock.deliver( + address(gns), + l1GNS, + newCuratorTokens, + _callhookData(RECEIVE_CURATOR_BALANCE_CODE, l1SubgraphId, other) + ); + + uint256 expectedNewCuratorSignal = gns.vSignalToNSignal( + l2SubgraphId, + curation.tokensToSignalNoTax(DEPLOYMENT_ID_0, newCuratorTokens) + ); + assertEq(gns.getCuratorSignal(l2SubgraphId, other), prevSignal + expectedNewCuratorSignal); + } + + function test_ReceiveCuratorBalance_RevertWhen_CallerNotGateway() public { + uint256 l1SubgraphId = _l1SubgraphId(); + _transferMockSubgraphFromL1(l1SubgraphId, L1_CURATED_TOKENS); + bytes memory data = _callhookData(RECEIVE_CURATOR_BALANCE_CODE, l1SubgraphId, me); + vm.expectRevert(bytes("ONLY_GATEWAY")); + vm.prank(me); + gns.onTokenTransfer(l1GNS, 1 ether, data); + } + + function test_ReceiveCuratorBalance_RevertWhen_L1SenderNotL1GNS() public { + uint256 l1SubgraphId = _l1SubgraphId(); + _transferMockSubgraphFromL1(l1SubgraphId, L1_CURATED_TOKENS); + bytes memory data = _callhookData(RECEIVE_CURATOR_BALANCE_CODE, l1SubgraphId, me); + vm.expectRevert(bytes("ONLY_L1_GNS_THROUGH_BRIDGE")); + gatewayMock.deliver(address(gns), me, 1 ether, data); + } + + function test_ReceiveCuratorBalance_ReturnsTokensWhenSubgraphDoesNotExist() public { + _assertReturnedToBeneficiary(_l1SubgraphId()); + } + + function test_ReceiveCuratorBalance_ReturnsTokensForL2NativeSubgraph() public { + // Only happens on a subgraph ID clash, but the behaviour must be well defined + uint256 l2SubgraphId = _publishNewSubgraph(me, DEPLOYMENT_ID_0); + _assertReturnedToBeneficiary(l2SubgraphId); + } + + function test_ReceiveCuratorBalance_ReturnsTokensWhenTransferNotFinished() public { + uint256 l1SubgraphId = _l1SubgraphId(); + gatewayMock.deliver( + address(gns), + l1GNS, + L1_CURATED_TOKENS, + _callhookData(RECEIVE_SUBGRAPH_CODE, l1SubgraphId, me) + ); + + // The subgraph exists, but the transfer is not finished + _assertReturnedToBeneficiary(l1SubgraphId); + } + + function test_ReceiveCuratorBalance_ReturnsTokensWhenDeprecatedAfterTransfer() public { + uint256 l1SubgraphId = _l1SubgraphId(); + _transferMockSubgraphFromL1(l1SubgraphId, L1_CURATED_TOKENS); + uint256 l2SubgraphId = gns.getAliasedL2SubgraphID(l1SubgraphId); + vm.prank(me); + gns.deprecateSubgraph(l2SubgraphId); + + // Transferred, but deprecated now + _assertReturnedToBeneficiary(l1SubgraphId); + } + + function _assertReturnedToBeneficiary(uint256 l1SubgraphId) internal { + uint256 curatorTokensBefore = grt.balanceOf(me); + uint256 gnsBalanceBefore = grt.balanceOf(address(gns)); + + vm.expectEmit(address(gns)); + emit IL2GNSLike.CuratorBalanceReturnedToBeneficiary(l1SubgraphId, me, 1 ether); + gatewayMock.deliver( + address(gns), + l1GNS, + 1 ether, + _callhookData(RECEIVE_CURATOR_BALANCE_CODE, l1SubgraphId, me) + ); + + assertEq(grt.balanceOf(me), curatorTokensBefore + 1 ether); + // The gateway mints the tokens that are sent on to the curator + assertEq(grt.balanceOf(address(gns)), gnsBalanceBefore); + } +} diff --git a/packages/subgraph-discovery/test/unit/l2-gns/ReceiveSubgraphFromL1.t.sol b/packages/subgraph-discovery/test/unit/l2-gns/ReceiveSubgraphFromL1.t.sol new file mode 100644 index 000000000..cd5b63b39 --- /dev/null +++ b/packages/subgraph-discovery/test/unit/l2-gns/ReceiveSubgraphFromL1.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { GNSHelpers } from "../shared/GNSHelpers.t.sol"; +import { IL2GNSLike } from "../interfaces/IL2GNSLike.sol"; + +contract ReceiveSubgraphFromL1Test is GNSHelpers { + // -- receiving a subgraph from L1 (onTokenTransfer) -- + + function test_ReceiveSubgraphFromL1_RevertWhen_CallerNotGateway() public { + bytes memory data = _callhookData(RECEIVE_SUBGRAPH_CODE, _l1SubgraphId(), me); + vm.expectRevert(bytes("ONLY_GATEWAY")); + vm.prank(me); + gns.onTokenTransfer(l1GNS, L1_CURATED_TOKENS, data); + } + + function test_ReceiveSubgraphFromL1_RevertWhen_L1SenderNotL1GNS() public { + bytes memory data = _callhookData(RECEIVE_SUBGRAPH_CODE, _l1SubgraphId(), me); + vm.expectRevert(bytes("ONLY_L1_GNS_THROUGH_BRIDGE")); + gatewayMock.deliver(address(gns), me, L1_CURATED_TOKENS, data); + } + + function test_ReceiveSubgraphFromL1_CreatesDisabledSubgraph() public { + uint256 l1SubgraphId = _l1SubgraphId(); + uint256 l2SubgraphId = gns.getAliasedL2SubgraphID(l1SubgraphId); + + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphReceivedFromL1(l1SubgraphId, l2SubgraphId, me, L1_CURATED_TOKENS); + gatewayMock.deliver( + address(gns), + l1GNS, + L1_CURATED_TOKENS, + _callhookData(RECEIVE_SUBGRAPH_CODE, l1SubgraphId, me) + ); + + _assertReceivedSubgraph(l2SubgraphId); + } + + function test_ReceiveSubgraphFromL1_DoesNotConflictWithLocalSubgraph() public { + uint256 localSubgraphId = _publishNewSubgraph(me, DEPLOYMENT_ID_0); + + uint256 l1SubgraphId = _l1SubgraphId(); + uint256 l2SubgraphId = gns.getAliasedL2SubgraphID(l1SubgraphId); + + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphReceivedFromL1(l1SubgraphId, l2SubgraphId, me, L1_CURATED_TOKENS); + gatewayMock.deliver( + address(gns), + l1GNS, + L1_CURATED_TOKENS, + _callhookData(RECEIVE_SUBGRAPH_CODE, l1SubgraphId, me) + ); + + _assertReceivedSubgraph(l2SubgraphId); + + assertNotEq(localSubgraphId, l2SubgraphId); + Subgraph memory local = _subgraph(localSubgraphId); + assertEq(local.vSignal, 0); + assertEq(local.nSignal, 0); + assertEq(local.subgraphDeploymentID, DEPLOYMENT_ID_0); + assertEq(local.reserveRatio, DEFAULT_RESERVE_RATIO); + assertFalse(local.disabled); + assertEq(local.withdrawableGRT, 0); + } + + // -- onTokenTransfer with invalid codes -- + + function test_OnTokenTransferWithInvalidCodes_RevertWhen_InvalidCode() public { + // Code 2 does not exist + bytes memory data = _callhookData(2, 1337, me); + vm.expectRevert(bytes("INVALID_CODE")); + gatewayMock.deliver(address(gns), l1GNS, 1 ether, data); + } + + function _assertReceivedSubgraph(uint256 l2SubgraphId) internal view { + (uint256 tokens, bool l2Done, uint256 receivedBlock) = gns.subgraphL2TransferData(l2SubgraphId); + assertEq(tokens, L1_CURATED_TOKENS); + assertFalse(l2Done); + assertEq(receivedBlock, block.number); + + Subgraph memory s = _subgraph(l2SubgraphId); + assertEq(s.vSignal, 0); + assertEq(s.nSignal, 0); + assertEq(s.subgraphDeploymentID, bytes32(0)); + assertEq(s.reserveRatio, DEFAULT_RESERVE_RATIO); + assertTrue(s.disabled); + // Not the same as a deprecated subgraph + assertEq(s.withdrawableGRT, 0); + + assertEq(gns.ownerOf(l2SubgraphId), me); + } +} diff --git a/packages/subgraph-discovery/test/unit/shared/GNSHelpers.t.sol b/packages/subgraph-discovery/test/unit/shared/GNSHelpers.t.sol new file mode 100644 index 000000000..aeb0bc245 --- /dev/null +++ b/packages/subgraph-discovery/test/unit/shared/GNSHelpers.t.sol @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { DiscoveryTest } from "./DiscoveryTest.t.sol"; +import { IL2GNSLike } from "../interfaces/IL2GNSLike.sol"; + +/// Ports the `lib/gnsUtils.ts` helpers used by the L2GNS tests, assertions included. +abstract contract GNSHelpers is DiscoveryTest { + uint32 internal constant DEFAULT_RESERVE_RATIO = 1_000_000; + uint256 internal constant MAX_PPM = 1_000_000; + + uint8 internal constant RECEIVE_SUBGRAPH_CODE = 0; + uint8 internal constant RECEIVE_CURATOR_BALANCE_CODE = 1; + + bytes32 internal constant DEPLOYMENT_ID_0 = keccak256("subgraphDeploymentID0"); + bytes32 internal constant DEPLOYMENT_ID_1 = keccak256("subgraphDeploymentID1"); + + struct Subgraph { + uint256 vSignal; + uint256 nSignal; + bytes32 subgraphDeploymentID; + uint32 reserveRatio; + bool disabled; + uint256 withdrawableGRT; + } + + function setUp() public virtual override { + super.setUp(); + vm.prank(governor); + grt.mint(me, 10_000 ether); + } + + function _versionMetadata(bytes32 deploymentId) internal pure returns (bytes32) { + return keccak256(abi.encode("versionMetadata", deploymentId)); + } + + function _subgraphMetadata(bytes32 deploymentId) internal pure returns (bytes32) { + return keccak256(abi.encode("subgraphMetadata", deploymentId)); + } + + function _subgraph(uint256 subgraphId) internal view returns (Subgraph memory s) { + (s.vSignal, s.nSignal, s.subgraphDeploymentID, s.reserveRatio, s.disabled, s.withdrawableGRT) = gns.subgraphs( + subgraphId + ); + } + + function _tokensAndVSignal(bytes32 deploymentId) internal view returns (uint256 tokens, uint256 vSignal) { + (tokens, , ) = curation.pools(deploymentId); + vSignal = curation.getCurationPoolSignal(deploymentId); + } + + // -- L1 transfers (from l2GNS.test.ts) -- + + uint256 internal constant L1_CURATED_TOKENS = 1337 ether; + bytes32 internal constant L1_SUBGRAPH_METADATA = keccak256("l1SubgraphMetadata"); + bytes32 internal constant L1_VERSION_METADATA = keccak256("l1VersionMetadata"); + + function _l1SubgraphId() internal view returns (uint256) { + return uint256(keccak256(abi.encodePacked(me, uint256(1), block.chainid))); + } + + function _callhookData(uint8 code, uint256 l1SubgraphId, address beneficiary) internal pure returns (bytes memory) { + return abi.encode(code, l1SubgraphId, beneficiary); + } + + /// Receives the subgraph from L1 and finishes the transfer on DEPLOYMENT_ID_0, as `me` + function _transferMockSubgraphFromL1(uint256 l1SubgraphId, uint256 curatedTokens) internal { + gatewayMock.deliver(address(gns), l1GNS, curatedTokens, _callhookData(RECEIVE_SUBGRAPH_CODE, l1SubgraphId, me)); + uint256 l2SubgraphId = gns.getAliasedL2SubgraphID(l1SubgraphId); + vm.prank(me); + gns.finishSubgraphTransferFromL1(l2SubgraphId, DEPLOYMENT_ID_0, L1_SUBGRAPH_METADATA, L1_VERSION_METADATA); + } + + function _publishNewSubgraph(address account, bytes32 deploymentId) internal returns (uint256 subgraphId) { + subgraphId = uint256(keccak256(abi.encodePacked(account, gns.nextAccountSeqID(account), block.chainid))); + bytes32 versionMetadata = _versionMetadata(deploymentId); + bytes32 subgraphMetadata = _subgraphMetadata(deploymentId); + + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphPublished(subgraphId, deploymentId, DEFAULT_RESERVE_RATIO); + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphMetadataUpdated(subgraphId, subgraphMetadata); + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphVersionUpdated(subgraphId, deploymentId, versionMetadata); + vm.prank(account); + gns.publishNewSubgraph(deploymentId, versionMetadata, subgraphMetadata); + + Subgraph memory s = _subgraph(subgraphId); + assertEq(s.vSignal, 0); + assertEq(s.nSignal, 0); + assertEq(s.subgraphDeploymentID, deploymentId); + assertEq(s.reserveRatio, DEFAULT_RESERVE_RATIO); + assertFalse(s.disabled); + assertEq(s.withdrawableGRT, 0); + + assertEq(gns.ownerOf(subgraphId), account); + } + + /// Mirrors GNS._chargeOwnerTax (the helper's formula only matches it when the division is exact) + function _ownerTaxAdjusted( + uint256 tokens, + uint32 curationTaxPercentage, + uint32 ownerTaxPercentage + ) internal pure returns (uint256) { + if (curationTaxPercentage == 0 || ownerTaxPercentage == 0) return tokens; + uint256 taxOnOriginal = (tokens * curationTaxPercentage) / MAX_PPM; + uint256 totalWithoutOwnerTax = tokens - taxOnOriginal; + uint256 ownerTax = (taxOnOriginal * ownerTaxPercentage) / MAX_PPM; + uint256 totalWithOwnerTax = totalWithoutOwnerTax + ownerTax; + return (totalWithOwnerTax * MAX_PPM) / (MAX_PPM - curationTaxPercentage); + } + + struct VersionUpgrade { + uint256 totalAdjustedUp; + uint256 newVSignalEstimate; + uint256 newCurationTaxEstimate; + uint256 beforeTokensNewCurve; + uint256 beforeVSignalNewCurve; + } + + function _estimateVersionUpgrade( + uint256 subgraphId, + uint256 nSignal, + bytes32 newDeploymentId + ) internal view returns (VersionUpgrade memory u) { + if (nSignal > 0) { + // No tax on burning nSignal + (, uint256 tokensReceivedEstimate) = gns.nSignalToTokens(subgraphId, nSignal); + u.totalAdjustedUp = _ownerTaxAdjusted( + tokensReceivedEstimate, + curation.curationTaxPercentage(), + gns.ownerTaxPercentage() + ); + (u.newVSignalEstimate, u.newCurationTaxEstimate) = curation.tokensToSignal( + newDeploymentId, + u.totalAdjustedUp + ); + } + (u.beforeTokensNewCurve, u.beforeVSignalNewCurve) = _tokensAndVSignal(newDeploymentId); + } + + function _publishNewVersion(address account, uint256 subgraphId, bytes32 newDeploymentId) internal { + Subgraph memory beforeSubgraph = _subgraph(subgraphId); + VersionUpgrade memory u = _estimateVersionUpgrade(subgraphId, beforeSubgraph.nSignal, newDeploymentId); + + // Only emits SubgraphUpgraded if there was signal to upgrade + if (beforeSubgraph.nSignal > 0) { + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphUpgraded(subgraphId, u.newVSignalEstimate, u.totalAdjustedUp, newDeploymentId); + } + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphVersionUpdated(subgraphId, newDeploymentId, _versionMetadata(newDeploymentId)); + vm.prank(account); + gns.publishNewVersion(subgraphId, newDeploymentId, _versionMetadata(newDeploymentId), 0); + + // Old curve is emptied + (uint256 afterTokensOldCurve, uint256 afterVSignalOldCurve) = _tokensAndVSignal( + beforeSubgraph.subgraphDeploymentID + ); + assertEq(afterTokensOldCurve, 0); + assertEq(afterVSignalOldCurve, 0); + + (uint256 afterTokensNewCurve, uint256 afterVSignalNewCurve) = _tokensAndVSignal(newDeploymentId); + assertEq(afterTokensNewCurve, u.beforeTokensNewCurve + u.totalAdjustedUp - u.newCurationTaxEstimate); + assertEq(afterVSignalNewCurve, u.beforeVSignalNewCurve + u.newVSignalEstimate); + + Subgraph memory afterSubgraph = _subgraph(subgraphId); + assertEq(afterSubgraph.vSignal, afterVSignalNewCurve - u.beforeVSignalNewCurve); + assertEq(afterSubgraph.vSignal, u.newVSignalEstimate); + assertEq(afterSubgraph.nSignal, beforeSubgraph.nSignal); + assertEq(afterSubgraph.subgraphDeploymentID, newDeploymentId); + + assertEq(gns.ownerOf(subgraphId), account); + } + + function _mintSignal(address account, uint256 subgraphId, uint256 tokensIn) internal { + Subgraph memory beforeSubgraph = _subgraph(subgraphId); + (uint256 beforeTokens, uint256 beforeVSignal) = _tokensAndVSignal(beforeSubgraph.subgraphDeploymentID); + + (uint256 vSignalExpected, uint256 nSignalExpected, uint256 curationTax) = gns.tokensToNSignal( + subgraphId, + tokensIn + ); + vm.expectEmit(address(gns)); + emit IL2GNSLike.SignalMinted(subgraphId, account, nSignalExpected, vSignalExpected, tokensIn); + vm.prank(account); + gns.mintSignal(subgraphId, tokensIn, 0); + + Subgraph memory afterSubgraph = _subgraph(subgraphId); + (uint256 afterTokens, uint256 afterVSignal) = _tokensAndVSignal(afterSubgraph.subgraphDeploymentID); + assertEq(afterTokens, beforeTokens + tokensIn - curationTax); + assertEq(afterVSignal, beforeVSignal + vSignalExpected); + assertEq(afterSubgraph.nSignal, beforeSubgraph.nSignal + nSignalExpected); + assertEq(afterSubgraph.vSignal, beforeVSignal + vSignalExpected); + } + + function _burnSignal(address account, uint256 subgraphId) internal { + Subgraph memory beforeSubgraph = _subgraph(subgraphId); + (uint256 beforeTokens, uint256 beforeVSignal) = _tokensAndVSignal(beforeSubgraph.subgraphDeploymentID); + uint256 beforeUsersNSignal = gns.getCuratorSignal(subgraphId, account); + + (uint256 vSignalExpected, uint256 tokensExpected) = gns.nSignalToTokens(subgraphId, beforeUsersNSignal); + assertEq(gns.nSignalToVSignal(subgraphId, beforeUsersNSignal), vSignalExpected); + vm.expectEmit(address(gns)); + emit IL2GNSLike.SignalBurned(subgraphId, account, beforeUsersNSignal, vSignalExpected, tokensExpected); + vm.prank(account); + gns.burnSignal(subgraphId, beforeUsersNSignal, 0); + + Subgraph memory afterSubgraph = _subgraph(subgraphId); + (uint256 afterTokens, uint256 afterVSignal) = _tokensAndVSignal(afterSubgraph.subgraphDeploymentID); + assertEq(afterTokens, beforeTokens - tokensExpected); + assertEq(afterVSignal, beforeVSignal - vSignalExpected); + assertEq(afterSubgraph.nSignal, beforeSubgraph.nSignal - beforeUsersNSignal); + } + + function _deprecateSubgraph(address account, uint256 subgraphId) internal { + Subgraph memory beforeSubgraph = _subgraph(subgraphId); + (uint256 beforeTokens, ) = _tokensAndVSignal(beforeSubgraph.subgraphDeploymentID); + // All vSignal in these tests is held by the subgraph's nSignal, so the whole pool is withdrawable + uint256 ownerBalanceBefore = grt.balanceOf(account); + + vm.expectEmit(address(gns)); + emit IL2GNSLike.SubgraphDeprecated(subgraphId, beforeTokens); + vm.prank(account); + gns.deprecateSubgraph(subgraphId); + + Subgraph memory afterSubgraph = _subgraph(subgraphId); + assertTrue(afterSubgraph.disabled); + assertEq(afterSubgraph.vSignal, 0); + assertEq(afterSubgraph.reserveRatio, 0); + assertEq(afterSubgraph.withdrawableGRT, beforeTokens); + + assertEq(grt.balanceOf(address(gns)), afterSubgraph.withdrawableGRT); + assertEq(grt.balanceOf(account), ownerBalanceBefore); + + // NFT is burned + vm.expectRevert(bytes("ERC721: owner query for nonexistent token")); + gns.ownerOf(subgraphId); + } +} diff --git a/scripts/contracts-migration/test-mapping/t3b.md b/scripts/contracts-migration/test-mapping/t3b.md new file mode 100644 index 000000000..aa8c37cc2 --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t3b.md @@ -0,0 +1,56 @@ +# T3b test mapping: L2GNS + +New tests are in `packages/subgraph-discovery`. Each `describe` of `l2GNS.test.ts` has its own file under `test/unit/l2-gns/` (`onTokenTransfer with invalid codes` lives in `ReceiveSubgraphFromL1.t.sol`). The `lib/gnsUtils.ts` helpers, with their assertions, and the L1 transfer helpers are in `test/unit/shared/GNSHelpers.t.sol`, on top of `DiscoveryTest`. L1 deliveries go through `L2GatewayMock.deliver`, which stands in for `L2GraphTokenGateway.finalizeInboundTransfer`. Random metadata and IDs are fixed values. `test/unit/Setup.t.sol::test_Setup_PublishNewSubgraph` checks the publish helper and has no old test. + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › publishNewVersion › should publish a new version on an existing subgraph | test/unit/l2-gns/PublishNewVersion.t.sol::test_PublishNewVersion_OnExistingSubgraph | Owner-tax math follows `GNS._chargeOwnerTax` | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › publishNewVersion › should publish a new version on an existing subgraph when owner tax is zero | test/unit/l2-gns/PublishNewVersion.t.sol::test_PublishNewVersion_WhenOwnerTaxIsZero | Expected tokens use the contract's zero-tax branch (tokens unchanged); same value as the TS formula for these numbers | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › publishNewVersion › should publish a new version on an existing subgraph with no current signal | test/unit/l2-gns/PublishNewVersion.t.sol::test_PublishNewVersion_WithNoCurrentSignal | Random `buildSubgraph()` becomes a fixed deployment ID | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › publishNewVersion › should reject a new version with the same subgraph deployment ID | test/unit/l2-gns/PublishNewVersion.t.sol::test_PublishNewVersion_RevertWhen_SameDeploymentID | | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › publishNewVersion › should reject publishing a version to a subgraph that does not exist | test/unit/l2-gns/PublishNewVersion.t.sol::test_PublishNewVersion_RevertWhen_SubgraphDoesNotExist | Random ID becomes a fixed ID | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › publishNewVersion › reject if not the owner | test/unit/l2-gns/PublishNewVersion.t.sol::test_PublishNewVersion_RevertWhen_NotOwner | | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › publishNewVersion › should NOT fail when upgrade tries to point to a pre-curated | test/unit/l2-gns/PublishNewVersion.t.sol::test_PublishNewVersion_ToPreCuratedDeployment | | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › publishNewVersion › should upgrade version when there is no signal with no signal migration | test/unit/l2-gns/PublishNewVersion.t.sol::test_PublishNewVersion_WithNoSignalNoMigration | | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › publishNewVersion › should fail when subgraph is deprecated | test/unit/l2-gns/PublishNewVersion.t.sol::test_PublishNewVersion_RevertWhen_Deprecated | `_deprecateSubgraph` also asserts `vSignal == 0` and the unchanged owner balance (no-op `expect(bool)` in the TS helper) | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › publishNewVersion › should protect against front-running with slippage protection | test/unit/l2-gns/PublishNewVersion.t.sol::test_PublishNewVersion_RevertWhen_FrontRunBeyondSlippage | Staking stake/allocate lines dropped (never affected the assertions); pool inflated by minting to curation and `collect` as `subgraphService` instead of re-pointing the subgraph service at the attacker | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › publishNewVersion › should succeed when slippage protection is satisfied | test/unit/l2-gns/PublishNewVersion.t.sol::test_PublishNewVersion_WhenSlippageProtectionSatisfied | | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › publishNewVersion › should succeed using L1 version (3-param) when deployment is NOT pre-curated | test/unit/l2-gns/PublishNewVersion.t.sol::test_PublishNewVersion_L1VersionWhenNotPreCurated | | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › publishNewVersion › should revert using L1 version (3-param) when deployment IS pre-curated | test/unit/l2-gns/PublishNewVersion.t.sol::test_PublishNewVersion_RevertWhen_L1VersionPreCurated | | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › receiving a subgraph from L1 (onTokenTransfer) › cannot be called by someone other than the L2GraphTokenGateway | test/unit/l2-gns/ReceiveSubgraphFromL1.t.sol::test_ReceiveSubgraphFromL1_RevertWhen_CallerNotGateway | | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › receiving a subgraph from L1 (onTokenTransfer) › rejects calls if the L1 sender is not the L1GNS | test/unit/l2-gns/ReceiveSubgraphFromL1.t.sol::test_ReceiveSubgraphFromL1_RevertWhen_L1SenderNotL1GNS | Delivered through `L2GatewayMock` | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › receiving a subgraph from L1 (onTokenTransfer) › creates a subgraph in a disabled state | test/unit/l2-gns/ReceiveSubgraphFromL1.t.sol::test_ReceiveSubgraphFromL1_CreatesDisabledSubgraph | `DepositFinalized` assertion covered by t2b L2GraphTokenGatewayTransfers | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › receiving a subgraph from L1 (onTokenTransfer) › does not conflict with a locally created subgraph | test/unit/l2-gns/ReceiveSubgraphFromL1.t.sol::test_ReceiveSubgraphFromL1_DoesNotConflictWithLocalSubgraph | `DepositFinalized` assertion covered by t2b L2GraphTokenGatewayTransfers | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › finishing a subgraph transfer from L1 › publishes the transferred subgraph and mints signal with no tax | test/unit/l2-gns/FinishSubgraphTransfer.t.sol::test_FinishSubgraphTransfer_PublishesAndMintsSignalWithNoTax | Events asserted in emission order; `SignalMinted` nSignal computed before the call (1:1) and checked equal to the post-call `vSignalToNSignal` | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › finishing a subgraph transfer from L1 › cannot be called by someone other than the subgraph owner | test/unit/l2-gns/FinishSubgraphTransfer.t.sol::test_FinishSubgraphTransfer_RevertWhen_NotOwner | | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › finishing a subgraph transfer from L1 › rejects calls for a subgraph that does not exist | test/unit/l2-gns/FinishSubgraphTransfer.t.sol::test_FinishSubgraphTransfer_RevertWhen_SubgraphDoesNotExist | | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › finishing a subgraph transfer from L1 › rejects calls for a subgraph that was not transferred from L1 | test/unit/l2-gns/FinishSubgraphTransfer.t.sol::test_FinishSubgraphTransfer_RevertWhen_NotTransferredFromL1 | | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › finishing a subgraph transfer from L1 › accepts calls to a pre-curated subgraph deployment | test/unit/l2-gns/FinishSubgraphTransfer.t.sol::test_FinishSubgraphTransfer_ToPreCuratedDeployment | Events asserted in emission order | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › finishing a subgraph transfer from L1 › rejects calls if the subgraph deployment ID is zero | test/unit/l2-gns/FinishSubgraphTransfer.t.sol::test_FinishSubgraphTransfer_RevertWhen_DeploymentIDZero | | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › finishing a subgraph transfer from L1 › rejects calls if the subgraph transfer was already finished | test/unit/l2-gns/FinishSubgraphTransfer.t.sol::test_FinishSubgraphTransfer_RevertWhen_AlreadyFinished | | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › claiming a curator balance with a message from L1 (onTokenTransfer) › assigns a curator balance to a beneficiary | test/unit/l2-gns/ReceiveCuratorBalance.t.sol::test_ReceiveCuratorBalance_AssignsBalanceToBeneficiary | L1GNS alias ETH funding dropped (the mock gateway makes the call) | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › claiming a curator balance with a message from L1 (onTokenTransfer) › adds the signal to any existing signal for the beneficiary | test/unit/l2-gns/ReceiveCuratorBalance.t.sol::test_ReceiveCuratorBalance_AddsToExistingSignal | As above | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › claiming a curator balance with a message from L1 (onTokenTransfer) › cannot be called by someone other than the L2GraphTokenGateway | test/unit/l2-gns/ReceiveCuratorBalance.t.sol::test_ReceiveCuratorBalance_RevertWhen_CallerNotGateway | | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › claiming a curator balance with a message from L1 (onTokenTransfer) › rejects calls if the L1 sender is not the L1GNS | test/unit/l2-gns/ReceiveCuratorBalance.t.sol::test_ReceiveCuratorBalance_RevertWhen_L1SenderNotL1GNS | | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › claiming a curator balance with a message from L1 (onTokenTransfer) › if a subgraph does not exist, it returns the tokens to the beneficiary | test/unit/l2-gns/ReceiveCuratorBalance.t.sol::test_ReceiveCuratorBalance_ReturnsTokensWhenSubgraphDoesNotExist | L1GNS alias ETH funding dropped | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › claiming a curator balance with a message from L1 (onTokenTransfer) › for an L2-native subgraph, it sends the tokens to the beneficiary | test/unit/l2-gns/ReceiveCuratorBalance.t.sol::test_ReceiveCuratorBalance_ReturnsTokensForL2NativeSubgraph | As above | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › claiming a curator balance with a message from L1 (onTokenTransfer) › if a subgraph transfer was not finished, it returns the tokens to the beneficiary | test/unit/l2-gns/ReceiveCuratorBalance.t.sol::test_ReceiveCuratorBalance_ReturnsTokensWhenTransferNotFinished | As above | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › claiming a curator balance with a message from L1 (onTokenTransfer) › if a subgraph was deprecated after transfer, it returns the tokens to the beneficiary | test/unit/l2-gns/ReceiveCuratorBalance.t.sol::test_ReceiveCuratorBalance_ReturnsTokensWhenDeprecatedAfterTransfer | As above | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › onTokenTransfer with invalid codes › reverts | test/unit/l2-gns/ReceiveSubgraphFromL1.t.sol::test_OnTokenTransferWithInvalidCodes_RevertWhen_InvalidCode | | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › getAliasedL2SubgraphID › returns the L2 subgraph ID that is the L1 subgraph ID with an offset | test/unit/l2-gns/AliasedSubgraphId.t.sol::test_GetAliasedL2SubgraphID_AddsOffset | Same L1 ID | +| packages/contracts-test/tests/unit/l2/l2GNS.test.ts | L2GNS › getAliasedL2SubgraphID › wraps around MAX_UINT256 in case of overflow | test/unit/l2-gns/AliasedSubgraphId.t.sol::test_GetAliasedL2SubgraphID_WrapsAroundMaxUint256 | Also asserts the wrapped value is `offset - 1` | +| packages/contracts-test/tests/unit/gns.test.ts | L1GNS (whole file, `describe.skip`) | — | dropped: 88 cases, L1GNS | + +## Coverage + +Checked against `coverage-baseline.json` for `contracts/l2/discovery/L2GNS.sol`, `contracts/discovery/GNS.sol`, `contracts/discovery/SubgraphNFT.sol`, `contracts/base/Multicall.sol` and `contracts/utils/TokenUtils.sol`. + +MISSING initialize: `GNS.initialize` is called by `DiscoveryTest` through `abi.encodeCall` in the proxy init data (`acceptProxyAndCall`), so every test runs it. + +MISSING setSubgraphMetadata: `SubgraphNFT.setSubgraphMetadata` is `onlyMinter` (the minter is GNS) and is reached through `gns.publishNewSubgraph` and `gns.finishSubgraphTransferFromL1`. Likewise `SubgraphNFT.mint` and `burn` (the grep matches `.mint(`/`.burn(` on GRT and curation) are reached through `publishNewSubgraph` / the L1 subgraph callhook and `deprecateSubgraph`. `SubgraphNFT`'s constructor, `setMinter` and `setTokenDescriptor` run in `DiscoveryTest.setUp`. + +MISSING burnTokens, pullTokens, pushTokens: `TokenUtils` is an internal library. `pullTokens` is reached through `gns.mintSignal`, the owner tax in `gns.publishNewVersion` and `curation.mint`; `burnTokens` through `curation.mint` with a non-zero curation tax (the `PublishNewVersion` setup and pre-curation); `pushTokens` through `curation.burn` (`publishNewVersion`, `burnSignal`, `deprecateSubgraph`, and T3a's L2Curation burn tests). + +GNS functions not in the baseline (`setSubgraphNFT`, `setDefaultName`, `updateSubgraphMetadata`, `transferSignal`, `withdraw`, `migrateLegacySubgraph`, `subgraphSignal`, `subgraphTokens`, `isPublished`, `isLegacySubgraph`, `getLegacySubgraphKey`) were only exercised by the L1GNS suite in `gns.test.ts`, which was `describe.skip` and is dropped. + +Additions with no old test: `test/unit/l2-gns/Multicall.t.sol::test_GNS_Multicall` (`multicall` is in the baseline; publishes a subgraph and mints signal in one batch, as the skipped L1GNS `batch calls` test did). From 00c080e02856de728921c4db9e90ae5f73b32019 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 26 Oct 2026 11:57:01 -0300 Subject: [PATCH 13/20] test(horizon): port EpochManager tests to Foundry --- .../contracts-test/tests/unit/epochs.test.ts | 171 ---------------- packages/horizon/foundry.toml | 2 +- .../test/unit/epochs/EpochManager.t.sol | 188 ++++++++++++++++++ .../test/unit/epochs/IEpochManagerLike.sol | 20 ++ .../test/unit/shared/ProtocolFixture.t.sol | 66 ++++++ .../contracts-migration/test-mapping/t4.md | 23 +++ 6 files changed, 298 insertions(+), 172 deletions(-) delete mode 100644 packages/contracts-test/tests/unit/epochs.test.ts create mode 100644 packages/horizon/test/unit/epochs/EpochManager.t.sol create mode 100644 packages/horizon/test/unit/epochs/IEpochManagerLike.sol create mode 100644 packages/horizon/test/unit/shared/ProtocolFixture.t.sol create mode 100644 scripts/contracts-migration/test-mapping/t4.md diff --git a/packages/contracts-test/tests/unit/epochs.test.ts b/packages/contracts-test/tests/unit/epochs.test.ts deleted file mode 100644 index bbd433b7d..000000000 --- a/packages/contracts-test/tests/unit/epochs.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { EpochManager } from '@graphprotocol/contracts' -import { deploy, DeployType, helpers, toBN } from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber } from 'ethers' -import hre from 'hardhat' - -describe('EpochManager', () => { - const graph = hre.graph() - const defaults = graph.graphConfig.defaults - let me: SignerWithAddress - let governor: SignerWithAddress - - let epochManager: EpochManager - - const epochLength: BigNumber = toBN('3') - - before(async function () { - ;[me, governor] = await graph.getTestAccounts() - ;({ governor } = await graph.getNamedAccounts()) - }) - - beforeEach(async function () { - const { contract: controller } = await deploy( - DeployType.DeployAndSave, - governor, - { - name: 'Controller', - }, - graph.addressBook, - ) - await deploy( - DeployType.DeployAndSave, - governor, - { - name: 'GraphProxyAdmin', - }, - graph.addressBook, - ) - const epochManagerResult = await deploy( - DeployType.DeployWithProxy, - governor, - { - name: 'EpochManager', - args: [controller.address, defaults.epochs.lengthInBlocks], - }, - graph.addressBook, - { - name: 'GraphProxy', - }, - ) - epochManager = epochManagerResult.contract as EpochManager - }) - - describe('configuration', () => { - it('should set `epochLength', async function () { - // Set right in the constructor - expect(await epochManager.epochLength()).eq(defaults.epochs.lengthInBlocks) - - // Update and check new value - const newEpochLength = toBN('4') - const currentEpoch = await epochManager.currentEpoch() - const tx = epochManager.connect(governor).setEpochLength(newEpochLength) - await expect(tx).emit(epochManager, 'EpochLengthUpdate').withArgs(currentEpoch, newEpochLength) - expect(await epochManager.epochLength()).eq(newEpochLength) - }) - - it('reject set `epochLength` if zero', async function () { - // Update and check new value - const newEpochLength = toBN('0') - const tx = epochManager.connect(governor).setEpochLength(newEpochLength) - await expect(tx).revertedWith('Epoch length cannot be 0') - }) - }) - - describe('epoch lifecycle', function () { - // Use epochs every three blocks - // Blocks -> (1,2,3)(4,5,6)(7,8,9) - // Epochs -> 1 2 3 - beforeEach(async function () { - await epochManager.connect(governor).setEpochLength(epochLength) - }) - - describe('calculations', () => { - it('first epoch should be 1', async function () { - const currentEpoch = await epochManager.currentEpoch() - expect(currentEpoch).eq(1) - }) - - it('should return correct block number', async function () { - const currentBlock = await helpers.latestBlock() - expect(await epochManager.blockNum()).eq(currentBlock) - }) - - it('should return same starting block if we stay on the same epoch', async function () { - // Move right to the start of a new epoch - const blocksSinceEpochStart = await epochManager.currentEpochBlockSinceStart() - const blocksToNextEpoch = epochLength.sub(blocksSinceEpochStart) - await helpers.mineUpTo((await epochManager.blockNum()).add(blocksToNextEpoch)) - - const beforeCurrentEpochBlock = await epochManager.currentEpochBlock() - - // Advance block - will not jump to next epoch - await helpers.mine() - - const afterCurrentEpochBlock = await epochManager.currentEpochBlock() - expect(afterCurrentEpochBlock).equal(beforeCurrentEpochBlock) - }) - - it('should return next starting block if we move to the next epoch', async function () { - const beforeCurrentEpochBlock = await epochManager.currentEpochBlock() - - // Advance blocks to move to the next epoch - await helpers.mineUpTo(beforeCurrentEpochBlock.add(epochLength)) - - const afterCurrentEpochBlock = await epochManager.currentEpochBlock() - expect(afterCurrentEpochBlock).not.eq(beforeCurrentEpochBlock) - }) - - it('should return next epoch if advance > epochLength', async function () { - const nextEpoch = (await epochManager.currentEpoch()).add(toBN('1')) - - // Advance blocks and move to the next epoch - const currentEpochBlock = await epochManager.currentEpochBlock() - await helpers.mineUpTo(currentEpochBlock.add(epochLength)) - - const afterCurrentEpoch = await epochManager.currentEpoch() - expect(afterCurrentEpoch).eq(nextEpoch) - }) - }) - - describe('progression', () => { - beforeEach(async function () { - const currentEpochBlock = await epochManager.currentEpochBlock() - await helpers.mineUpTo(currentEpochBlock.add(epochLength)) - }) - - context('> epoch not run', function () { - it('should return that current epoch is not run', async function () { - expect(await epochManager.isCurrentEpochRun()).eq(false) - }) - - it('should run new epoch', async function () { - // Run epoch - const currentEpoch = await epochManager.currentEpoch() - const tx = epochManager.connect(me).runEpoch() - await expect(tx).emit(epochManager, 'EpochRun').withArgs(currentEpoch, me.address) - - // State - const lastRunEpoch = await epochManager.lastRunEpoch() - expect(lastRunEpoch).eq(currentEpoch) - }) - }) - - context('> epoch run', function () { - beforeEach(async function () { - await epochManager.runEpoch() - }) - - it('should return current epoch is already run', async function () { - expect(await epochManager.isCurrentEpochRun()).eq(true) - }) - - it('reject run new epoch', async function () { - const tx = epochManager.runEpoch() - await expect(tx).revertedWith('Current epoch already run') - }) - }) - }) - }) -}) diff --git a/packages/horizon/foundry.toml b/packages/horizon/foundry.toml index 1301b718b..63b1fc545 100644 --- a/packages/horizon/foundry.toml +++ b/packages/horizon/foundry.toml @@ -5,7 +5,7 @@ libs = ["node_modules"] auto_detect_remappings = false test = 'test' cache_path = 'cache_forge' -fs_permissions = [{ access = "read", path = "./" }] +fs_permissions = [{ access = "read", path = "./" }, { access = "read", path = "node_modules/@graphprotocol/contracts/artifacts" }] solc_version = '0.8.35' evm_version = 'cancun' # Suppress solc warnings emitted from third-party code we don't control diff --git a/packages/horizon/test/unit/epochs/EpochManager.t.sol b/packages/horizon/test/unit/epochs/EpochManager.t.sol new file mode 100644 index 000000000..6b21b8ef3 --- /dev/null +++ b/packages/horizon/test/unit/epochs/EpochManager.t.sol @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IEpochManagerLike } from "./IEpochManagerLike.sol"; +import { ProtocolFixture } from "../shared/ProtocolFixture.t.sol"; + +contract EpochManagerTest is ProtocolFixture { + event EpochRun(uint256 indexed epoch, address caller); + event EpochLengthUpdate(uint256 indexed epoch, uint256 epochLength); + + uint256 internal constant DEFAULT_EPOCH_LENGTH = 60; + uint256 internal constant EPOCH_LENGTH = 3; + + IEpochManagerLike internal epochManager; + address internal me = makeAddr("me"); + + function setUp() public { + vm.warp(1_700_000_000); + _deployProtocolBase(); + address impl = _deployArtifact(string.concat(PKG_ARTIFACTS, "epochs/EpochManager.sol/EpochManager.json"), ""); + epochManager = IEpochManagerLike( + _deployBehindProxy( + impl, + abi.encodeCall(IEpochManagerLike.initialize, (address(controller), DEFAULT_EPOCH_LENGTH)) + ) + ); + } + + modifier withLifecycleLength() { + vm.prank(governor); + epochManager.setEpochLength(EPOCH_LENGTH); + _; + } + + function _rollToNextEpoch() internal { + vm.roll(epochManager.currentEpochBlock() + EPOCH_LENGTH); + } + + // -- configuration -- + + function test_Configuration_SetEpochLength() public { + assertEq(epochManager.epochLength(), DEFAULT_EPOCH_LENGTH); + + uint256 newEpochLength = 4; + uint256 currentEpoch = epochManager.currentEpoch(); + vm.expectEmit(address(epochManager)); + emit EpochLengthUpdate(currentEpoch, newEpochLength); + vm.prank(governor); + epochManager.setEpochLength(newEpochLength); + assertEq(epochManager.epochLength(), newEpochLength); + } + + function test_Configuration_RevertWhen_EpochLengthZero() public { + vm.expectRevert(bytes("Epoch length cannot be 0")); + vm.prank(governor); + epochManager.setEpochLength(0); + } + + function test_Configuration_RevertWhen_EpochLengthSame() public { + vm.expectRevert(bytes("Epoch length must be different to current")); + vm.prank(governor); + epochManager.setEpochLength(DEFAULT_EPOCH_LENGTH); + } + + // -- epoch lifecycle: calculations -- + + function test_Calculations_FirstEpochIsOne() public withLifecycleLength { + assertEq(epochManager.currentEpoch(), 1); + } + + function test_Calculations_BlockNum() public withLifecycleLength { + assertEq(epochManager.blockNum(), block.number); + vm.roll(block.number + 7); + assertEq(epochManager.blockNum(), block.number); + } + + function test_Calculations_SameStartingBlockOnSameEpoch() public withLifecycleLength { + // Move right to the start of a new epoch + uint256 blocksToNextEpoch = EPOCH_LENGTH - epochManager.currentEpochBlockSinceStart(); + vm.roll(epochManager.blockNum() + blocksToNextEpoch); + + uint256 beforeCurrentEpochBlock = epochManager.currentEpochBlock(); + + // Advance block - will not jump to next epoch + vm.roll(block.number + 1); + + assertEq(epochManager.currentEpochBlock(), beforeCurrentEpochBlock); + } + + function test_Calculations_NextStartingBlockOnNextEpoch() public withLifecycleLength { + uint256 beforeCurrentEpochBlock = epochManager.currentEpochBlock(); + + vm.roll(beforeCurrentEpochBlock + EPOCH_LENGTH); + + assertEq(epochManager.currentEpochBlock(), beforeCurrentEpochBlock + EPOCH_LENGTH); + } + + function test_Calculations_NextEpochWhenAdvancePastLength() public withLifecycleLength { + uint256 nextEpoch = epochManager.currentEpoch() + 1; + + _rollToNextEpoch(); + + assertEq(epochManager.currentEpoch(), nextEpoch); + } + + function test_EpochManager_SameBlockDoesNotAdvance() public withLifecycleLength { + uint256 epochBefore = epochManager.currentEpoch(); + uint256 epochBlockBefore = epochManager.currentEpochBlock(); + + assertEq(epochManager.currentEpoch(), epochBefore); + assertEq(epochManager.currentEpochBlock(), epochBlockBefore); + + vm.roll(epochBlockBefore + EPOCH_LENGTH - 1); + assertEq(epochManager.currentEpoch(), epochBefore); + assertEq(epochManager.currentEpochBlock(), epochBlockBefore); + + vm.roll(epochBlockBefore + EPOCH_LENGTH); + assertEq(epochManager.currentEpoch(), epochBefore + 1); + assertEq(epochManager.currentEpochBlock(), epochBlockBefore + EPOCH_LENGTH); + } + + function test_Calculations_EpochsSince() public withLifecycleLength { + uint256 startEpoch = epochManager.currentEpoch(); + uint256 startBlock = epochManager.currentEpochBlock(); + + vm.roll(startBlock + 2 * EPOCH_LENGTH + 1); + + assertEq(epochManager.currentEpoch(), startEpoch + 2); + assertEq(epochManager.epochsSince(startEpoch), 2); + assertEq(epochManager.epochsSince(startEpoch + 1), 1); + assertEq(epochManager.epochsSince(startEpoch + 2), 0); + assertEq(epochManager.epochsSince(startEpoch + 3), 0); + assertEq(epochManager.epochsSinceUpdate(), 2); + assertEq(epochManager.currentEpochBlockSinceStart(), 1); + } + + function test_Calculations_RevertWhen_BlockHashNotPast() public withLifecycleLength { + vm.roll(1000); + uint256 current = epochManager.blockNum(); + + vm.expectRevert(bytes("Can only retrieve past block hashes")); + epochManager.blockHash(current); + + vm.expectRevert(bytes("Can only retrieve past block hashes")); + epochManager.blockHash(current + 1); + } + + function test_Calculations_RevertWhen_BlockHashTooOld() public withLifecycleLength { + vm.roll(1000); + + vm.expectRevert(bytes("Can only retrieve hashes for last 256 blocks")); + epochManager.blockHash(1000 - 257); + } + + // -- epoch lifecycle: progression -- + + function test_Progression_NotRun() public withLifecycleLength { + _rollToNextEpoch(); + assertFalse(epochManager.isCurrentEpochRun()); + } + + function test_Progression_RunEpoch() public withLifecycleLength { + _rollToNextEpoch(); + + uint256 currentEpoch = epochManager.currentEpoch(); + vm.expectEmit(address(epochManager)); + emit EpochRun(currentEpoch, me); + vm.prank(me); + epochManager.runEpoch(); + + assertEq(epochManager.lastRunEpoch(), currentEpoch); + } + + function test_Progression_AlreadyRun() public withLifecycleLength { + _rollToNextEpoch(); + epochManager.runEpoch(); + + assertTrue(epochManager.isCurrentEpochRun()); + } + + function test_Progression_RevertWhen_AlreadyRun() public withLifecycleLength { + _rollToNextEpoch(); + epochManager.runEpoch(); + + vm.expectRevert(bytes("Current epoch already run")); + epochManager.runEpoch(); + } +} diff --git a/packages/horizon/test/unit/epochs/IEpochManagerLike.sol b/packages/horizon/test/unit/epochs/IEpochManagerLike.sol new file mode 100644 index 000000000..e170070f4 --- /dev/null +++ b/packages/horizon/test/unit/epochs/IEpochManagerLike.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IEpochManager } from "@graphprotocol/interfaces/contracts/contracts/epochs/IEpochManager.sol"; + +/// IEpochManager plus the initializer, storage getters and events the interface omits. +interface IEpochManagerLike is IEpochManager { + event EpochRun(uint256 indexed epoch, address caller); + event EpochLengthUpdate(uint256 indexed epoch, uint256 epochLength); + + function initialize(address controller, uint256 epochLength) external; + + function epochLength() external view returns (uint256); + + function lastRunEpoch() external view returns (uint256); + + function lastLengthUpdateEpoch() external view returns (uint256); + + function lastLengthUpdateBlock() external view returns (uint256); +} diff --git a/packages/horizon/test/unit/shared/ProtocolFixture.t.sol b/packages/horizon/test/unit/shared/ProtocolFixture.t.sol new file mode 100644 index 000000000..f38962c8e --- /dev/null +++ b/packages/horizon/test/unit/shared/ProtocolFixture.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Test } from "forge-std/Test.sol"; +import { IController } from "@graphprotocol/interfaces/contracts/contracts/governance/IController.sol"; +import { IGraphProxy } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxy.sol"; +import { IGraphProxyAdmin } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxyAdmin.sol"; + +abstract contract ProtocolFixture is Test { + string internal constant CORE_ARTIFACTS = "node_modules/@graphprotocol/contracts/artifacts/contracts/"; + string internal constant PKG_ARTIFACTS = "node_modules/@graphprotocol/contracts/artifacts/contracts/"; + + address internal governor; // makeAddr("governor"), set in _deployProtocolBase + IController internal controller; + IGraphProxyAdmin internal proxyAdmin; + + /// path is a full artifact path, e.g. string.concat(CORE_ARTIFACTS, "governance/Controller.sol/Controller.json"). + /// Deploys `.bytecode ++ args` with CREATE (honours an active vm.prank). + /// Reverts "artifact needs linking" if `.linkReferences` has any key (vm.parseJsonKeys), + /// and "artifact deploy failed" if CREATE returns 0. + function _deployArtifact(string memory path, bytes memory args) internal returns (address deployed) { + string memory json = vm.readFile(path); + require(vm.parseJsonKeys(json, ".linkReferences").length == 0, "artifact needs linking"); + bytes memory code = bytes.concat(vm.parseJsonBytes(json, ".bytecode"), args); + // solhint-disable-next-line no-inline-assembly + assembly { + deployed := create(0, add(code, 0x20), mload(code)) + } + require(deployed != address(0), "artifact deploy failed"); + } + + /// As governor: deploys Controller and GraphProxyAdmin from CORE_ARTIFACTS, then controller.setPaused(false). + /// Must be called with no active prank. + function _deployProtocolBase() internal { + governor = makeAddr("governor"); + vm.startPrank(governor); + controller = IController( + _deployArtifact(string.concat(CORE_ARTIFACTS, "governance/Controller.sol/Controller.json"), "") + ); + proxyAdmin = IGraphProxyAdmin( + _deployArtifact(string.concat(CORE_ARTIFACTS, "upgrades/GraphProxyAdmin.sol/GraphProxyAdmin.json"), "") + ); + controller.setPaused(false); + vm.stopPrank(); + } + + /// As governor: deploys GraphProxy(address(0), proxyAdmin) from CORE_ARTIFACTS, + /// proxyAdmin.upgrade(proxy, impl), proxyAdmin.acceptProxyAndCall(impl, proxy, initData). + /// Must be called with no active prank. + function _deployBehindProxy(address impl, bytes memory initData) internal returns (address proxy) { + vm.startPrank(governor); + proxy = _deployArtifact( + string.concat(CORE_ARTIFACTS, "upgrades/GraphProxy.sol/GraphProxy.json"), + abi.encode(address(0), address(proxyAdmin)) + ); + proxyAdmin.upgrade(IGraphProxy(proxy), impl); + proxyAdmin.acceptProxyAndCall(impl, IGraphProxy(proxy), initData); + vm.stopPrank(); + } + + /// As governor: controller.setContractProxy(keccak256(bytes(name)), addr). + function _registerContract(string memory name, address addr) internal { + vm.prank(governor); + controller.setContractProxy(keccak256(bytes(name)), addr); + } +} diff --git a/scripts/contracts-migration/test-mapping/t4.md b/scripts/contracts-migration/test-mapping/t4.md new file mode 100644 index 000000000..f9fa7eab1 --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t4.md @@ -0,0 +1,23 @@ +# T4 test mapping: EpochManager + +New tests are in `packages/horizon`, deploying the EpochManager Hardhat artifact behind a GraphProxy via `ProtocolFixture`. The old fixture's `epochs.lengthInBlocks` default (60) is the initial epoch length; the lifecycle tests call `setEpochLength(3)` as the old `beforeEach` did. + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ---- | +| packages/contracts-test/tests/unit/epochs.test.ts | EpochManager › configuration › should set `epochLength | test/unit/epochs/EpochManager.t.sol::test_Configuration_SetEpochLength | | +| packages/contracts-test/tests/unit/epochs.test.ts | EpochManager › configuration › reject set `epochLength` if zero | test/unit/epochs/EpochManager.t.sol::test_Configuration_RevertWhen_EpochLengthZero | | +| packages/contracts-test/tests/unit/epochs.test.ts | EpochManager › epoch lifecycle › calculations › first epoch should be 1 | test/unit/epochs/EpochManager.t.sol::test_Calculations_FirstEpochIsOne | | +| packages/contracts-test/tests/unit/epochs.test.ts | EpochManager › epoch lifecycle › calculations › should return correct block number | test/unit/epochs/EpochManager.t.sol::test_Calculations_BlockNum | | +| packages/contracts-test/tests/unit/epochs.test.ts | EpochManager › epoch lifecycle › calculations › should return same starting block if we stay on the same epoch | test/unit/epochs/EpochManager.t.sol::test_Calculations_SameStartingBlockOnSameEpoch | | +| packages/contracts-test/tests/unit/epochs.test.ts | EpochManager › epoch lifecycle › calculations › should return next starting block if we move to the next epoch | test/unit/epochs/EpochManager.t.sol::test_Calculations_NextStartingBlockOnNextEpoch | | +| packages/contracts-test/tests/unit/epochs.test.ts | EpochManager › epoch lifecycle › calculations › should return next epoch if advance > epochLength | test/unit/epochs/EpochManager.t.sol::test_Calculations_NextEpochWhenAdvancePastLength | | +| packages/contracts-test/tests/unit/epochs.test.ts | EpochManager › epoch lifecycle › progression › > epoch not run › should return that current epoch is not run | test/unit/epochs/EpochManager.t.sol::test_Progression_NotRun | | +| packages/contracts-test/tests/unit/epochs.test.ts | EpochManager › epoch lifecycle › progression › > epoch not run › should run new epoch | test/unit/epochs/EpochManager.t.sol::test_Progression_RunEpoch | | +| packages/contracts-test/tests/unit/epochs.test.ts | EpochManager › epoch lifecycle › progression › > epoch run › should return current epoch is already run | test/unit/epochs/EpochManager.t.sol::test_Progression_AlreadyRun | | +| packages/contracts-test/tests/unit/epochs.test.ts | EpochManager › epoch lifecycle › progression › > epoch run › reject run new epoch | test/unit/epochs/EpochManager.t.sol::test_Progression_RevertWhen_AlreadyRun | | + +## Coverage + +MISSING initialize: `EpochManager.initialize` is called through the proxy init data (`acceptProxyAndCall`) built with `abi.encodeCall` in `EpochManagerTest.setUp`, not by a `.initialize(` call; every test runs it, and `test_Configuration_SetEpochLength` asserts its result (`epochLength() == 60`). + +Additions with no old test: `test_Configuration_RevertWhen_EpochLengthSame` (`Epoch length must be different to current`), `test_Calculations_RevertWhen_BlockHashNotPast` and `test_Calculations_RevertWhen_BlockHashTooOld` (`blockHash` reverts), `test_Calculations_EpochsSince` (`epochsSince`, `epochsSinceUpdate`, `currentEpochBlockSinceStart` after rolls), and `test_EpochManager_SameBlockDoesNotAdvance` (same block, same epoch and epoch block). `runEpoch` and `isCurrentEpochRun` are covered by the ported progression tests. From 3b2a00ace16c37cef556f5d4770e0c6c7984b50b Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 26 Oct 2026 13:32:34 -0300 Subject: [PATCH 14/20] test(issuance): add RewardsManager Foundry fixture and port interface and config tests --- .../tests/unit/rewards/rewards-config.test.ts | 320 ------------------ .../unit/rewards/rewards-interface.test.ts | 144 -------- packages/issuance/foundry.toml | 2 +- packages/issuance/package.json | 3 +- .../test/unit/mocks/MockGraphToken.sol | 4 + .../rewards-manager/IRewardsManagerLike.sol | 45 +++ .../test/unit/rewards-manager/config.t.sol | 270 +++++++++++++++ .../test/unit/rewards-manager/interface.t.sol | 77 +++++ .../rewards-manager/mocks/RMCurationMock.sol | 46 +++ .../mocks/RMEligibilityOracleMock.sol | 37 ++ .../mocks/RMIssuanceAllocatorMock.sol | 48 +++ .../rewards-manager/mocks/RMIssuerMock.sol | 100 ++++++ .../test/unit/rewards-manager/setup.t.sol | 11 + .../test/unit/rewards-manager/shared.t.sol | 84 +++++ .../test/unit/shared/ProtocolFixture.t.sol | 66 ++++ pnpm-lock.yaml | 3 + .../contracts-migration/test-mapping/t5a.md | 45 +++ 17 files changed, 839 insertions(+), 466 deletions(-) delete mode 100644 packages/contracts-test/tests/unit/rewards/rewards-config.test.ts delete mode 100644 packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts create mode 100644 packages/issuance/test/unit/rewards-manager/IRewardsManagerLike.sol create mode 100644 packages/issuance/test/unit/rewards-manager/config.t.sol create mode 100644 packages/issuance/test/unit/rewards-manager/interface.t.sol create mode 100644 packages/issuance/test/unit/rewards-manager/mocks/RMCurationMock.sol create mode 100644 packages/issuance/test/unit/rewards-manager/mocks/RMEligibilityOracleMock.sol create mode 100644 packages/issuance/test/unit/rewards-manager/mocks/RMIssuanceAllocatorMock.sol create mode 100644 packages/issuance/test/unit/rewards-manager/mocks/RMIssuerMock.sol create mode 100644 packages/issuance/test/unit/rewards-manager/setup.t.sol create mode 100644 packages/issuance/test/unit/rewards-manager/shared.t.sol create mode 100644 packages/issuance/test/unit/shared/ProtocolFixture.t.sol create mode 100644 scripts/contracts-migration/test-mapping/t5a.md diff --git a/packages/contracts-test/tests/unit/rewards/rewards-config.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-config.test.ts deleted file mode 100644 index bd3b2569a..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards-config.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { Curation } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { IStaking } from '@graphprotocol/contracts' -import { RewardsManager } from '@graphprotocol/contracts' -import { GraphNetworkContracts, helpers, randomHexBytes, toBN, toGRT } from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const ISSUANCE_PER_BLOCK = toBN('200000000000000000000') // 200 GRT every block - -describe('Rewards - Configuration', () => { - const graph = hre.graph() - let governor: SignerWithAddress - let indexer1: SignerWithAddress - let indexer2: SignerWithAddress - let curator1: SignerWithAddress - let curator2: SignerWithAddress - let oracle: SignerWithAddress - let assetHolder: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let grt: GraphToken - let curation: Curation - let staking: IStaking - let rewardsManager: RewardsManager - - const subgraphDeploymentID1 = randomHexBytes() - - before(async function () { - const testAccounts = await graph.getTestAccounts() - ;[indexer1, indexer2, curator1, curator2, oracle, assetHolder] = testAccounts - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - grt = contracts.GraphToken as GraphToken - curation = contracts.Curation as Curation - staking = contracts.Staking as IStaking - rewardsManager = contracts.RewardsManager - - // 200 GRT per block - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - - // Distribute test funds - for (const wallet of [indexer1, indexer2, curator1, curator2, assetHolder]) { - await grt.connect(governor).mint(wallet.address, toGRT('1000000')) - await grt.connect(wallet).approve(staking.address, toGRT('1000000')) - await grt.connect(wallet).approve(curation.address, toGRT('1000000')) - } - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('configuration', function () { - describe('initialize', function () { - it('should revert when called on implementation contract', async function () { - // Try to call initialize on the implementation contract (should revert with onlyImpl) - const tx = rewardsManager.connect(governor).initialize(contracts.Controller.address) - await expect(tx).revertedWith('Only implementation') - }) - }) - - describe('issuance per block update', function () { - it('should reject set issuance per block if unauthorized', async function () { - const tx = rewardsManager.connect(indexer1).setIssuancePerBlock(toGRT('1.025')) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should set issuance rate to minimum allowed (0)', async function () { - const newIssuancePerBlock = toGRT('0') - await rewardsManager.connect(governor).setIssuancePerBlock(newIssuancePerBlock) - expect(await rewardsManager.issuancePerBlock()).eq(newIssuancePerBlock) - }) - - it('should set issuance rate', async function () { - const newIssuancePerBlock = toGRT('100.025') - await rewardsManager.connect(governor).setIssuancePerBlock(newIssuancePerBlock) - expect(await rewardsManager.issuancePerBlock()).eq(newIssuancePerBlock) - expect(await rewardsManager.accRewardsPerSignalLastBlockUpdated()).eq(await helpers.latestBlock()) - }) - - it('should update timestamp when transitioning from zero to non-zero issuance', async function () { - // Add some signal so rewards can be calculated - await curation.connect(curator1).mint(subgraphDeploymentID1, toGRT('10000'), 0) - - // Mine some blocks with rewards active - await helpers.mine(10) - - // Set issuance to zero - this updates timestamp correctly - await rewardsManager.connect(governor).setIssuancePerBlock(0) - const blockAfterZeroIssuance = await helpers.latestBlock() - - // Verify timestamp was updated - const timestampAfterZero = await rewardsManager.accRewardsPerSignalLastBlockUpdated() - expect(timestampAfterZero).to.equal(blockAfterZeroIssuance) - - // Mine blocks during zero issuance period - await helpers.mine(10) - - // Set issuance back to non-zero - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - const blockAfterRestore = await helpers.latestBlock() - - // Timestamp should be updated when transitioning from zero issuance - const timestampAfterRestore = await rewardsManager.accRewardsPerSignalLastBlockUpdated() - expect(timestampAfterRestore).to.equal( - blockAfterRestore, - 'Timestamp should be updated when transitioning from zero issuance', - ) - }) - - it('should not over-issue rewards after zero issuance period', async function () { - // Add signal - await curation.connect(curator1).mint(subgraphDeploymentID1, toGRT('10000'), 0) - - // Get signalled tokens for calculation - const signalledTokens = await grt.balanceOf(curation.address) - - // Mine some blocks with rewards active - await helpers.mine(10) - - // Capture rewards and timestamp before zero issuance period - await rewardsManager.connect(governor).updateAccRewardsPerSignal() - const rewardsAfterFirstPeriod = await rewardsManager.accRewardsPerSignal() - - // Set issuance to zero - await rewardsManager.connect(governor).setIssuancePerBlock(0) - const timestampAfterZeroSet = await rewardsManager.accRewardsPerSignalLastBlockUpdated() - - // Mine blocks during zero issuance - NO rewards should accumulate - await helpers.mine(10) - - // Restore issuance - record the block when non-zero issuance starts - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - const timestampAfterRestore = await rewardsManager.accRewardsPerSignalLastBlockUpdated() - - // Mine more blocks with rewards active - await helpers.mine(10) - - // Update and check final rewards - await rewardsManager.connect(governor).updateAccRewardsPerSignal() - const finalRewards = await rewardsManager.accRewardsPerSignal() - const finalTimestamp = await rewardsManager.accRewardsPerSignalLastBlockUpdated() - - // The actual rewards increase from first period to final - const rewardsIncrease = finalRewards.sub(rewardsAfterFirstPeriod) - - // Calculate expected rewards based on ACTUAL blocks where issuance was active - const FIXED_POINT_SCALING_FACTOR = BigNumber.from(10).pow(18) - const activeBlocksAfterRestore = finalTimestamp.sub(timestampAfterRestore) - const expectedIncrease = ISSUANCE_PER_BLOCK.mul(activeBlocksAfterRestore) - .mul(FIXED_POINT_SCALING_FACTOR) - .div(signalledTokens) - - // Key assertion: timestamp should advance during zero issuance period - expect(timestampAfterRestore.toNumber()).to.be.greaterThan( - timestampAfterZeroSet.toNumber(), - 'Timestamp should advance when setting non-zero issuance', - ) - - // Allow some tolerance for block timing (1 block variance) - const tolerance = ISSUANCE_PER_BLOCK.mul(1).mul(FIXED_POINT_SCALING_FACTOR).div(signalledTokens) - - // Rewards should match the active period only - expect(rewardsIncrease).to.be.closeTo(expectedIncrease, tolerance, 'Rewards should match active period only') - }) - }) - - describe('subgraph availability service', function () { - it('should reject set subgraph oracle if unauthorized', async function () { - const tx = rewardsManager.connect(indexer1).setSubgraphAvailabilityOracle(oracle.address) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should set subgraph oracle if governor', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - expect(await rewardsManager.subgraphAvailabilityOracle()).eq(oracle.address) - }) - - it('should reject to deny subgraph if not the oracle', async function () { - const tx = rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - await expect(tx).revertedWith('Caller must be the subgraph availability oracle') - }) - - it('should deny subgraph', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - - const tx = rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, true) - const blockNum = await helpers.latestBlock() - await expect(tx) - .emit(rewardsManager, 'RewardsDenylistUpdated') - .withArgs(subgraphDeploymentID1, blockNum + 1) - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(true) - }) - - it('should allow removing subgraph from denylist', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - - // First deny the subgraph - await rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, true) - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(true) - - // Then remove from denylist - const tx = rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, false) - await expect(tx).emit(rewardsManager, 'RewardsDenylistUpdated').withArgs(subgraphDeploymentID1, 0) - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(false) - }) - - it('should be a no-op when denying an already denied subgraph', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - - // Deny the subgraph - await rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, true) - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(true) - const denyBlockBefore = await rewardsManager.denylist(subgraphDeploymentID1) - - // Deny again - should not emit event or change denylist block number - const tx = rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, true) - await expect(tx).not.emit(rewardsManager, 'RewardsDenylistUpdated') - - // State should be unchanged - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(true) - const denyBlockAfter = await rewardsManager.denylist(subgraphDeploymentID1) - expect(denyBlockAfter).eq(denyBlockBefore) - }) - - it('should be a no-op when undenying an already not-denied subgraph', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - - // Subgraph is not denied by default - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(false) - - // Undeny should not emit event - const tx = rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, false) - await expect(tx).not.emit(rewardsManager, 'RewardsDenylistUpdated') - - // State should remain unchanged - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(false) - expect(await rewardsManager.denylist(subgraphDeploymentID1)).eq(0) - }) - - it('should reject setMinimumSubgraphSignal if unauthorized', async function () { - const tx = rewardsManager.connect(indexer1).setMinimumSubgraphSignal(toGRT('1000')) - await expect(tx).revertedWith('Not authorized') - }) - - it('should allow setMinimumSubgraphSignal from subgraph availability oracle', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - - const newMinimumSignal = toGRT('2000') - const tx = rewardsManager.connect(oracle).setMinimumSubgraphSignal(newMinimumSignal) - await expect(tx).emit(rewardsManager, 'ParameterUpdated').withArgs('minimumSubgraphSignal') - - expect(await rewardsManager.minimumSubgraphSignal()).eq(newMinimumSignal) - }) - - it('should allow setMinimumSubgraphSignal from governor', async function () { - const newMinimumSignal = toGRT('3000') - const tx = rewardsManager.connect(governor).setMinimumSubgraphSignal(newMinimumSignal) - await expect(tx).emit(rewardsManager, 'ParameterUpdated').withArgs('minimumSubgraphSignal') - - expect(await rewardsManager.minimumSubgraphSignal()).eq(newMinimumSignal) - }) - }) - - describe('revertOnIneligible', function () { - it('should reject setRevertOnIneligible if unauthorized', async function () { - const tx = rewardsManager.connect(indexer1).setRevertOnIneligible(true) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should set revertOnIneligible to true', async function () { - const tx = rewardsManager.connect(governor).setRevertOnIneligible(true) - await expect(tx).emit(rewardsManager, 'ParameterUpdated').withArgs('revertOnIneligible') - expect(await rewardsManager.getRevertOnIneligible()).eq(true) - }) - - it('should set revertOnIneligible to false', async function () { - // First set to true - await rewardsManager.connect(governor).setRevertOnIneligible(true) - - // Then set back to false - const tx = rewardsManager.connect(governor).setRevertOnIneligible(false) - await expect(tx).emit(rewardsManager, 'ParameterUpdated').withArgs('revertOnIneligible') - expect(await rewardsManager.getRevertOnIneligible()).eq(false) - }) - - it('should be a no-op when setting same value (false to false)', async function () { - // Default is false - expect(await rewardsManager.getRevertOnIneligible()).eq(false) - - const tx = rewardsManager.connect(governor).setRevertOnIneligible(false) - await expect(tx).to.not.emit(rewardsManager, 'ParameterUpdated') - - expect(await rewardsManager.getRevertOnIneligible()).eq(false) - }) - - it('should be a no-op when setting same value (true to true)', async function () { - await rewardsManager.connect(governor).setRevertOnIneligible(true) - - const tx = rewardsManager.connect(governor).setRevertOnIneligible(true) - await expect(tx).to.not.emit(rewardsManager, 'ParameterUpdated') - - expect(await rewardsManager.getRevertOnIneligible()).eq(true) - }) - }) - }) -}) diff --git a/packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts deleted file mode 100644 index db77d5f9b..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { RewardsManager } from '@graphprotocol/contracts' -import { - IERC165__factory, - IIssuanceTarget__factory, - IProviderEligibilityManagement__factory, - IRewardsManager__factory, -} from '@graphprotocol/interfaces/types' -import { GraphNetworkContracts, toGRT } from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { constants } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -describe('RewardsManager interfaces', () => { - const graph = hre.graph() - let governor: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let rewardsManager: RewardsManager - - before(async function () { - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - rewardsManager = contracts.RewardsManager - - // Set a default issuance per block - await rewardsManager.connect(governor).setIssuancePerBlock(toGRT('200')) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - /** - * Interface ID Stability Tests - * - * These tests verify that interface IDs remain stable across builds. - * Changes to these IDs indicate breaking changes to the interface definitions. - * - * If a test fails: - * 1. Verify the interface change was intentional - * 2. Understand the impact on deployed contracts - * 3. Update the expected ID if the change is correct - * 4. Document the breaking change in release notes - */ - describe('Interface ID Stability', () => { - it('IERC165 should have stable interface ID', () => { - expect(IERC165__factory.interfaceId).to.equal('0x01ffc9a7') - }) - - it('IIssuanceTarget should have stable interface ID', () => { - expect(IIssuanceTarget__factory.interfaceId).to.equal('0x19f6601a') - }) - - it('IRewardsManager should have stable interface ID', () => { - expect(IRewardsManager__factory.interfaceId).to.equal('0x8469b577') - }) - }) - - describe('supportsInterface', function () { - it('should support IIssuanceTarget interface', async function () { - const supports = await rewardsManager.supportsInterface(IIssuanceTarget__factory.interfaceId) - expect(supports).to.be.true - }) - - it('should support IRewardsManager interface', async function () { - const supports = await rewardsManager.supportsInterface(IRewardsManager__factory.interfaceId) - expect(supports).to.be.true - }) - - it('should support IERC165 interface', async function () { - const supports = await rewardsManager.supportsInterface(IERC165__factory.interfaceId) - expect(supports).to.be.true - }) - - it('should support IProviderEligibilityManagement interface', async function () { - const supports = await rewardsManager.supportsInterface(IProviderEligibilityManagement__factory.interfaceId) - expect(supports).to.be.true - }) - - it('should return false for unsupported interfaces', async function () { - // Test with an unknown interface ID - const unknownInterfaceId = '0x12345678' // Random interface ID - const supports = await rewardsManager.supportsInterface(unknownInterfaceId) - expect(supports).to.be.false - }) - }) - - describe('getter functions', function () { - it('should return zero address for issuance allocator when not set', async function () { - const allocator = await rewardsManager.getIssuanceAllocator() - expect(allocator).to.equal(constants.AddressZero) - }) - - it('should return zero address for rewards eligibility oracle when not set', async function () { - const oracle = await rewardsManager.getProviderEligibilityOracle() - expect(oracle).to.equal(constants.AddressZero) - }) - - it('should return zero address for reclaim address when not set', async function () { - const reclaimAddress = await rewardsManager.getReclaimAddress(constants.HashZero) - expect(reclaimAddress).to.equal(constants.AddressZero) - }) - }) - - describe('calcRewards', function () { - it('should calculate rewards correctly', async function () { - const tokens = toGRT('1000') - const accRewardsPerAllocatedToken = toGRT('0.5') - - // Expected: (1000 * 0.5 * 1e18) / 1e18 = 500 GRT - const expectedRewards = toGRT('500') - - const rewards = await rewardsManager.calcRewards(tokens, accRewardsPerAllocatedToken) - expect(rewards).to.equal(expectedRewards) - }) - - it('should return 0 when tokens is 0', async function () { - const tokens = toGRT('0') - const accRewardsPerAllocatedToken = toGRT('0.5') - - const rewards = await rewardsManager.calcRewards(tokens, accRewardsPerAllocatedToken) - expect(rewards).to.equal(0) - }) - - it('should return 0 when accRewardsPerAllocatedToken is 0', async function () { - const tokens = toGRT('1000') - const accRewardsPerAllocatedToken = toGRT('0') - - const rewards = await rewardsManager.calcRewards(tokens, accRewardsPerAllocatedToken) - expect(rewards).to.equal(0) - }) - }) -}) diff --git a/packages/issuance/foundry.toml b/packages/issuance/foundry.toml index 1a256a94c..e21cc61ba 100644 --- a/packages/issuance/foundry.toml +++ b/packages/issuance/foundry.toml @@ -10,7 +10,7 @@ remappings = [ "forge-std/=node_modules/forge-std/src/", ] cache_path = 'cache_forge' -fs_permissions = [{ access = "read", path = "./" }] +fs_permissions = [{ access = "read", path = "./" }, { access = "read", path = "node_modules/@graphprotocol/contracts/artifacts" }] solc_version = '0.8.35' evm_version = 'cancun' isolate = false diff --git a/packages/issuance/package.json b/packages/issuance/package.json index 535b53d85..e3da8f778 100644 --- a/packages/issuance/package.json +++ b/packages/issuance/package.json @@ -27,7 +27,7 @@ "clean": "rm -rf artifacts/ forge-artifacts/ cache_forge/ coverage/ cache/ types/ typechain-src/ .eslintcache test/node_modules/", "compile": "hardhat compile --quiet --no-tests", "typechain": "typechain --target ethers-v6 --out-dir typechain-src 'artifacts/contracts/**/!(*.dbg).json' && tsc -p tsconfig.typechain.json && rm -rf typechain-src && echo '{\"type\":\"commonjs\"}' > types/package.json", - "test": "pnpm test:self", + "test": "pnpm build && pnpm test:self", "test:self": "forge test", "test:coverage": "forge coverage", "test:coverage:self": "mkdir -p coverage && forge coverage --report lcov --report-file coverage/lcov.info", @@ -48,6 +48,7 @@ "README.md" ], "devDependencies": { + "@graphprotocol/contracts": "workspace:^", "@graphprotocol/interfaces": "workspace:^", "@nomicfoundation/hardhat-ethers": "^4.0.0", "@nomicfoundation/hardhat-ethers-chai-matchers": "^3.0.0", diff --git a/packages/issuance/test/unit/mocks/MockGraphToken.sol b/packages/issuance/test/unit/mocks/MockGraphToken.sol index dd07fab6e..664efb6ba 100644 --- a/packages/issuance/test/unit/mocks/MockGraphToken.sol +++ b/packages/issuance/test/unit/mocks/MockGraphToken.sol @@ -12,4 +12,8 @@ contract MockGraphToken is ERC20 { function mint(address to, uint256 amount) external { _mint(to, amount); } + + function burn(uint256 amount) external { + _burn(msg.sender, amount); + } } diff --git a/packages/issuance/test/unit/rewards-manager/IRewardsManagerLike.sol b/packages/issuance/test/unit/rewards-manager/IRewardsManagerLike.sol new file mode 100644 index 000000000..9d98e2088 --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/IRewardsManagerLike.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import { IManaged } from "@graphprotocol/interfaces/contracts/contracts/governance/IManaged.sol"; +import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; +import { IRewardsManagerDeprecated } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManagerDeprecated.sol"; +import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; +import { IProviderEligibilityManagement } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibilityManagement.sol"; + +/// Every interface RewardsManager implements, plus the initializer, storage getters and events they omit. +interface IRewardsManagerLike is + IRewardsManager, + IRewardsManagerDeprecated, + IManaged, + IIssuanceTarget, + IProviderEligibilityManagement, + IERC165 +{ + event ParameterUpdated(string param); + + function initialize(address controller) external; + + function accRewardsPerSignal() external view returns (uint256); + + function accRewardsPerSignalLastBlockUpdated() external view returns (uint256); + + function subgraphAvailabilityOracle() external view returns (address); + + function subgraphs( + bytes32 subgraphDeploymentId + ) + external + view + returns ( + uint256 accRewardsForSubgraph, + uint256 accRewardsForSubgraphSnapshot, + uint256 accRewardsPerSignalSnapshot, + uint256 accRewardsPerAllocatedToken + ); + + function denylist(bytes32 subgraphDeploymentId) external view returns (uint256); + + function minimumSubgraphSignal() external view returns (uint256); +} diff --git a/packages/issuance/test/unit/rewards-manager/config.t.sol b/packages/issuance/test/unit/rewards-manager/config.t.sol new file mode 100644 index 000000000..98522440f --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/config.t.sol @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Vm } from "forge-std/Vm.sol"; + +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +contract RewardsManagerConfigTest is RewardsManagerSharedTest { + event ParameterUpdated(string param); + event RewardsDenylistUpdated(bytes32 indexed subgraphDeploymentID, uint256 sinceBlock); + + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_1 = keccak256("subgraphDeploymentID1"); + + address internal availabilityOracle = makeAddr("availabilityOracle"); + + function _setAvailabilityOracle() internal { + vm.prank(governor); + rm.setSubgraphAvailabilityOracle(availabilityOracle); + } + + function _assertNotEmitted(Vm.Log[] memory logs, bytes32 topic) internal view { + for (uint256 i = 0; i < logs.length; ++i) { + if (logs[i].emitter == address(rm)) assertTrue(logs[i].topics[0] != topic, "unexpected event"); + } + } + + // -- initialize -- + + function test_Initialize_RevertWhen_CalledOnImplementation() public { + // As in the old test, the call goes through the proxy, so `onlyImpl` rejects the caller + vm.expectRevert(bytes("Only implementation")); + vm.prank(governor); + rm.initialize(address(controller)); + } + + // -- issuance per block update -- + + function test_IssuancePerBlockUpdate_RevertWhen_Unauthorized() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(indexer); + rm.setIssuancePerBlock(1.025 ether); + } + + function test_IssuancePerBlockUpdate_SetToMinimumZero() public { + vm.prank(governor); + rm.setIssuancePerBlock(0); + assertEq(rm.issuancePerBlock(), 0); + } + + function test_IssuancePerBlockUpdate_SetIssuanceRate() public { + uint256 newIssuancePerBlock = 100.025 ether; + vm.prank(governor); + rm.setIssuancePerBlock(newIssuancePerBlock); + assertEq(rm.issuancePerBlock(), newIssuancePerBlock); + assertEq(rm.accRewardsPerSignalLastBlockUpdated(), block.number); + } + + function test_IssuancePerBlockUpdate_UpdatesTimestampFromZeroToNonZero() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, 10_000 ether); + vm.roll(block.number + 10); + + vm.prank(governor); + rm.setIssuancePerBlock(0); + assertEq(rm.accRewardsPerSignalLastBlockUpdated(), block.number); + + vm.roll(block.number + 10); + + vm.prank(governor); + rm.setIssuancePerBlock(ISSUANCE_PER_BLOCK); + assertEq( + rm.accRewardsPerSignalLastBlockUpdated(), + block.number, + "Timestamp should be updated when transitioning from zero issuance" + ); + } + + function test_IssuancePerBlockUpdate_NoOverIssuanceAfterZeroPeriod() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, 10_000 ether); + uint256 signalledTokens = token.balanceOf(address(curation)); + + vm.roll(block.number + 10); + + vm.prank(governor); + rm.updateAccRewardsPerSignal(); + uint256 rewardsAfterFirstPeriod = rm.accRewardsPerSignal(); + + vm.prank(governor); + rm.setIssuancePerBlock(0); + uint256 timestampAfterZeroSet = rm.accRewardsPerSignalLastBlockUpdated(); + + // No rewards accrue during zero issuance + vm.roll(block.number + 10); + + vm.prank(governor); + rm.setIssuancePerBlock(ISSUANCE_PER_BLOCK); + uint256 timestampAfterRestore = rm.accRewardsPerSignalLastBlockUpdated(); + + vm.roll(block.number + 10); + + vm.prank(governor); + rm.updateAccRewardsPerSignal(); + uint256 finalRewards = rm.accRewardsPerSignal(); + uint256 finalTimestamp = rm.accRewardsPerSignalLastBlockUpdated(); + + assertGt( + timestampAfterRestore, + timestampAfterZeroSet, + "Timestamp should advance when setting non-zero issuance" + ); + + // Exact: the update and the zero-issuance set share a block, so no stray block accrues (old tolerance: 1 block) + assertEq( + finalRewards - rewardsAfterFirstPeriod, + _expectedRewardsPerSignal(finalTimestamp - timestampAfterRestore, signalledTokens), + "Rewards should match active period only" + ); + } + + // -- subgraph availability service -- + + function test_SubgraphAvailabilityService_RevertWhen_SetOracleUnauthorized() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(indexer); + rm.setSubgraphAvailabilityOracle(availabilityOracle); + } + + function test_SubgraphAvailabilityService_SetOracle() public { + _setAvailabilityOracle(); + assertEq(rm.subgraphAvailabilityOracle(), availabilityOracle); + } + + function test_SubgraphAvailabilityService_RevertWhen_DenyNotOracle() public { + vm.expectRevert(bytes("Caller must be the subgraph availability oracle")); + vm.prank(governor); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + } + + function test_SubgraphAvailabilityService_Deny() public { + _setAvailabilityOracle(); + + vm.expectEmit(address(rm)); + emit RewardsDenylistUpdated(SUBGRAPH_DEPLOYMENT_ID_1, block.number); + vm.prank(availabilityOracle); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + assertTrue(rm.isDenied(SUBGRAPH_DEPLOYMENT_ID_1)); + } + + function test_SubgraphAvailabilityService_Undeny() public { + _setAvailabilityOracle(); + + vm.prank(availabilityOracle); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + assertTrue(rm.isDenied(SUBGRAPH_DEPLOYMENT_ID_1)); + + vm.expectEmit(address(rm)); + emit RewardsDenylistUpdated(SUBGRAPH_DEPLOYMENT_ID_1, 0); + vm.prank(availabilityOracle); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, false); + assertFalse(rm.isDenied(SUBGRAPH_DEPLOYMENT_ID_1)); + } + + function test_SubgraphAvailabilityService_DenyAlreadyDeniedIsNoOp() public { + _setAvailabilityOracle(); + + vm.prank(availabilityOracle); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + assertTrue(rm.isDenied(SUBGRAPH_DEPLOYMENT_ID_1)); + uint256 denyBlockBefore = rm.denylist(SUBGRAPH_DEPLOYMENT_ID_1); + + vm.recordLogs(); + vm.prank(availabilityOracle); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + _assertNotEmitted(vm.getRecordedLogs(), RewardsDenylistUpdated.selector); + + assertTrue(rm.isDenied(SUBGRAPH_DEPLOYMENT_ID_1)); + assertEq(rm.denylist(SUBGRAPH_DEPLOYMENT_ID_1), denyBlockBefore); + } + + function test_SubgraphAvailabilityService_UndenyNotDeniedIsNoOp() public { + _setAvailabilityOracle(); + + assertFalse(rm.isDenied(SUBGRAPH_DEPLOYMENT_ID_1)); + + vm.recordLogs(); + vm.prank(availabilityOracle); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, false); + _assertNotEmitted(vm.getRecordedLogs(), RewardsDenylistUpdated.selector); + + assertFalse(rm.isDenied(SUBGRAPH_DEPLOYMENT_ID_1)); + assertEq(rm.denylist(SUBGRAPH_DEPLOYMENT_ID_1), 0); + } + + function test_SubgraphAvailabilityService_RevertWhen_SetMinimumSubgraphSignalUnauthorized() public { + vm.expectRevert(bytes("Not authorized")); + vm.prank(indexer); + rm.setMinimumSubgraphSignal(1000 ether); + } + + function test_SubgraphAvailabilityService_SetMinimumSubgraphSignalFromOracle() public { + _setAvailabilityOracle(); + + uint256 newMinimumSignal = 2000 ether; + vm.expectEmit(address(rm)); + emit ParameterUpdated("minimumSubgraphSignal"); + vm.prank(availabilityOracle); + rm.setMinimumSubgraphSignal(newMinimumSignal); + + assertEq(rm.minimumSubgraphSignal(), newMinimumSignal); + } + + function test_SubgraphAvailabilityService_SetMinimumSubgraphSignalFromGovernor() public { + uint256 newMinimumSignal = 3000 ether; + vm.expectEmit(address(rm)); + emit ParameterUpdated("minimumSubgraphSignal"); + vm.prank(governor); + rm.setMinimumSubgraphSignal(newMinimumSignal); + + assertEq(rm.minimumSubgraphSignal(), newMinimumSignal); + } + + // -- revertOnIneligible -- + + function test_RevertOnIneligible_RevertWhen_Unauthorized() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(indexer); + rm.setRevertOnIneligible(true); + } + + function test_RevertOnIneligible_SetTrue() public { + vm.expectEmit(address(rm)); + emit ParameterUpdated("revertOnIneligible"); + vm.prank(governor); + rm.setRevertOnIneligible(true); + assertTrue(rm.getRevertOnIneligible()); + } + + function test_RevertOnIneligible_SetFalse() public { + vm.prank(governor); + rm.setRevertOnIneligible(true); + + vm.expectEmit(address(rm)); + emit ParameterUpdated("revertOnIneligible"); + vm.prank(governor); + rm.setRevertOnIneligible(false); + assertFalse(rm.getRevertOnIneligible()); + } + + function test_RevertOnIneligible_SameValueFalseIsNoOp() public { + assertFalse(rm.getRevertOnIneligible()); + + vm.recordLogs(); + vm.prank(governor); + rm.setRevertOnIneligible(false); + _assertNotEmitted(vm.getRecordedLogs(), ParameterUpdated.selector); + + assertFalse(rm.getRevertOnIneligible()); + } + + function test_RevertOnIneligible_SameValueTrueIsNoOp() public { + vm.prank(governor); + rm.setRevertOnIneligible(true); + + vm.recordLogs(); + vm.prank(governor); + rm.setRevertOnIneligible(true); + _assertNotEmitted(vm.getRecordedLogs(), ParameterUpdated.selector); + + assertTrue(rm.getRevertOnIneligible()); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/interface.t.sol b/packages/issuance/test/unit/rewards-manager/interface.t.sol new file mode 100644 index 000000000..932ba3828 --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/interface.t.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; +import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; +import { IProviderEligibilityManagement } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibilityManagement.sol"; + +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +/// @notice Interface ids are stable across builds: a change is a breaking change to the interface definitions. +contract RewardsManagerInterfaceTest is RewardsManagerSharedTest { + // -- Interface ID Stability -- + + function test_InterfaceIdStability_IERC165() public pure { + assertEq(type(IERC165).interfaceId, bytes4(0x01ffc9a7)); + } + + function test_InterfaceIdStability_IIssuanceTarget() public pure { + assertEq(type(IIssuanceTarget).interfaceId, bytes4(0x19f6601a)); + } + + function test_InterfaceIdStability_IRewardsManager() public pure { + assertEq(type(IRewardsManager).interfaceId, bytes4(0x8469b577)); + } + + // -- supportsInterface -- + + function test_SupportsInterface_IIssuanceTarget() public view { + assertTrue(rm.supportsInterface(type(IIssuanceTarget).interfaceId)); + } + + function test_SupportsInterface_IRewardsManager() public view { + assertTrue(rm.supportsInterface(type(IRewardsManager).interfaceId)); + } + + function test_SupportsInterface_IERC165() public view { + assertTrue(rm.supportsInterface(type(IERC165).interfaceId)); + } + + function test_SupportsInterface_IProviderEligibilityManagement() public view { + assertTrue(rm.supportsInterface(type(IProviderEligibilityManagement).interfaceId)); + } + + function test_SupportsInterface_FalseForUnsupported() public view { + assertFalse(rm.supportsInterface(bytes4(0x12345678))); + } + + // -- getter functions -- + + function test_GetterFunctions_IssuanceAllocatorZeroWhenNotSet() public view { + assertEq(address(rm.getIssuanceAllocator()), address(0)); + } + + function test_GetterFunctions_EligibilityOracleZeroWhenNotSet() public view { + assertEq(address(rm.getProviderEligibilityOracle()), address(0)); + } + + function test_GetterFunctions_ReclaimAddressZeroWhenNotSet() public view { + assertEq(rm.getReclaimAddress(bytes32(0)), address(0)); + } + + // -- calcRewards -- + + function test_CalcRewards_Calculates() public view { + // (1000 * 0.5 * 1e18) / 1e18 = 500 GRT + assertEq(rm.calcRewards(1000 ether, 0.5 ether), 500 ether); + } + + function test_CalcRewards_ZeroWhenTokensZero() public view { + assertEq(rm.calcRewards(0, 0.5 ether), 0); + } + + function test_CalcRewards_ZeroWhenAccRewardsPerAllocatedTokenZero() public view { + assertEq(rm.calcRewards(1000 ether, 0), 0); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/mocks/RMCurationMock.sol b/packages/issuance/test/unit/rewards-manager/mocks/RMCurationMock.sol new file mode 100644 index 000000000..8d3cddd0a --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/mocks/RMCurationMock.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; +import { MockGraphToken } from "../../mocks/MockGraphToken.sol"; + +/// Curation stand-in for RewardsManager: pool tokens per deployment, with the signal held as this contract's +/// GRT balance (RewardsManager reads total signal as `graphToken.balanceOf(curation)`). +contract RMCurationMock { + MockGraphToken public immutable token; + IRewardsManager public rm; + + mapping(bytes32 subgraphDeploymentId => uint256 tokens) private _poolTokens; + + constructor(MockGraphToken token_) { + token = token_; + } + + function setRewardsManager(address rm_) external { + rm = IRewardsManager(rm_); + } + + /// Hook first, as Curation.mint does, so the RewardsManager accrues on the old signal. + function signal(bytes32 sd, uint256 tokens) external { + rm.onSubgraphSignalUpdate(sd); + _poolTokens[sd] += tokens; + token.mint(address(this), tokens); + } + + /// Hook first, as Curation.burn does. + function unsignal(bytes32 sd, uint256 tokens) external { + rm.onSubgraphSignalUpdate(sd); + _poolTokens[sd] -= tokens; + token.burn(tokens); + } + + /// Curation fees from query collection: no RewardsManager hook, as Curation.collect. + function addFees(bytes32 sd, uint256 tokens) external { + _poolTokens[sd] += tokens; + token.mint(address(this), tokens); + } + + function getCurationPoolTokens(bytes32 sd) external view returns (uint256) { + return _poolTokens[sd]; + } +} diff --git a/packages/issuance/test/unit/rewards-manager/mocks/RMEligibilityOracleMock.sol b/packages/issuance/test/unit/rewards-manager/mocks/RMEligibilityOracleMock.sol new file mode 100644 index 000000000..995f627f5 --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/mocks/RMEligibilityOracleMock.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import { IProviderEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibility.sol"; + +/// 0.8 port of packages/contracts/contracts/tests/MockRewardsEligibilityOracle.sol. +contract RMEligibilityOracleMock is IProviderEligibility, IERC165 { + mapping(address indexer => bool eligibility) private _eligible; + mapping(address indexer => bool set) private _isSet; + bool private _defaultResponse; + + constructor(bool newDefaultResponse) { + _defaultResponse = newDefaultResponse; + } + + function setIndexerEligible(address indexer, bool eligibility) external { + _eligible[indexer] = eligibility; + _isSet[indexer] = true; + } + + function setDefaultResponse(bool newDefaultResponse) external { + _defaultResponse = newDefaultResponse; + } + + /// Explicitly set indexers get their value; others get the default response. + function isEligible(address indexer) external view override returns (bool) { + if (_isSet[indexer]) { + return _eligible[indexer]; + } + return _defaultResponse; + } + + function supportsInterface(bytes4 interfaceId) public pure override returns (bool) { + return interfaceId == type(IProviderEligibility).interfaceId || interfaceId == type(IERC165).interfaceId; + } +} diff --git a/packages/issuance/test/unit/rewards-manager/mocks/RMIssuanceAllocatorMock.sol b/packages/issuance/test/unit/rewards-manager/mocks/RMIssuanceAllocatorMock.sol new file mode 100644 index 000000000..ecbb00dbd --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/mocks/RMIssuanceAllocatorMock.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import { TargetIssuancePerBlock } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceAllocatorTypes.sol"; +import { IIssuanceAllocationDistribution } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceAllocationDistribution.sol"; +import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; + +/// 0.8 port of packages/contracts/contracts/tests/MockIssuanceAllocator.sol. +contract RMIssuanceAllocatorMock is IERC165, IIssuanceAllocationDistribution { + mapping(address target => TargetIssuancePerBlock issuance) private _targetIssuance; + + function callBeforeIssuanceAllocationChange(address target) external { + IIssuanceTarget(target).beforeIssuanceAllocationChange(); + } + + function getTargetIssuancePerBlock(address target) external view override returns (TargetIssuancePerBlock memory) { + return _targetIssuance[target]; + } + + /// Always returns the current block number. + function distributeIssuance() external view override returns (uint256) { + return block.number; + } + + function setTargetAllocation( + address target, + uint256 allocatorIssuance, + uint256 selfIssuance, + bool callBefore + ) external { + if (callBefore) { + IIssuanceTarget(target).beforeIssuanceAllocationChange(); + } + _targetIssuance[target] = TargetIssuancePerBlock({ + allocatorIssuanceRate: allocatorIssuance, + allocatorIssuanceBlockAppliedTo: block.number, + selfIssuanceRate: selfIssuance, + selfIssuanceBlockAppliedTo: block.number + }); + } + + function supportsInterface(bytes4 interfaceId) public pure override returns (bool) { + return + interfaceId == type(IIssuanceAllocationDistribution).interfaceId || + interfaceId == type(IERC165).interfaceId; + } +} diff --git a/packages/issuance/test/unit/rewards-manager/mocks/RMIssuerMock.sol b/packages/issuance/test/unit/rewards-manager/mocks/RMIssuerMock.sol new file mode 100644 index 000000000..ce6fe3963 --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/mocks/RMIssuerMock.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRewardsIssuer } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsIssuer.sol"; +import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; + +/// Rewards issuer (subgraph service) stand-in for RewardsManager. +contract RMIssuerMock is IRewardsIssuer { + struct Allocation { + bool isActive; + address indexer; + bytes32 subgraphDeploymentId; + uint256 tokens; + uint256 accRewardsPerAllocatedToken; + uint256 accRewardsPending; + } + + IRewardsManager public rm; + + mapping(address allocationId => Allocation allocation) private _allocations; + mapping(bytes32 subgraphDeploymentId => uint256 tokens) private _subgraphAllocatedTokens; + + function setRewardsManager(address rm_) external { + rm = IRewardsManager(rm_); + } + + /// Hook first, so the snapshot is taken before the allocated tokens change. + function allocate(address allocationId, address indexer, bytes32 sd, uint256 tokens) external { + uint256 snapshot = rm.onSubgraphAllocationUpdate(sd); + _allocations[allocationId] = Allocation(true, indexer, sd, tokens, snapshot, 0); + _subgraphAllocatedTokens[sd] += tokens; + } + + /// Takes rewards while the allocation is still active, then deactivates it. + /// @dev Legacy Staking called `onSubgraphAllocationUpdate` instead of `takeRewards` for a zero POI or an + /// allocation under one epoch old. The ported tests never hit that case. + function close(address allocationId) external returns (uint256 rewards) { + rewards = rm.takeRewards(allocationId); + Allocation storage allocation = _allocations[allocationId]; + allocation.isActive = false; + _subgraphAllocatedTokens[allocation.subgraphDeploymentId] -= allocation.tokens; + } + + function setAllocation( + address allocationId, + bool isActive, + address indexer, + bytes32 sd, + uint256 tokens, + uint256 accRewardsPerAllocatedToken, + uint256 accRewardsPending + ) external { + _allocations[allocationId] = Allocation( + isActive, + indexer, + sd, + tokens, + accRewardsPerAllocatedToken, + accRewardsPending + ); + } + + function setSubgraphAllocatedTokens(bytes32 sd, uint256 tokens) external { + _subgraphAllocatedTokens[sd] = tokens; + } + + function reclaim(bytes32 reason, address allocationId) external returns (uint256) { + return rm.reclaimRewards(reason, allocationId); + } + + function getAllocationData( + address allocationId + ) + external + view + override + returns ( + bool isActive, + address indexer, + bytes32 subgraphDeploymentId, + uint256 tokens, + uint256 accRewardsPerAllocatedToken, + uint256 accRewardsPending + ) + { + Allocation storage allocation = _allocations[allocationId]; + return ( + allocation.isActive, + allocation.indexer, + allocation.subgraphDeploymentId, + allocation.tokens, + allocation.accRewardsPerAllocatedToken, + allocation.accRewardsPending + ); + } + + function getSubgraphAllocatedTokens(bytes32 sd) external view override returns (uint256) { + return _subgraphAllocatedTokens[sd]; + } +} diff --git a/packages/issuance/test/unit/rewards-manager/setup.t.sol b/packages/issuance/test/unit/rewards-manager/setup.t.sol new file mode 100644 index 000000000..9cdb39739 --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/setup.t.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +contract RewardsManagerSetupTest is RewardsManagerSharedTest { + function test_Setup_RewardsManagerWired() public view { + assertEq(address(rm.subgraphService()), address(issuer)); + assertEq(rm.issuancePerBlock(), 200 ether); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/shared.t.sol b/packages/issuance/test/unit/rewards-manager/shared.t.sol new file mode 100644 index 000000000..7c4c8416e --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/shared.t.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { ProtocolFixture } from "../shared/ProtocolFixture.t.sol"; +import { MockGraphToken } from "../mocks/MockGraphToken.sol"; +import { IRewardsManagerLike } from "./IRewardsManagerLike.sol"; +import { RMCurationMock } from "./mocks/RMCurationMock.sol"; +import { RMEligibilityOracleMock } from "./mocks/RMEligibilityOracleMock.sol"; +import { RMIssuanceAllocatorMock } from "./mocks/RMIssuanceAllocatorMock.sol"; +import { RMIssuerMock } from "./mocks/RMIssuerMock.sol"; + +/// @notice Shared setup for RewardsManager tests: the 0.7.6 RewardsManager artifact behind a GraphProxy, +/// wired to 0.8 mocks for GraphToken, Curation and the rewards issuer (subgraph service). +contract RewardsManagerSharedTest is ProtocolFixture { + uint256 internal constant ISSUANCE_PER_BLOCK = 200 ether; + uint256 internal constant SIGNAL = 1500 ether; + uint256 internal constant ALLOCATION = 12_500 ether; + + IRewardsManagerLike internal rm; + MockGraphToken internal token; + RMCurationMock internal curation; + RMIssuerMock internal issuer; + RMIssuanceAllocatorMock internal allocator; + RMEligibilityOracleMock internal oracle; + + address internal indexer = makeAddr("indexer"); + + function setUp() public virtual { + vm.warp(1_700_000_000); + vm.roll(1_000_000); + _deployProtocolBase(); + + address impl = _deployArtifact( + string.concat(PKG_ARTIFACTS, "rewards/RewardsManager.sol/RewardsManager.json"), + "" + ); + rm = IRewardsManagerLike( + _deployBehindProxy(impl, abi.encodeCall(IRewardsManagerLike.initialize, (address(controller)))) + ); + + token = new MockGraphToken(); + curation = new RMCurationMock(token); + issuer = new RMIssuerMock(); + allocator = new RMIssuanceAllocatorMock(); + oracle = new RMEligibilityOracleMock(true); + + _registerContract("GraphToken", address(token)); + _registerContract("Curation", address(curation)); + rm.syncAllContracts(); + + curation.setRewardsManager(address(rm)); + issuer.setRewardsManager(address(rm)); + + vm.startPrank(governor); + rm.setSubgraphService(address(issuer)); + rm.setIssuancePerBlock(ISSUANCE_PER_BLOCK); + vm.stopPrank(); + + vm.label(address(rm), "RewardsManager"); + vm.label(address(token), "GraphToken"); + vm.label(address(curation), "Curation"); + vm.label(address(issuer), "RewardsIssuer"); + vm.label(address(allocator), "IssuanceAllocator"); + vm.label(address(oracle), "EligibilityOracle"); + } + + // -- Helpers -- + + function _signal(bytes32 sd, uint256 tokens) internal { + curation.signal(sd, tokens); + } + + function _allocate(address allocationId, bytes32 sd, uint256 tokens) internal { + issuer.allocate(allocationId, indexer, sd, tokens); + } + + function _close(address allocationId) internal returns (uint256) { + return issuer.close(allocationId); + } + + function _expectedRewardsPerSignal(uint256 blocks, uint256 totalSignal) internal pure returns (uint256) { + return (blocks * ISSUANCE_PER_BLOCK * 1e18) / totalSignal; + } +} diff --git a/packages/issuance/test/unit/shared/ProtocolFixture.t.sol b/packages/issuance/test/unit/shared/ProtocolFixture.t.sol new file mode 100644 index 000000000..f38962c8e --- /dev/null +++ b/packages/issuance/test/unit/shared/ProtocolFixture.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Test } from "forge-std/Test.sol"; +import { IController } from "@graphprotocol/interfaces/contracts/contracts/governance/IController.sol"; +import { IGraphProxy } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxy.sol"; +import { IGraphProxyAdmin } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxyAdmin.sol"; + +abstract contract ProtocolFixture is Test { + string internal constant CORE_ARTIFACTS = "node_modules/@graphprotocol/contracts/artifacts/contracts/"; + string internal constant PKG_ARTIFACTS = "node_modules/@graphprotocol/contracts/artifacts/contracts/"; + + address internal governor; // makeAddr("governor"), set in _deployProtocolBase + IController internal controller; + IGraphProxyAdmin internal proxyAdmin; + + /// path is a full artifact path, e.g. string.concat(CORE_ARTIFACTS, "governance/Controller.sol/Controller.json"). + /// Deploys `.bytecode ++ args` with CREATE (honours an active vm.prank). + /// Reverts "artifact needs linking" if `.linkReferences` has any key (vm.parseJsonKeys), + /// and "artifact deploy failed" if CREATE returns 0. + function _deployArtifact(string memory path, bytes memory args) internal returns (address deployed) { + string memory json = vm.readFile(path); + require(vm.parseJsonKeys(json, ".linkReferences").length == 0, "artifact needs linking"); + bytes memory code = bytes.concat(vm.parseJsonBytes(json, ".bytecode"), args); + // solhint-disable-next-line no-inline-assembly + assembly { + deployed := create(0, add(code, 0x20), mload(code)) + } + require(deployed != address(0), "artifact deploy failed"); + } + + /// As governor: deploys Controller and GraphProxyAdmin from CORE_ARTIFACTS, then controller.setPaused(false). + /// Must be called with no active prank. + function _deployProtocolBase() internal { + governor = makeAddr("governor"); + vm.startPrank(governor); + controller = IController( + _deployArtifact(string.concat(CORE_ARTIFACTS, "governance/Controller.sol/Controller.json"), "") + ); + proxyAdmin = IGraphProxyAdmin( + _deployArtifact(string.concat(CORE_ARTIFACTS, "upgrades/GraphProxyAdmin.sol/GraphProxyAdmin.json"), "") + ); + controller.setPaused(false); + vm.stopPrank(); + } + + /// As governor: deploys GraphProxy(address(0), proxyAdmin) from CORE_ARTIFACTS, + /// proxyAdmin.upgrade(proxy, impl), proxyAdmin.acceptProxyAndCall(impl, proxy, initData). + /// Must be called with no active prank. + function _deployBehindProxy(address impl, bytes memory initData) internal returns (address proxy) { + vm.startPrank(governor); + proxy = _deployArtifact( + string.concat(CORE_ARTIFACTS, "upgrades/GraphProxy.sol/GraphProxy.json"), + abi.encode(address(0), address(proxyAdmin)) + ); + proxyAdmin.upgrade(IGraphProxy(proxy), impl); + proxyAdmin.acceptProxyAndCall(impl, IGraphProxy(proxy), initData); + vm.stopPrank(); + } + + /// As governor: controller.setContractProxy(keccak256(bytes(name)), addr). + function _registerContract(string memory name, address addr) internal { + vm.prank(governor); + controller.setContractProxy(keccak256(bytes(name)), addr); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 287d911a7..f9c30f314 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -976,6 +976,9 @@ importers: specifier: ^1.8.0 version: 1.8.0 devDependencies: + '@graphprotocol/contracts': + specifier: workspace:^ + version: link:../contracts '@graphprotocol/interfaces': specifier: workspace:^ version: link:../interfaces diff --git a/scripts/contracts-migration/test-mapping/t5a.md b/scripts/contracts-migration/test-mapping/t5a.md new file mode 100644 index 000000000..bd01b600e --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t5a.md @@ -0,0 +1,45 @@ +# T5a test mapping: RewardsManager interface and config + +New tests are in `packages/issuance`, deploying the RewardsManager Hardhat artifact behind a GraphProxy via `ProtocolFixture`. `RewardsManagerSharedTest` (`test/unit/rewards-manager/shared.t.sol`) wires it to 0.8 mocks: `MockGraphToken` as GraphToken, `RMCurationMock` as Curation and `RMIssuerMock` as the subgraph service, with `issuancePerBlock` at 200 GRT as in the old `before` hooks. `curation.mint` becomes `_signal`, `helpers.mine` becomes `vm.roll`, and the random deployment ID is a fixed `keccak256` value. `test/unit/rewards-manager/setup.t.sol::test_Setup_RewardsManagerWired` checks the fixture wiring and has no old test. + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › Interface ID Stability › IERC165 should have stable interface ID | test/unit/rewards-manager/interface.t.sol::test_InterfaceIdStability_IERC165 | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › Interface ID Stability › IIssuanceTarget should have stable interface ID | test/unit/rewards-manager/interface.t.sol::test_InterfaceIdStability_IIssuanceTarget | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › Interface ID Stability › IRewardsManager should have stable interface ID | test/unit/rewards-manager/interface.t.sol::test_InterfaceIdStability_IRewardsManager | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › supportsInterface › should support IIssuanceTarget interface | test/unit/rewards-manager/interface.t.sol::test_SupportsInterface_IIssuanceTarget | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › supportsInterface › should support IRewardsManager interface | test/unit/rewards-manager/interface.t.sol::test_SupportsInterface_IRewardsManager | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › supportsInterface › should support IERC165 interface | test/unit/rewards-manager/interface.t.sol::test_SupportsInterface_IERC165 | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › supportsInterface › should support IProviderEligibilityManagement interface | test/unit/rewards-manager/interface.t.sol::test_SupportsInterface_IProviderEligibilityManagement | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › supportsInterface › should return false for unsupported interfaces | test/unit/rewards-manager/interface.t.sol::test_SupportsInterface_FalseForUnsupported | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › getter functions › should return zero address for issuance allocator when not set | test/unit/rewards-manager/interface.t.sol::test_GetterFunctions_IssuanceAllocatorZeroWhenNotSet | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › getter functions › should return zero address for rewards eligibility oracle when not set | test/unit/rewards-manager/interface.t.sol::test_GetterFunctions_EligibilityOracleZeroWhenNotSet | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › getter functions › should return zero address for reclaim address when not set | test/unit/rewards-manager/interface.t.sol::test_GetterFunctions_ReclaimAddressZeroWhenNotSet | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › calcRewards › should calculate rewards correctly | test/unit/rewards-manager/interface.t.sol::test_CalcRewards_Calculates | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › calcRewards › should return 0 when tokens is 0 | test/unit/rewards-manager/interface.t.sol::test_CalcRewards_ZeroWhenTokensZero | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › calcRewards › should return 0 when accRewardsPerAllocatedToken is 0 | test/unit/rewards-manager/interface.t.sol::test_CalcRewards_ZeroWhenAccRewardsPerAllocatedTokenZero | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › initialize › should revert when called on implementation contract | test/unit/rewards-manager/config.t.sol::test_Initialize_RevertWhen_CalledOnImplementation | Called on the proxy, as the old test did | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › issuance per block update › should reject set issuance per block if unauthorized | test/unit/rewards-manager/config.t.sol::test_IssuancePerBlockUpdate_RevertWhen_Unauthorized | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › issuance per block update › should set issuance rate to minimum allowed (0) | test/unit/rewards-manager/config.t.sol::test_IssuancePerBlockUpdate_SetToMinimumZero | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › issuance per block update › should set issuance rate | test/unit/rewards-manager/config.t.sol::test_IssuancePerBlockUpdate_SetIssuanceRate | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › issuance per block update › should update timestamp when transitioning from zero to non-zero issuance | test/unit/rewards-manager/config.t.sol::test_IssuancePerBlockUpdate_UpdatesTimestampFromZeroToNonZero | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › issuance per block update › should not over-issue rewards after zero issuance period | test/unit/rewards-manager/config.t.sol::test_IssuancePerBlockUpdate_NoOverIssuanceAfterZeroPeriod | Exact instead of ±1 block: no stray block between the update and the zero-issuance set | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should reject set subgraph oracle if unauthorized | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_RevertWhen_SetOracleUnauthorized | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should set subgraph oracle if governor | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_SetOracle | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should reject to deny subgraph if not the oracle | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_RevertWhen_DenyNotOracle | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should deny subgraph | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_Deny | `blockNum + 1` becomes `block.number` | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should allow removing subgraph from denylist | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_Undeny | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should be a no-op when denying an already denied subgraph | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_DenyAlreadyDeniedIsNoOp | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should be a no-op when undenying an already not-denied subgraph | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_UndenyNotDeniedIsNoOp | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should reject setMinimumSubgraphSignal if unauthorized | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_RevertWhen_SetMinimumSubgraphSignalUnauthorized | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should allow setMinimumSubgraphSignal from subgraph availability oracle | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_SetMinimumSubgraphSignalFromOracle | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should allow setMinimumSubgraphSignal from governor | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_SetMinimumSubgraphSignalFromGovernor | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › revertOnIneligible › should reject setRevertOnIneligible if unauthorized | test/unit/rewards-manager/config.t.sol::test_RevertOnIneligible_RevertWhen_Unauthorized | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › revertOnIneligible › should set revertOnIneligible to true | test/unit/rewards-manager/config.t.sol::test_RevertOnIneligible_SetTrue | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › revertOnIneligible › should set revertOnIneligible to false | test/unit/rewards-manager/config.t.sol::test_RevertOnIneligible_SetFalse | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › revertOnIneligible › should be a no-op when setting same value (false to false) | test/unit/rewards-manager/config.t.sol::test_RevertOnIneligible_SameValueFalseIsNoOp | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › revertOnIneligible › should be a no-op when setting same value (true to true) | test/unit/rewards-manager/config.t.sol::test_RevertOnIneligible_SameValueTrueIsNoOp | | + +## Coverage + +MISSING getAccRewardsForSubgraph getAccRewardsPerAllocatedToken getAccRewardsPerSignal getAllocatedIssuancePerBlock getDefaultReclaimAddress getNewRewardsPerSignal getRawIssuancePerBlock getRewards setDefaultReclaimAddress setIssuanceAllocator setProviderEligibilityOracle setReclaimAddress: ported in T5b–T5f. `takeRewards`, `reclaimRewards` and `onSubgraphAllocationUpdate` match the grep only through the mocks (`RMIssuerMock`); no T5a test reaches `takeRewards` or `reclaimRewards` (ported in T5b–T5f), and `onSubgraphAllocationUpdate` is reached through `setDenied`. `initialize` runs in every `setUp` through the proxy init data. The checklist is closed out in T5f. From 47f8a8afa3d25edcacf3a91ba0761c7ffb589a56 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 26 Oct 2026 16:32:14 -0300 Subject: [PATCH 15/20] test(issuance): port RewardsManager calculations and distribution tests to Foundry --- .../unit/rewards/rewards-calculations.test.ts | 425 ---------- .../unit/rewards/rewards-distribution.test.ts | 745 ------------------ .../unit/rewards-manager/calculations.t.sol | 168 ++++ .../test/unit/rewards-manager/config.t.sol | 8 - .../unit/rewards-manager/distribution.t.sol | 265 +++++++ .../test/unit/rewards-manager/shared.t.sol | 24 + .../contracts-migration/test-mapping/t5b.md | 37 + 7 files changed, 494 insertions(+), 1178 deletions(-) delete mode 100644 packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts delete mode 100644 packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts create mode 100644 packages/issuance/test/unit/rewards-manager/calculations.t.sol create mode 100644 packages/issuance/test/unit/rewards-manager/distribution.t.sol create mode 100644 scripts/contracts-migration/test-mapping/t5b.md diff --git a/packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts deleted file mode 100644 index 168166745..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { Curation } from '@graphprotocol/contracts' -import { EpochManager } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { IStaking } from '@graphprotocol/contracts' -import { RewardsManager } from '@graphprotocol/contracts' -import { - deriveChannelKey, - formatGRT, - GraphNetworkContracts, - helpers, - randomHexBytes, - toBN, - toGRT, -} from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { BigNumber as BN } from 'bignumber.js' -import { expect } from 'chai' -import { BigNumber, constants } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const { HashZero, WeiPerEther } = constants - -const toRound = (n: BigNumber) => formatGRT(n.add(toGRT('0.5'))).split('.')[0] - -describe('Rewards - Calculations', () => { - const graph = hre.graph() - let governor: SignerWithAddress - let curator1: SignerWithAddress - let curator2: SignerWithAddress - let indexer1: SignerWithAddress - let indexer2: SignerWithAddress - let assetHolder: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let grt: GraphToken - let curation: Curation - let epochManager: EpochManager - let staking: IStaking - let rewardsManager: RewardsManager - - // Derive some channel keys for each indexer used to sign attestations - const channelKey1 = deriveChannelKey() - const channelKey2 = deriveChannelKey() - - const subgraphDeploymentID1 = randomHexBytes() - const subgraphDeploymentID2 = randomHexBytes() - - const allocationID1 = channelKey1.address - const allocationID2 = channelKey2.address - - const metadata = HashZero - - const ISSUANCE_RATE_PERIODS = 4 // blocks required to issue 800 GRT rewards - const ISSUANCE_PER_BLOCK = toBN('200000000000000000000') // 200 GRT every block - - // Core formula that gets accumulated rewards per signal for a period of time - const getRewardsPerSignal = (k: BN, t: BN, s: BN): string => { - if (s.eq(0)) { - return '0' - } - return k.times(t).div(s).toPrecision(18).toString() - } - - // Tracks the accumulated rewards as totalSignalled or supply changes across snapshots - class RewardsTracker { - totalSignalled = BigNumber.from(0) - lastUpdatedBlock = 0 - accumulated = BigNumber.from(0) - - static async create() { - const tracker = new RewardsTracker() - await tracker.snapshot() - return tracker - } - - async snapshot() { - this.accumulated = this.accumulated.add(await this.accrued()) - this.totalSignalled = await grt.balanceOf(curation.address) - this.lastUpdatedBlock = await helpers.latestBlock() - return this - } - - async elapsedBlocks() { - const currentBlock = await helpers.latestBlock() - return currentBlock - this.lastUpdatedBlock - } - - async accrued() { - const nBlocks = await this.elapsedBlocks() - return this.accruedByElapsed(nBlocks) - } - - accruedByElapsed(nBlocks: BigNumber | number) { - const n = getRewardsPerSignal( - new BN(ISSUANCE_PER_BLOCK.toString()), - new BN(nBlocks.toString()), - new BN(this.totalSignalled.toString()), - ) - return toGRT(n) - } - } - - // Test accumulated rewards per signal - const shouldGetNewRewardsPerSignal = async (nBlocks = ISSUANCE_RATE_PERIODS) => { - // -- t0 -- - const tracker = await RewardsTracker.create() - - // Jump - await helpers.mine(nBlocks) - - // -- t1 -- - - // Contract calculation - const contractAccrued = await rewardsManager.getNewRewardsPerSignal() - // Local calculation - const expectedAccrued = await tracker.accrued() - - // Check - expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) - return expectedAccrued - } - - before(async function () { - const testAccounts = await graph.getTestAccounts() - ;[indexer1, indexer2, curator1, curator2, assetHolder] = testAccounts - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - grt = contracts.GraphToken as GraphToken - curation = contracts.Curation as Curation - epochManager = contracts.EpochManager - staking = contracts.Staking as IStaking - rewardsManager = contracts.RewardsManager - - // 200 GRT per block - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - - // Distribute test funds - for (const wallet of [indexer1, indexer2, curator1, curator2, assetHolder]) { - await grt.connect(governor).mint(wallet.address, toGRT('1000000')) - await grt.connect(wallet).approve(staking.address, toGRT('1000000')) - await grt.connect(wallet).approve(curation.address, toGRT('1000000')) - } - - // Set the staking contract as the subgraph service so it can call takeRewards - await rewardsManager.connect(governor).setSubgraphService(staking.address) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - context('issuing rewards', function () { - beforeEach(async function () { - // 5% minute rate (4 blocks) - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - }) - - describe('getNewRewardsPerSignal', function () { - it('accrued per signal when no tokens signalled', async function () { - // When there is no tokens signalled no rewards are accrued - await helpers.mineEpoch(epochManager) - const accrued = await rewardsManager.getNewRewardsPerSignal() - expect(accrued).eq(0) - }) - - it('accrued per signal when tokens signalled', async function () { - // Update total signalled - const tokensToSignal = toGRT('1000') - await curation.connect(curator1).mint(subgraphDeploymentID1, tokensToSignal, 0) - - // Check - await shouldGetNewRewardsPerSignal() - }) - - it('accrued per signal when signalled tokens w/ many subgraphs', async function () { - // Update total signalled - await curation.connect(curator1).mint(subgraphDeploymentID1, toGRT('1000'), 0) - - // Check - await shouldGetNewRewardsPerSignal() - - // Update total signalled - await curation.connect(curator2).mint(subgraphDeploymentID2, toGRT('250'), 0) - - // Check - await shouldGetNewRewardsPerSignal() - }) - }) - - describe('updateAccRewardsPerSignal', function () { - it('update the accumulated rewards per signal state', async function () { - // Update total signalled - await curation.connect(curator1).mint(subgraphDeploymentID1, toGRT('1000'), 0) - // Snapshot - const tracker = await RewardsTracker.create() - - // Update - await rewardsManager.connect(governor).updateAccRewardsPerSignal() - const contractAccrued = await rewardsManager.accRewardsPerSignal() - - // Check - const expectedAccrued = await tracker.accrued() - expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) - }) - - it('update the accumulated rewards per signal state after many blocks', async function () { - // Update total signalled - await curation.connect(curator1).mint(subgraphDeploymentID1, toGRT('1000'), 0) - // Snapshot - const tracker = await RewardsTracker.create() - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Update - await rewardsManager.connect(governor).updateAccRewardsPerSignal() - const contractAccrued = await rewardsManager.accRewardsPerSignal() - - // Check - const expectedAccrued = await tracker.accrued() - expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) - }) - }) - - describe('getAccRewardsForSubgraph', function () { - it('accrued for each subgraph', async function () { - // Option B model: rewards only accumulate when allocations exist - const tokensToAllocate = toGRT('12500') - const signalled1 = toGRT('1500') - const signalled2 = toGRT('500') - - // Setup both subgraphs with signal first - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - await curation.connect(curator2).mint(subgraphDeploymentID2, signalled2, 0) - - // Setup both allocations - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - await staking.connect(indexer2).stake(tokensToAllocate) - await staking - .connect(indexer2) - .allocateFrom( - indexer2.address, - subgraphDeploymentID2, - tokensToAllocate, - allocationID2, - metadata, - await channelKey2.generateProof(indexer2.address), - ) - - // Jump to accumulate more rewards - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Get rewards from contract - const contractRewardsSG1 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) - const contractRewardsSG2 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID2) - - // Both subgraphs should have non-zero rewards - expect(contractRewardsSG1).to.be.gt(0) - expect(contractRewardsSG2).to.be.gt(0) - - // SG1 should have more rewards than SG2 (has more signal and allocation was created first) - expect(contractRewardsSG1).to.be.gt(contractRewardsSG2) - }) - - it('should return zero rewards when subgraph signal is below minimum threshold', async function () { - // Set a high minimum signal threshold - const highMinimumSignal = toGRT('2000') - await rewardsManager.connect(governor).setMinimumSubgraphSignal(highMinimumSignal) - - // Signal less than the minimum threshold - const lowSignal = toGRT('1000') - await curation.connect(curator1).mint(subgraphDeploymentID1, lowSignal, 0) - - // Jump some blocks to potentially accrue rewards - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Check that no rewards are accrued due to minimum signal threshold - const contractRewards = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) - expect(contractRewards).eq(0) - }) - }) - - describe('onSubgraphSignalUpdate', function () { - it('update the accumulated rewards for subgraph state', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - Option B requires allocation for rewards to accumulate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Snapshot after allocation - const tracker1 = await RewardsTracker.create() - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Update - await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID1) - - // Check - const contractRewardsSG1 = (await rewardsManager.subgraphs(subgraphDeploymentID1)).accRewardsForSubgraph - const rewardsPerSignal1 = await tracker1.accrued() - const expectedRewardsSG1 = rewardsPerSignal1.mul(signalled1).div(WeiPerEther) - expect(toRound(expectedRewardsSG1)).eq(toRound(contractRewardsSG1)) - - const contractAccrued = await rewardsManager.accRewardsPerSignal() - const expectedAccrued = await tracker1.accrued() - expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) - - const contractBlockUpdated = await rewardsManager.accRewardsPerSignalLastBlockUpdated() - const expectedBlockUpdated = await helpers.latestBlock() - expect(expectedBlockUpdated).eq(contractBlockUpdated) - }) - }) - - describe('getAccRewardsPerAllocatedToken', function () { - it('accrued per allocated token', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Check - const sg1 = await rewardsManager.subgraphs(subgraphDeploymentID1) - // We trust this function because it was individually tested in previous test - const accRewardsForSubgraphSG1 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) - const accruedRewardsSG1 = accRewardsForSubgraphSG1.sub(sg1.accRewardsForSubgraphSnapshot) - const expectedRewardsAT1 = accruedRewardsSG1.mul(WeiPerEther).div(tokensToAllocate) - const contractRewardsAT1 = (await rewardsManager.getAccRewardsPerAllocatedToken(subgraphDeploymentID1))[0] - expect(expectedRewardsAT1).eq(contractRewardsAT1) - }) - }) - - describe('onSubgraphAllocationUpdate', function () { - it('update the accumulated rewards for allocated tokens state', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Prepare expected results - // Option B model: accRewardsForSubgraph only tracks distributable rewards - // 2 blocks before allocation = reclaimed (NO_ALLOCATED_TOKENS), 5 blocks after = distributable - const expectedSubgraphRewards = toGRT('1000') // 5 blocks × 200 GRT/block - const expectedRewardsAT = toGRT('0.08') // 1000 GRT / 12500 allocated tokens - - // Update - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - // Check on demand results saved - const subgraph = await rewardsManager.subgraphs(subgraphDeploymentID1) - const contractSubgraphRewards = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) - const contractRewardsAT = subgraph.accRewardsPerAllocatedToken - - expect(toRound(expectedSubgraphRewards)).eq(toRound(contractSubgraphRewards)) - expect(toRound(expectedRewardsAT.mul(1000))).eq(toRound(contractRewardsAT.mul(1000))) - }) - }) - }) -}) diff --git a/packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts deleted file mode 100644 index e34ace2fd..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts +++ /dev/null @@ -1,745 +0,0 @@ -import { Curation } from '@graphprotocol/contracts' -import { EpochManager } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { IStaking } from '@graphprotocol/contracts' -import { RewardsManager } from '@graphprotocol/contracts' -import { - deriveChannelKey, - formatGRT, - GraphNetworkContracts, - helpers, - randomHexBytes, - toBN, - toGRT, -} from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber, constants } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const MAX_PPM = 1000000 - -// TODO: Behavior change - HorizonRewardsAssigned is no longer emitted when rewards == 0 -// Set to true if the old behavior is restored (emitting event for zero rewards) -const EMIT_EVENT_FOR_ZERO_REWARDS = false - -const { HashZero, WeiPerEther } = constants - -const toRound = (n: BigNumber) => formatGRT(n.add(toGRT('0.5'))).split('.')[0] - -describe('Rewards - Distribution', () => { - const graph = hre.graph() - let delegator: SignerWithAddress - let governor: SignerWithAddress - let curator1: SignerWithAddress - let curator2: SignerWithAddress - let indexer1: SignerWithAddress - let assetHolder: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let grt: GraphToken - let curation: Curation - let epochManager: EpochManager - let staking: IStaking - let rewardsManager: RewardsManager - - // Derive some channel keys for each indexer used to sign attestations - const channelKey1 = deriveChannelKey() - const channelKey2 = deriveChannelKey() - const channelKeyNull = deriveChannelKey() - - const subgraphDeploymentID1 = randomHexBytes() - const subgraphDeploymentID2 = randomHexBytes() - - const allocationID1 = channelKey1.address - const allocationID2 = channelKey2.address - const allocationIDNull = channelKeyNull.address - - const metadata = HashZero - - const ISSUANCE_RATE_PERIODS = 4 // blocks required to issue 800 GRT rewards - const ISSUANCE_PER_BLOCK = toBN('200000000000000000000') // 200 GRT every block - - before(async function () { - ;[delegator, curator1, curator2, indexer1, assetHolder] = await graph.getTestAccounts() - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - grt = contracts.GraphToken as GraphToken - curation = contracts.Curation as Curation - epochManager = contracts.EpochManager - staking = contracts.Staking as IStaking - rewardsManager = contracts.RewardsManager - - // 200 GRT per block - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - - // Distribute test funds - for (const wallet of [indexer1, curator1, curator2, assetHolder]) { - await grt.connect(governor).mint(wallet.address, toGRT('1000000')) - await grt.connect(wallet).approve(staking.address, toGRT('1000000')) - await grt.connect(wallet).approve(curation.address, toGRT('1000000')) - } - - // Set the staking contract as the subgraph service so it can call takeRewards - await rewardsManager.connect(governor).setSubgraphService(staking.address) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - context('issuing rewards', function () { - beforeEach(async function () { - // 5% minute rate (4 blocks) - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - }) - - describe('getRewards', function () { - it('calculate rewards using the subgraph signalled + allocated tokens', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Rewards - const contractRewards = await rewardsManager.getRewards(staking.address, allocationID1) - - // We trust using this function in the test because we tested it - // standalone in a previous test - const contractRewardsAT1 = (await rewardsManager.getAccRewardsPerAllocatedToken(subgraphDeploymentID1))[0] - - const expectedRewards = contractRewardsAT1.mul(tokensToAllocate).div(WeiPerEther) - expect(expectedRewards).eq(contractRewards) - }) - it('rewards should be zero if the allocation is closed', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - await helpers.mineEpoch(epochManager) - - // Close allocation - await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - // Rewards - const contractRewards = await rewardsManager.getRewards(staking.address, allocationID1) - expect(contractRewards).eq(BigNumber.from(0)) - }) - it('rewards should be zero if the allocation does not exist', async function () { - // Rewards - const contractRewards = await rewardsManager.getRewards(staking.address, allocationIDNull) - expect(contractRewards).eq(BigNumber.from(0)) - }) - }) - - describe('takeRewards', function () { - interface DelegationParameters { - indexingRewardCut: BigNumber - queryFeeCut: BigNumber - cooldownBlocks: number - } - - async function setupIndexerAllocation() { - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - } - - async function setupIndexerAllocationSignalingAfter() { - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - } - - async function setupIndexerAllocationWithDelegation( - tokensToDelegate: BigNumber, - delegationParams: DelegationParameters, - ) { - const tokensToAllocate = toGRT('12500') - - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Transfer some funds from the curator, I don't want to mint new tokens - await grt.connect(curator1).transfer(delegator.address, tokensToDelegate) - await grt.connect(delegator).approve(staking.address, tokensToDelegate) - - // Stake and set delegation parameters - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .setDelegationParameters(delegationParams.indexingRewardCut, delegationParams.queryFeeCut, 0) - - // Delegate - await staking.connect(delegator).delegate(indexer1.address, tokensToDelegate) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - } - - it('should distribute rewards on closed allocation and stake', async function () { - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup - await setupIndexerAllocation() - - // Jump - await helpers.mineEpoch(epochManager) - - // Before state - const beforeTokenSupply = await grt.totalSupply() - const beforeIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - const beforeIndexer1Balance = await grt.balanceOf(indexer1.address) - const beforeStakingBalance = await grt.balanceOf(staking.address) - - // All the rewards in this subgraph go to this allocation. - // Rewards per token will be (issuancePerBlock * nBlocks) / allocatedTokens - // The first snapshot is after allocating, that is 2 blocks after the signal is minted. - // The final snapshot is when we close the allocation, that happens 9 blocks after signal is minted. - // So the rewards will be ((issuancePerBlock * 7) / allocatedTokens) * allocatedTokens - const expectedIndexingRewards = toGRT('1400') - - // Close allocation. At this point rewards should be collected for that indexer - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - const event = rewardsManager.interface.parseLog(receipt.logs[1]).args - expect(event.indexer).eq(indexer1.address) - expect(event.allocationID).eq(allocationID1) - expect(toRound(event.amount)).eq(toRound(expectedIndexingRewards)) - - // After state - const afterTokenSupply = await grt.totalSupply() - const afterIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - const afterIndexer1Balance = await grt.balanceOf(indexer1.address) - const afterStakingBalance = await grt.balanceOf(staking.address) - - // Check that rewards are put into indexer stake - const expectedIndexerStake = beforeIndexer1Stake.add(expectedIndexingRewards) - const expectedTokenSupply = beforeTokenSupply.add(expectedIndexingRewards) - // Check stake should have increased with the rewards staked - expect(toRound(afterIndexer1Stake)).eq(toRound(expectedIndexerStake)) - // Check indexer balance remains the same - expect(afterIndexer1Balance).eq(beforeIndexer1Balance) - // Check indexing rewards are kept in the staking contract - expect(toRound(afterStakingBalance)).eq(toRound(beforeStakingBalance.add(expectedIndexingRewards))) - // Check that tokens have been minted - expect(toRound(afterTokenSupply)).eq(toRound(expectedTokenSupply)) - }) - - it('does not revert with an underflow if the minimum signal changes', async function () { - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup - await setupIndexerAllocation() - - await rewardsManager.connect(governor).setMinimumSubgraphSignal(toGRT(14000)) - - // Jump - await helpers.mineEpoch(epochManager) - - // Close allocation. At this point rewards should be collected for that indexer - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - if (EMIT_EVENT_FOR_ZERO_REWARDS) { - await expect(tx) - .emit(rewardsManager, 'HorizonRewardsAssigned') - .withArgs(indexer1.address, allocationID1, toBN(0)) - } else { - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - } - }) - - it('does not revert with an underflow if the minimum signal changes, and signal came after allocation', async function () { - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup - await setupIndexerAllocationSignalingAfter() - - await rewardsManager.connect(governor).setMinimumSubgraphSignal(toGRT(14000)) - - // Jump - await helpers.mineEpoch(epochManager) - - // Close allocation. At this point rewards should be collected for that indexer - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - if (EMIT_EVENT_FOR_ZERO_REWARDS) { - await expect(tx) - .emit(rewardsManager, 'HorizonRewardsAssigned') - .withArgs(indexer1.address, allocationID1, toBN(0)) - } else { - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - } - }) - - it('does not revert if signal was already under minimum', async function () { - await rewardsManager.connect(governor).setMinimumSubgraphSignal(toGRT(2000)) - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup - await setupIndexerAllocation() - - // Jump - await helpers.mineEpoch(epochManager) - // Close allocation. At this point rewards should be collected for that indexer - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - if (EMIT_EVENT_FOR_ZERO_REWARDS) { - await expect(tx) - .emit(rewardsManager, 'HorizonRewardsAssigned') - .withArgs(indexer1.address, allocationID1, toBN(0)) - } else { - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - } - }) - - it('should distribute rewards on closed allocation and send to destination', async function () { - const destinationAddress = randomHexBytes(20) - await staking.connect(indexer1).setRewardsDestination(destinationAddress) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup - await setupIndexerAllocation() - - // Jump - await helpers.mineEpoch(epochManager) - - // Before state - const beforeTokenSupply = await grt.totalSupply() - const beforeIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - const beforeDestinationBalance = await grt.balanceOf(destinationAddress) - const beforeStakingBalance = await grt.balanceOf(staking.address) - - // All the rewards in this subgraph go to this allocation. - // Rewards per token will be (issuancePerBlock * nBlocks) / allocatedTokens - // The first snapshot is after allocating, that is 2 blocks after the signal is minted. - // The final snapshot is when we close the allocation, that happens 9 blocks after signal is minted. - // So the rewards will be ((issuancePerBlock * 7) / allocatedTokens) * allocatedTokens - const expectedIndexingRewards = toGRT('1400') - - // Close allocation. At this point rewards should be collected for that indexer - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - const event = rewardsManager.interface.parseLog(receipt.logs[1]).args - expect(event.indexer).eq(indexer1.address) - expect(event.allocationID).eq(allocationID1) - expect(toRound(event.amount)).eq(toRound(expectedIndexingRewards)) - - // After state - const afterTokenSupply = await grt.totalSupply() - const afterIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - const afterDestinationBalance = await grt.balanceOf(destinationAddress) - const afterStakingBalance = await grt.balanceOf(staking.address) - - // Check that rewards are properly assigned - const expectedIndexerStake = beforeIndexer1Stake - const expectedTokenSupply = beforeTokenSupply.add(expectedIndexingRewards) - // Check stake should not have changed - expect(toRound(afterIndexer1Stake)).eq(toRound(expectedIndexerStake)) - // Check indexing rewards are received by the rewards destination - expect(toRound(afterDestinationBalance)).eq(toRound(beforeDestinationBalance.add(expectedIndexingRewards))) - // Check indexing rewards were not sent to the staking contract - expect(afterStakingBalance).eq(beforeStakingBalance) - // Check that tokens have been minted - expect(toRound(afterTokenSupply)).eq(toRound(expectedTokenSupply)) - }) - - it('should distribute rewards on closed allocation w/delegators', async function () { - // Setup - const delegationParams = { - indexingRewardCut: toBN('823000'), // 82.30% - queryFeeCut: toBN('80000'), // 8% - cooldownBlocks: 0, - } - const tokensToDelegate = toGRT('2000') - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup the allocation and delegators - await setupIndexerAllocationWithDelegation(tokensToDelegate, delegationParams) - - // Jump - await helpers.mineEpoch(epochManager) - - // Before state - const beforeTokenSupply = await grt.totalSupply() - const beforeDelegationPool = await staking.delegationPools(indexer1.address) - const beforeIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - - // Close allocation. At this point rewards should be collected for that indexer - await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - // After state - const afterTokenSupply = await grt.totalSupply() - const afterDelegationPool = await staking.delegationPools(indexer1.address) - const afterIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - - // Check that rewards are put into indexer stake (only indexer cut) - // Check that rewards are put into delegators pool accordingly - - // All the rewards in this subgraph go to this allocation. - // Rewards per token will be (issuancePerBlock * nBlocks) / allocatedTokens - // The first snapshot is after allocating, that is 1 block after the signal is minted. - // The final snapshot is when we close the allocation, that happens 4 blocks after signal is minted. - // So the rewards will be ((issuancePerBlock * 3) / allocatedTokens) * allocatedTokens - const expectedIndexingRewards = toGRT('600') - // Calculate delegators cut - const indexerRewards = delegationParams.indexingRewardCut.mul(expectedIndexingRewards).div(toBN(MAX_PPM)) - // Calculate indexer cut - const delegatorsRewards = expectedIndexingRewards.sub(indexerRewards) - // Check - const expectedIndexerStake = beforeIndexer1Stake.add(indexerRewards) - const expectedDelegatorsPoolTokens = beforeDelegationPool.tokens.add(delegatorsRewards) - const expectedTokenSupply = beforeTokenSupply.add(expectedIndexingRewards) - expect(toRound(afterIndexer1Stake)).eq(toRound(expectedIndexerStake)) - expect(toRound(afterDelegationPool.tokens)).eq(toRound(expectedDelegatorsPoolTokens)) - // Check that tokens have been minted - expect(toRound(afterTokenSupply)).eq(toRound(expectedTokenSupply)) - }) - - it('should deny rewards if subgraph on denylist', async function () { - // Setup: create allocation BEFORE denying the subgraph - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Create allocation - await setupIndexerAllocation() - - // Jump to earn some rewards - await helpers.mineEpoch(epochManager) - - // Now deny the subgraph - this freezes accRewardsPerAllocatedToken - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Close allocation - pre-denial rewards should be denied - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - await expect(tx).emit(rewardsManager, 'RewardsDenied').withArgs(indexer1.address, allocationID1) - }) - - it('should handle zero rewards scenario correctly', async function () { - // Setup allocation with zero issuance to create zero rewards scenario - await rewardsManager.connect(governor).setIssuancePerBlock(0) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Before state - const beforeTokenSupply = await grt.totalSupply() - const beforeStakingBalance = await grt.balanceOf(staking.address) - - // Close allocation. At this point rewards should be zero - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - if (EMIT_EVENT_FOR_ZERO_REWARDS) { - await expect(tx).emit(rewardsManager, 'HorizonRewardsAssigned').withArgs(indexer1.address, allocationID1, 0) - } else { - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - } - - // After state - should be unchanged since no rewards were minted - const afterTokenSupply = await grt.totalSupply() - const afterStakingBalance = await grt.balanceOf(staking.address) - - // Check that no tokens were minted (rewards were 0) - expect(afterTokenSupply).eq(beforeTokenSupply) - expect(afterStakingBalance).eq(beforeStakingBalance) - }) - }) - }) - - describe('edge scenarios', function () { - it('close allocation on a subgraph that no longer have signal', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Jump - await helpers.mineEpoch(epochManager) - - // Remove all signal from the subgraph - const curatorShares = await curation.getCuratorSignal(curator1.address, subgraphDeploymentID1) - await curation.connect(curator1).burn(subgraphDeploymentID1, curatorShares, 0) - - // Close allocation. At this point rewards should be collected for that indexer - await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - }) - }) - - describe('multiple allocations', function () { - it('two allocations in the same block with a GRT burn in the middle should succeed', async function () { - // If rewards are not monotonically increasing, this can trigger - // a subtraction overflow error as seen in mainnet tx: - // 0xb6bf7bbc446720a7409c482d714aebac239dd62e671c3c94f7e93dd3a61835ab - await helpers.mineEpoch(epochManager) - - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Stake - const tokensToStake = toGRT('12500') - await staking.connect(indexer1).stake(tokensToStake) - - // Allocate simultaneously, burning in the middle - const tokensToAlloc = toGRT('5000') - await helpers.setAutoMine(false) - const tx1 = await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAlloc, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - const tx2 = await grt.connect(indexer1).burn(toGRT(1)) - const tx3 = await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAlloc, - allocationID2, - metadata, - await channelKey2.generateProof(indexer1.address), - ) - - await helpers.mine() - await helpers.setAutoMine(true) - - await expect(tx1).emit(staking, 'AllocationCreated') - await expect(tx2).emit(grt, 'Transfer') - await expect(tx3).emit(staking, 'AllocationCreated') - }) - it('two simultanous-similar allocations should get same amount of rewards', async function () { - await helpers.mineEpoch(epochManager) - - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Stake - const tokensToStake = toGRT('12500') - await staking.connect(indexer1).stake(tokensToStake) - - // Allocate simultaneously - const tokensToAlloc = toGRT('5000') - const tx1 = await staking.populateTransaction.allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAlloc, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - const tx2 = await staking.populateTransaction.allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAlloc, - allocationID2, - metadata, - await channelKey2.generateProof(indexer1.address), - ) - await staking.connect(indexer1).multicall([tx1.data, tx2.data]) - - // Jump - await helpers.mineEpoch(epochManager) - - // Close allocations simultaneously - const tx3 = await staking.populateTransaction.closeAllocation(allocationID1, randomHexBytes()) - const tx4 = await staking.populateTransaction.closeAllocation(allocationID2, randomHexBytes()) - const tx5 = await staking.connect(indexer1).multicall([tx3.data, tx4.data]) - - // Both allocations should receive the same amount of rewards - const receipt = await tx5.wait() - const event1 = rewardsManager.interface.parseLog(receipt.logs[1]).args - const event2 = rewardsManager.interface.parseLog(receipt.logs[5]).args - expect(event1.amount).eq(event2.amount) - }) - }) - - describe('rewards progression when collecting query fees', function () { - it('collect query fees with two subgraphs and one allocation', async function () { - async function getRewardsAccrual(subgraphs) { - const [sg1, sg2] = await Promise.all(subgraphs.map((sg) => rewardsManager.getAccRewardsForSubgraph(sg))) - return { - sg1, - sg2, - all: sg1.add(sg2), - } - } - - // set curation percentage - await staking.connect(governor).setCurationPercentage(100000) - - // allow the asset holder - const tokensToCollect = toGRT('10000') - - // signal in two subgraphs in the same block - const subgraphs = [subgraphDeploymentID1, subgraphDeploymentID2] - await hre.network.provider.send('evm_setAutomine', [false]) - for (const sub of subgraphs) { - await curation.connect(curator1).mint(sub, toGRT('1500'), 0) - } - await hre.network.provider.send('evm_mine') - await hre.network.provider.send('evm_setAutomine', [true]) - - // allocate - const tokensToAllocate = toGRT('12500') - await staking - .connect(indexer1) - .multicall([ - await staking.populateTransaction.stake(tokensToAllocate).then((tx) => tx.data), - await staking.populateTransaction - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - .then((tx) => tx.data), - ]) - - // snapshot block after allocation (rewards before allocation were reclaimed for subgraph1) - const b1 = await epochManager.blockNum().then((x) => x.toNumber()) - - // move time fwd - await helpers.mineEpoch(epochManager) - - // collect funds into staking for that sub - await staking.connect(assetHolder).collect(tokensToCollect, allocationID1) - - // check rewards diff - await rewardsManager.getRewards(staking.address, allocationID1).then(formatGRT) - - await helpers.mine() - const accrual = await getRewardsAccrual(subgraphs) - const b2 = await epochManager.blockNum().then((x) => x.toNumber()) - - // Only check subgraph1 (with allocation) - subgraph2 has no allocation so its rewards - // are calculated from signal time, not from allocation time - // Each subgraph gets half the issuance (equal signal) - // Small tolerance for fixed-point arithmetic rounding - const expectedSg1Rewards = ISSUANCE_PER_BLOCK.div(2).mul(b2 - b1) - expect(toRound(accrual.sg1.mul(100).div(expectedSg1Rewards))).eq(toRound(BigNumber.from(100))) - }) - }) -}) diff --git a/packages/issuance/test/unit/rewards-manager/calculations.t.sol b/packages/issuance/test/unit/rewards-manager/calculations.t.sol new file mode 100644 index 000000000..58f890613 --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/calculations.t.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +contract RewardsManagerCalculationsTest is RewardsManagerSharedTest { + uint256 internal constant ISSUANCE_RATE_PERIODS = 4; + + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_1 = keccak256("subgraphDeploymentID1"); + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_2 = keccak256("subgraphDeploymentID2"); + + address internal allocationID1 = makeAddr("allocationID1"); + address internal allocationID2 = makeAddr("allocationID2"); + address internal indexer2 = makeAddr("indexer2"); + + /// Signal, then allocate one block later, so the pre-allocation block is reclaimed (NO_ALLOCATED_TOKENS). + function _signalAndAllocate() internal { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + vm.roll(block.number + 1); + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + } + + // -- Review Focus 4: same-block semantics -- + + function test_Rewards_ZeroWhenNoBlocksElapsed() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, 1000 ether); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + rm.updateAccRewardsPerSignal(); + uint256 first = rm.accRewardsPerSignal(); + assertEq(first, _expectedRewardsPerSignal(ISSUANCE_RATE_PERIODS, 1000 ether)); + + rm.updateAccRewardsPerSignal(); + assertEq(rm.accRewardsPerSignal(), first); + assertEq(rm.getNewRewardsPerSignal(), 0); + } + + // -- getNewRewardsPerSignal -- + + function test_GetNewRewardsPerSignal_ZeroWhenNoTokensSignalled() public { + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + assertEq(rm.getNewRewardsPerSignal(), 0); + } + + function test_GetNewRewardsPerSignal_AccruedWhenTokensSignalled() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, 1000 ether); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + assertEq(rm.getNewRewardsPerSignal(), _expectedRewardsPerSignal(ISSUANCE_RATE_PERIODS, 1000 ether)); + } + + function test_GetNewRewardsPerSignal_AccruedWithManySubgraphs() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, 1000 ether); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + assertEq(rm.getNewRewardsPerSignal(), _expectedRewardsPerSignal(ISSUANCE_RATE_PERIODS, 1000 ether)); + + // The signal hook brings accRewardsPerSignal up to date, so new rewards restart from here + _signal(SUBGRAPH_DEPLOYMENT_ID_2, 250 ether); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + assertEq(rm.getNewRewardsPerSignal(), _expectedRewardsPerSignal(ISSUANCE_RATE_PERIODS, 1250 ether)); + } + + // -- updateAccRewardsPerSignal -- + + function test_UpdateAccRewardsPerSignal_UpdatesState() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, 1000 ether); + vm.roll(block.number + 1); + + vm.prank(governor); + rm.updateAccRewardsPerSignal(); + assertEq(rm.accRewardsPerSignal(), _expectedRewardsPerSignal(1, 1000 ether)); + } + + function test_UpdateAccRewardsPerSignal_UpdatesStateAfterManyBlocks() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, 1000 ether); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + vm.prank(governor); + rm.updateAccRewardsPerSignal(); + assertEq(rm.accRewardsPerSignal(), _expectedRewardsPerSignal(ISSUANCE_RATE_PERIODS, 1000 ether)); + } + + // -- getAccRewardsForSubgraph -- + + function test_GetAccRewardsForSubgraph_AccruedForEachSubgraph() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + _signal(SUBGRAPH_DEPLOYMENT_ID_2, 500 ether); + + // SG1's allocation is created a block before SG2's + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + vm.roll(block.number + 1); + issuer.allocate(allocationID2, indexer2, SUBGRAPH_DEPLOYMENT_ID_2, ALLOCATION); + + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + uint256 rewardsSG1 = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_1); + uint256 rewardsSG2 = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_2); + assertGt(rewardsSG1, 0); + assertGt(rewardsSG2, 0); + assertGt(rewardsSG1, rewardsSG2); + } + + function test_GetAccRewardsForSubgraph_ZeroWhenSignalBelowMinimum() public { + vm.prank(governor); + rm.setMinimumSubgraphSignal(2000 ether); + + _signal(SUBGRAPH_DEPLOYMENT_ID_1, 1000 ether); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + assertEq(rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_1), 0); + } + + // -- onSubgraphSignalUpdate -- + + function test_OnSubgraphSignalUpdate_UpdatesAccRewardsForSubgraph() public { + _signalAndAllocate(); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + vm.prank(governor); + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID_1); + + // Only the blocks since the allocation count for the subgraph; the block before it was reclaimed + (uint256 accRewardsForSubgraph, , , ) = rm.subgraphs(SUBGRAPH_DEPLOYMENT_ID_1); + assertEq(accRewardsForSubgraph, _expectedSubgraphRewards(ISSUANCE_RATE_PERIODS, SIGNAL, SIGNAL)); + + // accRewardsPerSignal is global and accrues from the signal block, in two updates (allocation, then hook) + assertEq( + rm.accRewardsPerSignal(), + _expectedRewardsPerSignal(1, SIGNAL) + _expectedRewardsPerSignal(ISSUANCE_RATE_PERIODS, SIGNAL) + ); + assertEq(rm.accRewardsPerSignalLastBlockUpdated(), block.number); + } + + // -- getAccRewardsPerAllocatedToken -- + + function test_GetAccRewardsPerAllocatedToken_AccruedPerAllocatedToken() public { + _signalAndAllocate(); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + (, uint256 accRewardsForSubgraphSnapshot, , ) = rm.subgraphs(SUBGRAPH_DEPLOYMENT_ID_1); + uint256 accruedRewardsSG1 = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_1) - + accRewardsForSubgraphSnapshot; + assertEq(accruedRewardsSG1, _expectedSubgraphRewards(ISSUANCE_RATE_PERIODS, SIGNAL, SIGNAL)); + + (uint256 rewardsPerAllocatedToken, ) = rm.getAccRewardsPerAllocatedToken(SUBGRAPH_DEPLOYMENT_ID_1); + assertEq(rewardsPerAllocatedToken, _expectedRewardsPerAllocatedToken(accruedRewardsSG1, ALLOCATION)); + } + + // -- onSubgraphAllocationUpdate -- + + function test_OnSubgraphAllocationUpdate_UpdatesAccRewardsPerAllocatedToken() public { + _signalAndAllocate(); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + // Only distributable rewards (blocks since the allocation) count; the block before it was reclaimed + uint256 expectedSubgraphRewards = _expectedSubgraphRewards(ISSUANCE_RATE_PERIODS, SIGNAL, SIGNAL); + uint256 expectedRewardsPerAllocatedToken = _expectedRewardsPerAllocatedToken( + expectedSubgraphRewards, + ALLOCATION + ); + + vm.prank(governor); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID_1); + + (, , , uint256 accRewardsPerAllocatedToken) = rm.subgraphs(SUBGRAPH_DEPLOYMENT_ID_1); + assertEq(rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_1), expectedSubgraphRewards); + assertEq(accRewardsPerAllocatedToken, expectedRewardsPerAllocatedToken); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/config.t.sol b/packages/issuance/test/unit/rewards-manager/config.t.sol index 98522440f..6c133a66c 100644 --- a/packages/issuance/test/unit/rewards-manager/config.t.sol +++ b/packages/issuance/test/unit/rewards-manager/config.t.sol @@ -1,8 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; -import { Vm } from "forge-std/Vm.sol"; - import { RewardsManagerSharedTest } from "./shared.t.sol"; contract RewardsManagerConfigTest is RewardsManagerSharedTest { @@ -18,12 +16,6 @@ contract RewardsManagerConfigTest is RewardsManagerSharedTest { rm.setSubgraphAvailabilityOracle(availabilityOracle); } - function _assertNotEmitted(Vm.Log[] memory logs, bytes32 topic) internal view { - for (uint256 i = 0; i < logs.length; ++i) { - if (logs[i].emitter == address(rm)) assertTrue(logs[i].topics[0] != topic, "unexpected event"); - } - } - // -- initialize -- function test_Initialize_RevertWhen_CalledOnImplementation() public { diff --git a/packages/issuance/test/unit/rewards-manager/distribution.t.sol b/packages/issuance/test/unit/rewards-manager/distribution.t.sol new file mode 100644 index 000000000..ccf10a54d --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/distribution.t.sol @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +contract RewardsManagerDistributionTest is RewardsManagerSharedTest { + event HorizonRewardsAssigned(address indexed indexer, address indexed allocationID, uint256 amount); + event RewardsDenied(address indexed indexer, address indexed allocationID); + event Transfer(address indexed from, address indexed to, uint256 value); + + uint256 internal constant ISSUANCE_RATE_PERIODS = 4; + /// Blocks per `helpers.mineEpoch`: the old tests set the epoch length to 10 + uint256 internal constant EPOCH_LENGTH = 10; + + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_1 = keccak256("subgraphDeploymentID1"); + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_2 = keccak256("subgraphDeploymentID2"); + + address internal allocationID1 = makeAddr("allocationID1"); + address internal allocationID2 = makeAddr("allocationID2"); + address internal allocationIDNull = makeAddr("allocationIDNull"); + + /// Signal, then allocate one block later, so the pre-allocation block is reclaimed (NO_ALLOCATED_TOKENS). + function _setupIndexerAllocation() internal { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + vm.roll(block.number + 1); + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + } + + function _setupIndexerAllocationSignalingAfter() internal { + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + vm.roll(block.number + 1); + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + } + + /// Rewards for an allocation of `tokens` out of `allocatedTokens` on a subgraph holding all the signal. + function _expectedAllocationRewards( + uint256 blocks, + uint256 tokens, + uint256 allocatedTokens + ) internal pure returns (uint256) { + uint256 subgraphRewards = _expectedSubgraphRewards(blocks, SIGNAL, SIGNAL); + return (_expectedRewardsPerAllocatedToken(subgraphRewards, allocatedTokens) * tokens) / 1e18; + } + + /// RewardsManager half of the legacy-Staking close cases: event amount, mint to the issuer and total supply. + function _assertRewardsDistributedOnClose() internal { + // All the subgraph's rewards since the allocation go to it + uint256 expectedIndexingRewards = _expectedAllocationRewards(EPOCH_LENGTH, ALLOCATION, ALLOCATION); + uint256 beforeTokenSupply = token.totalSupply(); + uint256 beforeIssuerBalance = token.balanceOf(address(issuer)); + + vm.expectEmit(address(rm)); + emit HorizonRewardsAssigned(indexer, allocationID1, expectedIndexingRewards); + uint256 rewards = _close(allocationID1); + + assertEq(rewards, expectedIndexingRewards); + assertEq(token.balanceOf(address(issuer)), beforeIssuerBalance + expectedIndexingRewards); + assertEq(token.totalSupply(), beforeTokenSupply + expectedIndexingRewards); + } + + // -- getRewards -- + + function test_GetRewards_UsesSignalledAndAllocatedTokens() public { + _setupIndexerAllocation(); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + uint256 rewards = rm.getRewards(address(issuer), allocationID1); + + (uint256 accRewardsPerAllocatedToken, ) = rm.getAccRewardsPerAllocatedToken(SUBGRAPH_DEPLOYMENT_ID_1); + assertEq(rewards, (accRewardsPerAllocatedToken * ALLOCATION) / 1e18); + assertEq(rewards, _expectedAllocationRewards(ISSUANCE_RATE_PERIODS, ALLOCATION, ALLOCATION)); + } + + function test_GetRewards_ZeroWhenAllocationClosed() public { + _setupIndexerAllocation(); + vm.roll(block.number + ISSUANCE_RATE_PERIODS + EPOCH_LENGTH); + + _close(allocationID1); + + assertEq(rm.getRewards(address(issuer), allocationID1), 0); + } + + function test_GetRewards_ZeroWhenAllocationDoesNotExist() public view { + assertEq(rm.getRewards(address(issuer), allocationIDNull), 0); + } + + // -- takeRewards -- + + function test_TakeRewards_DistributesOnClosedAllocationAndStake() public { + _setupIndexerAllocation(); + vm.roll(block.number + EPOCH_LENGTH); + + _assertRewardsDistributedOnClose(); + } + + function test_TakeRewards_NoUnderflowWhenMinimumSignalChanges() public { + _setupIndexerAllocation(); + + vm.prank(governor); + rm.setMinimumSubgraphSignal(14_000 ether); + + vm.roll(block.number + EPOCH_LENGTH); + + vm.recordLogs(); + _close(allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), HorizonRewardsAssigned.selector); + } + + function test_TakeRewards_NoUnderflowWhenMinimumSignalChangesAndSignalAfterAllocation() public { + _setupIndexerAllocationSignalingAfter(); + + vm.prank(governor); + rm.setMinimumSubgraphSignal(14_000 ether); + + vm.roll(block.number + EPOCH_LENGTH); + + vm.recordLogs(); + _close(allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), HorizonRewardsAssigned.selector); + } + + function test_TakeRewards_NoRevertWhenSignalAlreadyUnderMinimum() public { + vm.prank(governor); + rm.setMinimumSubgraphSignal(2000 ether); + + _setupIndexerAllocation(); + vm.roll(block.number + EPOCH_LENGTH); + + vm.recordLogs(); + _close(allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), HorizonRewardsAssigned.selector); + } + + function test_TakeRewards_DistributesOnClosedAllocationToDestination() public { + _setupIndexerAllocation(); + vm.roll(block.number + EPOCH_LENGTH); + + _assertRewardsDistributedOnClose(); + } + + function test_TakeRewards_DistributesOnClosedAllocationWithDelegators() public { + _setupIndexerAllocation(); + vm.roll(block.number + EPOCH_LENGTH); + + _assertRewardsDistributedOnClose(); + } + + function test_TakeRewards_DeniesRewardsWhenSubgraphDenied() public { + vm.prank(governor); + rm.setSubgraphAvailabilityOracle(governor); + + // Allocation created before the subgraph is denied + _setupIndexerAllocation(); + vm.roll(block.number + EPOCH_LENGTH); + + vm.prank(governor); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + + vm.expectEmit(address(rm)); + emit RewardsDenied(indexer, allocationID1); + _close(allocationID1); + } + + function test_TakeRewards_ZeroRewards() public { + vm.prank(governor); + rm.setIssuancePerBlock(0); + + _setupIndexerAllocation(); + vm.roll(block.number + EPOCH_LENGTH); + + uint256 beforeTokenSupply = token.totalSupply(); + uint256 beforeIssuerBalance = token.balanceOf(address(issuer)); + + vm.recordLogs(); + _close(allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), HorizonRewardsAssigned.selector); + + assertEq(token.totalSupply(), beforeTokenSupply); + assertEq(token.balanceOf(address(issuer)), beforeIssuerBalance); + } + + // -- edge scenarios -- + + function test_EdgeScenarios_CloseAllocationOnSubgraphWithNoSignal() public { + _setupIndexerAllocation(); + vm.roll(block.number + EPOCH_LENGTH); + + curation.unsignal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + vm.roll(block.number + 1); + + // Must not revert + _close(allocationID1); + } + + // -- multiple allocations -- + + function test_MultipleAllocations_SameBlockWithGrtBurnInTheMiddle() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + vm.roll(block.number + 1); + + uint256 tokensToAlloc = 5000 ether; + + // No vm.roll between the three calls: they run in the same block + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, tokensToAlloc); + vm.expectEmit(address(token)); + emit Transfer(address(this), address(0), 1 ether); + token.burn(1 ether); + _allocate(allocationID2, SUBGRAPH_DEPLOYMENT_ID_1, tokensToAlloc); + + (bool isActive1, , , , , ) = issuer.getAllocationData(allocationID1); + (bool isActive2, , , , , ) = issuer.getAllocationData(allocationID2); + assertTrue(isActive1); + assertTrue(isActive2); + assertEq(issuer.getSubgraphAllocatedTokens(SUBGRAPH_DEPLOYMENT_ID_1), 2 * tokensToAlloc); + } + + function test_MultipleAllocations_SimultaneousSimilarAllocationsGetSameRewards() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + vm.roll(block.number + 1); + + uint256 tokensToAlloc = 5000 ether; + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, tokensToAlloc); + _allocate(allocationID2, SUBGRAPH_DEPLOYMENT_ID_1, tokensToAlloc); + + vm.roll(block.number + EPOCH_LENGTH); + + uint256 expectedRewards = _expectedAllocationRewards(EPOCH_LENGTH, tokensToAlloc, 2 * tokensToAlloc); + + // Closed in the same block + vm.expectEmit(address(rm)); + emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); + uint256 rewards1 = _close(allocationID1); + vm.expectEmit(address(rm)); + emit HorizonRewardsAssigned(indexer, allocationID2, expectedRewards); + uint256 rewards2 = _close(allocationID2); + + assertEq(rewards1, rewards2); + } + + // -- rewards progression when collecting query fees -- + + function test_RewardsProgressionWhenCollectingQueryFees_TwoSubgraphsOneAllocation() public { + uint256 curationFees = 1_000 ether; // 10% curation share of 10,000 GRT collected + + // Signal in two subgraphs in the same block + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + _signal(SUBGRAPH_DEPLOYMENT_ID_2, SIGNAL); + vm.roll(block.number + 1); + + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + uint256 b1 = block.number; + + vm.roll(block.number + EPOCH_LENGTH); + + curation.addFees(SUBGRAPH_DEPLOYMENT_ID_1, curationFees); + + vm.roll(block.number + 1); + uint256 accrualSG1 = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_1); + uint256 b2 = block.number; + + // Fees reach Curation without a RewardsManager hook, so the view prices every block since the + // allocation at the current signal: SG1's pool and the total both include the fees + assertEq(accrualSG1, _expectedSubgraphRewards(b2 - b1, 2 * SIGNAL + curationFees, SIGNAL + curationFees)); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/shared.t.sol b/packages/issuance/test/unit/rewards-manager/shared.t.sol index 7c4c8416e..6f65b0472 100644 --- a/packages/issuance/test/unit/rewards-manager/shared.t.sol +++ b/packages/issuance/test/unit/rewards-manager/shared.t.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; +import { Vm } from "forge-std/Vm.sol"; + import { ProtocolFixture } from "../shared/ProtocolFixture.t.sol"; import { MockGraphToken } from "../mocks/MockGraphToken.sol"; import { IRewardsManagerLike } from "./IRewardsManagerLike.sol"; @@ -81,4 +83,26 @@ contract RewardsManagerSharedTest is ProtocolFixture { function _expectedRewardsPerSignal(uint256 blocks, uint256 totalSignal) internal pure returns (uint256) { return (blocks * ISSUANCE_PER_BLOCK * 1e18) / totalSignal; } + + /// Rewards a subgraph with `subgraphSignal` accrues over `blocks` while total signal is `totalSignal`. + function _expectedSubgraphRewards( + uint256 blocks, + uint256 totalSignal, + uint256 subgraphSignal + ) internal pure returns (uint256) { + return (_expectedRewardsPerSignal(blocks, totalSignal) * subgraphSignal) / 1e18; + } + + function _expectedRewardsPerAllocatedToken( + uint256 subgraphRewards, + uint256 allocatedTokens + ) internal pure returns (uint256) { + return (subgraphRewards * 1e18) / allocatedTokens; + } + + function _assertNotEmitted(Vm.Log[] memory logs, bytes32 topic) internal view { + for (uint256 i = 0; i < logs.length; ++i) { + if (logs[i].emitter == address(rm)) assertTrue(logs[i].topics[0] != topic, "unexpected event"); + } + } } diff --git a/scripts/contracts-migration/test-mapping/t5b.md b/scripts/contracts-migration/test-mapping/t5b.md new file mode 100644 index 000000000..4303f5e65 --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t5b.md @@ -0,0 +1,37 @@ +# T5b test mapping: RewardsManager calculations and distribution + +New tests are in `packages/issuance`, on T5a's `RewardsManagerSharedTest` (the RewardsManager Hardhat artifact behind a GraphProxy, wired to `MockGraphToken`, `RMCurationMock` and `RMIssuerMock` as the subgraph service). `curation.mint` becomes `_signal`, `curation.burn` becomes `curation.unsignal`, `staking.allocateFrom` becomes `_allocate`, `closeAllocation` becomes `_close`, and `helpers.mine(n)` / `helpers.mineEpoch` become `vm.roll` (an epoch is 10 blocks, the length the old `takeRewards` setups set). Old `mineEpoch` calls that only aligned to an epoch boundary are dropped, since nothing here reads the epoch. The separate stake/allocate transactions become a one-block roll between signal and allocation, so the pre-allocation block is reclaimed (`NO_ALLOCATED_TOKENS`) as before. Expected values are derived from the explicit rolls with `_expectedRewardsPerSignal`, `_expectedSubgraphRewards` and `_expectedRewardsPerAllocatedToken` (shared.t.sol), and asserted exactly where the old tests used `toRound`. No ported case allocates zero tokens. `test/unit/rewards-manager/calculations.t.sol::test_Rewards_ZeroWhenNoBlocksElapsed` is Review Focus 4 and has no old test. + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › getNewRewardsPerSignal › accrued per signal when no tokens signalled | test/unit/rewards-manager/calculations.t.sol::test_GetNewRewardsPerSignal_ZeroWhenNoTokensSignalled | | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › getNewRewardsPerSignal › accrued per signal when tokens signalled | test/unit/rewards-manager/calculations.t.sol::test_GetNewRewardsPerSignal_AccruedWhenTokensSignalled | Exact instead of `toRound` | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › getNewRewardsPerSignal › accrued per signal when signalled tokens w/ many subgraphs | test/unit/rewards-manager/calculations.t.sol::test_GetNewRewardsPerSignal_AccruedWithManySubgraphs | Exact instead of `toRound` | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › updateAccRewardsPerSignal › update the accumulated rewards per signal state | test/unit/rewards-manager/calculations.t.sol::test_UpdateAccRewardsPerSignal_UpdatesState | The block Hardhat mined for the update tx is an explicit one-block roll; exact | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › updateAccRewardsPerSignal › update the accumulated rewards per signal state after many blocks | test/unit/rewards-manager/calculations.t.sol::test_UpdateAccRewardsPerSignal_UpdatesStateAfterManyBlocks | Exact instead of `toRound` | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › getAccRewardsForSubgraph › accrued for each subgraph | test/unit/rewards-manager/calculations.t.sol::test_GetAccRewardsForSubgraph_AccruedForEachSubgraph | SG2's allocation is one block after SG1's, as in the old test; same `gt` assertions | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › getAccRewardsForSubgraph › should return zero rewards when subgraph signal is below minimum threshold | test/unit/rewards-manager/calculations.t.sol::test_GetAccRewardsForSubgraph_ZeroWhenSignalBelowMinimum | | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › onSubgraphSignalUpdate › update the accumulated rewards for subgraph state | test/unit/rewards-manager/calculations.t.sol::test_OnSubgraphSignalUpdate_UpdatesAccRewardsForSubgraph | `accRewardsPerSignal` asserted as its real value (accrued from the signal block); the old test compared it with the since-allocation tracker, which only matched under `toRound` | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › getAccRewardsPerAllocatedToken › accrued per allocated token | test/unit/rewards-manager/calculations.t.sol::test_GetAccRewardsPerAllocatedToken_AccruedPerAllocatedToken | Old self-referential check kept; the accrual is also checked against the formula | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › onSubgraphAllocationUpdate › update the accumulated rewards for allocated tokens state | test/unit/rewards-manager/calculations.t.sol::test_OnSubgraphAllocationUpdate_UpdatesAccRewardsPerAllocatedToken | Hard-coded 1000 GRT / 0.08 (5 Hardhat blocks) replaced by values derived from the 4-block roll; exact | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › getRewards › calculate rewards using the subgraph signalled + allocated tokens | test/unit/rewards-manager/distribution.t.sol::test_GetRewards_UsesSignalledAndAllocatedTokens | Old self-referential check kept, plus the formula value | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › getRewards › rewards should be zero if the allocation is closed | test/unit/rewards-manager/distribution.t.sol::test_GetRewards_ZeroWhenAllocationClosed | | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › getRewards › rewards should be zero if the allocation does not exist | test/unit/rewards-manager/distribution.t.sol::test_GetRewards_ZeroWhenAllocationDoesNotExist | | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › takeRewards › should distribute rewards on closed allocation and stake | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_DistributesOnClosedAllocationAndStake | Port of the RewardsManager half (`HorizonRewardsAssigned` amount, mint to the issuer, total supply), exact instead of `toRound`. Dropped: legacy Staking behaviour; Horizon equivalent in subgraph-service test/unit/subgraphService/collect/indexing/indexing.t.sol, `test_SubgraphService_Collect_Indexing` | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › takeRewards › does not revert with an underflow if the minimum signal changes | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_NoUnderflowWhenMinimumSignalChanges | `not.emit` via recordLogs (`EMIT_EVENT_FOR_ZERO_REWARDS` is false) | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › takeRewards › does not revert with an underflow if the minimum signal changes, and signal came after allocation | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_NoUnderflowWhenMinimumSignalChangesAndSignalAfterAllocation | `not.emit` via recordLogs | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › takeRewards › does not revert if signal was already under minimum | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_NoRevertWhenSignalAlreadyUnderMinimum | `not.emit` via recordLogs | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › takeRewards › should distribute rewards on closed allocation and send to destination | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_DistributesOnClosedAllocationToDestination | Port of the RewardsManager half (`HorizonRewardsAssigned` amount, mint to the issuer, total supply), exact instead of `toRound`. Dropped: legacy Staking behaviour; Horizon equivalent in subgraph-service test/unit/subgraphService/collect/indexing/indexing.t.sol, `test_SubgraphService_Collect_Indexing_RewardsDestination` | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › takeRewards › should distribute rewards on closed allocation w/delegators | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_DistributesOnClosedAllocationWithDelegators | Port of the RewardsManager half (`HorizonRewardsAssigned` amount, mint to the issuer, total supply), exact instead of `toRound`. Dropped: legacy Staking behaviour; Horizon equivalent in subgraph-service test/unit/subgraphService/collect/indexing/indexing.t.sol, `test_SubgraphService_Collect_Indexing_WithDelegation`. The delegation setup only affected Staking, so the RewardsManager half has the same setup as the other two | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › takeRewards › should deny rewards if subgraph on denylist | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_DeniesRewardsWhenSubgraphDenied | | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › takeRewards › should handle zero rewards scenario correctly | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_ZeroRewards | Staking balance becomes the issuer's balance | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › edge scenarios › close allocation on a subgraph that no longer have signal | test/unit/rewards-manager/distribution.t.sol::test_EdgeScenarios_CloseAllocationOnSubgraphWithNoSignal | No assertions in the old test (must not revert); one-block roll between unsignal and close, as the separate Hardhat txs | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › multiple allocations › two allocations in the same block with a GRT burn in the middle should succeed | test/unit/rewards-manager/distribution.t.sol::test_MultipleAllocations_SameBlockWithGrtBurnInTheMiddle | `MockGraphToken.burn` from the test contract; consecutive calls with no roll; `AllocationCreated` (legacy Staking event) becomes both allocations active in the issuer | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › multiple allocations › two simultanous-similar allocations should get same amount of rewards | test/unit/rewards-manager/distribution.t.sol::test_MultipleAllocations_SimultaneousSimilarAllocationsGetSameRewards | Multicalls become consecutive calls with no roll; both event amounts are the derived value | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › rewards progression when collecting query fees › collect query fees with two subgraphs and one allocation | test/unit/rewards-manager/distribution.t.sol::test_RewardsProgressionWhenCollectingQueryFees_TwoSubgraphsOneAllocation | `staking.collect(10,000 GRT)` at 10% curation becomes `addFees(sd, 1_000 ether)`. The old ±1% check against half the issuance was vacuous (`toRound` formats wei ratios as GRT, so both sides were `"0"`); the real value is asserted exactly: fees reach Curation with no hook, so the view prices every block since the allocation at the post-fee signal | + +## Coverage + +`contracts/rewards/RewardsManager.sol`: MISSING getAccRewardsPerSignal getAllocatedIssuancePerBlock getDefaultReclaimAddress getRawIssuancePerBlock setDefaultReclaimAddress setReclaimAddress: ported in T5c–T5f. `reclaimRewards` matches the grep only through `RMIssuerMock.reclaim`; no T5b test calls it (T5d). `takeRewards` is now reached by the distribution tests through `RMIssuerMock.close`, which must be the caller. The checklist is closed out in T5f. + +`contracts/staking/libs/MathUtils.sol`: `diffOrZero` is internal and reached through `getAccRewardsPerAllocatedToken` (called directly in calculations.t.sol and distribution.t.sol). `min` and `weightedAverageRoundingUp`: reached only through legacy Staking. From 7dbe58ae552af14a70528e1535cc751825216935 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 26 Oct 2026 16:58:54 -0300 Subject: [PATCH 16/20] test(issuance): port RewardsManager issuance allocator and subgraph service tests to Foundry --- .../rewards-issuance-allocator.test.ts | 420 --------------- .../rewards/rewards-subgraph-service.test.ts | 483 ------------------ .../rewards-manager/issuanceAllocator.t.sol | 256 ++++++++++ .../rewards-manager/subgraphService.t.sol | 257 ++++++++++ .../contracts-migration/test-mapping/t5c.md | 46 ++ 5 files changed, 559 insertions(+), 903 deletions(-) delete mode 100644 packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts delete mode 100644 packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts create mode 100644 packages/issuance/test/unit/rewards-manager/issuanceAllocator.t.sol create mode 100644 packages/issuance/test/unit/rewards-manager/subgraphService.t.sol create mode 100644 scripts/contracts-migration/test-mapping/t5c.md diff --git a/packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts deleted file mode 100644 index 8047b8fd6..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts +++ /dev/null @@ -1,420 +0,0 @@ -import { Curation } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { RewardsManager } from '@graphprotocol/contracts' -import { GraphNetworkContracts, helpers, randomHexBytes, toGRT } from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { constants } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -describe('Rewards - Issuance Allocator', () => { - const graph = hre.graph() - let curator1: SignerWithAddress - let governor: SignerWithAddress - let indexer1: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let grt: GraphToken - let curation: Curation - let rewardsManager: RewardsManager - - const subgraphDeploymentID1 = randomHexBytes() - - const ISSUANCE_PER_BLOCK = toGRT('200') // 200 GRT every block - - before(async function () { - const testAccounts = await graph.getTestAccounts() - curator1 = testAccounts[0] - indexer1 = testAccounts[1] - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - grt = contracts.GraphToken as GraphToken - curation = contracts.Curation as Curation - rewardsManager = contracts.RewardsManager as RewardsManager - - // 200 GRT per block - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - - // Distribute test funds - for (const wallet of [curator1]) { - await grt.connect(governor).mint(wallet.address, toGRT('1000000')) - await grt.connect(wallet).approve(curation.address, toGRT('1000000')) - } - }) - - beforeEach(async function () { - await fixture.setUp() - // Reset issuance allocator to ensure we use direct issuancePerBlock - await rewardsManager.connect(governor).setIssuanceAllocator(constants.AddressZero) - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('setIssuanceAllocator', function () { - describe('ERC-165 validation', function () { - it('should successfully set an issuance allocator that supports the interface', async function () { - // Deploy a mock issuance allocator that supports ERC-165 and IIssuanceAllocationDistribution - const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', - ) - const mockAllocator = await MockIssuanceAllocatorFactory.deploy() - await mockAllocator.deployed() - - // Should succeed because MockIssuanceAllocator supports IIssuanceAllocationDistribution - await expect(rewardsManager.connect(governor).setIssuanceAllocator(mockAllocator.address)) - .to.emit(rewardsManager, 'IssuanceAllocatorSet') - .withArgs(constants.AddressZero, mockAllocator.address) - - // Verify the allocator was set - expect(await rewardsManager.getIssuanceAllocator()).to.equal(mockAllocator.address) - }) - - it('should revert when setting to EOA address (no contract code)', async function () { - const eoaAddress = indexer1.address - - // Should revert because EOAs don't have contract code to call supportsInterface on - await expect(rewardsManager.connect(governor).setIssuanceAllocator(eoaAddress)).to.be.reverted - }) - - it('should revert when setting to contract that does not support IIssuanceAllocationDistribution', async function () { - // Deploy a contract that supports ERC-165 but not IIssuanceAllocationDistribution - const MockERC165Factory = await hre.ethers.getContractFactory('contracts/tests/MockERC165.sol:MockERC165') - const mockERC165 = await MockERC165Factory.deploy() - await mockERC165.deployed() - - // Should revert because the contract doesn't support IIssuanceAllocationDistribution - await expect(rewardsManager.connect(governor).setIssuanceAllocator(mockERC165.address)).to.be.revertedWith( - 'Contract does not support IIssuanceAllocationDistribution interface', - ) - }) - - it('should validate interface before updating rewards calculation', async function () { - // This test ensures that ERC165 validation happens before updateAccRewardsPerSignal - // Deploy a contract that supports ERC-165 but not IIssuanceAllocationDistribution - const MockERC165Factory = await hre.ethers.getContractFactory('contracts/tests/MockERC165.sol:MockERC165') - const mockERC165 = await MockERC165Factory.deploy() - await mockERC165.deployed() - - // Should revert with interface error, not with any rewards calculation error - await expect(rewardsManager.connect(governor).setIssuanceAllocator(mockERC165.address)).to.be.revertedWith( - 'Contract does not support IIssuanceAllocationDistribution interface', - ) - }) - }) - - describe('access control', function () { - it('should revert when called by non-governor', async function () { - const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', - ) - const mockAllocator = await MockIssuanceAllocatorFactory.deploy() - await mockAllocator.deployed() - - // Should revert because indexer1 is not the governor - await expect(rewardsManager.connect(indexer1).setIssuanceAllocator(mockAllocator.address)).to.be.revertedWith( - 'Only Controller governor', - ) - }) - }) - - describe('state management', function () { - it('should allow setting issuance allocator to zero address (disable)', async function () { - // First set a valid allocator - const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', - ) - const mockAllocator = await MockIssuanceAllocatorFactory.deploy() - await mockAllocator.deployed() - - await rewardsManager.connect(governor).setIssuanceAllocator(mockAllocator.address) - expect(await rewardsManager.getIssuanceAllocator()).to.equal(mockAllocator.address) - - // Now disable by setting to zero address - await expect(rewardsManager.connect(governor).setIssuanceAllocator(constants.AddressZero)) - .to.emit(rewardsManager, 'IssuanceAllocatorSet') - .withArgs(mockAllocator.address, constants.AddressZero) - - expect(await rewardsManager.getIssuanceAllocator()).to.equal(constants.AddressZero) - - // Should now use local issuancePerBlock again — both getters agree - expect(await rewardsManager.getAllocatedIssuancePerBlock()).eq(ISSUANCE_PER_BLOCK) - expect(await rewardsManager.getRawIssuancePerBlock()).eq(ISSUANCE_PER_BLOCK) - }) - - it('should emit IssuanceAllocatorSet event when setting allocator', async function () { - const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', - ) - const mockIssuanceAllocator = await MockIssuanceAllocatorFactory.deploy() - await mockIssuanceAllocator.deployed() - - const tx = rewardsManager.connect(governor).setIssuanceAllocator(mockIssuanceAllocator.address) - await expect(tx) - .emit(rewardsManager, 'IssuanceAllocatorSet') - .withArgs(constants.AddressZero, mockIssuanceAllocator.address) - }) - - it('should not emit event when setting to same allocator address', async function () { - // Deploy a mock issuance allocator - const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', - ) - const mockAllocator = await MockIssuanceAllocatorFactory.deploy() - await mockAllocator.deployed() - - // Set the allocator first time - await rewardsManager.connect(governor).setIssuanceAllocator(mockAllocator.address) - - // Setting to same address should not emit event - const tx = await rewardsManager.connect(governor).setIssuanceAllocator(mockAllocator.address) - const receipt = await tx.wait() - - // Filter for IssuanceAllocatorSet events - const events = receipt.events?.filter((e) => e.event === 'IssuanceAllocatorSet') || [] - expect(events.length).to.equal(0) - }) - - it('should update rewards before changing issuance allocator', async function () { - // This test verifies that updateAccRewardsPerSignal is called when setting allocator - const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', - ) - const mockIssuanceAllocator = await MockIssuanceAllocatorFactory.deploy() - await mockIssuanceAllocator.deployed() - - // Setting the allocator should trigger updateAccRewardsPerSignal - // We can't easily test this directly, but we can verify the allocator was set - await rewardsManager.connect(governor).setIssuanceAllocator(mockIssuanceAllocator.address) - expect(await rewardsManager.getIssuanceAllocator()).eq(mockIssuanceAllocator.address) - }) - }) - }) - - describe('getAllocatedIssuancePerBlock', function () { - it('should return issuancePerBlock when no issuanceAllocator is set', async function () { - const expectedIssuance = toGRT('100.025') - await rewardsManager.connect(governor).setIssuancePerBlock(expectedIssuance) - - // Ensure no issuanceAllocator is set - expect(await rewardsManager.getIssuanceAllocator()).eq(constants.AddressZero) - - // Both getters should agree when no allocator is set - expect(await rewardsManager.getAllocatedIssuancePerBlock()).eq(expectedIssuance) - expect(await rewardsManager.getRawIssuancePerBlock()).eq(expectedIssuance) - }) - - it('should return value from issuanceAllocator when set', async function () { - // Create a mock IssuanceAllocator - const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', - ) - const mockIssuanceAllocator = await MockIssuanceAllocatorFactory.deploy() - await mockIssuanceAllocator.deployed() - - // Set the mock allocator on RewardsManager - await rewardsManager.connect(governor).setIssuanceAllocator(mockIssuanceAllocator.address) - - // Verify the allocator was set - expect(await rewardsManager.getIssuanceAllocator()).eq(mockIssuanceAllocator.address) - - // Set RewardsManager as a self-minting target with 25 GRT per block - const expectedIssuance = toGRT('25') - await mockIssuanceAllocator['setTargetAllocation(address,uint256,uint256,bool)']( - rewardsManager.address, - 0, // allocator issuance - expectedIssuance, // self issuance - true, - ) - - // Allocated getter returns the allocator value, raw getter still returns storage value - expect(await rewardsManager.getAllocatedIssuancePerBlock()).eq(expectedIssuance) - expect(await rewardsManager.getRawIssuancePerBlock()).eq(ISSUANCE_PER_BLOCK) - }) - - it('should return 0 when issuanceAllocator is set but target not registered as self-minter', async function () { - // Create a mock IssuanceAllocator - const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', - ) - const mockIssuanceAllocator = await MockIssuanceAllocatorFactory.deploy() - await mockIssuanceAllocator.deployed() - - // Set the mock allocator on RewardsManager - await rewardsManager.connect(governor).setIssuanceAllocator(mockIssuanceAllocator.address) - - // Set RewardsManager as an allocator-minting target (only allocator issuance) - await mockIssuanceAllocator['setTargetAllocation(address,uint256,uint256,bool)']( - rewardsManager.address, - toGRT('25'), // allocator issuance - 0, // self issuance - false, - ) - - // Allocated returns 0 (not a self-minting target), raw is unchanged - expect(await rewardsManager.getAllocatedIssuancePerBlock()).eq(0) - expect(await rewardsManager.getRawIssuancePerBlock()).eq(ISSUANCE_PER_BLOCK) - }) - }) - - describe('setIssuancePerBlock', function () { - it('should allow setIssuancePerBlock when issuanceAllocator is set', async function () { - // Create and set a mock IssuanceAllocator - const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', - ) - const mockIssuanceAllocator = await MockIssuanceAllocatorFactory.deploy() - await mockIssuanceAllocator.deployed() - await rewardsManager.connect(governor).setIssuanceAllocator(mockIssuanceAllocator.address) - - // Should allow setting issuancePerBlock even when allocator is set - const newIssuancePerBlock = toGRT('100') - await rewardsManager.connect(governor).setIssuancePerBlock(newIssuancePerBlock) - - // Both raw getter and storage variable reflect the new value - expect(await rewardsManager.issuancePerBlock()).eq(newIssuancePerBlock) - expect(await rewardsManager.getRawIssuancePerBlock()).eq(newIssuancePerBlock) - - // But the effective (allocated) issuance still comes from the allocator - expect(await rewardsManager.getAllocatedIssuancePerBlock()).not.eq(newIssuancePerBlock) - }) - }) - - describe('beforeIssuanceAllocationChange', function () { - it('should handle beforeIssuanceAllocationChange correctly', async function () { - // Create and set a mock IssuanceAllocator - const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', - ) - const mockIssuanceAllocator = await MockIssuanceAllocatorFactory.deploy() - await mockIssuanceAllocator.deployed() - await rewardsManager.connect(governor).setIssuanceAllocator(mockIssuanceAllocator.address) - - // Anyone should be able to call this function - await rewardsManager.connect(governor).beforeIssuanceAllocationChange() - - // Should also succeed when called by the allocator - await mockIssuanceAllocator.callBeforeIssuanceAllocationChange(rewardsManager.address) - }) - }) - - describe('issuance allocator integration', function () { - let mockIssuanceAllocator: any - - beforeEach(async function () { - // Create and setup mock allocator - const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', - ) - mockIssuanceAllocator = await MockIssuanceAllocatorFactory.deploy() - await mockIssuanceAllocator.deployed() - }) - - it('should accumulate rewards using allocator rate over time', async function () { - // Setup: Create signal - const totalSignal = toGRT('1000') - await curation.connect(curator1).mint(subgraphDeploymentID1, totalSignal, 0) - - // Set allocator with specific rate (50 GRT per block, different from local 200 GRT) - const allocatorRate = toGRT('50') - await mockIssuanceAllocator.setTargetAllocation(rewardsManager.address, 0, allocatorRate, false) - await rewardsManager.connect(governor).setIssuanceAllocator(mockIssuanceAllocator.address) - - // Snapshot state after setting allocator - const rewardsAfterSet = await rewardsManager.getAccRewardsPerSignal() - - // Mine blocks to accrue rewards at allocator rate - const blocksToMine = 10 - await helpers.mine(blocksToMine) - - // Get accumulated rewards - const rewardsAfterMining = await rewardsManager.getAccRewardsPerSignal() - const actualAccrued = rewardsAfterMining.sub(rewardsAfterSet) - - // Calculate expected rewards: (rate × blocks) / totalSignal - // Expected = (50 GRT × 10 blocks) / 1000 GRT signal = 0.5 GRT per signal - const expectedAccrued = allocatorRate.mul(blocksToMine).mul(toGRT('1')).div(totalSignal) - - // Verify rewards accumulated at allocator rate (not local rate of 200 GRT/block) - expect(actualAccrued).to.eq(expectedAccrued) - - // Verify NOT using local rate (would be 4x higher: 200 vs 50) - const wrongExpected = ISSUANCE_PER_BLOCK.mul(blocksToMine).mul(toGRT('1')).div(totalSignal) - expect(actualAccrued).to.not.eq(wrongExpected) - }) - - it('should maintain reward consistency when switching between rates', async function () { - // Setup: Create signal - const totalSignal = toGRT('2000') - await curation.connect(curator1).mint(subgraphDeploymentID1, totalSignal, 0) - - // Snapshot initial state - const block0 = await helpers.latestBlock() - const rewards0 = await rewardsManager.getAccRewardsPerSignal() - - // Phase 1: Accrue at local rate (200 GRT/block) - await helpers.mine(5) - const block1 = await helpers.latestBlock() - const rewards1 = await rewardsManager.getAccRewardsPerSignal() - - // Calculate phase 1 accrual - const blocksPhase1 = block1 - block0 - const phase1Accrued = rewards1.sub(rewards0) - const expectedPhase1 = ISSUANCE_PER_BLOCK.mul(blocksPhase1).mul(toGRT('1')).div(totalSignal) - expect(phase1Accrued).to.eq(expectedPhase1) - - // Phase 2: Switch to allocator with different rate (100 GRT/block) - const allocatorRate = toGRT('100') - await mockIssuanceAllocator.setTargetAllocation(rewardsManager.address, 0, allocatorRate, false) - await rewardsManager.connect(governor).setIssuanceAllocator(mockIssuanceAllocator.address) - - const block2 = await helpers.latestBlock() - const rewards2 = await rewardsManager.getAccRewardsPerSignal() - - await helpers.mine(8) - const block3 = await helpers.latestBlock() - const rewards3 = await rewardsManager.getAccRewardsPerSignal() - - // Calculate phase 2 accrual (includes the setIssuanceAllocator block at local rate) - const blocksPhase2 = block3 - block2 - const phase2Accrued = rewards3.sub(rewards2) - const expectedPhase2 = allocatorRate.mul(blocksPhase2).mul(toGRT('1')).div(totalSignal) - expect(phase2Accrued).to.eq(expectedPhase2) - - // Phase 3: Switch back to local rate (200 GRT/block) - await rewardsManager.connect(governor).setIssuanceAllocator(constants.AddressZero) - - const block4 = await helpers.latestBlock() - const rewards4 = await rewardsManager.getAccRewardsPerSignal() - - await helpers.mine(4) - const block5 = await helpers.latestBlock() - const rewards5 = await rewardsManager.getAccRewardsPerSignal() - - // Calculate phase 3 accrual - const blocksPhase3 = block5 - block4 - const phase3Accrued = rewards5.sub(rewards4) - const expectedPhase3 = ISSUANCE_PER_BLOCK.mul(blocksPhase3).mul(toGRT('1')).div(totalSignal) - expect(phase3Accrued).to.eq(expectedPhase3) - - // Verify total consistency: all rewards from start to end must equal sum of all phases - // including the transition blocks (setIssuanceAllocator calls mine blocks too) - const transitionPhase1to2 = rewards2.sub(rewards1) // Block mined by setIssuanceAllocator - const transitionPhase2to3 = rewards4.sub(rewards3) // Block mined by removing allocator - const totalExpected = phase1Accrued - .add(transitionPhase1to2) - .add(phase2Accrued) - .add(transitionPhase2to3) - .add(phase3Accrued) - const totalActual = rewards5.sub(rewards0) - expect(totalActual).to.eq(totalExpected) - }) - }) -}) diff --git a/packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts deleted file mode 100644 index 58338cac8..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts +++ /dev/null @@ -1,483 +0,0 @@ -import { Curation } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { RewardsManager } from '@graphprotocol/contracts' -import { GraphNetworkContracts, helpers, randomAddress, randomHexBytes, toGRT } from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { constants } from 'ethers' -import hre from 'hardhat' -import { network } from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -// TODO: Behavior change - HorizonRewardsAssigned is no longer emitted when rewards == 0 -// Set to true if the old behavior is restored (emitting event for zero rewards) -const EMIT_EVENT_FOR_ZERO_REWARDS = false - -describe('Rewards - SubgraphService', () => { - const graph = hre.graph() - let curator1: SignerWithAddress - let governor: SignerWithAddress - let indexer1: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let grt: GraphToken - let curation: Curation - let rewardsManager: RewardsManager - - const subgraphDeploymentID1 = randomHexBytes() - const allocationID1 = randomAddress() - - const ISSUANCE_PER_BLOCK = toGRT('200') // 200 GRT every block - - before(async function () { - const testAccounts = await graph.getTestAccounts() - curator1 = testAccounts[0] - indexer1 = testAccounts[1] - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - grt = contracts.GraphToken as GraphToken - curation = contracts.Curation as Curation - rewardsManager = contracts.RewardsManager - - // 200 GRT per block - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - - // Distribute test funds - for (const wallet of [indexer1, curator1]) { - await grt.connect(governor).mint(wallet.address, toGRT('1000000')) - await grt.connect(wallet).approve(curation.address, toGRT('1000000')) - } - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('subgraph service configuration', function () { - it('should reject setSubgraphService if unauthorized', async function () { - const newService = randomAddress() - const tx = rewardsManager.connect(indexer1).setSubgraphService(newService) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should set subgraph service if governor', async function () { - const newService = randomAddress() - const tx = rewardsManager.connect(governor).setSubgraphService(newService) - - await expect(tx).emit(rewardsManager, 'SubgraphServiceSet').withArgs(constants.AddressZero, newService) - - expect(await rewardsManager.subgraphService()).eq(newService) - }) - - it('should allow setting to zero address', async function () { - const service = randomAddress() - await rewardsManager.connect(governor).setSubgraphService(service) - - const tx = rewardsManager.connect(governor).setSubgraphService(constants.AddressZero) - await expect(tx).emit(rewardsManager, 'SubgraphServiceSet').withArgs(service, constants.AddressZero) - - expect(await rewardsManager.subgraphService()).eq(constants.AddressZero) - }) - - it('should emit event when setting different address', async function () { - const service1 = randomAddress() - const service2 = randomAddress() - - await rewardsManager.connect(governor).setSubgraphService(service1) - - // Setting a different address should emit event - const tx = await rewardsManager.connect(governor).setSubgraphService(service2) - await expect(tx).emit(rewardsManager, 'SubgraphServiceSet').withArgs(service1, service2) - }) - }) - - describe('subgraph service as rewards issuer', function () { - let mockSubgraphService: any - - beforeEach(async function () { - // Deploy mock SubgraphService - const MockSubgraphServiceFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockSubgraphService.sol:MockSubgraphService', - ) - mockSubgraphService = await MockSubgraphServiceFactory.deploy() - await mockSubgraphService.deployed() - - // Set it on RewardsManager - await rewardsManager.connect(governor).setSubgraphService(mockSubgraphService.address) - }) - - describe('getRewards from subgraph service', function () { - it('should calculate rewards for subgraph service allocations', async function () { - // Setup: Create signal for rewards calculation - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Setup allocation data in mock - const tokensAllocated = toGRT('12500') - await mockSubgraphService.setAllocation( - allocationID1, - true, // isActive - indexer1.address, - subgraphDeploymentID1, - tokensAllocated, - 0, // accRewardsPerAllocatedToken - 0, // accRewardsPending - ) - - await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensAllocated) - - // Mine some blocks to accrue rewards - await helpers.mine(10) - - // Get rewards - should return calculated amount - const rewards = await rewardsManager.getRewards(mockSubgraphService.address, allocationID1) - expect(rewards).to.be.gt(0) - }) - - it('should return zero for inactive allocation', async function () { - // Setup allocation as inactive - await mockSubgraphService.setAllocation( - allocationID1, - false, // isActive = false - indexer1.address, - subgraphDeploymentID1, - toGRT('12500'), - 0, - 0, - ) - - const rewards = await rewardsManager.getRewards(mockSubgraphService.address, allocationID1) - expect(rewards).to.equal(0) - }) - - it('should reject getRewards from non-rewards-issuer contract', async function () { - const randomContract = randomAddress() - const tx = rewardsManager.getRewards(randomContract, allocationID1) - await expect(tx).revertedWith('Not a rewards issuer') - }) - }) - - describe('takeRewards from subgraph service', function () { - it('should take rewards through subgraph service', async function () { - // Setup: Create signal for rewards calculation - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Setup allocation data in mock - const tokensAllocated = toGRT('12500') - await mockSubgraphService.setAllocation( - allocationID1, - true, // isActive - indexer1.address, - subgraphDeploymentID1, - tokensAllocated, - 0, // accRewardsPerAllocatedToken - 0, // accRewardsPending - ) - - await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensAllocated) - - // Mine some blocks to accrue rewards - await helpers.mine(10) - - // Before state - const beforeSubgraphServiceBalance = await grt.balanceOf(mockSubgraphService.address) - const beforeTotalSupply = await grt.totalSupply() - - // Impersonate the mock subgraph service contract - await network.provider.request({ - method: 'hardhat_impersonateAccount', - params: [mockSubgraphService.address], - }) - await network.provider.send('hardhat_setBalance', [mockSubgraphService.address, '0x1000000000000000000']) - - const mockSubgraphServiceSigner = await hre.ethers.getSigner(mockSubgraphService.address) - - // Take rewards (called by subgraph service) - const tx = await rewardsManager.connect(mockSubgraphServiceSigner).takeRewards(allocationID1) - const receipt = await tx.wait() - - // Stop impersonating - await network.provider.request({ - method: 'hardhat_stopImpersonatingAccount', - params: [mockSubgraphService.address], - }) - - // Parse the event - const event = receipt.logs - .map((log: any) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .find((e: any) => e?.name === 'HorizonRewardsAssigned') - - expect(event).to.not.be.undefined - expect(event?.args.indexer).to.equal(indexer1.address) - expect(event?.args.allocationID).to.equal(allocationID1) - expect(event?.args.amount).to.be.gt(0) - - // After state - verify tokens minted to subgraph service - const afterSubgraphServiceBalance = await grt.balanceOf(mockSubgraphService.address) - const afterTotalSupply = await grt.totalSupply() - - expect(afterSubgraphServiceBalance).to.be.gt(beforeSubgraphServiceBalance) - expect(afterTotalSupply).to.be.gt(beforeTotalSupply) - }) - - it('should return zero rewards for inactive allocation', async function () { - // Setup allocation as inactive - await mockSubgraphService.setAllocation( - allocationID1, - false, // isActive = false - indexer1.address, - subgraphDeploymentID1, - toGRT('12500'), - 0, - 0, - ) - - // Impersonate the mock subgraph service contract - await network.provider.request({ - method: 'hardhat_impersonateAccount', - params: [mockSubgraphService.address], - }) - await network.provider.send('hardhat_setBalance', [mockSubgraphService.address, '0x1000000000000000000']) - - const mockSubgraphServiceSigner = await hre.ethers.getSigner(mockSubgraphService.address) - - // Take rewards should return 0 - const tx = rewardsManager.connect(mockSubgraphServiceSigner).takeRewards(allocationID1) - if (EMIT_EVENT_FOR_ZERO_REWARDS) { - await expect(tx).emit(rewardsManager, 'HorizonRewardsAssigned').withArgs(indexer1.address, allocationID1, 0) - } else { - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - } - - // Stop impersonating - await network.provider.request({ - method: 'hardhat_stopImpersonatingAccount', - params: [mockSubgraphService.address], - }) - }) - - it('should reject takeRewards from non-rewards-issuer contract', async function () { - const tx = rewardsManager.connect(indexer1).takeRewards(allocationID1) - await expect(tx).revertedWith('Caller must be a rewards issuer') - }) - - it('should handle zero rewards scenario', async function () { - // Setup with zero issuance - await rewardsManager.connect(governor).setIssuancePerBlock(0) - - // Setup allocation - await mockSubgraphService.setAllocation( - allocationID1, - true, - indexer1.address, - subgraphDeploymentID1, - toGRT('12500'), - 0, - 0, - ) - - await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, toGRT('12500')) - - // Mine blocks - await helpers.mine(10) - - // Impersonate the mock subgraph service contract - await network.provider.request({ - method: 'hardhat_impersonateAccount', - params: [mockSubgraphService.address], - }) - await network.provider.send('hardhat_setBalance', [mockSubgraphService.address, '0x1000000000000000000']) - - const mockSubgraphServiceSigner = await hre.ethers.getSigner(mockSubgraphService.address) - - // Take rewards should succeed with 0 amount - const tx = rewardsManager.connect(mockSubgraphServiceSigner).takeRewards(allocationID1) - if (EMIT_EVENT_FOR_ZERO_REWARDS) { - await expect(tx).emit(rewardsManager, 'HorizonRewardsAssigned').withArgs(indexer1.address, allocationID1, 0) - } else { - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - } - - // Stop impersonating - await network.provider.request({ - method: 'hardhat_stopImpersonatingAccount', - params: [mockSubgraphService.address], - }) - }) - }) - - describe('mixed allocations from staking and subgraph service', function () { - it('should account for both staking and subgraph service allocations in getAccRewardsPerAllocatedToken', async function () { - // This test verifies that getSubgraphAllocatedTokens is called for both issuers - // and rewards are distributed proportionally - - // Setup: Create signal - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Setup subgraph service allocation - const tokensFromSubgraphService = toGRT('5000') - await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensFromSubgraphService) - - // Note: We can't easily create a real staking allocation in this test - // but the contract code at lines 381-388 loops through both issuers - // and sums their allocated tokens. This test verifies the subgraph service path. - - // Mine some blocks - await helpers.mine(5) - - // Get accumulated rewards per allocated token - const [accRewardsPerAllocatedToken, accRewardsForSubgraph] = - await rewardsManager.getAccRewardsPerAllocatedToken(subgraphDeploymentID1) - - // Should have calculated rewards based on subgraph service allocations - expect(accRewardsPerAllocatedToken).to.be.gt(0) - expect(accRewardsForSubgraph).to.be.gt(0) - }) - - it('should handle case where only subgraph service has allocations', async function () { - // Setup: Create signal - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Only subgraph service has allocations - const tokensFromSubgraphService = toGRT('10000') - await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensFromSubgraphService) - - // Mine blocks - await helpers.mine(5) - - // Get rewards - const [accRewardsPerAllocatedToken] = await rewardsManager.getAccRewardsPerAllocatedToken(subgraphDeploymentID1) - - expect(accRewardsPerAllocatedToken).to.be.gt(0) - }) - - it('should return zero when neither issuer has allocations', async function () { - // Setup: Create signal but no allocations - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // No allocations from either issuer - await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, 0) - - // Mine blocks - await helpers.mine(5) - - // Get rewards - Option B: with no allocations, rewards are reclaimed (NO_ALLOCATED_TOKENS) - // so both accRewardsPerAllocatedToken and accRewardsForSubgraph remain 0 - const [accRewardsPerAllocatedToken, accRewardsForSubgraph] = - await rewardsManager.getAccRewardsPerAllocatedToken(subgraphDeploymentID1) - - expect(accRewardsPerAllocatedToken).to.equal(0) - expect(accRewardsForSubgraph).to.equal(0) // Option B: rewards reclaimed when no allocations - }) - }) - - describe('subgraph service with denylist and eligibility', function () { - it('should deny rewards from subgraph service when subgraph is on denylist', async function () { - // Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Setup allocation with some pending rewards so rewards > 0 - await mockSubgraphService.setAllocation( - allocationID1, - true, - indexer1.address, - subgraphDeploymentID1, - toGRT('12500'), - 0, - toGRT('100'), // accRewardsPending > 0 so rewards will be calculated - ) - - await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, toGRT('12500')) - - // Impersonate the mock subgraph service contract - await network.provider.request({ - method: 'hardhat_impersonateAccount', - params: [mockSubgraphService.address], - }) - await network.provider.send('hardhat_setBalance', [mockSubgraphService.address, '0x1000000000000000000']) - - const mockSubgraphServiceSigner = await hre.ethers.getSigner(mockSubgraphService.address) - - // Take rewards should be denied - const tx = rewardsManager.connect(mockSubgraphServiceSigner).takeRewards(allocationID1) - await expect(tx).emit(rewardsManager, 'RewardsDenied').withArgs(indexer1.address, allocationID1) - - // Stop impersonating - await network.provider.request({ - method: 'hardhat_stopImpersonatingAccount', - params: [mockSubgraphService.address], - }) - }) - - it('should deny rewards from subgraph service when indexer is ineligible', async function () { - // Setup REO that denies indexer1 - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockREO = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny by default - await mockREO.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockREO.address) - - // Setup: Create signal - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Setup allocation - const tokensAllocated = toGRT('12500') - await mockSubgraphService.setAllocation( - allocationID1, - true, - indexer1.address, - subgraphDeploymentID1, - tokensAllocated, - 0, - 0, - ) - - await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensAllocated) - - // Mine blocks to accrue rewards - await helpers.mine(5) - - // Impersonate the mock subgraph service contract - await network.provider.request({ - method: 'hardhat_impersonateAccount', - params: [mockSubgraphService.address], - }) - await network.provider.send('hardhat_setBalance', [mockSubgraphService.address, '0x1000000000000000000']) - - const mockSubgraphServiceSigner = await hre.ethers.getSigner(mockSubgraphService.address) - - // Take rewards should be denied due to eligibility - const tx = rewardsManager.connect(mockSubgraphServiceSigner).takeRewards(allocationID1) - await expect(tx).emit(rewardsManager, 'RewardsDeniedDueToEligibility') - - // Stop impersonating - await network.provider.request({ - method: 'hardhat_stopImpersonatingAccount', - params: [mockSubgraphService.address], - }) - }) - }) - }) -}) diff --git a/packages/issuance/test/unit/rewards-manager/issuanceAllocator.t.sol b/packages/issuance/test/unit/rewards-manager/issuanceAllocator.t.sol new file mode 100644 index 000000000..f65d67de6 --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/issuanceAllocator.t.sol @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IIssuanceAllocationDistribution } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceAllocationDistribution.sol"; + +import { MockERC165 } from "../../../contracts/test/allocate/MockERC165.sol"; +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +contract RewardsManagerIssuanceAllocatorTest is RewardsManagerSharedTest { + event IssuanceAllocatorSet( + IIssuanceAllocationDistribution indexed oldIssuanceAllocator, + IIssuanceAllocationDistribution indexed newIssuanceAllocator + ); + + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_1 = keccak256("subgraphDeploymentID1"); + + function _setAllocator(address newAllocator) internal { + vm.prank(governor); + rm.setIssuanceAllocator(IIssuanceAllocationDistribution(newAllocator)); + } + + // -- setIssuanceAllocator: ERC-165 validation -- + + function test_ERC165Validation_SetsAllocatorThatSupportsTheInterface() public { + vm.expectEmit(address(rm)); + emit IssuanceAllocatorSet( + IIssuanceAllocationDistribution(address(0)), + IIssuanceAllocationDistribution(address(allocator)) + ); + _setAllocator(address(allocator)); + + assertEq(address(rm.getIssuanceAllocator()), address(allocator)); + } + + function test_ERC165Validation_RevertWhen_AllocatorIsEOA() public { + vm.prank(governor); + vm.expectRevert(); + rm.setIssuanceAllocator(IIssuanceAllocationDistribution(indexer)); + } + + function test_ERC165Validation_RevertWhen_InterfaceNotSupported() public { + MockERC165 mockERC165 = new MockERC165(); + + vm.prank(governor); + vm.expectRevert(bytes("Contract does not support IIssuanceAllocationDistribution interface")); + rm.setIssuanceAllocator(IIssuanceAllocationDistribution(address(mockERC165))); + } + + function test_ERC165Validation_RevertWhen_InterfaceNotSupportedBeforeRewardsUpdate() public { + MockERC165 mockERC165 = new MockERC165(); + + vm.prank(governor); + vm.expectRevert(bytes("Contract does not support IIssuanceAllocationDistribution interface")); + rm.setIssuanceAllocator(IIssuanceAllocationDistribution(address(mockERC165))); + } + + // -- setIssuanceAllocator: access control -- + + function test_AccessControl_RevertWhen_CalledByNonGovernor() public { + vm.prank(indexer); + vm.expectRevert(bytes("Only Controller governor")); + rm.setIssuanceAllocator(IIssuanceAllocationDistribution(address(allocator))); + } + + // -- setIssuanceAllocator: state management -- + + function test_StateManagement_AllowsDisablingWithZeroAddress() public { + _setAllocator(address(allocator)); + assertEq(address(rm.getIssuanceAllocator()), address(allocator)); + + vm.expectEmit(address(rm)); + emit IssuanceAllocatorSet( + IIssuanceAllocationDistribution(address(allocator)), + IIssuanceAllocationDistribution(address(0)) + ); + _setAllocator(address(0)); + + assertEq(address(rm.getIssuanceAllocator()), address(0)); + + // Local issuancePerBlock is used again, both getters agree + assertEq(rm.getAllocatedIssuancePerBlock(), ISSUANCE_PER_BLOCK); + assertEq(rm.getRawIssuancePerBlock(), ISSUANCE_PER_BLOCK); + } + + function test_StateManagement_EmitsIssuanceAllocatorSet() public { + vm.expectEmit(address(rm)); + emit IssuanceAllocatorSet( + IIssuanceAllocationDistribution(address(0)), + IIssuanceAllocationDistribution(address(allocator)) + ); + _setAllocator(address(allocator)); + } + + function test_StateManagement_DoesNotEmitWhenSettingSameAllocator() public { + _setAllocator(address(allocator)); + + vm.recordLogs(); + _setAllocator(address(allocator)); + _assertNotEmitted(vm.getRecordedLogs(), IssuanceAllocatorSet.selector); + } + + function test_StateManagement_UpdatesRewardsBeforeChangingAllocator() public { + // Old test only checked the allocator was set; the pre-change update is also visible in the block marker + vm.roll(block.number + 1); + _setAllocator(address(allocator)); + + assertEq(address(rm.getIssuanceAllocator()), address(allocator)); + assertEq(rm.accRewardsPerSignalLastBlockUpdated(), block.number); + } + + // -- getAllocatedIssuancePerBlock -- + + function test_GetAllocatedIssuancePerBlock_ReturnsIssuancePerBlockWhenNoAllocatorSet() public { + uint256 expectedIssuance = 100.025 ether; + vm.prank(governor); + rm.setIssuancePerBlock(expectedIssuance); + + assertEq(address(rm.getIssuanceAllocator()), address(0)); + + assertEq(rm.getAllocatedIssuancePerBlock(), expectedIssuance); + assertEq(rm.getRawIssuancePerBlock(), expectedIssuance); + } + + function test_GetAllocatedIssuancePerBlock_ReturnsValueFromAllocatorWhenSet() public { + _setAllocator(address(allocator)); + assertEq(address(rm.getIssuanceAllocator()), address(allocator)); + + // RewardsManager is a self-minting target with 25 GRT per block + uint256 expectedIssuance = 25 ether; + allocator.setTargetAllocation(address(rm), 0, expectedIssuance, true); + + // Allocated getter returns the allocator value, raw getter still returns storage value + assertEq(rm.getAllocatedIssuancePerBlock(), expectedIssuance); + assertEq(rm.getRawIssuancePerBlock(), ISSUANCE_PER_BLOCK); + } + + function test_GetAllocatedIssuancePerBlock_ZeroWhenTargetNotRegisteredAsSelfMinter() public { + _setAllocator(address(allocator)); + + // RewardsManager is an allocator-minting target (only allocator issuance) + allocator.setTargetAllocation(address(rm), 25 ether, 0, false); + + assertEq(rm.getAllocatedIssuancePerBlock(), 0); + assertEq(rm.getRawIssuancePerBlock(), ISSUANCE_PER_BLOCK); + } + + // -- setIssuancePerBlock -- + + function test_SetIssuancePerBlock_AllowedWhenAllocatorIsSet() public { + _setAllocator(address(allocator)); + + uint256 newIssuancePerBlock = 100 ether; + vm.prank(governor); + rm.setIssuancePerBlock(newIssuancePerBlock); + + assertEq(rm.issuancePerBlock(), newIssuancePerBlock); + assertEq(rm.getRawIssuancePerBlock(), newIssuancePerBlock); + + // The effective issuance still comes from the allocator (no target registered, so zero) + assertNotEq(rm.getAllocatedIssuancePerBlock(), newIssuancePerBlock); + assertEq(rm.getAllocatedIssuancePerBlock(), 0); + } + + // -- beforeIssuanceAllocationChange -- + + function test_BeforeIssuanceAllocationChange_HandlesCallsFromAnyoneAndTheAllocator() public { + _setAllocator(address(allocator)); + + // Anyone can call this function + vm.roll(block.number + 1); + vm.prank(governor); + rm.beforeIssuanceAllocationChange(); + assertEq(rm.accRewardsPerSignalLastBlockUpdated(), block.number); + + // Also succeeds when called by the allocator + vm.roll(block.number + 1); + allocator.callBeforeIssuanceAllocationChange(address(rm)); + assertEq(rm.accRewardsPerSignalLastBlockUpdated(), block.number); + } + + // -- issuance allocator integration -- + + function test_IssuanceAllocatorIntegration_AccumulatesRewardsUsingAllocatorRateOverTime() public { + uint256 totalSignal = 1000 ether; + _signal(SUBGRAPH_DEPLOYMENT_ID_1, totalSignal); + + // Allocator rate of 50 GRT per block, different from the local 200 + uint256 allocatorRate = 50 ether; + allocator.setTargetAllocation(address(rm), 0, allocatorRate, false); + _setAllocator(address(allocator)); + + uint256 rewardsAfterSet = rm.getAccRewardsPerSignal(); + + uint256 blocksToMine = 10; + vm.roll(block.number + blocksToMine); + + uint256 actualAccrued = rm.getAccRewardsPerSignal() - rewardsAfterSet; + + // (rate x blocks) / totalSignal = 0.5 GRT per signal + uint256 expectedAccrued = (allocatorRate * blocksToMine * 1e18) / totalSignal; + assertEq(actualAccrued, expectedAccrued); + + // Not the local rate, which would be 4x higher + assertNotEq(actualAccrued, _expectedRewardsPerSignal(blocksToMine, totalSignal)); + } + + function test_IssuanceAllocatorIntegration_MaintainsRewardConsistencyWhenSwitchingBetweenRates() public { + uint256 totalSignal = 2000 ether; + _signal(SUBGRAPH_DEPLOYMENT_ID_1, totalSignal); + + uint256 rewards0 = rm.getAccRewardsPerSignal(); + // Sum of every phase and transition accrual, checked against the overall difference at the end + uint256 totalExpected; + uint256 previous = rewards0; + uint256 allocatorRate = 100 ether; + + // Phase 1: accrue at the local rate (200 GRT per block) + vm.roll(block.number + 5); + uint256 current = rm.getAccRewardsPerSignal(); + assertEq(current - previous, _expectedRewardsPerSignal(5, totalSignal)); + totalExpected += current - previous; + previous = current; + + // Phase 2: switch to the allocator at 100 GRT per block. The switch happens one block later (the block + // Hardhat mined for the tx), which still accrues at the local rate + allocator.setTargetAllocation(address(rm), 0, allocatorRate, false); + vm.roll(block.number + 1); + _setAllocator(address(allocator)); + current = rm.getAccRewardsPerSignal(); + assertEq(current - previous, _expectedRewardsPerSignal(1, totalSignal)); + totalExpected += current - previous; + previous = current; + + vm.roll(block.number + 8); + current = rm.getAccRewardsPerSignal(); + assertEq(current - previous, (allocatorRate * 8 * 1e18) / totalSignal); + totalExpected += current - previous; + previous = current; + + // Phase 3: switch back to the local rate. The block of the switch still accrues at the allocator rate + vm.roll(block.number + 1); + _setAllocator(address(0)); + current = rm.getAccRewardsPerSignal(); + assertEq(current - previous, (allocatorRate * 1 * 1e18) / totalSignal); + totalExpected += current - previous; + previous = current; + + vm.roll(block.number + 4); + current = rm.getAccRewardsPerSignal(); + assertEq(current - previous, _expectedRewardsPerSignal(4, totalSignal)); + totalExpected += current - previous; + + // All rewards from start to end equal the sum of the phases and the transition blocks + assertEq(current - rewards0, totalExpected); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/subgraphService.t.sol b/packages/issuance/test/unit/rewards-manager/subgraphService.t.sol new file mode 100644 index 000000000..a7e2eca7a --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/subgraphService.t.sol @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IProviderEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibility.sol"; + +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +contract RewardsManagerSubgraphServiceTest is RewardsManagerSharedTest { + event SubgraphServiceSet(address indexed oldSubgraphService, address indexed newSubgraphService); + event HorizonRewardsAssigned(address indexed indexer, address indexed allocationID, uint256 amount); + event RewardsDenied(address indexed indexer, address indexed allocationID); + event RewardsDeniedDueToEligibility(address indexed indexer, address indexed allocationID, uint256 amount); + + uint256 internal constant ISSUANCE_RATE_PERIODS = 5; + + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_1 = keccak256("subgraphDeploymentID1"); + + address internal allocationID1 = makeAddr("allocationID1"); + + /// Rewards of an allocation holding all of the allocated tokens, on a subgraph holding all the signal. + function _expectedAllocationRewards(uint256 blocks, uint256 tokens) internal pure returns (uint256) { + uint256 subgraphRewards = _expectedSubgraphRewards(blocks, SIGNAL, SIGNAL); + return (_expectedRewardsPerAllocatedToken(subgraphRewards, tokens) * tokens) / 1e18; + } + + function _setActiveAllocation(uint256 tokens, uint256 accRewardsPending) internal { + issuer.setAllocation(allocationID1, true, indexer, SUBGRAPH_DEPLOYMENT_ID_1, tokens, 0, accRewardsPending); + issuer.setSubgraphAllocatedTokens(SUBGRAPH_DEPLOYMENT_ID_1, tokens); + } + + // -- subgraph service configuration -- + + function test_SubgraphServiceConfiguration_RevertWhen_SetByNonGovernor() public { + vm.prank(indexer); + vm.expectRevert(bytes("Only Controller governor")); + rm.setSubgraphService(makeAddr("newService")); + } + + function test_SubgraphServiceConfiguration_SetsSubgraphServiceAsGovernor() public { + address newService = makeAddr("newService"); + + vm.expectEmit(address(rm)); + emit SubgraphServiceSet(address(issuer), newService); + vm.prank(governor); + rm.setSubgraphService(newService); + + assertEq(address(rm.subgraphService()), newService); + } + + function test_SubgraphServiceConfiguration_AllowsSettingToZeroAddress() public { + address service = makeAddr("service"); + vm.prank(governor); + rm.setSubgraphService(service); + + vm.expectEmit(address(rm)); + emit SubgraphServiceSet(service, address(0)); + vm.prank(governor); + rm.setSubgraphService(address(0)); + + assertEq(address(rm.subgraphService()), address(0)); + } + + function test_SubgraphServiceConfiguration_EmitsEventWhenSettingDifferentAddress() public { + address service1 = makeAddr("service1"); + address service2 = makeAddr("service2"); + + vm.prank(governor); + rm.setSubgraphService(service1); + + vm.expectEmit(address(rm)); + emit SubgraphServiceSet(service1, service2); + vm.prank(governor); + rm.setSubgraphService(service2); + } + + // -- getRewards from subgraph service -- + + function test_GetRewards_CalculatesRewardsForSubgraphServiceAllocations() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + _setActiveAllocation(ALLOCATION, 0); + + vm.roll(block.number + 10); + + uint256 rewards = rm.getRewards(address(issuer), allocationID1); + assertGt(rewards, 0); + assertEq(rewards, _expectedAllocationRewards(10, ALLOCATION)); + } + + function test_GetRewards_ZeroForInactiveAllocation() public { + issuer.setAllocation(allocationID1, false, indexer, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION, 0, 0); + + assertEq(rm.getRewards(address(issuer), allocationID1), 0); + } + + function test_GetRewards_RevertWhen_NotARewardsIssuer() public { + address randomContract = makeAddr("randomContract"); + + vm.expectRevert(bytes("Not a rewards issuer")); + rm.getRewards(randomContract, allocationID1); + } + + // -- takeRewards from subgraph service -- + + function test_TakeRewards_TakesRewardsThroughSubgraphService() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + _setActiveAllocation(ALLOCATION, 0); + + vm.roll(block.number + 10); + + uint256 expectedRewards = _expectedAllocationRewards(10, ALLOCATION); + uint256 beforeIssuerBalance = token.balanceOf(address(issuer)); + uint256 beforeTotalSupply = token.totalSupply(); + + vm.expectEmit(address(rm)); + emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); + vm.prank(address(issuer)); + uint256 rewards = rm.takeRewards(allocationID1); + + assertEq(rewards, expectedRewards); + assertEq(token.balanceOf(address(issuer)), beforeIssuerBalance + expectedRewards); + assertEq(token.totalSupply(), beforeTotalSupply + expectedRewards); + } + + function test_TakeRewards_ZeroRewardsForInactiveAllocation() public { + issuer.setAllocation(allocationID1, false, indexer, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION, 0, 0); + + vm.recordLogs(); + vm.prank(address(issuer)); + rm.takeRewards(allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), HorizonRewardsAssigned.selector); + } + + function test_TakeRewards_RevertWhen_CallerIsNotARewardsIssuer() public { + vm.prank(indexer); + vm.expectRevert(bytes("Caller must be a rewards issuer")); + rm.takeRewards(allocationID1); + } + + function test_TakeRewards_HandlesZeroRewardsScenario() public { + vm.prank(governor); + rm.setIssuancePerBlock(0); + + _setActiveAllocation(ALLOCATION, 0); + + vm.roll(block.number + 10); + + vm.recordLogs(); + vm.prank(address(issuer)); + uint256 rewards = rm.takeRewards(allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), HorizonRewardsAssigned.selector); + + assertEq(rewards, 0); + } + + // -- mixed allocations from staking and subgraph service -- + // Titles are stale: there is a single rewards issuer now, so the assertions run against `issuer`. + + function test_MixedAllocationsFromStakingAndSubgraphService_AccountsForSubgraphServiceAllocationsInAccRewardsPerAllocatedToken() + public + { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + + uint256 tokensFromSubgraphService = 5000 ether; + issuer.setSubgraphAllocatedTokens(SUBGRAPH_DEPLOYMENT_ID_1, tokensFromSubgraphService); + + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + (uint256 accRewardsPerAllocatedToken, uint256 accRewardsForSubgraph) = rm.getAccRewardsPerAllocatedToken( + SUBGRAPH_DEPLOYMENT_ID_1 + ); + + assertGt(accRewardsPerAllocatedToken, 0); + assertGt(accRewardsForSubgraph, 0); + uint256 expectedForSubgraph = _expectedSubgraphRewards(ISSUANCE_RATE_PERIODS, SIGNAL, SIGNAL); + assertEq(accRewardsForSubgraph, expectedForSubgraph); + assertEq( + accRewardsPerAllocatedToken, + _expectedRewardsPerAllocatedToken(expectedForSubgraph, tokensFromSubgraphService) + ); + } + + function test_MixedAllocationsFromStakingAndSubgraphService_HandlesCaseWhereOnlySubgraphServiceHasAllocations() + public + { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + + uint256 tokensFromSubgraphService = 10_000 ether; + issuer.setSubgraphAllocatedTokens(SUBGRAPH_DEPLOYMENT_ID_1, tokensFromSubgraphService); + + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + (uint256 accRewardsPerAllocatedToken, ) = rm.getAccRewardsPerAllocatedToken(SUBGRAPH_DEPLOYMENT_ID_1); + + assertGt(accRewardsPerAllocatedToken, 0); + assertEq( + accRewardsPerAllocatedToken, + _expectedRewardsPerAllocatedToken( + _expectedSubgraphRewards(ISSUANCE_RATE_PERIODS, SIGNAL, SIGNAL), + tokensFromSubgraphService + ) + ); + } + + function test_MixedAllocationsFromStakingAndSubgraphService_ZeroWhenNeitherIssuerHasAllocations() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + + issuer.setSubgraphAllocatedTokens(SUBGRAPH_DEPLOYMENT_ID_1, 0); + + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + // With no allocations, rewards are reclaimed (NO_ALLOCATED_TOKENS), so both stay at zero + (uint256 accRewardsPerAllocatedToken, uint256 accRewardsForSubgraph) = rm.getAccRewardsPerAllocatedToken( + SUBGRAPH_DEPLOYMENT_ID_1 + ); + + assertEq(accRewardsPerAllocatedToken, 0); + assertEq(accRewardsForSubgraph, 0); + } + + // -- subgraph service with denylist and eligibility -- + + function test_SubgraphServiceWithDenylistAndEligibility_DeniesRewardsWhenSubgraphIsOnDenylist() public { + vm.startPrank(governor); + rm.setSubgraphAvailabilityOracle(governor); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + vm.stopPrank(); + + // Pending rewards > 0 so there are rewards to deny + _setActiveAllocation(ALLOCATION, 100 ether); + + vm.expectEmit(address(rm)); + emit RewardsDenied(indexer, allocationID1); + vm.prank(address(issuer)); + rm.takeRewards(allocationID1); + } + + function test_SubgraphServiceWithDenylistAndEligibility_DeniesRewardsWhenIndexerIsIneligible() public { + // Oracle denies by default + oracle.setDefaultResponse(false); + vm.prank(governor); + rm.setProviderEligibilityOracle(IProviderEligibility(address(oracle))); + + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + _setActiveAllocation(ALLOCATION, 0); + + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + vm.expectEmit(address(rm)); + emit RewardsDeniedDueToEligibility( + indexer, + allocationID1, + _expectedAllocationRewards(ISSUANCE_RATE_PERIODS, ALLOCATION) + ); + vm.prank(address(issuer)); + rm.takeRewards(allocationID1); + } +} diff --git a/scripts/contracts-migration/test-mapping/t5c.md b/scripts/contracts-migration/test-mapping/t5c.md new file mode 100644 index 000000000..b049044b6 --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t5c.md @@ -0,0 +1,46 @@ +# T5c test mapping: RewardsManager issuance allocator and subgraph service + +New tests are in `packages/issuance`, on T5a's `RewardsManagerSharedTest` (the RewardsManager Hardhat artifact behind a GraphProxy). `RMIssuanceAllocatorMock` and `RMEligibilityOracleMock` replace `MockIssuanceAllocator` / `MockRewardsEligibilityOracle`, and `RMIssuerMock` (`issuer`) plays the old `MockSubgraphService`. `curation.mint` becomes `_signal`, `helpers.mine(n)` becomes `vm.roll`, and the `hardhat_impersonateAccount` blocks become `vm.prank(address(issuer))`. The old `beforeEach` that reset the allocator to the zero address is dropped, since the fixture starts with none. Expected values are derived from the explicit rolls with the shared formula helpers and asserted exactly. No ported case uses zero-token allocations (the carried `RMIssuerMock` hook caveat does not apply; allocations are set with `setAllocation`, as the old tests did). + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts | Rewards - Issuance Allocator › setIssuanceAllocator › ERC-165 validation › should successfully set an issuance allocator that supports the interface | test/unit/rewards-manager/issuanceAllocator.t.sol::test_ERC165Validation_SetsAllocatorThatSupportsTheInterface | `RMIssuanceAllocatorMock` instead of `MockIssuanceAllocator` | +| packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts | Rewards - Issuance Allocator › setIssuanceAllocator › ERC-165 validation › should revert when setting to EOA address (no contract code) | test/unit/rewards-manager/issuanceAllocator.t.sol::test_ERC165Validation_RevertWhen_AllocatorIsEOA | Bare `vm.expectRevert()`, as the old `.reverted` | +| packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts | Rewards - Issuance Allocator › setIssuanceAllocator › ERC-165 validation › should revert when setting to contract that does not support IIssuanceAllocationDistribution | test/unit/rewards-manager/issuanceAllocator.t.sol::test_ERC165Validation_RevertWhen_InterfaceNotSupported | Issuance's `MockERC165` | +| packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts | Rewards - Issuance Allocator › setIssuanceAllocator › ERC-165 validation › should validate interface before updating rewards calculation | test/unit/rewards-manager/issuanceAllocator.t.sol::test_ERC165Validation_RevertWhen_InterfaceNotSupportedBeforeRewardsUpdate | Same assertion as the old test (revert string). The title is stale: `setIssuanceAllocator` calls `updateAccRewardsPerSignal` before the check, and the whole call reverts | +| packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts | Rewards - Issuance Allocator › setIssuanceAllocator › access control › should revert when called by non-governor | test/unit/rewards-manager/issuanceAllocator.t.sol::test_AccessControl_RevertWhen_CalledByNonGovernor | | +| packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts | Rewards - Issuance Allocator › setIssuanceAllocator › state management › should allow setting issuance allocator to zero address (disable) | test/unit/rewards-manager/issuanceAllocator.t.sol::test_StateManagement_AllowsDisablingWithZeroAddress | | +| packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts | Rewards - Issuance Allocator › setIssuanceAllocator › state management › should emit IssuanceAllocatorSet event when setting allocator | test/unit/rewards-manager/issuanceAllocator.t.sol::test_StateManagement_EmitsIssuanceAllocatorSet | | +| packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts | Rewards - Issuance Allocator › setIssuanceAllocator › state management › should not emit event when setting to same allocator address | test/unit/rewards-manager/issuanceAllocator.t.sol::test_StateManagement_DoesNotEmitWhenSettingSameAllocator | `vm.recordLogs` + `_assertNotEmitted` | +| packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts | Rewards - Issuance Allocator › setIssuanceAllocator › state management › should update rewards before changing issuance allocator | test/unit/rewards-manager/issuanceAllocator.t.sol::test_StateManagement_UpdatesRewardsBeforeChangingAllocator | The old test only asserted the allocator was set (the comment said the update could not be tested). Kept that assertion and added `accRewardsPerSignalLastBlockUpdated == block.number` after a one-block roll, which shows the update ran | +| packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts | Rewards - Issuance Allocator › getAllocatedIssuancePerBlock › should return issuancePerBlock when no issuanceAllocator is set | test/unit/rewards-manager/issuanceAllocator.t.sol::test_GetAllocatedIssuancePerBlock_ReturnsIssuancePerBlockWhenNoAllocatorSet | | +| packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts | Rewards - Issuance Allocator › getAllocatedIssuancePerBlock › should return value from issuanceAllocator when set | test/unit/rewards-manager/issuanceAllocator.t.sol::test_GetAllocatedIssuancePerBlock_ReturnsValueFromAllocatorWhenSet | `callBefore` is `true`, as the old 4-argument call | +| packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts | Rewards - Issuance Allocator › getAllocatedIssuancePerBlock › should return 0 when issuanceAllocator is set but target not registered as self-minter | test/unit/rewards-manager/issuanceAllocator.t.sol::test_GetAllocatedIssuancePerBlock_ZeroWhenTargetNotRegisteredAsSelfMinter | | +| packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts | Rewards - Issuance Allocator › setIssuancePerBlock › should allow setIssuancePerBlock when issuanceAllocator is set | test/unit/rewards-manager/issuanceAllocator.t.sol::test_SetIssuancePerBlock_AllowedWhenAllocatorIsSet | Old `not.eq(new)` kept, plus the real value (0: no target registered in the mock) | +| packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts | Rewards - Issuance Allocator › beforeIssuanceAllocationChange › should handle beforeIssuanceAllocationChange correctly | test/unit/rewards-manager/issuanceAllocator.t.sol::test_BeforeIssuanceAllocationChange_HandlesCallsFromAnyoneAndTheAllocator | One-block roll before each call (Hardhat mined a block per tx); asserts `accRewardsPerSignalLastBlockUpdated` after each, which the old test did not | +| packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts | Rewards - Issuance Allocator › issuance allocator integration › should accumulate rewards using allocator rate over time | test/unit/rewards-manager/issuanceAllocator.t.sol::test_IssuanceAllocatorIntegration_AccumulatesRewardsUsingAllocatorRateOverTime | 10-block roll after the allocator is set; exact, plus the `not.eq` local-rate check | +| packages/contracts-test/tests/unit/rewards/rewards-issuance-allocator.test.ts | Rewards - Issuance Allocator › issuance allocator integration › should maintain reward consistency when switching between rates | test/unit/rewards-manager/issuanceAllocator.t.sol::test_IssuanceAllocatorIntegration_MaintainsRewardConsistencyWhenSwitchingBetweenRates | Block-number differences replaced by explicit rolls (5, then 1 + switch, 8, 1 + switch, 4). The one-block rolls before each switch reproduce the transition block Hardhat mined for the switch tx, so the transition terms stay non-zero. Each phase is asserted against the formula, then the total against the sum | +| packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts | Rewards - SubgraphService › subgraph service configuration › should reject setSubgraphService if unauthorized | test/unit/rewards-manager/subgraphService.t.sol::test_SubgraphServiceConfiguration_RevertWhen_SetByNonGovernor | | +| packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts | Rewards - SubgraphService › subgraph service configuration › should set subgraph service if governor | test/unit/rewards-manager/subgraphService.t.sol::test_SubgraphServiceConfiguration_SetsSubgraphServiceAsGovernor | Old address in the event is `issuer` (set in `setUp`), not the zero address | +| packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts | Rewards - SubgraphService › subgraph service configuration › should allow setting to zero address | test/unit/rewards-manager/subgraphService.t.sol::test_SubgraphServiceConfiguration_AllowsSettingToZeroAddress | | +| packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts | Rewards - SubgraphService › subgraph service configuration › should emit event when setting different address | test/unit/rewards-manager/subgraphService.t.sol::test_SubgraphServiceConfiguration_EmitsEventWhenSettingDifferentAddress | | +| packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts | Rewards - SubgraphService › subgraph service as rewards issuer › getRewards from subgraph service › should calculate rewards for subgraph service allocations | test/unit/rewards-manager/subgraphService.t.sol::test_GetRewards_CalculatesRewardsForSubgraphServiceAllocations | `issuer` plays the mock subgraph service; old `gt(0)` kept, plus the exact value derived from the 10-block roll | +| packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts | Rewards - SubgraphService › subgraph service as rewards issuer › getRewards from subgraph service › should return zero for inactive allocation | test/unit/rewards-manager/subgraphService.t.sol::test_GetRewards_ZeroForInactiveAllocation | | +| packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts | Rewards - SubgraphService › subgraph service as rewards issuer › getRewards from subgraph service › should reject getRewards from non-rewards-issuer contract | test/unit/rewards-manager/subgraphService.t.sol::test_GetRewards_RevertWhen_NotARewardsIssuer | | +| packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts | Rewards - SubgraphService › subgraph service as rewards issuer › takeRewards from subgraph service › should take rewards through subgraph service | test/unit/rewards-manager/subgraphService.t.sol::test_TakeRewards_TakesRewardsThroughSubgraphService | `vm.prank(issuer)` replaces the impersonation. Old `gt(0)` event amount, balance and supply checks are exact (derived from the 10-block roll) | +| packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts | Rewards - SubgraphService › subgraph service as rewards issuer › takeRewards from subgraph service › should return zero rewards for inactive allocation | test/unit/rewards-manager/subgraphService.t.sol::test_TakeRewards_ZeroRewardsForInactiveAllocation | `EMIT_EVENT_FOR_ZERO_REWARDS` is false, so asserts no `HorizonRewardsAssigned` | +| packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts | Rewards - SubgraphService › subgraph service as rewards issuer › takeRewards from subgraph service › should reject takeRewards from non-rewards-issuer contract | test/unit/rewards-manager/subgraphService.t.sol::test_TakeRewards_RevertWhen_CallerIsNotARewardsIssuer | | +| packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts | Rewards - SubgraphService › subgraph service as rewards issuer › takeRewards from subgraph service › should handle zero rewards scenario | test/unit/rewards-manager/subgraphService.t.sol::test_TakeRewards_HandlesZeroRewardsScenario | Also asserts the returned amount is 0 | +| packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts | Rewards - SubgraphService › subgraph service as rewards issuer › mixed allocations from staking and subgraph service › should account for both staking and subgraph service allocations in getAccRewardsPerAllocatedToken | test/unit/rewards-manager/subgraphService.t.sol::test_MixedAllocationsFromStakingAndSubgraphService_AccountsForSubgraphServiceAllocationsInAccRewardsPerAllocatedToken | Title is stale (one rewards issuer now); asserted against the single `issuer`. Old `gt(0)` kept, plus exact values | +| packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts | Rewards - SubgraphService › subgraph service as rewards issuer › mixed allocations from staking and subgraph service › should handle case where only subgraph service has allocations | test/unit/rewards-manager/subgraphService.t.sol::test_MixedAllocationsFromStakingAndSubgraphService_HandlesCaseWhereOnlySubgraphServiceHasAllocations | As above; exact value added | +| packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts | Rewards - SubgraphService › subgraph service as rewards issuer › mixed allocations from staking and subgraph service › should return zero when neither issuer has allocations | test/unit/rewards-manager/subgraphService.t.sol::test_MixedAllocationsFromStakingAndSubgraphService_ZeroWhenNeitherIssuerHasAllocations | As above | +| packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts | Rewards - SubgraphService › subgraph service as rewards issuer › subgraph service with denylist and eligibility › should deny rewards from subgraph service when subgraph is on denylist | test/unit/rewards-manager/subgraphService.t.sol::test_SubgraphServiceWithDenylistAndEligibility_DeniesRewardsWhenSubgraphIsOnDenylist | | +| packages/contracts-test/tests/unit/rewards/rewards-subgraph-service.test.ts | Rewards - SubgraphService › subgraph service as rewards issuer › subgraph service with denylist and eligibility › should deny rewards from subgraph service when indexer is ineligible | test/unit/rewards-manager/subgraphService.t.sol::test_SubgraphServiceWithDenylistAndEligibility_DeniesRewardsWhenIndexerIsIneligible | `RMEligibilityOracleMock` with default response `false`. The old `emit` had no args; the event now carries `amount`, asserted as the derived rewards | + +## Coverage + +`contracts/rewards/RewardsManager.sol`, external/public names in `functionsHit` that no issuance test calls yet (closed in T5f; T5d's reclaim port is expected to reach them): + +- `getDefaultReclaimAddress`: reclaim configuration, ported by T5d (`rewards-reclaim.test.ts`). +- `setDefaultReclaimAddress`: as above. +- `setReclaimAddress`: as above. From b75038d1db82f07d7f47497b77b4182747c164da Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 26 Oct 2026 17:26:57 -0300 Subject: [PATCH 17/20] test(issuance): port RewardsManager eligibility and reclaim tests to Foundry --- .../rewards-eligibility-oracle.test.ts | 700 ----------- .../unit/rewards/rewards-reclaim.test.ts | 1115 ----------------- .../unit/rewards-manager/distribution.t.sol | 16 +- .../unit/rewards-manager/eligibility.t.sol | 283 +++++ .../test/unit/rewards-manager/reclaim.t.sol | 715 +++++++++++ .../test/unit/rewards-manager/shared.t.sol | 28 + .../rewards-manager/subgraphService.t.sol | 12 +- .../contracts-migration/test-mapping/t5d.md | 66 + 8 files changed, 1096 insertions(+), 1839 deletions(-) delete mode 100644 packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts delete mode 100644 packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts create mode 100644 packages/issuance/test/unit/rewards-manager/eligibility.t.sol create mode 100644 packages/issuance/test/unit/rewards-manager/reclaim.t.sol create mode 100644 scripts/contracts-migration/test-mapping/t5d.md diff --git a/packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts deleted file mode 100644 index c2137dc64..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts +++ /dev/null @@ -1,700 +0,0 @@ -import { Curation } from '@graphprotocol/contracts' -import { EpochManager } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { IStaking } from '@graphprotocol/contracts' -import { RewardsManager } from '@graphprotocol/contracts' -import { deriveChannelKey, GraphNetworkContracts, helpers, randomHexBytes, toGRT } from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber, constants } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const { HashZero } = constants - -// Tolerance for fixed-point arithmetic rounding errors (matching Foundry tests) -const REWARDS_TOLERANCE = 20000 - -// Helper to check approximate equality for rewards (allows for rounding errors in fixed-point math) -function expectApproxEq(actual: BigNumber, expected: BigNumber, message: string) { - const diff = actual.sub(expected).abs() - expect( - diff.lte(REWARDS_TOLERANCE), - `${message}: difference ${diff.toString()} exceeds tolerance ${REWARDS_TOLERANCE}`, - ).to.be.true -} - -describe('Rewards - Eligibility Oracle', () => { - const graph = hre.graph() - let curator1: SignerWithAddress - let governor: SignerWithAddress - let indexer1: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let grt: GraphToken - let curation: Curation - let epochManager: EpochManager - let staking: IStaking - let rewardsManager: RewardsManager - - // Derive channel key for indexer used to sign attestations - const channelKey1 = deriveChannelKey() - - const subgraphDeploymentID1 = randomHexBytes() - - const allocationID1 = channelKey1.address - - const metadata = HashZero - - const ISSUANCE_PER_BLOCK = toGRT('200') // 200 GRT every block - - async function setupIndexerAllocation() { - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - } - - before(async function () { - const testAccounts = await graph.getTestAccounts() - curator1 = testAccounts[0] - indexer1 = testAccounts[1] - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - grt = contracts.GraphToken as GraphToken - curation = contracts.Curation as Curation - epochManager = contracts.EpochManager - staking = contracts.Staking as IStaking - rewardsManager = contracts.RewardsManager - - // 200 GRT per block - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - - // Distribute test funds - for (const wallet of [indexer1, curator1]) { - await grt.connect(governor).mint(wallet.address, toGRT('1000000')) - await grt.connect(wallet).approve(staking.address, toGRT('1000000')) - await grt.connect(wallet).approve(curation.address, toGRT('1000000')) - } - - // Set the staking contract as the subgraph service so it can call takeRewards - await rewardsManager.connect(governor).setSubgraphService(staking.address) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('rewards eligibility oracle', function () { - it('should reject setProviderEligibilityOracle if unauthorized', async function () { - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) - await mockOracle.deployed() - const tx = rewardsManager.connect(indexer1).setProviderEligibilityOracle(mockOracle.address) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should set rewards eligibility oracle if governor', async function () { - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) - await mockOracle.deployed() - - const tx = rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - await expect(tx) - .emit(rewardsManager, 'ProviderEligibilityOracleSet') - .withArgs(constants.AddressZero, mockOracle.address) - - expect(await rewardsManager.getProviderEligibilityOracle()).eq(mockOracle.address) - }) - - it('should allow setting rewards eligibility oracle to zero address', async function () { - // First set an oracle - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Then set to zero address to disable - const tx = rewardsManager.connect(governor).setProviderEligibilityOracle(constants.AddressZero) - await expect(tx) - .emit(rewardsManager, 'ProviderEligibilityOracleSet') - .withArgs(mockOracle.address, constants.AddressZero) - - expect(await rewardsManager.getProviderEligibilityOracle()).eq(constants.AddressZero) - }) - - it('should reject setting oracle that does not support interface', async function () { - // Try to set an EOA (externally owned account) as the rewards eligibility oracle - const tx = rewardsManager.connect(governor).setProviderEligibilityOracle(indexer1.address) - // EOA doesn't have code, so the call will revert (error message may vary by ethers version) - await expect(tx).to.be.reverted - }) - - it('should reject setting oracle that does not support IProviderEligibility interface', async function () { - // Deploy a contract that supports ERC165 but not IProviderEligibility - const MockERC165Factory = await hre.ethers.getContractFactory('contracts/tests/MockERC165.sol:MockERC165') - const mockERC165 = await MockERC165Factory.deploy() - await mockERC165.deployed() - - const tx = rewardsManager.connect(governor).setProviderEligibilityOracle(mockERC165.address) - await expect(tx).revertedWith('Contract does not support IProviderEligibility interface') - }) - - it('should not emit event when setting same oracle address', async function () { - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Setting the same oracle again should not emit an event - const tx = rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - await expect(tx).to.not.emit(rewardsManager, 'ProviderEligibilityOracleSet') - }) - }) - - describe('rewards eligibility in takeRewards', function () { - it('should deny rewards due to rewards eligibility oracle', async function () { - // Setup rewards eligibility oracle that denies rewards for indexer1 - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Default to deny - await mockOracle.deployed() - - // Set the rewards eligibility oracle - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Calculate expected rewards (for verification in the event) - const expectedIndexingRewards = toGRT('1400') - - // Close allocation. At this point rewards should be denied due to eligibility - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const rewardsDeniedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((event) => event?.name === 'RewardsDeniedDueToEligibility') - - expect(rewardsDeniedEvents.length).to.equal(1, 'RewardsDeniedDueToEligibility event not found') - const event = rewardsDeniedEvents[0]! - expect(event.args[0]).to.equal(indexer1.address) - expect(event.args[1]).to.equal(allocationID1) - expectApproxEq(event.args[2], expectedIndexingRewards, 'rewards amount') - }) - - it('should allow rewards when rewards eligibility oracle approves', async function () { - // Setup rewards eligibility oracle that allows rewards for indexer1 - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) // Default to allow - await mockOracle.deployed() - - // Set the rewards eligibility oracle - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Calculate expected rewards - const expectedIndexingRewards = toGRT('1400') - - // Close allocation. At this point rewards should be assigned normally - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const rewardsAssignedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((event) => event?.name === 'HorizonRewardsAssigned') - - expect(rewardsAssignedEvents.length).to.equal(1, 'HorizonRewardsAssigned event not found') - const event = rewardsAssignedEvents[0]! - expect(event.args[0]).to.equal(indexer1.address) - expect(event.args[1]).to.equal(allocationID1) - expectApproxEq(event.args[2], expectedIndexingRewards, 'rewards amount') - }) - }) - - describe('rewards eligibility oracle and denylist interaction', function () { - // Note: With subgraph-level denial, rewards for denied subgraphs are handled via - // onSubgraphAllocationUpdate() at the subgraph level. The allocation-level _deniedRewards() - // path (which checks eligibility) is not reached because rewards = 0 for allocations - // created while denied (frozen accumulator). - - it('should prioritize denylist over REO when both deny', async function () { - // Setup BOTH denial mechanisms - // 1. Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // 2. Setup REO that also denies - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation (created while denied - accumulator frozen) - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Close allocation - subgraph denial takes precedence (handled at subgraph level) - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - // With subgraph-level denial, rewards = 0 (frozen accumulator), so allocation-level - // denial events are not emitted. Rewards are reclaimed at subgraph level. - await expect(tx).to.not.emit(rewardsManager, 'RewardsDenied') - await expect(tx).to.not.emit(rewardsManager, 'RewardsDeniedDueToEligibility') - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - }) - - it('should check REO when denylist allows but indexer ineligible', async function () { - // Setup: Subgraph is allowed (no denylist), but indexer is ineligible - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny indexer - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - const expectedIndexingRewards = toGRT('1400') - - // Close allocation - REO should be checked - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const rewardsDeniedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((event) => event?.name === 'RewardsDeniedDueToEligibility') - - expect(rewardsDeniedEvents.length).to.equal(1, 'RewardsDeniedDueToEligibility event not found') - const event = rewardsDeniedEvents[0]! - expect(event.args[0]).to.equal(indexer1.address) - expect(event.args[1]).to.equal(allocationID1) - expectApproxEq(event.args[2], expectedIndexingRewards, 'rewards amount') - }) - - it('should handle indexer becoming ineligible mid-allocation', async function () { - // Setup: Indexer starts eligible - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) // Start eligible - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation while indexer is eligible - await setupIndexerAllocation() - - // Jump to next epoch (rewards accrue) - await helpers.mineEpoch(epochManager) - - // Change eligibility AFTER allocation created but BEFORE closing - await mockOracle.setIndexerEligible(indexer1.address, false) - - const expectedIndexingRewards = toGRT('1600') - - // Close allocation - should be denied at close time (not creation time) - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const rewardsDeniedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((event) => event?.name === 'RewardsDeniedDueToEligibility') - - expect(rewardsDeniedEvents.length).to.equal(1, 'RewardsDeniedDueToEligibility event not found') - const event = rewardsDeniedEvents[0]! - expect(event.args[0]).to.equal(indexer1.address) - expect(event.args[1]).to.equal(allocationID1) - expectApproxEq(event.args[2], expectedIndexingRewards, 'rewards amount') - }) - - it('should handle indexer becoming eligible mid-allocation', async function () { - // Setup: Indexer starts ineligible - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Start ineligible - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation while indexer is ineligible - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Change eligibility before closing - await mockOracle.setIndexerEligible(indexer1.address, true) - - const expectedIndexingRewards = toGRT('1600') - - // Close allocation - should now be allowed - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const rewardsAssignedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((event) => event?.name === 'HorizonRewardsAssigned') - - expect(rewardsAssignedEvents.length).to.equal(1, 'HorizonRewardsAssigned event not found') - const event = rewardsAssignedEvents[0]! - expect(event.args[0]).to.equal(indexer1.address) - expect(event.args[1]).to.equal(allocationID1) - expectApproxEq(event.args[2], expectedIndexingRewards, 'rewards amount') - }) - - it('should handle denylist being added mid-allocation', async function () { - // Setup: Start with subgraph NOT denied - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation when subgraph is allowed - await setupIndexerAllocation() - - // Jump to next epoch (rewards accrue) - await helpers.mineEpoch(epochManager) - - // Deny the subgraph before closing allocation - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Close allocation - should be denied even though it was created when allowed - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - await expect(tx).emit(rewardsManager, 'RewardsDenied').withArgs(indexer1.address, allocationID1) - }) - - it('should handle denylist being removed mid-allocation', async function () { - // Setup: Start with subgraph denied - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation (created while denied - accumulator frozen at this point) - await setupIndexerAllocation() - - // Jump to next epoch (rewards accrue but are reclaimed at subgraph level while denied) - await helpers.mineEpoch(epochManager) - - // Remove from denylist - this snapshots and starts accumulator updating again - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, false) - - // Wait for another epoch to accrue POST-undeny rewards - // Only post-undeny rewards are available (denied-period rewards were reclaimed) - await helpers.mineEpoch(epochManager) - - // Close allocation - should get post-undeny rewards only - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - // Verify rewards are assigned (exact amount depends on blocks since undeny) - await expect(tx).emit(rewardsManager, 'HorizonRewardsAssigned') - }) - - it('should allow rewards when REO is zero address (disabled)', async function () { - // Ensure REO is not set (zero address = disabled) - expect(await rewardsManager.getProviderEligibilityOracle()).eq(constants.AddressZero) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - const expectedIndexingRewards = toGRT('1400') - - // Close allocation - should get rewards (no eligibility check when REO is zero) - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const rewardsAssignedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((event) => event?.name === 'HorizonRewardsAssigned') - - expect(rewardsAssignedEvents.length).to.equal(1, 'HorizonRewardsAssigned event not found') - const event = rewardsAssignedEvents[0]! - expect(event.args[0]).to.equal(indexer1.address) - expect(event.args[1]).to.equal(allocationID1) - expectApproxEq(event.args[2], expectedIndexingRewards, 'rewards amount') - }) - - it('should revert for ineligible indexer when revertOnIneligible is true', async function () { - // Setup REO that denies indexer1 - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Enable revert on ineligible - await rewardsManager.connect(governor).setRevertOnIneligible(true) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Close allocation - should revert because indexer is ineligible - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - await expect(tx).revertedWith('Indexer not eligible for rewards') - }) - - it('should not revert for eligible indexer when revertOnIneligible is true', async function () { - // Setup REO that allows indexer1 - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) // Allow - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Enable revert on ineligible - await rewardsManager.connect(governor).setRevertOnIneligible(true) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Close allocation - should succeed (indexer is eligible) - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - await expect(tx).emit(rewardsManager, 'HorizonRewardsAssigned') - }) - - it('should reclaim (not revert) for ineligible indexer when revertOnIneligible is false', async function () { - // Setup REO that denies indexer1 - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Ensure revertOnIneligible is false (default) - expect(await rewardsManager.getRevertOnIneligible()).eq(false) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Close allocation - should succeed but deny rewards - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Should emit RewardsDeniedDueToEligibility (not revert) - const rewardsDeniedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((event) => event?.name === 'RewardsDeniedDueToEligibility') - - expect(rewardsDeniedEvents.length).to.equal(1, 'RewardsDeniedDueToEligibility event not found') - }) - - it('should verify event structure differences between denial mechanisms', async function () { - // Test 1: Denylist denial - event WITHOUT amount - // Create allocation FIRST, then deny (so there are pre-denial rewards to deny) - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - await helpers.mineEpoch(epochManager) - await setupIndexerAllocation() - await helpers.mineEpoch(epochManager) - - // Deny AFTER allocation created (so rewards have accrued) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - const tx1 = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt1 = await tx1.wait() - - // Find the RewardsDenied event - search in logs as events may be from different contracts - const rewardsDeniedEvent = receipt1.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .find((event) => event?.name === 'RewardsDenied') - - expect(rewardsDeniedEvent).to.not.be.undefined - - // Verify it only has indexer and allocationID (no amount parameter) - expect(rewardsDeniedEvent?.args?.indexer).to.equal(indexer1.address) - expect(rewardsDeniedEvent?.args?.allocationID).to.equal(allocationID1) - // RewardsDenied has only 2 args, amount should not exist - expect(rewardsDeniedEvent?.args?.amount).to.be.undefined - - // Reset for test 2 - await fixture.tearDown() - await fixture.setUp() - - // Test 2: REO denial - event WITH amount - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - await helpers.mineEpoch(epochManager) - await setupIndexerAllocation() - await helpers.mineEpoch(epochManager) - - const tx2 = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt2 = await tx2.wait() - - // Find the RewardsDeniedDueToEligibility event - const eligibilityEvent = receipt2.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .find((event) => event?.name === 'RewardsDeniedDueToEligibility') - - expect(eligibilityEvent).to.not.be.undefined - - // Verify it has indexer, allocationID, AND amount - expect(eligibilityEvent?.args?.indexer).to.equal(indexer1.address) - expect(eligibilityEvent?.args?.allocationID).to.equal(allocationID1) - expect(eligibilityEvent?.args?.amount).to.not.be.undefined - expect(eligibilityEvent?.args?.amount).to.be.gt(0) // Shows what they would have gotten - }) - }) -}) diff --git a/packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts deleted file mode 100644 index a1a17269a..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts +++ /dev/null @@ -1,1115 +0,0 @@ -import { Curation } from '@graphprotocol/contracts' -import { EpochManager } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { IStaking } from '@graphprotocol/contracts' -import { RewardsManager } from '@graphprotocol/contracts' -import { deriveChannelKey, GraphNetworkContracts, helpers, randomHexBytes, toGRT } from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber, constants, utils } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const { HashZero } = constants - -// Condition identifiers (matching RewardsCondition.sol) -const INDEXER_INELIGIBLE = utils.id('INDEXER_INELIGIBLE') -const SUBGRAPH_DENIED = utils.id('SUBGRAPH_DENIED') -const CLOSE_ALLOCATION = utils.id('CLOSE_ALLOCATION') -const NO_SIGNAL = utils.id('NO_SIGNAL') - -// Tolerance for fixed-point arithmetic rounding errors (matching Foundry tests) -const REWARDS_TOLERANCE = 20000 - -// Helper to check approximate equality for rewards (allows for rounding errors in fixed-point math) -function expectApproxEq(actual: BigNumber, expected: BigNumber, message: string) { - const diff = actual.sub(expected).abs() - expect( - diff.lte(REWARDS_TOLERANCE), - `${message}: difference ${diff.toString()} exceeds tolerance ${REWARDS_TOLERANCE}`, - ).to.be.true -} -const NO_ALLOCATED_TOKENS = utils.id('NO_ALLOCATED_TOKENS') -const BELOW_MINIMUM_SIGNAL = utils.id('BELOW_MINIMUM_SIGNAL') - -describe('Rewards - Reclaim Addresses', () => { - const graph = hre.graph() - let curator1: SignerWithAddress - let governor: SignerWithAddress - let indexer1: SignerWithAddress - let reclaimWallet: SignerWithAddress - let otherWallet: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let grt: GraphToken - let curation: Curation - let epochManager: EpochManager - let staking: IStaking - let rewardsManager: RewardsManager - - // Derive channel key for indexer used to sign attestations - const channelKey1 = deriveChannelKey() - - const subgraphDeploymentID1 = randomHexBytes() - - const allocationID1 = channelKey1.address - - const metadata = HashZero - - const ISSUANCE_PER_BLOCK = toGRT('200') // 200 GRT every block - - async function setupIndexerAllocation() { - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - } - - before(async function () { - const testAccounts = await graph.getTestAccounts() - curator1 = testAccounts[0] - indexer1 = testAccounts[1] - reclaimWallet = testAccounts[2] - otherWallet = testAccounts[3] - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - grt = contracts.GraphToken as GraphToken - curation = contracts.Curation as Curation - epochManager = contracts.EpochManager - staking = contracts.Staking as IStaking - rewardsManager = contracts.RewardsManager - - // 200 GRT per block - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - - // Distribute test funds - for (const wallet of [indexer1, curator1]) { - await grt.connect(governor).mint(wallet.address, toGRT('1000000')) - await grt.connect(wallet).approve(staking.address, toGRT('1000000')) - await grt.connect(wallet).approve(curation.address, toGRT('1000000')) - } - - // Set the staking contract as the subgraph service so it can call takeRewards - await rewardsManager.connect(governor).setSubgraphService(staking.address) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('setReclaimAddress', function () { - it('should reject if not governor', async function () { - const tx = rewardsManager.connect(indexer1).setReclaimAddress(INDEXER_INELIGIBLE, reclaimWallet.address) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should reject setting reclaim address for NONE', async function () { - const tx = rewardsManager.connect(governor).setReclaimAddress(HashZero, reclaimWallet.address) - await expect(tx).revertedWith('Cannot set reclaim address for NONE') - }) - - it('should set eligibility reclaim address if governor', async function () { - const tx = rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, reclaimWallet.address) - await expect(tx) - .emit(rewardsManager, 'ReclaimAddressSet') - .withArgs(INDEXER_INELIGIBLE, constants.AddressZero, reclaimWallet.address) - - expect(await rewardsManager.getReclaimAddress(INDEXER_INELIGIBLE)).eq(reclaimWallet.address) - }) - - it('should set subgraph denied reclaim address if governor', async function () { - const tx = rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, reclaimWallet.address) - await expect(tx) - .emit(rewardsManager, 'ReclaimAddressSet') - .withArgs(SUBGRAPH_DENIED, constants.AddressZero, reclaimWallet.address) - - expect(await rewardsManager.getReclaimAddress(SUBGRAPH_DENIED)).eq(reclaimWallet.address) - }) - - it('should allow setting to zero address', async function () { - await rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, reclaimWallet.address) - - const tx = rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, constants.AddressZero) - await expect(tx) - .emit(rewardsManager, 'ReclaimAddressSet') - .withArgs(INDEXER_INELIGIBLE, reclaimWallet.address, constants.AddressZero) - - expect(await rewardsManager.getReclaimAddress(INDEXER_INELIGIBLE)).eq(constants.AddressZero) - }) - - it('should not emit event when setting same address', async function () { - await rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, reclaimWallet.address) - - const tx = rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, reclaimWallet.address) - await expect(tx).to.not.emit(rewardsManager, 'ReclaimAddressSet') - }) - }) - - describe('reclaim denied rewards - subgraph denylist', function () { - // Note: With the new denied-period rewards implementation, rewards for denied subgraphs - // are reclaimed at the subgraph level via onSubgraphAllocationUpdate(), not at the - // allocation level via _deniedRewards(). This means: - // - RewardsDenied is NOT emitted (legacy allocation-level event) - // - RewardsReclaimed IS emitted but with address(0) for indexer/allocationID - // - Allocations created while denied have frozen accumulator, so rewards = 0 at close - - it('should mint to reclaim address when subgraph denied and reclaim address set', async function () { - // Setup reclaim address - await rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, reclaimWallet.address) - - // Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Calculate expected rewards (approximate - timing can cause slight variations) - const expectedRewards = toGRT('1400') - - // Check reclaim wallet balance before - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Close allocation - rewards are reclaimed at subgraph level (address(0) for indexer/allocationID) - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - // RewardsDenied is not emitted - denial is handled at subgraph level now - await expect(tx).to.not.emit(rewardsManager, 'RewardsDenied') - // RewardsReclaimed emitted with address(0) for indexer/allocationID (subgraph-level reclaim) - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - // Check reclaim wallet received the rewards (allow for rounding errors) - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expectApproxEq(balanceAfter.sub(balanceBefore), expectedRewards, 'reclaimed rewards') - }) - - it('should reclaim pre-denial rewards via _deniedRewards when denied after allocation', async function () { - // Setup reclaim address BEFORE allocation - await rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, reclaimWallet.address) - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Create allocation FIRST (before deny) - await setupIndexerAllocation() - - // Mine blocks to accrue rewards - await helpers.mineEpoch(epochManager) - - // Deny AFTER allocation — pre-denial rewards exist at the allocation level - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Check reclaim wallet balance before - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Close allocation — pre-denial rewards flow through _deniedRewards → _reclaimRewards - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const parsedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((e) => e !== null) - - // RewardsDenied IS emitted (allocation-level denial for pre-denial rewards) - const deniedEvents = parsedEvents.filter((e) => e!.name === 'RewardsDenied') - expect(deniedEvents.length).to.equal(1, 'RewardsDenied event not found') - expect(deniedEvents[0]!.args[0]).to.equal(indexer1.address) - expect(deniedEvents[0]!.args[1]).to.equal(allocationID1) - - // RewardsReclaimed emitted with actual indexer/allocationID (allocation-level reclaim) - const reclaimEvents = parsedEvents.filter((e) => e!.name === 'RewardsReclaimed') - expect(reclaimEvents.length).to.be.gte(1, 'RewardsReclaimed event not found') - // Find the allocation-level reclaim (has non-zero indexer and allocationID) - const allocationReclaim = reclaimEvents.find((e) => e!.args[2] !== constants.AddressZero) - expect(allocationReclaim).to.not.be.undefined - expect(allocationReclaim!.args[0]).to.equal(SUBGRAPH_DENIED) - expectApproxEq(allocationReclaim!.args[1], toGRT('1400'), 'reclaimed amount') - expect(allocationReclaim!.args[2]).to.equal(indexer1.address) - expect(allocationReclaim!.args[3]).to.equal(allocationID1) - expect(allocationReclaim!.args[4]).to.equal(subgraphDeploymentID1) - - // Reclaim wallet received the pre-denial rewards (may receive additional rewards from subgraph-level reclaim) - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expect(balanceAfter.sub(balanceBefore)).gte(toGRT('1400')) - }) - - it('should not mint to reclaim address when reclaim address not set', async function () { - // Do NOT set reclaim address (defaults to zero address) - - // Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Close allocation - no events emitted when no reclaim address configured - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - // RewardsDenied is not emitted - denial is handled at subgraph level now - await expect(tx).to.not.emit(rewardsManager, 'RewardsDenied') - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - }) - - describe('reclaim denied rewards - eligibility', function () { - it('should mint to reclaim address when eligibility denied and reclaim address set', async function () { - // Setup reclaim address - await rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, reclaimWallet.address) - - // Setup eligibility oracle that denies - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Calculate expected rewards - const expectedRewards = toGRT('1400') - - // Check reclaim wallet balance before - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Close allocation - should emit both denial and reclaim events - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const parsedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((e) => e !== null) - - // Check RewardsDeniedDueToEligibility event - const denialEvents = parsedEvents.filter((e) => e!.name === 'RewardsDeniedDueToEligibility') - expect(denialEvents.length).to.equal(1, 'RewardsDeniedDueToEligibility event not found') - expect(denialEvents[0]!.args[0]).to.equal(indexer1.address) - expect(denialEvents[0]!.args[1]).to.equal(allocationID1) - expectApproxEq(denialEvents[0]!.args[2], expectedRewards, 'denied rewards amount') - - // Check RewardsReclaimed event exists and verify args - const reclaimEvents = parsedEvents.filter((e) => e!.name === 'RewardsReclaimed') - expect(reclaimEvents.length).to.be.gte(1, 'RewardsReclaimed event not found') - const reclaimEvent = reclaimEvents.find((e) => e!.args[0] === INDEXER_INELIGIBLE) - expect(reclaimEvent).to.not.be.undefined - expect(reclaimEvent!.args[0]).to.equal(INDEXER_INELIGIBLE) - expectApproxEq(reclaimEvent!.args[1], expectedRewards, 'reclaimed amount') - expect(reclaimEvent!.args[2]).to.equal(indexer1.address) - expect(reclaimEvent!.args[3]).to.equal(allocationID1) - expect(reclaimEvent!.args[4]).to.equal(subgraphDeploymentID1) - - // Check reclaim wallet received the rewards - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expectApproxEq(balanceAfter.sub(balanceBefore), expectedRewards, 'wallet balance increase') - }) - - it('should not mint to reclaim address when reclaim address not set', async function () { - // Do NOT set reclaim address (defaults to zero address) - - // Setup eligibility oracle that denies - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - const expectedRewards = toGRT('1400') - - // Close allocation - should only emit denial event, not reclaim - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const parsedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((e) => e !== null) - - // Check RewardsDeniedDueToEligibility event - const denialEvents = parsedEvents.filter((e) => e!.name === 'RewardsDeniedDueToEligibility') - expect(denialEvents.length).to.equal(1, 'RewardsDeniedDueToEligibility event not found') - expect(denialEvents[0]!.args[0]).to.equal(indexer1.address) - expect(denialEvents[0]!.args[1]).to.equal(allocationID1) - expectApproxEq(denialEvents[0]!.args[2], expectedRewards, 'denied rewards amount') - - // Check no RewardsReclaimed event - const reclaimEvents = parsedEvents.filter((e) => e!.name === 'RewardsReclaimed') - expect(reclaimEvents.length).to.equal(0, 'RewardsReclaimed event should not be emitted') - }) - }) - - describe('reclaim precedence - first successful reclaim wins', function () { - // Note: With subgraph-level denial, rewards are reclaimed via onSubgraphAllocationUpdate() - // and the allocation-level _deniedRewards() path (which checks eligibility) is not reached - // because rewards = 0 for allocations created while denied. - - it('should reclaim to SUBGRAPH_DENIED when both fail and both addresses configured', async function () { - // Setup BOTH reclaim addresses - await rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, reclaimWallet.address) - await rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, otherWallet.address) - - // Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Setup eligibility oracle that denies - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - const expectedRewards = toGRT('1400') - - // Check balances before - const subgraphDeniedBalanceBefore = await grt.balanceOf(reclaimWallet.address) - const indexerIneligibleBalanceBefore = await grt.balanceOf(otherWallet.address) - - // Close allocation - subgraph denial takes precedence (handled at subgraph level) - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - // No allocation-level denial events - handled at subgraph level - await expect(tx).to.not.emit(rewardsManager, 'RewardsDenied') - await expect(tx).to.not.emit(rewardsManager, 'RewardsDeniedDueToEligibility') - // RewardsReclaimed emitted (subgraph-level reclaim) - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - // Only SUBGRAPH_DENIED wallet should receive rewards (allow for rounding errors) - const subgraphDeniedBalanceAfter = await grt.balanceOf(reclaimWallet.address) - const indexerIneligibleBalanceAfter = await grt.balanceOf(otherWallet.address) - - expectApproxEq( - subgraphDeniedBalanceAfter.sub(subgraphDeniedBalanceBefore), - expectedRewards, - 'SUBGRAPH_DENIED wallet balance', - ) - expect(indexerIneligibleBalanceAfter.sub(indexerIneligibleBalanceBefore)).eq(0) - }) - - it('should reclaim to SUBGRAPH_DENIED even when only INDEXER_INELIGIBLE address configured', async function () { - // Setup ONLY INDEXER_INELIGIBLE reclaim address (not SUBGRAPH_DENIED) - await rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, otherWallet.address) - - // Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Setup eligibility oracle that denies - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Check balance before - const balanceBefore = await grt.balanceOf(otherWallet.address) - - // Close allocation - subgraph denial is handled at subgraph level, but no SUBGRAPH_DENIED - // reclaim address is configured, so rewards are dropped (not reclaimed) - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - // No allocation-level denial events - handled at subgraph level - await expect(tx).to.not.emit(rewardsManager, 'RewardsDenied') - await expect(tx).to.not.emit(rewardsManager, 'RewardsDeniedDueToEligibility') - // No reclaim because SUBGRAPH_DENIED address not configured (eligibility path not reached) - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - - // INDEXER_INELIGIBLE wallet should NOT receive rewards (subgraph denial takes precedence) - const balanceAfter = await grt.balanceOf(otherWallet.address) - expect(balanceAfter.sub(balanceBefore)).eq(0) - }) - - it('should reclaim to INDEXER_INELIGIBLE when both fail but only INDEXER_INELIGIBLE address configured (pre-denial allocation)', async function () { - // This tests the ternary in _deniedRewards that falls back to INDEXER_INELIGIBLE - // when SUBGRAPH_DENIED address is not configured - - // Setup ONLY INDEXER_INELIGIBLE reclaim address (not SUBGRAPH_DENIED) - await rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, reclaimWallet.address) - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - // Setup eligibility oracle that denies - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Create allocation FIRST (before denial) - this is the key difference - await setupIndexerAllocation() - - // Mine blocks to accrue rewards - await helpers.mineEpoch(epochManager) - - // NOW deny the subgraph (after allocation exists) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - const expectedRewards = toGRT('1400') - - // Check balance before - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Close allocation - pre-denial rewards flow through _deniedRewards - // Both conditions are true, but SUBGRAPH_DENIED address is not set - // So it should fall back to INDEXER_INELIGIBLE - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - // RewardsDenied IS emitted (allocation-level denial for pre-denial rewards) - await expect(tx).emit(rewardsManager, 'RewardsDenied').withArgs(indexer1.address, allocationID1) - // RewardsDeniedDueToEligibility IS emitted - await expect(tx).emit(rewardsManager, 'RewardsDeniedDueToEligibility') - // RewardsReclaimed should emit with INDEXER_INELIGIBLE reason (fallback) - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - // INDEXER_INELIGIBLE wallet should receive rewards (fallback from SUBGRAPH_DENIED) - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expectApproxEq(balanceAfter.sub(balanceBefore), expectedRewards, 'INDEXER_INELIGIBLE wallet balance') - }) - - it('should drop rewards when both fail and neither address configured', async function () { - // Do NOT set any reclaim addresses - - // Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Setup eligibility oracle that denies - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Close allocation - no events, rewards dropped - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - await expect(tx).to.not.emit(rewardsManager, 'RewardsDenied') - await expect(tx).to.not.emit(rewardsManager, 'RewardsDeniedDueToEligibility') - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - - it('should drop rewards when subgraph denied without address even if indexer eligible', async function () { - // Do NOT set SUBGRAPH_DENIED reclaim address - - // Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Setup eligibility oracle that ALLOWS (indexer is eligible) - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) // Allow - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Close allocation - no events because subgraph denial handled at subgraph level - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - await expect(tx).to.not.emit(rewardsManager, 'RewardsDenied') - await expect(tx).to.not.emit(rewardsManager, 'RewardsDeniedDueToEligibility') - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - }) - }) - - describe('reclaimRewards - force close allocation', function () { - let mockSubgraphService: any - - beforeEach(async function () { - // Deploy mock subgraph service - const MockSubgraphServiceFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockSubgraphService.sol:MockSubgraphService', - ) - mockSubgraphService = await MockSubgraphServiceFactory.deploy() - await mockSubgraphService.deployed() - - // Set it as the subgraph service in rewards manager - await rewardsManager.connect(governor).setSubgraphService(mockSubgraphService.address) - }) - - it('should reclaim rewards when reclaim address is set', async function () { - // Set reclaim address for ForceCloseAllocation - await rewardsManager.connect(governor).setReclaimAddress(CLOSE_ALLOCATION, reclaimWallet.address) - - // Setup allocation in real staking contract - await setupIndexerAllocation() - - // Also set allocation data in mock so RewardsManager can query it - const tokensAllocated = toGRT('12500') - await mockSubgraphService.setAllocation( - allocationID1, - true, // isActive - indexer1.address, - subgraphDeploymentID1, - tokensAllocated, - 0, // accRewardsPerAllocatedToken starts at 0 - 0, // accRewardsPending - ) - await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensAllocated) - - // Jump to next epoch to accrue rewards - await helpers.mineEpoch(epochManager) - - // Check balance before - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Call reclaimRewards via mock subgraph service - const tx = await mockSubgraphService.callReclaimRewards(rewardsManager.address, CLOSE_ALLOCATION, allocationID1) - - // Verify event was emitted (don't check exact amount, it depends on rewards calculation) - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - // Check balance after - should have increased - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - const rewardsClaimed = balanceAfter.sub(balanceBefore) - expect(rewardsClaimed).to.be.gt(0) - }) - - it('should not reclaim when reclaim address is not set', async function () { - // Do NOT set reclaim address (defaults to zero) - - // Setup allocation in real staking contract - await setupIndexerAllocation() - - // Also set allocation data in mock - const tokensAllocated = toGRT('12500') - await mockSubgraphService.setAllocation( - allocationID1, - true, - indexer1.address, - subgraphDeploymentID1, - tokensAllocated, - 0, - 0, - ) - await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensAllocated) - - // Jump to next epoch to accrue rewards - await helpers.mineEpoch(epochManager) - - // Call reclaimRewards via mock subgraph service - should not emit RewardsReclaimed - const tx = await mockSubgraphService.callReclaimRewards(rewardsManager.address, CLOSE_ALLOCATION, allocationID1) - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - - it('should return 0 and not emit when reclaim address is not set and no rewards', async function () { - // Do NOT set reclaim address (zero address) - - // Setup allocation but mark it as inactive (no rewards) - const tokensAllocated = toGRT('12500') - await mockSubgraphService.setAllocation( - allocationID1, - false, // NOT active - this will return 0 rewards - indexer1.address, - subgraphDeploymentID1, - tokensAllocated, - 0, - 0, - ) - await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensAllocated) - - // Call reclaimRewards - should return 0 and not emit - const result = await mockSubgraphService.callStatic.callReclaimRewards( - rewardsManager.address, - CLOSE_ALLOCATION, - allocationID1, - ) - expect(result).eq(0) - - const tx = await mockSubgraphService.callReclaimRewards(rewardsManager.address, CLOSE_ALLOCATION, allocationID1) - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - - it('should return 0 when reason is NONE', async function () { - // Setup allocation in real staking contract - await setupIndexerAllocation() - - // Also set allocation data in mock so RewardsManager can query it - const tokensAllocated = toGRT('12500') - await mockSubgraphService.setAllocation( - allocationID1, - true, - indexer1.address, - subgraphDeploymentID1, - tokensAllocated, - 0, - 0, - ) - await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensAllocated) - - // Jump to next epoch to accrue rewards - await helpers.mineEpoch(epochManager) - - // Call reclaimRewards with NONE (HashZero) - should return 0 - const result = await mockSubgraphService.callStatic.callReclaimRewards( - rewardsManager.address, - HashZero, - allocationID1, - ) - expect(result).eq(0) - - // Verify no RewardsReclaimed event emitted - const tx = await mockSubgraphService.callReclaimRewards(rewardsManager.address, HashZero, allocationID1) - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - - it('should reject when called by unauthorized address', async function () { - // Try to call reclaimRewards directly from indexer1 (not the subgraph service) - const abiCoder = hre.ethers.utils.defaultAbiCoder - const selector = hre.ethers.utils.id('reclaimRewards(bytes32,address)').slice(0, 10) - const params = abiCoder.encode(['bytes32', 'address'], [CLOSE_ALLOCATION, allocationID1]) - const data = selector + params.slice(2) - - const tx = indexer1.sendTransaction({ - to: rewardsManager.address, - data: data, - }) - await expect(tx).revertedWith('Not a rewards issuer') - }) - }) - - describe('setDefaultReclaimAddress', function () { - it('should reject if not governor', async function () { - const tx = rewardsManager.connect(indexer1).setDefaultReclaimAddress(reclaimWallet.address) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should set default reclaim address if governor', async function () { - const tx = rewardsManager.connect(governor).setDefaultReclaimAddress(reclaimWallet.address) - await expect(tx) - .emit(rewardsManager, 'DefaultReclaimAddressSet') - .withArgs(constants.AddressZero, reclaimWallet.address) - - // Verify the getter returns the correct value - expect(await rewardsManager.getDefaultReclaimAddress()).eq(reclaimWallet.address) - }) - - it('should allow setting to zero address', async function () { - await rewardsManager.connect(governor).setDefaultReclaimAddress(reclaimWallet.address) - - const tx = rewardsManager.connect(governor).setDefaultReclaimAddress(constants.AddressZero) - await expect(tx) - .emit(rewardsManager, 'DefaultReclaimAddressSet') - .withArgs(reclaimWallet.address, constants.AddressZero) - }) - - it('should not emit event when setting same address', async function () { - await rewardsManager.connect(governor).setDefaultReclaimAddress(reclaimWallet.address) - - const tx = rewardsManager.connect(governor).setDefaultReclaimAddress(reclaimWallet.address) - await expect(tx).to.not.emit(rewardsManager, 'DefaultReclaimAddressSet') - }) - }) - - describe('default reclaim address fallback', function () { - beforeEach(async function () { - await setupIndexerAllocation() - // Set governor as the subgraph availability oracle for setDenied calls - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - }) - - it('should use default reclaim address when reason-specific not set', async function () { - // Set default but NOT SUBGRAPH_DENIED specific - await rewardsManager.connect(governor).setDefaultReclaimAddress(reclaimWallet.address) - - // Deny the subgraph - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Mine blocks to accrue rewards - await helpers.mine(5) - - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Trigger reclaim via onSubgraphAllocationUpdate - const tx = rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - // Should reclaim to default address with SUBGRAPH_DENIED reason - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expect(balanceAfter).gt(balanceBefore) - }) - - it('should prefer reason-specific address over default', async function () { - // Set both default AND SUBGRAPH_DENIED specific - await rewardsManager.connect(governor).setDefaultReclaimAddress(otherWallet.address) - await rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, reclaimWallet.address) - - // Deny the subgraph - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Mine blocks to accrue rewards - await helpers.mine(5) - - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - const otherBalanceBefore = await grt.balanceOf(otherWallet.address) - - // Trigger reclaim - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - const otherBalanceAfter = await grt.balanceOf(otherWallet.address) - - // Should go to reason-specific, not default - expect(balanceAfter).gt(balanceBefore) - expect(otherBalanceAfter).eq(otherBalanceBefore) - }) - }) - - describe('reclaim NO_SIGNAL - zero total signal', function () { - it('should reclaim when no signal and NO_SIGNAL address set', async function () { - // Set reclaim address for NO_SIGNAL - await rewardsManager.connect(governor).setReclaimAddress(NO_SIGNAL, reclaimWallet.address) - - // Don't create any signal - just mine blocks - await helpers.mine(5) - - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Trigger updateAccRewardsPerSignal (called internally when signal changes, or directly) - const tx = rewardsManager.connect(governor).updateAccRewardsPerSignal() - - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expect(balanceAfter).gt(balanceBefore) - }) - - it('should drop rewards when no signal and no reclaim address', async function () { - // Don't set any reclaim address - just mine blocks - await helpers.mine(5) - - // Trigger updateAccRewardsPerSignal - const tx = rewardsManager.connect(governor).updateAccRewardsPerSignal() - - // Should not emit RewardsReclaimed (dropped) - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - - it('should use default reclaim address for NO_SIGNAL when specific not set', async function () { - // Set default but NOT NO_SIGNAL specific - await rewardsManager.connect(governor).setDefaultReclaimAddress(reclaimWallet.address) - - await helpers.mine(5) - - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - const tx = rewardsManager.connect(governor).updateAccRewardsPerSignal() - - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expect(balanceAfter).gt(balanceBefore) - }) - }) - - describe('reclaim NO_ALLOCATED_TOKENS - signal but no allocations', function () { - it('should reclaim when signal exists but no allocations and NO_ALLOCATED_TOKENS address set', async function () { - // Set reclaim address for NO_ALLOCATED_TOKENS - await rewardsManager.connect(governor).setReclaimAddress(NO_ALLOCATED_TOKENS, reclaimWallet.address) - - // Create signal but NO allocation - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Mine blocks to accrue rewards - await helpers.mine(5) - - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Trigger onSubgraphAllocationUpdate - will see signal but no allocations - const tx = rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expect(balanceAfter).gt(balanceBefore) - }) - - it('should drop rewards when no allocations and no reclaim address', async function () { - // Create signal but NO allocation, and don't set reclaim address - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - await helpers.mine(5) - - // Trigger onSubgraphAllocationUpdate - const tx = rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - // Should not emit RewardsReclaimed (dropped) - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - }) - - describe('reclaim BELOW_MINIMUM_SIGNAL', function () { - const MINIMUM_SIGNAL = toGRT('1000') - - beforeEach(async function () { - // Set minimum signal threshold - await rewardsManager.connect(governor).setMinimumSubgraphSignal(MINIMUM_SIGNAL) - }) - - it('should reclaim when signal below minimum and BELOW_MINIMUM_SIGNAL address set', async function () { - // Set reclaim address for BELOW_MINIMUM_SIGNAL - await rewardsManager.connect(governor).setReclaimAddress(BELOW_MINIMUM_SIGNAL, reclaimWallet.address) - - // Create signal BELOW minimum (minimum is 1000, we signal 500) - const signalled1 = toGRT('500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Mine blocks - await helpers.mine(5) - - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Trigger onSubgraphAllocationUpdate - const tx = rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expect(balanceAfter).gt(balanceBefore) - }) - - it('should not reclaim when signal at or above minimum', async function () { - // Set reclaim address - await rewardsManager.connect(governor).setReclaimAddress(BELOW_MINIMUM_SIGNAL, reclaimWallet.address) - - // Create signal AT minimum - const signalled1 = MINIMUM_SIGNAL - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Also need an allocation for rewards to accumulate normally - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - await helpers.mine(5) - - // Trigger onSubgraphAllocationUpdate - const tx = rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - // Should NOT emit RewardsReclaimed for BELOW_MINIMUM_SIGNAL - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - - it('should drop rewards when below minimum and no reclaim address', async function () { - // Don't set reclaim address - // Create signal BELOW minimum - const signalled1 = toGRT('500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - await helpers.mine(5) - - const tx = rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - // Should not emit RewardsReclaimed (dropped) - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - - it('should use BELOW_MINIMUM_SIGNAL when denied but SUBGRAPH_DENIED address not configured', async function () { - // This tests line 574: the branch where subgraph is denied but reclaim address is zero, - // so it falls back to BELOW_MINIMUM_SIGNAL - - // Set BELOW_MINIMUM_SIGNAL address but NOT SUBGRAPH_DENIED - await rewardsManager.connect(governor).setReclaimAddress(BELOW_MINIMUM_SIGNAL, reclaimWallet.address) - - // Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Create signal BELOW minimum (minimum is 1000, we signal 500) - const signalled1 = toGRT('500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Mine blocks - await helpers.mine(5) - - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Trigger onSubgraphAllocationUpdate - // Subgraph is denied but no SUBGRAPH_DENIED address, so should fall back to BELOW_MINIMUM_SIGNAL - const tx = rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - // Should reclaim to BELOW_MINIMUM_SIGNAL address - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expect(balanceAfter).gt(balanceBefore) - }) - }) - - describe('dual denial - SUBGRAPH_DENIED takes precedence when configured', function () { - it('should reclaim to SUBGRAPH_DENIED when both conditions true and SUBGRAPH_DENIED address configured (pre-denial allocation)', async function () { - // This tests line 747-748: when both denied and ineligible, and SUBGRAPH_DENIED IS configured - - // Setup BOTH reclaim addresses - await rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, reclaimWallet.address) - await rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, otherWallet.address) - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - // Setup eligibility oracle that denies - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Create allocation FIRST (before denial) - await setupIndexerAllocation() - - // Mine blocks to accrue rewards - await helpers.mineEpoch(epochManager) - - // NOW deny the subgraph (after allocation exists) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Check balances before - const subgraphDeniedBalanceBefore = await grt.balanceOf(reclaimWallet.address) - const indexerIneligibleBalanceBefore = await grt.balanceOf(otherWallet.address) - - // Close allocation - pre-denial rewards flow through _deniedRewards - // Both conditions are true, SUBGRAPH_DENIED IS configured, so it should use SUBGRAPH_DENIED - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - // RewardsDenied IS emitted - await expect(tx).emit(rewardsManager, 'RewardsDenied').withArgs(indexer1.address, allocationID1) - // RewardsDeniedDueToEligibility IS emitted - await expect(tx).emit(rewardsManager, 'RewardsDeniedDueToEligibility') - // RewardsReclaimed should emit with SUBGRAPH_DENIED reason - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - // SUBGRAPH_DENIED wallet should receive rewards (not INDEXER_INELIGIBLE) - const subgraphDeniedBalanceAfter = await grt.balanceOf(reclaimWallet.address) - const indexerIneligibleBalanceAfter = await grt.balanceOf(otherWallet.address) - - expect(subgraphDeniedBalanceAfter.sub(subgraphDeniedBalanceBefore)).gt(0) - expect(indexerIneligibleBalanceAfter.sub(indexerIneligibleBalanceBefore)).eq(0) - }) - }) -}) diff --git a/packages/issuance/test/unit/rewards-manager/distribution.t.sol b/packages/issuance/test/unit/rewards-manager/distribution.t.sol index ccf10a54d..2ec0c444f 100644 --- a/packages/issuance/test/unit/rewards-manager/distribution.t.sol +++ b/packages/issuance/test/unit/rewards-manager/distribution.t.sol @@ -9,8 +9,6 @@ contract RewardsManagerDistributionTest is RewardsManagerSharedTest { event Transfer(address indexed from, address indexed to, uint256 value); uint256 internal constant ISSUANCE_RATE_PERIODS = 4; - /// Blocks per `helpers.mineEpoch`: the old tests set the epoch length to 10 - uint256 internal constant EPOCH_LENGTH = 10; bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_1 = keccak256("subgraphDeploymentID1"); bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_2 = keccak256("subgraphDeploymentID2"); @@ -21,9 +19,7 @@ contract RewardsManagerDistributionTest is RewardsManagerSharedTest { /// Signal, then allocate one block later, so the pre-allocation block is reclaimed (NO_ALLOCATED_TOKENS). function _setupIndexerAllocation() internal { - _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); - vm.roll(block.number + 1); - _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + _signalThenAllocate(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); } function _setupIndexerAllocationSignalingAfter() internal { @@ -32,16 +28,6 @@ contract RewardsManagerDistributionTest is RewardsManagerSharedTest { _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); } - /// Rewards for an allocation of `tokens` out of `allocatedTokens` on a subgraph holding all the signal. - function _expectedAllocationRewards( - uint256 blocks, - uint256 tokens, - uint256 allocatedTokens - ) internal pure returns (uint256) { - uint256 subgraphRewards = _expectedSubgraphRewards(blocks, SIGNAL, SIGNAL); - return (_expectedRewardsPerAllocatedToken(subgraphRewards, allocatedTokens) * tokens) / 1e18; - } - /// RewardsManager half of the legacy-Staking close cases: event amount, mint to the issuer and total supply. function _assertRewardsDistributedOnClose() internal { // All the subgraph's rewards since the allocation go to it diff --git a/packages/issuance/test/unit/rewards-manager/eligibility.t.sol b/packages/issuance/test/unit/rewards-manager/eligibility.t.sol new file mode 100644 index 000000000..30ac1a6fa --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/eligibility.t.sol @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Vm } from "forge-std/Vm.sol"; +import { IProviderEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibility.sol"; + +import { MockERC165 } from "../../../contracts/test/allocate/MockERC165.sol"; +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { + event ProviderEligibilityOracleSet(IProviderEligibility indexed oldOracle, IProviderEligibility indexed newOracle); + event HorizonRewardsAssigned(address indexed indexer, address indexed allocationID, uint256 amount); + event RewardsDenied(address indexed indexer, address indexed allocationID); + event RewardsDeniedDueToEligibility(address indexed indexer, address indexed allocationID, uint256 amount); + + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_1 = keccak256("subgraphDeploymentID1"); + + address internal allocationID1 = makeAddr("allocationID1"); + + /// Old `setupIndexerAllocation` followed by `mineEpoch`: the allocation earns `EPOCH_LENGTH` blocks. + function _allocateAndAccrue() internal returns (uint256 expectedRewards) { + _signalThenAllocate(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); + vm.roll(block.number + EPOCH_LENGTH); + return _expectedAllocationRewards(EPOCH_LENGTH, ALLOCATION, ALLOCATION); + } + + function _setAvailabilityOracle() internal { + vm.prank(governor); + rm.setSubgraphAvailabilityOracle(governor); + } + + function _setDenied(bool deny) internal { + vm.prank(governor); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, deny); + } + + function _findLog(Vm.Log[] memory logs, bytes32 topic) internal view returns (Vm.Log memory) { + for (uint256 i = 0; i < logs.length; ++i) { + if (logs[i].emitter == address(rm) && logs[i].topics[0] == topic) return logs[i]; + } + revert("log not found"); + } + + // -- rewards eligibility oracle -- + + function test_RewardsEligibilityOracle_RevertWhen_Unauthorized() public { + vm.prank(indexer); + vm.expectRevert(bytes("Only Controller governor")); + rm.setProviderEligibilityOracle(IProviderEligibility(address(oracle))); + } + + function test_RewardsEligibilityOracle_SetsOracleAsGovernor() public { + vm.expectEmit(address(rm)); + emit ProviderEligibilityOracleSet(IProviderEligibility(address(0)), IProviderEligibility(address(oracle))); + vm.prank(governor); + rm.setProviderEligibilityOracle(IProviderEligibility(address(oracle))); + + assertEq(address(rm.getProviderEligibilityOracle()), address(oracle)); + } + + function test_RewardsEligibilityOracle_AllowsSettingToZeroAddress() public { + vm.prank(governor); + rm.setProviderEligibilityOracle(IProviderEligibility(address(oracle))); + + vm.expectEmit(address(rm)); + emit ProviderEligibilityOracleSet(IProviderEligibility(address(oracle)), IProviderEligibility(address(0))); + vm.prank(governor); + rm.setProviderEligibilityOracle(IProviderEligibility(address(0))); + + assertEq(address(rm.getProviderEligibilityOracle()), address(0)); + } + + function test_RewardsEligibilityOracle_RevertWhen_OracleIsEOA() public { + // An EOA has no code, so the supportsInterface call reverts + vm.prank(governor); + vm.expectRevert(); + rm.setProviderEligibilityOracle(IProviderEligibility(indexer)); + } + + function test_RewardsEligibilityOracle_RevertWhen_InterfaceNotSupported() public { + MockERC165 mockERC165 = new MockERC165(); + + vm.prank(governor); + vm.expectRevert(bytes("Contract does not support IProviderEligibility interface")); + rm.setProviderEligibilityOracle(IProviderEligibility(address(mockERC165))); + } + + function test_RewardsEligibilityOracle_NoEventWhenSettingSameOracle() public { + vm.prank(governor); + rm.setProviderEligibilityOracle(IProviderEligibility(address(oracle))); + + vm.recordLogs(); + vm.prank(governor); + rm.setProviderEligibilityOracle(IProviderEligibility(address(oracle))); + _assertNotEmitted(vm.getRecordedLogs(), ProviderEligibilityOracleSet.selector); + } + + // -- rewards eligibility in takeRewards -- + + function test_RewardsEligibilityInTakeRewards_DeniesRewardsDueToEligibilityOracle() public { + _useEligibilityOracle(false); + uint256 expectedRewards = _allocateAndAccrue(); + + vm.expectEmit(address(rm), 1); + emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); + _close(allocationID1); + } + + function test_RewardsEligibilityInTakeRewards_AllowsRewardsWhenOracleApproves() public { + _useEligibilityOracle(true); + uint256 expectedRewards = _allocateAndAccrue(); + + vm.expectEmit(address(rm), 1); + emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); + _close(allocationID1); + } + + // -- rewards eligibility oracle and denylist interaction -- + + function test_RewardsEligibilityOracleAndDenylistInteraction_PrioritizesDenylistOverOracleWhenBothDeny() public { + _setAvailabilityOracle(); + _setDenied(true); + _useEligibilityOracle(false); + + // Created while denied: the accumulator is frozen, so the allocation has no rewards to deny + _allocateAndAccrue(); + + vm.recordLogs(); + _close(allocationID1); + Vm.Log[] memory logs = vm.getRecordedLogs(); + _assertNotEmitted(logs, RewardsDenied.selector); + _assertNotEmitted(logs, RewardsDeniedDueToEligibility.selector); + _assertNotEmitted(logs, HorizonRewardsAssigned.selector); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_ChecksOracleWhenDenylistAllowsButIndexerIneligible() + public + { + _useEligibilityOracle(false); + uint256 expectedRewards = _allocateAndAccrue(); + + vm.expectEmit(address(rm), 1); + emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); + _close(allocationID1); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_IndexerBecomesIneligibleMidAllocation() public { + _useEligibilityOracle(true); + uint256 expectedRewards = _allocateAndAccrue(); + + // Eligibility is checked at close time, not at creation time + oracle.setIndexerEligible(indexer, false); + + vm.expectEmit(address(rm), 1); + emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); + _close(allocationID1); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_IndexerBecomesEligibleMidAllocation() public { + _useEligibilityOracle(false); + uint256 expectedRewards = _allocateAndAccrue(); + + oracle.setIndexerEligible(indexer, true); + + vm.expectEmit(address(rm), 1); + emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); + _close(allocationID1); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_DenylistAddedMidAllocation() public { + _setAvailabilityOracle(); + _allocateAndAccrue(); + + _setDenied(true); + vm.roll(block.number + 1); + + vm.expectEmit(address(rm)); + emit RewardsDenied(indexer, allocationID1); + _close(allocationID1); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_DenylistRemovedMidAllocation() public { + _setAvailabilityOracle(); + _setDenied(true); + + // Created while denied: rewards accrued while denied are reclaimed at the subgraph level + _allocateAndAccrue(); + + // Undeny snapshots the accumulator, then only post-undeny rewards accrue + _setDenied(false); + vm.roll(block.number + EPOCH_LENGTH); + + vm.expectEmit(address(rm)); + emit HorizonRewardsAssigned( + indexer, + allocationID1, + _expectedAllocationRewards(EPOCH_LENGTH, ALLOCATION, ALLOCATION) + ); + _close(allocationID1); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_AllowsRewardsWhenOracleIsZeroAddress() public { + assertEq(address(rm.getProviderEligibilityOracle()), address(0)); + + uint256 expectedRewards = _allocateAndAccrue(); + + vm.expectEmit(address(rm), 1); + emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); + _close(allocationID1); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_RevertWhen_IneligibleAndRevertOnIneligible() public { + _useEligibilityOracle(false); + vm.prank(governor); + rm.setRevertOnIneligible(true); + + _allocateAndAccrue(); + + vm.expectRevert(bytes("Indexer not eligible for rewards")); + issuer.close(allocationID1); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_NoRevertForEligibleWhenRevertOnIneligible() public { + _useEligibilityOracle(true); + vm.prank(governor); + rm.setRevertOnIneligible(true); + + uint256 expectedRewards = _allocateAndAccrue(); + + vm.expectEmit(address(rm)); + emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); + _close(allocationID1); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_ReclaimsForIneligibleWhenNotRevertOnIneligible() + public + { + _useEligibilityOracle(false); + assertFalse(rm.getRevertOnIneligible()); + + uint256 expectedRewards = _allocateAndAccrue(); + + vm.expectEmit(address(rm), 1); + emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); + uint256 rewards = _close(allocationID1); + assertEq(rewards, 0); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_EventStructureDiffersBetweenDenialMechanisms() public { + uint256 snapshot = vm.snapshotState(); + + // Denylist denial: event WITHOUT amount. Deny after the allocation so there are rewards to deny. + _setAvailabilityOracle(); + _allocateAndAccrue(); + _setDenied(true); + vm.roll(block.number + 1); + + vm.recordLogs(); + _close(allocationID1); + Vm.Log memory denied = _findLog(vm.getRecordedLogs(), RewardsDenied.selector); + assertEq(denied.topics.length, 3); + assertEq(denied.topics[1], bytes32(uint256(uint160(indexer)))); + assertEq(denied.topics[2], bytes32(uint256(uint160(allocationID1)))); + assertEq(denied.data.length, 0); + + // Reset, as the old test's fixture teardown and setup + vm.revertToState(snapshot); + + // Eligibility denial: event WITH amount, what the indexer would have received + _useEligibilityOracle(false); + uint256 expectedRewards = _allocateAndAccrue(); + + vm.recordLogs(); + _close(allocationID1); + Vm.Log memory ineligible = _findLog(vm.getRecordedLogs(), RewardsDeniedDueToEligibility.selector); + assertEq(ineligible.topics.length, 3); + assertEq(ineligible.topics[1], bytes32(uint256(uint160(indexer)))); + assertEq(ineligible.topics[2], bytes32(uint256(uint160(allocationID1)))); + uint256 amount = abi.decode(ineligible.data, (uint256)); + assertGt(amount, 0); + assertEq(amount, expectedRewards); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/reclaim.t.sol b/packages/issuance/test/unit/rewards-manager/reclaim.t.sol new file mode 100644 index 000000000..1107a487a --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/reclaim.t.sol @@ -0,0 +1,715 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Vm } from "forge-std/Vm.sol"; +import { RewardsCondition } from "@graphprotocol/interfaces/contracts/contracts/rewards/RewardsCondition.sol"; + +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +contract RewardsManagerReclaimTest is RewardsManagerSharedTest { + event ReclaimAddressSet(bytes32 indexed reason, address indexed oldAddress, address indexed newAddress); + event DefaultReclaimAddressSet(address indexed oldAddress, address indexed newAddress); + event RewardsReclaimed( + bytes32 indexed reason, + uint256 amount, + address indexed indexer, + address indexed allocationID, + bytes32 subgraphDeploymentID + ); + event HorizonRewardsAssigned(address indexed indexer, address indexed allocationID, uint256 amount); + event RewardsDenied(address indexed indexer, address indexed allocationID); + event RewardsDeniedDueToEligibility(address indexed indexer, address indexed allocationID, uint256 amount); + + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_1 = keccak256("subgraphDeploymentID1"); + uint256 internal constant MINIMUM_SIGNAL = 1000 ether; + uint256 internal constant BELOW_MINIMUM = 500 ether; + /// Blocks mined by the old `helpers.mine(5)` + uint256 internal constant MINED_BLOCKS = 5; + + address internal allocationID1 = makeAddr("allocationID1"); + address internal reclaimWallet = makeAddr("reclaimWallet"); + address internal otherWallet = makeAddr("otherWallet"); + + // -- Helpers -- + + /// Old `setupIndexerAllocation` followed by `mineEpoch`: the allocation earns `EPOCH_LENGTH` blocks. + function _allocateAndAccrue() internal { + _signalThenAllocate(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); + vm.roll(block.number + EPOCH_LENGTH); + } + + /// Rewards of the single allocation over `EPOCH_LENGTH` blocks. + function _epochAllocationRewards() internal pure returns (uint256) { + return _expectedAllocationRewards(EPOCH_LENGTH, ALLOCATION, ALLOCATION); + } + + /// Rewards of the subgraph (holding all the signal) over `blocks`, as reclaimed at the subgraph level. + function _subgraphRewards(uint256 blocks) internal pure returns (uint256) { + return _expectedSubgraphRewards(blocks, SIGNAL, SIGNAL); + } + + function _setReclaimAddress(bytes32 reason, address target) internal { + vm.prank(governor); + rm.setReclaimAddress(reason, target); + } + + function _setDefaultReclaimAddress(address target) internal { + vm.prank(governor); + rm.setDefaultReclaimAddress(target); + } + + function _setAvailabilityOracle() internal { + vm.prank(governor); + rm.setSubgraphAvailabilityOracle(governor); + } + + function _setDenied(bool deny) internal { + vm.prank(governor); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, deny); + } + + function _deny() internal { + _setAvailabilityOracle(); + _setDenied(true); + } + + /// Allocation data on the issuer, as the old test's `MockSubgraphService.setAllocation`. + function _setActiveIssuerAllocation(bool isActive) internal { + issuer.setAllocation(allocationID1, isActive, indexer, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION, 0, 0); + issuer.setSubgraphAllocatedTokens(SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + } + + function _triggerAllocationUpdate() internal { + vm.prank(governor); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID_1); + } + + modifier withMinimumSignal() { + vm.prank(governor); + rm.setMinimumSubgraphSignal(MINIMUM_SIGNAL); + _; + } + + /// Old `default reclaim address fallback` beforeEach. + modifier withAllocationAndAvailabilityOracle() { + _signalThenAllocate(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); + _setAvailabilityOracle(); + _; + } + + // -- setReclaimAddress -- + + function test_SetReclaimAddress_RevertWhen_NotGovernor() public { + vm.prank(indexer); + vm.expectRevert(bytes("Only Controller governor")); + rm.setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); + } + + function test_SetReclaimAddress_RevertWhen_ReasonIsNone() public { + vm.prank(governor); + vm.expectRevert(bytes("Cannot set reclaim address for NONE")); + rm.setReclaimAddress(RewardsCondition.NONE, reclaimWallet); + } + + function test_SetReclaimAddress_SetsEligibilityReclaimAddressAsGovernor() public { + vm.expectEmit(address(rm)); + emit ReclaimAddressSet(RewardsCondition.INDEXER_INELIGIBLE, address(0), reclaimWallet); + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); + + assertEq(rm.getReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE), reclaimWallet); + } + + function test_SetReclaimAddress_SetsSubgraphDeniedReclaimAddressAsGovernor() public { + vm.expectEmit(address(rm)); + emit ReclaimAddressSet(RewardsCondition.SUBGRAPH_DENIED, address(0), reclaimWallet); + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + + assertEq(rm.getReclaimAddress(RewardsCondition.SUBGRAPH_DENIED), reclaimWallet); + } + + function test_SetReclaimAddress_AllowsSettingToZeroAddress() public { + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); + + vm.expectEmit(address(rm)); + emit ReclaimAddressSet(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet, address(0)); + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, address(0)); + + assertEq(rm.getReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE), address(0)); + } + + function test_SetReclaimAddress_NoEventWhenSettingSameAddress() public { + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); + + vm.recordLogs(); + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); + _assertNotEmitted(vm.getRecordedLogs(), ReclaimAddressSet.selector); + } + + // -- reclaim denied rewards - subgraph denylist -- + // Denied before the allocation: the accumulator is frozen, rewards are reclaimed at the subgraph level + // (address(0) indexer and allocation) and the allocation has none at close. + + function test_ReclaimDeniedRewardsSubgraphDenylist_MintsToReclaimAddressWhenSet() public { + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + _deny(); + _allocateAndAccrue(); + + uint256 expectedRewards = _subgraphRewards(EPOCH_LENGTH); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.recordLogs(); + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.SUBGRAPH_DENIED, + expectedRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _close(allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), RewardsDenied.selector); + + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + function test_ReclaimDeniedRewardsSubgraphDenylist_ReclaimsPreDenialRewardsWhenDeniedAfterAllocation() public { + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + _setAvailabilityOracle(); + + // Allocate first, then deny: the pre-denial rewards stay with the allocation + _allocateAndAccrue(); + _setDenied(true); + // Close in the next block, as in Hardhat: that block is reclaimed at the subgraph level + vm.roll(block.number + 1); + + uint256 allocationRewards = _epochAllocationRewards(); + uint256 subgraphLevelRewards = _subgraphRewards(1); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.SUBGRAPH_DENIED, + subgraphLevelRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID_1 + ); + vm.expectEmit(address(rm), 1); + emit RewardsDenied(indexer, allocationID1); + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.SUBGRAPH_DENIED, + allocationRewards, + indexer, + allocationID1, + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _close(allocationID1); + + assertGe(token.balanceOf(reclaimWallet) - balanceBefore, allocationRewards); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, allocationRewards + subgraphLevelRewards); + } + + function test_ReclaimDeniedRewardsSubgraphDenylist_NoMintWhenReclaimAddressNotSet() public { + _deny(); + _allocateAndAccrue(); + + vm.recordLogs(); + _close(allocationID1); + Vm.Log[] memory logs = vm.getRecordedLogs(); + _assertNotEmitted(logs, RewardsDenied.selector); + _assertNotEmitted(logs, RewardsReclaimed.selector); + } + + // -- reclaim denied rewards - eligibility -- + + function test_ReclaimDeniedRewardsEligibility_MintsToReclaimAddressWhenSet() public { + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); + _useEligibilityOracle(false); + _allocateAndAccrue(); + + uint256 expectedRewards = _epochAllocationRewards(); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.expectEmit(address(rm), 1); + emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.INDEXER_INELIGIBLE, + expectedRewards, + indexer, + allocationID1, + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _close(allocationID1); + + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + function test_ReclaimDeniedRewardsEligibility_NoMintWhenReclaimAddressNotSet() public { + _useEligibilityOracle(false); + _allocateAndAccrue(); + + vm.recordLogs(); + vm.expectEmit(address(rm), 1); + emit RewardsDeniedDueToEligibility(indexer, allocationID1, _epochAllocationRewards()); + _close(allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), RewardsReclaimed.selector); + } + + // -- reclaim precedence - first successful reclaim wins -- + + function test_ReclaimPrecedenceFirstSuccessfulReclaimWins_ReclaimsToSubgraphDeniedWhenBothConfigured() public { + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, otherWallet); + _deny(); + _useEligibilityOracle(false); + _allocateAndAccrue(); + + uint256 expectedRewards = _subgraphRewards(EPOCH_LENGTH); + uint256 subgraphDeniedBalanceBefore = token.balanceOf(reclaimWallet); + uint256 indexerIneligibleBalanceBefore = token.balanceOf(otherWallet); + + vm.recordLogs(); + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.SUBGRAPH_DENIED, + expectedRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _close(allocationID1); + Vm.Log[] memory logs = vm.getRecordedLogs(); + _assertNotEmitted(logs, RewardsDenied.selector); + _assertNotEmitted(logs, RewardsDeniedDueToEligibility.selector); + + assertEq(token.balanceOf(reclaimWallet) - subgraphDeniedBalanceBefore, expectedRewards); + assertEq(token.balanceOf(otherWallet), indexerIneligibleBalanceBefore); + } + + function test_ReclaimPrecedenceFirstSuccessfulReclaimWins_DropsWhenOnlyIndexerIneligibleConfigured() public { + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, otherWallet); + _deny(); + _useEligibilityOracle(false); + _allocateAndAccrue(); + + uint256 balanceBefore = token.balanceOf(otherWallet); + + // Subgraph-level denial without a SUBGRAPH_DENIED address: dropped, and the eligibility path is not reached + vm.recordLogs(); + _close(allocationID1); + Vm.Log[] memory logs = vm.getRecordedLogs(); + _assertNotEmitted(logs, RewardsDenied.selector); + _assertNotEmitted(logs, RewardsDeniedDueToEligibility.selector); + _assertNotEmitted(logs, RewardsReclaimed.selector); + + assertEq(token.balanceOf(otherWallet), balanceBefore); + } + + function test_ReclaimPrecedenceFirstSuccessfulReclaimWins_FallsBackToIndexerIneligibleForPreDenialAllocation() + public + { + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); + _setAvailabilityOracle(); + _useEligibilityOracle(false); + + // Allocate first, then deny, so the rewards reach `_deniedRewards` + _allocateAndAccrue(); + _setDenied(true); + vm.roll(block.number + 1); + + uint256 expectedRewards = _epochAllocationRewards(); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + // Both conditions hold; with no SUBGRAPH_DENIED address the reason falls back to INDEXER_INELIGIBLE + vm.expectEmit(address(rm)); + emit RewardsDenied(indexer, allocationID1); + vm.expectEmit(address(rm)); + emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.INDEXER_INELIGIBLE, + expectedRewards, + indexer, + allocationID1, + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _close(allocationID1); + + // The subgraph-level block after the denial has no SUBGRAPH_DENIED address and is dropped + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + function test_ReclaimPrecedenceFirstSuccessfulReclaimWins_DropsWhenNeitherAddressConfigured() public { + _deny(); + _useEligibilityOracle(false); + _allocateAndAccrue(); + + vm.recordLogs(); + _close(allocationID1); + Vm.Log[] memory logs = vm.getRecordedLogs(); + _assertNotEmitted(logs, RewardsDenied.selector); + _assertNotEmitted(logs, RewardsDeniedDueToEligibility.selector); + _assertNotEmitted(logs, RewardsReclaimed.selector); + } + + function test_ReclaimPrecedenceFirstSuccessfulReclaimWins_DropsWhenSubgraphDeniedWithoutAddressEvenIfEligible() + public + { + _deny(); + _useEligibilityOracle(true); + _allocateAndAccrue(); + + vm.recordLogs(); + _close(allocationID1); + Vm.Log[] memory logs = vm.getRecordedLogs(); + _assertNotEmitted(logs, RewardsDenied.selector); + _assertNotEmitted(logs, RewardsDeniedDueToEligibility.selector); + _assertNotEmitted(logs, RewardsReclaimed.selector); + _assertNotEmitted(logs, HorizonRewardsAssigned.selector); + } + + // -- reclaimRewards - force close allocation -- + // `issuer` replaces the old MockSubgraphService; the legacy Staking allocation is not needed, since + // RewardsManager only reads allocation data from the subgraph service. + + function test_ReclaimRewardsForceCloseAllocation_ReclaimsWhenReclaimAddressSet() public { + _setReclaimAddress(RewardsCondition.CLOSE_ALLOCATION, reclaimWallet); + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + _setActiveIssuerAllocation(true); + vm.roll(block.number + EPOCH_LENGTH); + + uint256 expectedRewards = _epochAllocationRewards(); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.CLOSE_ALLOCATION, + expectedRewards, + indexer, + allocationID1, + SUBGRAPH_DEPLOYMENT_ID_1 + ); + uint256 reclaimed = issuer.reclaim(RewardsCondition.CLOSE_ALLOCATION, allocationID1); + + uint256 rewardsClaimed = token.balanceOf(reclaimWallet) - balanceBefore; + assertGt(rewardsClaimed, 0); + assertEq(rewardsClaimed, expectedRewards); + assertEq(reclaimed, expectedRewards); + } + + function test_ReclaimRewardsForceCloseAllocation_NoReclaimWhenReclaimAddressNotSet() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + _setActiveIssuerAllocation(true); + vm.roll(block.number + EPOCH_LENGTH); + + vm.recordLogs(); + uint256 reclaimed = issuer.reclaim(RewardsCondition.CLOSE_ALLOCATION, allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), RewardsReclaimed.selector); + assertEq(reclaimed, 0); + } + + function test_ReclaimRewardsForceCloseAllocation_ReturnsZeroWhenNoAddressAndNoRewards() public { + // Inactive allocation: no rewards + _setActiveIssuerAllocation(false); + + vm.recordLogs(); + uint256 reclaimed = issuer.reclaim(RewardsCondition.CLOSE_ALLOCATION, allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), RewardsReclaimed.selector); + assertEq(reclaimed, 0); + } + + function test_ReclaimRewardsForceCloseAllocation_ReturnsZeroWhenReasonIsNone() public { + // A default reclaim address makes the NONE guard, not a missing address, the reason for 0 + _setDefaultReclaimAddress(reclaimWallet); + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + _setActiveIssuerAllocation(true); + vm.roll(block.number + EPOCH_LENGTH); + + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.recordLogs(); + uint256 reclaimed = issuer.reclaim(RewardsCondition.NONE, allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), RewardsReclaimed.selector); + assertEq(reclaimed, 0); + assertEq(token.balanceOf(reclaimWallet), balanceBefore); + } + + function test_ReclaimRewardsForceCloseAllocation_RevertWhen_CalledByUnauthorizedAddress() public { + vm.prank(indexer); + vm.expectRevert(bytes("Not a rewards issuer")); + rm.reclaimRewards(RewardsCondition.CLOSE_ALLOCATION, allocationID1); + } + + // -- setDefaultReclaimAddress -- + + function test_SetDefaultReclaimAddress_RevertWhen_NotGovernor() public { + vm.prank(indexer); + vm.expectRevert(bytes("Only Controller governor")); + rm.setDefaultReclaimAddress(reclaimWallet); + } + + function test_SetDefaultReclaimAddress_SetsDefaultReclaimAddressAsGovernor() public { + vm.expectEmit(address(rm)); + emit DefaultReclaimAddressSet(address(0), reclaimWallet); + _setDefaultReclaimAddress(reclaimWallet); + + assertEq(rm.getDefaultReclaimAddress(), reclaimWallet); + } + + function test_SetDefaultReclaimAddress_AllowsSettingToZeroAddress() public { + _setDefaultReclaimAddress(reclaimWallet); + + vm.expectEmit(address(rm)); + emit DefaultReclaimAddressSet(reclaimWallet, address(0)); + _setDefaultReclaimAddress(address(0)); + } + + function test_SetDefaultReclaimAddress_NoEventWhenSettingSameAddress() public { + _setDefaultReclaimAddress(reclaimWallet); + + vm.recordLogs(); + _setDefaultReclaimAddress(reclaimWallet); + _assertNotEmitted(vm.getRecordedLogs(), DefaultReclaimAddressSet.selector); + } + + // -- default reclaim address fallback -- + + function test_DefaultReclaimAddressFallback_UsesDefaultWhenReasonSpecificNotSet() + public + withAllocationAndAvailabilityOracle + { + _setDefaultReclaimAddress(reclaimWallet); + _setDenied(true); + vm.roll(block.number + MINED_BLOCKS); + + uint256 expectedRewards = _subgraphRewards(MINED_BLOCKS); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + // Reclaimed to the default address under the SUBGRAPH_DENIED reason + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.SUBGRAPH_DENIED, + expectedRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _triggerAllocationUpdate(); + + assertGt(token.balanceOf(reclaimWallet), balanceBefore); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + function test_DefaultReclaimAddressFallback_PrefersReasonSpecificOverDefault() + public + withAllocationAndAvailabilityOracle + { + _setDefaultReclaimAddress(otherWallet); + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + _setDenied(true); + vm.roll(block.number + MINED_BLOCKS); + + uint256 balanceBefore = token.balanceOf(reclaimWallet); + uint256 otherBalanceBefore = token.balanceOf(otherWallet); + + _triggerAllocationUpdate(); + + assertGt(token.balanceOf(reclaimWallet), balanceBefore); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, _subgraphRewards(MINED_BLOCKS)); + assertEq(token.balanceOf(otherWallet), otherBalanceBefore); + } + + // -- reclaim NO_SIGNAL - zero total signal -- + + function test_ReclaimNoSignalZeroTotalSignal_ReclaimsWhenNoSignalAddressSet() public { + _setReclaimAddress(RewardsCondition.NO_SIGNAL, reclaimWallet); + vm.roll(block.number + MINED_BLOCKS); + + uint256 expectedRewards = MINED_BLOCKS * ISSUANCE_PER_BLOCK; + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.expectEmit(address(rm)); + emit RewardsReclaimed(RewardsCondition.NO_SIGNAL, expectedRewards, address(0), address(0), bytes32(0)); + vm.prank(governor); + rm.updateAccRewardsPerSignal(); + + assertGt(token.balanceOf(reclaimWallet), balanceBefore); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + function test_ReclaimNoSignalZeroTotalSignal_DropsWhenNoReclaimAddress() public { + vm.roll(block.number + MINED_BLOCKS); + + vm.recordLogs(); + vm.prank(governor); + rm.updateAccRewardsPerSignal(); + _assertNotEmitted(vm.getRecordedLogs(), RewardsReclaimed.selector); + } + + function test_ReclaimNoSignalZeroTotalSignal_UsesDefaultWhenSpecificNotSet() public { + _setDefaultReclaimAddress(reclaimWallet); + vm.roll(block.number + MINED_BLOCKS); + + uint256 expectedRewards = MINED_BLOCKS * ISSUANCE_PER_BLOCK; + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.expectEmit(address(rm)); + emit RewardsReclaimed(RewardsCondition.NO_SIGNAL, expectedRewards, address(0), address(0), bytes32(0)); + vm.prank(governor); + rm.updateAccRewardsPerSignal(); + + assertGt(token.balanceOf(reclaimWallet), balanceBefore); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + // -- reclaim NO_ALLOCATED_TOKENS - signal but no allocations -- + + function test_ReclaimNoAllocatedTokensSignalButNoAllocations_ReclaimsWhenNoAllocatedTokensAddressSet() public { + _setReclaimAddress(RewardsCondition.NO_ALLOCATED_TOKENS, reclaimWallet); + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + vm.roll(block.number + MINED_BLOCKS); + + uint256 expectedRewards = _subgraphRewards(MINED_BLOCKS); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.NO_ALLOCATED_TOKENS, + expectedRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _triggerAllocationUpdate(); + + assertGt(token.balanceOf(reclaimWallet), balanceBefore); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + function test_ReclaimNoAllocatedTokensSignalButNoAllocations_DropsWhenNoReclaimAddress() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + vm.roll(block.number + MINED_BLOCKS); + + vm.recordLogs(); + _triggerAllocationUpdate(); + _assertNotEmitted(vm.getRecordedLogs(), RewardsReclaimed.selector); + } + + // -- reclaim BELOW_MINIMUM_SIGNAL -- + + function test_ReclaimBelowMinimumSignal_ReclaimsWhenBelowMinimumAddressSet() public withMinimumSignal { + _setReclaimAddress(RewardsCondition.BELOW_MINIMUM_SIGNAL, reclaimWallet); + _signal(SUBGRAPH_DEPLOYMENT_ID_1, BELOW_MINIMUM); + vm.roll(block.number + MINED_BLOCKS); + + uint256 expectedRewards = _expectedSubgraphRewards(MINED_BLOCKS, BELOW_MINIMUM, BELOW_MINIMUM); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.BELOW_MINIMUM_SIGNAL, + expectedRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _triggerAllocationUpdate(); + + assertGt(token.balanceOf(reclaimWallet), balanceBefore); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + function test_ReclaimBelowMinimumSignal_NoReclaimWhenSignalAtOrAboveMinimum() public withMinimumSignal { + _setReclaimAddress(RewardsCondition.BELOW_MINIMUM_SIGNAL, reclaimWallet); + _signal(SUBGRAPH_DEPLOYMENT_ID_1, MINIMUM_SIGNAL); + vm.roll(block.number + 1); + // An allocation, so rewards accumulate normally + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + vm.roll(block.number + MINED_BLOCKS); + + vm.recordLogs(); + _triggerAllocationUpdate(); + _assertNotEmitted(vm.getRecordedLogs(), RewardsReclaimed.selector); + } + + function test_ReclaimBelowMinimumSignal_DropsWhenNoReclaimAddress() public withMinimumSignal { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, BELOW_MINIMUM); + vm.roll(block.number + MINED_BLOCKS); + + vm.recordLogs(); + _triggerAllocationUpdate(); + _assertNotEmitted(vm.getRecordedLogs(), RewardsReclaimed.selector); + } + + function test_ReclaimBelowMinimumSignal_UsesBelowMinimumWhenDeniedWithoutSubgraphDeniedAddress() + public + withMinimumSignal + { + _setReclaimAddress(RewardsCondition.BELOW_MINIMUM_SIGNAL, reclaimWallet); + _deny(); + _signal(SUBGRAPH_DEPLOYMENT_ID_1, BELOW_MINIMUM); + vm.roll(block.number + MINED_BLOCKS); + + uint256 expectedRewards = _expectedSubgraphRewards(MINED_BLOCKS, BELOW_MINIMUM, BELOW_MINIMUM); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + // Denied, but with no SUBGRAPH_DENIED address the condition falls back to BELOW_MINIMUM_SIGNAL + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.BELOW_MINIMUM_SIGNAL, + expectedRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _triggerAllocationUpdate(); + + assertGt(token.balanceOf(reclaimWallet), balanceBefore); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + // -- dual denial - SUBGRAPH_DENIED takes precedence when configured -- + + function test_DualDenialSubgraphDeniedTakesPrecedenceWhenConfigured_ReclaimsToSubgraphDeniedForPreDenialAllocation() + public + { + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, otherWallet); + _setAvailabilityOracle(); + _useEligibilityOracle(false); + + // Allocate first, then deny, so the rewards reach `_deniedRewards` + _allocateAndAccrue(); + _setDenied(true); + vm.roll(block.number + 1); + + uint256 allocationRewards = _epochAllocationRewards(); + uint256 subgraphLevelRewards = _subgraphRewards(1); + uint256 subgraphDeniedBalanceBefore = token.balanceOf(reclaimWallet); + uint256 indexerIneligibleBalanceBefore = token.balanceOf(otherWallet); + + vm.expectEmit(address(rm)); + emit RewardsDenied(indexer, allocationID1); + vm.expectEmit(address(rm)); + emit RewardsDeniedDueToEligibility(indexer, allocationID1, allocationRewards); + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.SUBGRAPH_DENIED, + allocationRewards, + indexer, + allocationID1, + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _close(allocationID1); + + // SUBGRAPH_DENIED wallet gets the allocation's rewards plus the subgraph-level block after the denial + assertGt(token.balanceOf(reclaimWallet) - subgraphDeniedBalanceBefore, 0); + assertEq( + token.balanceOf(reclaimWallet) - subgraphDeniedBalanceBefore, + allocationRewards + subgraphLevelRewards + ); + assertEq(token.balanceOf(otherWallet), indexerIneligibleBalanceBefore); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/shared.t.sol b/packages/issuance/test/unit/rewards-manager/shared.t.sol index 6f65b0472..9f7057a97 100644 --- a/packages/issuance/test/unit/rewards-manager/shared.t.sol +++ b/packages/issuance/test/unit/rewards-manager/shared.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.27; import { Vm } from "forge-std/Vm.sol"; +import { IProviderEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibility.sol"; import { ProtocolFixture } from "../shared/ProtocolFixture.t.sol"; import { MockGraphToken } from "../mocks/MockGraphToken.sol"; @@ -17,6 +18,8 @@ contract RewardsManagerSharedTest is ProtocolFixture { uint256 internal constant ISSUANCE_PER_BLOCK = 200 ether; uint256 internal constant SIGNAL = 1500 ether; uint256 internal constant ALLOCATION = 12_500 ether; + /// Blocks per `helpers.mineEpoch`: the old tests set the epoch length to 10 + uint256 internal constant EPOCH_LENGTH = 10; IRewardsManagerLike internal rm; MockGraphToken internal token; @@ -80,6 +83,21 @@ contract RewardsManagerSharedTest is ProtocolFixture { return issuer.close(allocationId); } + /// Signal `SIGNAL`, then allocate `ALLOCATION` one block later, as the old `setupIndexerAllocation` did + /// in separate transactions: the pre-allocation block is reclaimed under the subgraph's condition. + function _signalThenAllocate(bytes32 sd, address allocationId) internal { + _signal(sd, SIGNAL); + vm.roll(block.number + 1); + _allocate(allocationId, sd, ALLOCATION); + } + + /// Sets the fixture's `oracle` as the eligibility oracle, answering `eligibleByDefault` for every indexer. + function _useEligibilityOracle(bool eligibleByDefault) internal { + oracle.setDefaultResponse(eligibleByDefault); + vm.prank(governor); + rm.setProviderEligibilityOracle(IProviderEligibility(address(oracle))); + } + function _expectedRewardsPerSignal(uint256 blocks, uint256 totalSignal) internal pure returns (uint256) { return (blocks * ISSUANCE_PER_BLOCK * 1e18) / totalSignal; } @@ -100,6 +118,16 @@ contract RewardsManagerSharedTest is ProtocolFixture { return (subgraphRewards * 1e18) / allocatedTokens; } + /// Rewards for an allocation of `tokens` out of `allocatedTokens` on a subgraph holding all the signal. + function _expectedAllocationRewards( + uint256 blocks, + uint256 tokens, + uint256 allocatedTokens + ) internal pure returns (uint256) { + uint256 subgraphRewards = _expectedSubgraphRewards(blocks, SIGNAL, SIGNAL); + return (_expectedRewardsPerAllocatedToken(subgraphRewards, allocatedTokens) * tokens) / 1e18; + } + function _assertNotEmitted(Vm.Log[] memory logs, bytes32 topic) internal view { for (uint256 i = 0; i < logs.length; ++i) { if (logs[i].emitter == address(rm)) assertTrue(logs[i].topics[0] != topic, "unexpected event"); diff --git a/packages/issuance/test/unit/rewards-manager/subgraphService.t.sol b/packages/issuance/test/unit/rewards-manager/subgraphService.t.sol index a7e2eca7a..aad516bb4 100644 --- a/packages/issuance/test/unit/rewards-manager/subgraphService.t.sol +++ b/packages/issuance/test/unit/rewards-manager/subgraphService.t.sol @@ -17,12 +17,6 @@ contract RewardsManagerSubgraphServiceTest is RewardsManagerSharedTest { address internal allocationID1 = makeAddr("allocationID1"); - /// Rewards of an allocation holding all of the allocated tokens, on a subgraph holding all the signal. - function _expectedAllocationRewards(uint256 blocks, uint256 tokens) internal pure returns (uint256) { - uint256 subgraphRewards = _expectedSubgraphRewards(blocks, SIGNAL, SIGNAL); - return (_expectedRewardsPerAllocatedToken(subgraphRewards, tokens) * tokens) / 1e18; - } - function _setActiveAllocation(uint256 tokens, uint256 accRewardsPending) internal { issuer.setAllocation(allocationID1, true, indexer, SUBGRAPH_DEPLOYMENT_ID_1, tokens, 0, accRewardsPending); issuer.setSubgraphAllocatedTokens(SUBGRAPH_DEPLOYMENT_ID_1, tokens); @@ -83,7 +77,7 @@ contract RewardsManagerSubgraphServiceTest is RewardsManagerSharedTest { uint256 rewards = rm.getRewards(address(issuer), allocationID1); assertGt(rewards, 0); - assertEq(rewards, _expectedAllocationRewards(10, ALLOCATION)); + assertEq(rewards, _expectedAllocationRewards(10, ALLOCATION, ALLOCATION)); } function test_GetRewards_ZeroForInactiveAllocation() public { @@ -107,7 +101,7 @@ contract RewardsManagerSubgraphServiceTest is RewardsManagerSharedTest { vm.roll(block.number + 10); - uint256 expectedRewards = _expectedAllocationRewards(10, ALLOCATION); + uint256 expectedRewards = _expectedAllocationRewards(10, ALLOCATION, ALLOCATION); uint256 beforeIssuerBalance = token.balanceOf(address(issuer)); uint256 beforeTotalSupply = token.totalSupply(); @@ -249,7 +243,7 @@ contract RewardsManagerSubgraphServiceTest is RewardsManagerSharedTest { emit RewardsDeniedDueToEligibility( indexer, allocationID1, - _expectedAllocationRewards(ISSUANCE_RATE_PERIODS, ALLOCATION) + _expectedAllocationRewards(ISSUANCE_RATE_PERIODS, ALLOCATION, ALLOCATION) ); vm.prank(address(issuer)); rm.takeRewards(allocationID1); diff --git a/scripts/contracts-migration/test-mapping/t5d.md b/scripts/contracts-migration/test-mapping/t5d.md new file mode 100644 index 000000000..a64ae96a0 --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t5d.md @@ -0,0 +1,66 @@ +# T5d test mapping: RewardsManager eligibility oracle and reclaim + +New tests are in `packages/issuance`, on T5a's `RewardsManagerSharedTest` (the RewardsManager Hardhat artifact behind a GraphProxy). `RMEligibilityOracleMock` (the fixture's `oracle`, deployed eligible by default) replaces `MockRewardsEligibilityOracle`: where the old test deployed it with `false`, the port calls `setDefaultResponse(false)` through the shared `_useEligibilityOracle`. `RMIssuerMock` (`issuer`) plays the legacy Staking contract and the old `MockSubgraphService`, so `closeAllocation` becomes `_close` (`takeRewards`) and `callReclaimRewards` becomes `issuer.reclaim`. `setupIndexerAllocation` becomes the shared `_signalThenAllocate` (signal, `vm.roll(+1)`, allocate, as the separate Hardhat transactions), and `mineEpoch` / `mine(n)` become `vm.roll(EPOCH_LENGTH)` / `vm.roll(5)`. The hand-counted 1400 / 1600 GRT values (`expectApproxEq`, 20000 wei) are replaced by exact values derived from the rolls with the shared formula helpers. Where a pre-denial allocation was closed in the Hardhat transaction after `setDenied`, the port rolls one block too, so the subgraph-level reclaim of that block stays in play. No ported case uses a zero-token allocation (the carried `RMIssuerMock` hook caveat does not apply). + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle › should reject setProviderEligibilityOracle if unauthorized | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracle_RevertWhen_Unauthorized | Fixture `oracle` (default `true`, as the old deploy) | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle › should set rewards eligibility oracle if governor | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracle_SetsOracleAsGovernor | Fixture `oracle` (default `true`, as the old deploy) | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle › should allow setting rewards eligibility oracle to zero address | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracle_AllowsSettingToZeroAddress | Fixture `oracle` (default `true`, as the old deploy) | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle › should reject setting oracle that does not support interface | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracle_RevertWhen_OracleIsEOA | Bare `vm.expectRevert()`, as the old `.reverted` | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle › should reject setting oracle that does not support IProviderEligibility interface | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracle_RevertWhen_InterfaceNotSupported | Issuance's `MockERC165` | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle › should not emit event when setting same oracle address | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracle_NoEventWhenSettingSameOracle | `not.emit` → `_assertNotEmitted` | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility in takeRewards › should deny rewards due to rewards eligibility oracle | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityInTakeRewards_DeniesRewardsDueToEligibilityOracle | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). 1400 GRT hand count (`expectApproxEq`, 20000 wei) replaced by the exact `_expectedAllocationRewards(EPOCH_LENGTH, …)` from the explicit rolls. "Exactly one event" → `vm.expectEmit(address(rm), 1)` | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility in takeRewards › should allow rewards when rewards eligibility oracle approves | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityInTakeRewards_AllowsRewardsWhenOracleApproves | Fixture `oracle` (default `true`, as the old deploy). 1400 GRT hand count (`expectApproxEq`, 20000 wei) replaced by the exact `_expectedAllocationRewards(EPOCH_LENGTH, …)` from the explicit rolls | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should prioritize denylist over REO when both deny | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_PrioritizesDenylistOverOracleWhenBothDeny | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`) | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should check REO when denylist allows but indexer ineligible | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_ChecksOracleWhenDenylistAllowsButIndexerIneligible | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). 1400 GRT hand count (`expectApproxEq`, 20000 wei) replaced by the exact `_expectedAllocationRewards(EPOCH_LENGTH, …)` from the explicit rolls | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should handle indexer becoming ineligible mid-allocation | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_IndexerBecomesIneligibleMidAllocation | Fixture `oracle` (default `true`, as the old deploy), then `setIndexerEligible(indexer, false)`. 1600 GRT hand count (one extra Hardhat block) replaced by the exact value from the rolls | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should handle indexer becoming eligible mid-allocation | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_IndexerBecomesEligibleMidAllocation | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`), then `setIndexerEligible(indexer, true)`. 1600 GRT hand count replaced by the exact value from the rolls | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should handle denylist being added mid-allocation | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_DenylistAddedMidAllocation | `vm.roll(+1)` between `setDenied` and close, as the next Hardhat transaction | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should handle denylist being removed mid-allocation | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_DenylistRemovedMidAllocation | Old test only checked the event; the amount is now asserted exactly (post-undeny `EPOCH_LENGTH` blocks only) | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should allow rewards when REO is zero address (disabled) | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_AllowsRewardsWhenOracleIsZeroAddress | The fixture never sets its `oracle` on the RewardsManager. 1400 GRT hand count (`expectApproxEq`, 20000 wei) replaced by the exact `_expectedAllocationRewards(EPOCH_LENGTH, …)` from the explicit rolls | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should revert for ineligible indexer when revertOnIneligible is true | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_RevertWhen_IneligibleAndRevertOnIneligible | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). `vm.expectRevert` right before `issuer.close` | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should not revert for eligible indexer when revertOnIneligible is true | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_NoRevertForEligibleWhenRevertOnIneligible | Fixture `oracle` (default `true`, as the old deploy). Event amount asserted exactly (old: event only) | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should reclaim (not revert) for ineligible indexer when revertOnIneligible is false | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_ReclaimsForIneligibleWhenNotRevertOnIneligible | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). Event count 1 with exact amount, plus `takeRewards` returning 0 | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should verify event structure differences between denial mechanisms | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_EventStructureDiffersBetweenDenialMechanisms | Mid-test `fixture.tearDown/setUp` → `vm.snapshotState`/`vm.revertToState`. "No `amount` arg" → `RewardsDenied` log has 3 topics and empty data; `RewardsDeniedDueToEligibility` data decodes to the exact amount (> 0). Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`) for part 2 | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setReclaimAddress › should reject if not governor | test/unit/rewards-manager/reclaim.t.sol::test_SetReclaimAddress_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setReclaimAddress › should reject setting reclaim address for NONE | test/unit/rewards-manager/reclaim.t.sol::test_SetReclaimAddress_RevertWhen_ReasonIsNone | `RewardsCondition.NONE` (`HashZero`) | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setReclaimAddress › should set eligibility reclaim address if governor | test/unit/rewards-manager/reclaim.t.sol::test_SetReclaimAddress_SetsEligibilityReclaimAddressAsGovernor | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setReclaimAddress › should set subgraph denied reclaim address if governor | test/unit/rewards-manager/reclaim.t.sol::test_SetReclaimAddress_SetsSubgraphDeniedReclaimAddressAsGovernor | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setReclaimAddress › should allow setting to zero address | test/unit/rewards-manager/reclaim.t.sol::test_SetReclaimAddress_AllowsSettingToZeroAddress | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setReclaimAddress › should not emit event when setting same address | test/unit/rewards-manager/reclaim.t.sol::test_SetReclaimAddress_NoEventWhenSettingSameAddress | `not.emit` → `_assertNotEmitted` | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim denied rewards - subgraph denylist › should mint to reclaim address when subgraph denied and reclaim address set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimDeniedRewardsSubgraphDenylist_MintsToReclaimAddressWhenSet | 1400 GRT `expectApproxEq` → exact `_expectedSubgraphRewards(EPOCH_LENGTH, …)`; `RewardsReclaimed` now checked with all args (SUBGRAPH_DENIED, amount, `address(0)`, `address(0)`, deployment) | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim denied rewards - subgraph denylist › should reclaim pre-denial rewards via \_deniedRewards when denied after allocation | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimDeniedRewardsSubgraphDenylist_ReclaimsPreDenialRewardsWhenDeniedAfterAllocation | `vm.roll(+1)` before close, as Hardhat: the subgraph-level reclaim of that block and the allocation-level reclaim are both asserted with args, in order. Old `gte(1400)` kept as `assertGe`, plus the exact sum | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim denied rewards - subgraph denylist › should not mint to reclaim address when reclaim address not set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimDeniedRewardsSubgraphDenylist_NoMintWhenReclaimAddressNotSet | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim denied rewards - eligibility › should mint to reclaim address when eligibility denied and reclaim address set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimDeniedRewardsEligibility_MintsToReclaimAddressWhenSet | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). 1400 GRT `expectApproxEq` → exact allocation rewards | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim denied rewards - eligibility › should not mint to reclaim address when reclaim address not set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimDeniedRewardsEligibility_NoMintWhenReclaimAddressNotSet | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). Exact denied amount | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim precedence - first successful reclaim wins › should reclaim to SUBGRAPH_DENIED when both fail and both addresses configured | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimPrecedenceFirstSuccessfulReclaimWins_ReclaimsToSubgraphDeniedWhenBothConfigured | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). Exact subgraph-level amount; event args asserted | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim precedence - first successful reclaim wins › should reclaim to SUBGRAPH_DENIED even when only INDEXER_INELIGIBLE address configured | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimPrecedenceFirstSuccessfulReclaimWins_DropsWhenOnlyIndexerIneligibleConfigured | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). Title is stale: the old assertions (and the contract) drop the rewards; ported as asserted | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim precedence - first successful reclaim wins › should reclaim to INDEXER_INELIGIBLE when both fail but only INDEXER_INELIGIBLE address configured (pre-denial allocation) | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimPrecedenceFirstSuccessfulReclaimWins_FallsBackToIndexerIneligibleForPreDenialAllocation | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). `vm.roll(+1)` before close; events asserted with args (reason INDEXER_INELIGIBLE); exact balance | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim precedence - first successful reclaim wins › should drop rewards when both fail and neither address configured | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimPrecedenceFirstSuccessfulReclaimWins_DropsWhenNeitherAddressConfigured | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`) | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim precedence - first successful reclaim wins › should drop rewards when subgraph denied without address even if indexer eligible | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimPrecedenceFirstSuccessfulReclaimWins_DropsWhenSubgraphDeniedWithoutAddressEvenIfEligible | Fixture `oracle` (default `true`, as the old deploy) | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaimRewards - force close allocation › should reclaim rewards when reclaim address is set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimRewardsForceCloseAllocation_ReclaimsWhenReclaimAddressSet | `issuer.reclaim` replaces `MockSubgraphService.callReclaimRewards`; the `beforeEach` deploying it is dropped and the legacy Staking allocation is replaced by `_signal` (RM reads allocations only from the subgraph service). Old `gt(0)` kept, plus exact amount, event args and return value | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaimRewards - force close allocation › should not reclaim when reclaim address is not set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimRewardsForceCloseAllocation_NoReclaimWhenReclaimAddressNotSet | As above; also asserts the return value 0 | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaimRewards - force close allocation › should return 0 and not emit when reclaim address is not set and no rewards | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimRewardsForceCloseAllocation_ReturnsZeroWhenNoAddressAndNoRewards | `callStatic` + tx → one call checking return value and logs | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaimRewards - force close allocation › should return 0 when reason is NONE | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimRewardsForceCloseAllocation_ReturnsZeroWhenReasonIsNone | Old assertion was vacuous (no reclaim address, so 0 regardless of reason); a default reclaim address is set so only the NONE guard yields 0, and the wallet balance is checked | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaimRewards - force close allocation › should reject when called by unauthorized address | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimRewardsForceCloseAllocation_RevertWhen_CalledByUnauthorizedAddress | Raw calldata → `vm.prank(indexer)` + `rm.reclaimRewards` | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setDefaultReclaimAddress › should reject if not governor | test/unit/rewards-manager/reclaim.t.sol::test_SetDefaultReclaimAddress_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setDefaultReclaimAddress › should set default reclaim address if governor | test/unit/rewards-manager/reclaim.t.sol::test_SetDefaultReclaimAddress_SetsDefaultReclaimAddressAsGovernor | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setDefaultReclaimAddress › should allow setting to zero address | test/unit/rewards-manager/reclaim.t.sol::test_SetDefaultReclaimAddress_AllowsSettingToZeroAddress | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setDefaultReclaimAddress › should not emit event when setting same address | test/unit/rewards-manager/reclaim.t.sol::test_SetDefaultReclaimAddress_NoEventWhenSettingSameAddress | `not.emit` → `_assertNotEmitted` | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › default reclaim address fallback › should use default reclaim address when reason-specific not set | test/unit/rewards-manager/reclaim.t.sol::test_DefaultReclaimAddressFallback_UsesDefaultWhenReasonSpecificNotSet | `beforeEach` → modifier `withAllocationAndAvailabilityOracle`. `mine(5)` → `vm.roll`; event args and exact amount asserted, `gt` kept | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › default reclaim address fallback › should prefer reason-specific address over default | test/unit/rewards-manager/reclaim.t.sol::test_DefaultReclaimAddressFallback_PrefersReasonSpecificOverDefault | As above | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim NO_SIGNAL - zero total signal › should reclaim when no signal and NO_SIGNAL address set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimNoSignalZeroTotalSignal_ReclaimsWhenNoSignalAddressSet | Exact `5 * ISSUANCE_PER_BLOCK`; event args asserted, `gt` kept | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim NO_SIGNAL - zero total signal › should drop rewards when no signal and no reclaim address | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimNoSignalZeroTotalSignal_DropsWhenNoReclaimAddress | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim NO_SIGNAL - zero total signal › should use default reclaim address for NO_SIGNAL when specific not set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimNoSignalZeroTotalSignal_UsesDefaultWhenSpecificNotSet | As the NO_SIGNAL reclaim case | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim NO_ALLOCATED_TOKENS - signal but no allocations › should reclaim when signal exists but no allocations and NO_ALLOCATED_TOKENS address set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimNoAllocatedTokensSignalButNoAllocations_ReclaimsWhenNoAllocatedTokensAddressSet | Exact subgraph rewards; event args asserted, `gt` kept | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim NO_ALLOCATED_TOKENS - signal but no allocations › should drop rewards when no allocations and no reclaim address | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimNoAllocatedTokensSignalButNoAllocations_DropsWhenNoReclaimAddress | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim BELOW_MINIMUM_SIGNAL › should reclaim when signal below minimum and BELOW_MINIMUM_SIGNAL address set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimBelowMinimumSignal_ReclaimsWhenBelowMinimumAddressSet | `beforeEach` → modifier `withMinimumSignal`. Exact amount; event args asserted, `gt` kept | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim BELOW_MINIMUM_SIGNAL › should not reclaim when signal at or above minimum | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimBelowMinimumSignal_NoReclaimWhenSignalAtOrAboveMinimum | `staking.allocateFrom` → `_allocate` one block after the signal | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim BELOW_MINIMUM_SIGNAL › should drop rewards when below minimum and no reclaim address | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimBelowMinimumSignal_DropsWhenNoReclaimAddress | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim BELOW_MINIMUM_SIGNAL › should use BELOW_MINIMUM_SIGNAL when denied but SUBGRAPH_DENIED address not configured | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimBelowMinimumSignal_UsesBelowMinimumWhenDeniedWithoutSubgraphDeniedAddress | Event reason BELOW_MINIMUM_SIGNAL asserted (old: event only); exact amount | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › dual denial - SUBGRAPH_DENIED takes precedence when configured › should reclaim to SUBGRAPH_DENIED when both conditions true and SUBGRAPH_DENIED address configured (pre-denial allocation) | test/unit/rewards-manager/reclaim.t.sol::test_DualDenialSubgraphDeniedTakesPrecedenceWhenConfigured_ReclaimsToSubgraphDeniedForPreDenialAllocation | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). `vm.roll(+1)` before close; events asserted with args (reason SUBGRAPH_DENIED); exact balance = allocation rewards + the subgraph-level block, `gt` kept | + +## Coverage + +`contracts/rewards/RewardsManager.sol`: every external/public name in `functionsHit` is now called by an issuance test (`setReclaimAddress`, `setDefaultReclaimAddress` and `getDefaultReclaimAddress`, left open by T5c, are covered here). No `MISSING` names. From 168bc53494c54fe81b85269d5baa4cf3da94f4ac Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 26 Oct 2026 17:43:56 -0300 Subject: [PATCH 18/20] test(issuance): port RewardsManager accounting and snapshot inversion tests to Foundry --- .../rewards-signal-allocation-update.test.ts | 564 ------------------ .../rewards-snapshot-inversion.test.ts | 444 -------------- .../test/unit/rewards-manager/reclaim.t.sol | 5 - .../test/unit/rewards-manager/shared.t.sol | 15 + .../signalAllocationUpdate.t.sol | 303 ++++++++++ .../rewards-manager/snapshotInversion.t.sol | 302 ++++++++++ .../contracts-migration/test-mapping/t5e.md | 31 + 7 files changed, 651 insertions(+), 1013 deletions(-) delete mode 100644 packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts delete mode 100644 packages/contracts-test/tests/unit/rewards/rewards-snapshot-inversion.test.ts create mode 100644 packages/issuance/test/unit/rewards-manager/signalAllocationUpdate.t.sol create mode 100644 packages/issuance/test/unit/rewards-manager/snapshotInversion.t.sol create mode 100644 scripts/contracts-migration/test-mapping/t5e.md diff --git a/packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts deleted file mode 100644 index 930c2b21c..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts +++ /dev/null @@ -1,564 +0,0 @@ -import { Curation } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { IStaking } from '@graphprotocol/contracts' -import { RewardsManager } from '@graphprotocol/contracts' -import { deriveChannelKey, GraphNetworkContracts, helpers, randomHexBytes, toBN, toGRT } from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { constants } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const { HashZero } = constants - -/** - * Invariant: signal/allocation update accounting. - * - * When `onSubgraphSignalUpdate()` runs before `onSubgraphAllocationUpdate()` in the SAME BLOCK, - * the per-signal delta is zero. Rewards already tracked in `accRewardsForSubgraph` must still be - * distributed to allocations via the snapshot delta - * (`accRewardsForSubgraph - accRewardsForSubgraphSnapshot`), rather than relying on the per-signal - * delta alone. Distribution must never depend on the ordering of these two calls within a block. - * - * IMPORTANT: These tests use evm_setAutomine to batch transactions into one block so the - * per-signal delta is zero, exercising the snapshot-delta path. - */ -describe('Rewards: Signal and Allocation Update Accounting', () => { - const graph = hre.graph() - let governor: SignerWithAddress - let curator: SignerWithAddress - let indexer: SignerWithAddress - - let fixture: NetworkFixture - let contracts: GraphNetworkContracts - let grt: GraphToken - let curation: Curation - let staking: IStaking - let rewardsManager: RewardsManager - - const channelKey = deriveChannelKey() - const subgraphDeploymentID = randomHexBytes() - const allocationID = channelKey.address - const metadata = HashZero - - const ISSUANCE_PER_BLOCK = toBN('200000000000000000000') // 200 GRT every block - const tokensToSignal = toGRT('1000') - const tokensToStake = toGRT('100000') - const tokensToAllocate = toGRT('10000') - - before(async function () { - ;[curator, indexer] = await graph.getTestAccounts() - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - grt = contracts.GraphToken as GraphToken - curation = contracts.Curation as Curation - staking = contracts.Staking as IStaking - rewardsManager = contracts.RewardsManager as RewardsManager - - // Set the staking contract as the subgraph service so it can call takeRewards - await rewardsManager.connect(governor).setSubgraphService(staking.address) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - async function setupSubgraphWithAllocation() { - // Setup: curator signals on subgraph - await grt.connect(governor).mint(curator.address, tokensToSignal) - await grt.connect(curator).approve(curation.address, tokensToSignal) - await curation.connect(curator).mint(subgraphDeploymentID, tokensToSignal, 0) - - // Setup: indexer stakes and allocates - await grt.connect(governor).mint(indexer.address, tokensToStake) - await grt.connect(indexer).approve(staking.address, tokensToStake) - await staking.connect(indexer).stake(tokensToStake) - await staking - .connect(indexer) - .allocateFrom( - indexer.address, - subgraphDeploymentID, - tokensToAllocate, - allocationID, - metadata, - await channelKey.generateProof(indexer.address), - ) - } - - describe('onSubgraphSignalUpdate followed by onSubgraphAllocationUpdate', function () { - it('should properly distribute rewards when signal update precedes allocation update (same block)', async function () { - await setupSubgraphWithAllocation() - - // Advance blocks to accumulate rewards - await helpers.mine(100) - - // Get expected rewards before any updates - const expectedRewardsForSubgraph = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID) - expect(expectedRewardsForSubgraph).to.be.gt(0, 'Should have accumulated rewards') - - // Get initial state - const subgraphBefore = await rewardsManager.subgraphs(subgraphDeploymentID) - const accRewardsPerAllocatedTokenBefore = subgraphBefore.accRewardsPerAllocatedToken - - // Disable automine to batch transactions into the same block - await hre.network.provider.send('evm_setAutomine', [false]) - - try { - // First: call onSubgraphSignalUpdate (this zeros the per-signal delta) - // This simulates what happens when a curator mints/burns signal - const signalTx = await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID) - - // Second: call onSubgraphAllocationUpdate (in same block, per-signal delta is 0) - // This simulates what happens when an allocation is opened/closed - const allocTx = await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - - // Mine both transactions in the same block - await hre.network.provider.send('evm_mine') - - // Wait for both transactions to be mined - await signalTx.wait() - await allocTx.wait() - } finally { - // Re-enable automine - await hre.network.provider.send('evm_setAutomine', [true]) - } - - // Verify rewards were tracked at subgraph level - const subgraphAfterSignal = await rewardsManager.subgraphs(subgraphDeploymentID) - expect(subgraphAfterSignal.accRewardsForSubgraph).to.be.gt( - 0, - 'accRewardsForSubgraph should be updated after signal update', - ) - - // Get final state - const subgraphAfterAllocation = await rewardsManager.subgraphs(subgraphDeploymentID) - - // accRewardsPerAllocatedToken must advance via the snapshot delta even when the per-signal - // delta is zero, so accumulated rewards reach allocations regardless of update ordering. - expect(subgraphAfterAllocation.accRewardsPerAllocatedToken).to.be.gt( - accRewardsPerAllocatedTokenBefore, - 'accRewardsPerAllocatedToken should increase when a signal update precedes an allocation update in the same block', - ) - - // Verify snapshot consistency - expect(subgraphAfterAllocation.accRewardsForSubgraphSnapshot).to.equal( - subgraphAfterAllocation.accRewardsForSubgraph, - 'Snapshots should be in sync after updates', - ) - }) - - it('should not brick rewards when signal update zeros the per-signal delta (same block)', async function () { - await setupSubgraphWithAllocation() - - // Advance blocks - await helpers.mine(100) - - // Get the view function result (what rewards SHOULD be) before any updates - // Note: We call this to ensure the function works, but we verify via stored state below - await rewardsManager.getAccRewardsPerAllocatedToken(subgraphDeploymentID) - - // Disable automine to batch transactions into the same block - await hre.network.provider.send('evm_setAutomine', [false]) - - try { - // Call signal update first (zeros per-signal delta and accumulates rewards in accRewardsForSubgraph) - const signalTx = await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID) - - // Call allocation update (per-signal delta is now 0, but rewards are in accRewardsForSubgraph) - const allocTx = await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - - // Mine both transactions in the same block - await hre.network.provider.send('evm_mine') - - // Wait for both transactions to be mined - await signalTx.wait() - await allocTx.wait() - } finally { - // Re-enable automine - await hre.network.provider.send('evm_setAutomine', [true]) - } - - // Get the rewards accumulated in accRewardsForSubgraph - const afterSignal = await rewardsManager.subgraphs(subgraphDeploymentID) - const rewardsAccumulated = afterSignal.accRewardsForSubgraph - - // These rewards should eventually be distributed to allocations - expect(rewardsAccumulated).to.be.gt(0, 'Rewards should be accumulated at subgraph level') - - // Get stored state - const subgraph = await rewardsManager.subgraphs(subgraphDeploymentID) - - // Even when the per-signal delta is zero, accRewardsPerAllocatedToken must reflect the - // rewards accumulated in accRewardsForSubgraph via the snapshot delta, so they are - // distributed to allocations rather than stranded. - expect(subgraph.accRewardsPerAllocatedToken).to.be.gt( - 0, - 'accRewardsPerAllocatedToken should be non-zero so accumulated rewards are distributed, not stranded', - ) - - // Verify view function and stored state are consistent - const [viewAccRewardsPerAllocatedToken] = - await rewardsManager.getAccRewardsPerAllocatedToken(subgraphDeploymentID) - - // The view should equal the stored value (since snapshots are synced) - expect(viewAccRewardsPerAllocatedToken).to.equal( - subgraph.accRewardsPerAllocatedToken, - 'View function should match stored state after updates', - ) - }) - - it('should handle multiple signal updates without losing rewards (same block allocation)', async function () { - await setupSubgraphWithAllocation() - - // Advance blocks - await helpers.mine(50) - - // First signal update - await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID) - const afterFirstSignal = await rewardsManager.subgraphs(subgraphDeploymentID) - - // Advance more blocks - await helpers.mine(50) - - // Disable automine to batch signal + allocation into same block - await hre.network.provider.send('evm_setAutomine', [false]) - - try { - // Second signal update (without allocation update in between) - const signalTx = await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID) - - // Allocation update in the same block (per-signal delta is 0) - const allocTx = await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - - // Mine both in the same block - await hre.network.provider.send('evm_mine') - - await signalTx.wait() - await allocTx.wait() - } finally { - await hre.network.provider.send('evm_setAutomine', [true]) - } - - const afterSecondSignal = await rewardsManager.subgraphs(subgraphDeploymentID) - - // Rewards should have accumulated - expect(afterSecondSignal.accRewardsForSubgraph).to.be.gt( - afterFirstSignal.accRewardsForSubgraph, - 'Rewards should accumulate across signal updates', - ) - - const afterAllocation = await rewardsManager.subgraphs(subgraphDeploymentID) - - // All accumulated rewards should be distributed - expect(afterAllocation.accRewardsPerAllocatedToken).to.be.gt( - 0, - 'Rewards from multiple signal updates should be distributed', - ) - - // Snapshots should be in sync - expect(afterAllocation.accRewardsForSubgraphSnapshot).to.equal( - afterAllocation.accRewardsForSubgraph, - 'Snapshots should be in sync', - ) - }) - }) - - describe('snapshot consistency in reclaim paths', function () { - it('should update accRewardsForSubgraphSnapshot when rewards are reclaimed due to denial', async function () { - await setupSubgraphWithAllocation() - - // Deny the subgraph - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID, true) - - // Advance blocks to accumulate rewards - await helpers.mine(100) - - // Get state before - const subgraphBefore = await rewardsManager.subgraphs(subgraphDeploymentID) - - // Call allocation update - should reclaim (not distribute) rewards - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - - // Get state after - const subgraphAfter = await rewardsManager.subgraphs(subgraphDeploymentID) - - // accRewardsPerAllocatedToken should NOT increase (rewards reclaimed, not distributed) - expect(subgraphAfter.accRewardsPerAllocatedToken).to.equal( - subgraphBefore.accRewardsPerAllocatedToken, - 'accRewardsPerAllocatedToken should not increase when denied', - ) - - // accRewardsForSubgraphSnapshot must advance in the reclaim path so the same rewards - // cannot be reclaimed again on a later update. - expect(subgraphAfter.accRewardsForSubgraphSnapshot).to.be.gte( - subgraphBefore.accRewardsForSubgraphSnapshot, - 'accRewardsForSubgraphSnapshot should be updated in reclaim path', - ) - }) - - it('should not double-reclaim rewards after snapshot update', async function () { - await setupSubgraphWithAllocation() - - // Deny the subgraph - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID, true) - - // Advance blocks - await helpers.mine(100) - - // First allocation update - reclaims rewards - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - const afterFirstReclaim = await rewardsManager.subgraphs(subgraphDeploymentID) - - // Second allocation update - each tx advances a block, so there's 1 more block of rewards - // The key invariant is that rewards are properly accounted for, not double-reclaimed - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - const afterSecondReclaim = await rewardsManager.subgraphs(subgraphDeploymentID) - - // The snapshot should have advanced by at most 1 block's worth of rewards - // (Each transaction creates a new block in Hardhat) - const maxOneBlockReward = ISSUANCE_PER_BLOCK.mul(tokensToSignal).div(await grt.balanceOf(curation.address)) - - const snapshotDiff = afterSecondReclaim.accRewardsForSubgraphSnapshot.sub( - afterFirstReclaim.accRewardsForSubgraphSnapshot, - ) - - // The difference should be at most one block's worth of rewards - expect(snapshotDiff).to.be.lte( - maxOneBlockReward.mul(2), // Allow for rounding and timing - 'Should only process one block worth of new rewards', - ) - - // Verify accRewardsPerAllocatedToken didn't increase (rewards still reclaimed, not distributed) - expect(afterSecondReclaim.accRewardsPerAllocatedToken).to.equal( - afterFirstReclaim.accRewardsPerAllocatedToken, - 'accRewardsPerAllocatedToken should not change during reclaim', - ) - }) - }) - - describe('onSubgraphSignalUpdate on denied subgraph', function () { - it('should reclaim rewards when onSubgraphSignalUpdate is called on denied subgraph', async function () { - await setupSubgraphWithAllocation() - - // Configure reclaim address for SUBGRAPH_DENIED - const SUBGRAPH_DENIED = hre.ethers.utils.id('SUBGRAPH_DENIED') - await rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, governor.address) - - // Verify reclaim address was set - const reclaimAddr = await rewardsManager.getReclaimAddress(SUBGRAPH_DENIED) - expect(reclaimAddr).to.equal(governor.address, 'Reclaim address should be set') - - // Deny the subgraph - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID, true) - - // Record state after denial (setDenied calls onSubgraphAllocationUpdate internally) - const afterDenial = await rewardsManager.subgraphs(subgraphDeploymentID) - - // Advance blocks - rewards should accumulate - await helpers.mine(100) - - // Call onSubgraphSignalUpdate (simulates curator action) - // For a denied subgraph, rewards are reclaimed immediately rather than accumulated. - const tx = await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID) - const receipt = await tx.wait() - const afterSignalUpdate = await rewardsManager.subgraphs(subgraphDeploymentID) - - // For a denied subgraph, accRewardsForSubgraph must NOT change - // (rewards are reclaimed directly, not stored) - expect(afterSignalUpdate.accRewardsForSubgraph).to.equal( - afterDenial.accRewardsForSubgraph, - 'accRewardsForSubgraph should not change for denied subgraphs (rewards reclaimed)', - ) - - // Verify reclaim event was emitted - const reclaimEvent = receipt.events?.find((e) => e.event === 'RewardsReclaimed') - expect(reclaimEvent).to.not.be.undefined - // Event args: (reason, rewards, indexer, allocationId, subgraphDeploymentId) - const rewards = reclaimEvent!.args![1] // rewards is second arg - expect(rewards).to.be.gt(0, 'Should have reclaimed rewards') - }) - - it('should accumulate rewards for claimable subgraphs in onSubgraphSignalUpdate', async function () { - await setupSubgraphWithAllocation() - - // Record initial state (subgraph is claimable by default) - const initialState = await rewardsManager.subgraphs(subgraphDeploymentID) - - // Advance blocks - rewards should accumulate - await helpers.mine(100) - - // Call onSubgraphSignalUpdate - await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID) - const afterSignalUpdate = await rewardsManager.subgraphs(subgraphDeploymentID) - - // For claimable subgraphs: accRewardsForSubgraph SHOULD increase - expect(afterSignalUpdate.accRewardsForSubgraph).to.be.gt( - initialState.accRewardsForSubgraph, - 'accRewardsForSubgraph should increase for claimable subgraphs', - ) - }) - - it('view function getAccRewardsForSubgraph should not jump during denial', async function () { - await setupSubgraphWithAllocation() - - // Accumulate some rewards while claimable - await helpers.mine(50) - - // Deny the subgraph (setDenied distributes pre-denial rewards via onSubgraphAllocationUpdate) - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID, true) - - // Record view value immediately after denial - const rewardsAtDenial = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID) - expect(rewardsAtDenial).to.be.gt(0, 'Should have accumulated pre-denial rewards') - - // Advance blocks during denial - await helpers.mine(100) - - // View function should return SAME value (no jump up during denial) - const rewardsDuringDenial = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID) - expect(rewardsDuringDenial).to.equal(rewardsAtDenial, 'View should not increase during denial') - - // A signal update on a denied subgraph reclaims the accumulated rewards, so the view stays - // stable and does not jump on the next allocation update. - // Configure reclaim address so rewards are reclaimed - const SUBGRAPH_DENIED = hre.ethers.utils.id('SUBGRAPH_DENIED') - await rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, governor.address) - await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID) - - // View function should STILL return same value (rewards reclaimed, not accumulated) - const rewardsAfterSignalUpdate = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID) - expect(rewardsAfterSignalUpdate).to.equal(rewardsAtDenial, 'View should not jump after signal update') - - // Mine more blocks - await helpers.mine(50) - - // Call allocation update - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - - // View should STILL be stable (rewards reclaimed, not accumulated) - const rewardsAfterAllocationUpdate = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID) - expect(rewardsAfterAllocationUpdate).to.equal(rewardsAtDenial, 'View should not jump after allocation update') - }) - }) - - describe('onSubgraphSignalUpdate with no allocations', function () { - it('should reclaim as NO_ALLOCATED_TOKENS when signal exists but no allocations', async function () { - // Setup: only signal, no allocation - await grt.connect(governor).mint(curator.address, tokensToSignal) - await grt.connect(curator).approve(curation.address, tokensToSignal) - await curation.connect(curator).mint(subgraphDeploymentID, tokensToSignal, 0) - - // Configure reclaim address for NO_ALLOCATED_TOKENS - const NO_ALLOCATED_TOKENS = hre.ethers.utils.id('NO_ALLOCATED_TOKENS') - await rewardsManager.connect(governor).setReclaimAddress(NO_ALLOCATED_TOKENS, governor.address) - - // Record initial state - const initialState = await rewardsManager.subgraphs(subgraphDeploymentID) - - // Advance blocks - rewards should accumulate - await helpers.mine(100) - - // Call onSubgraphSignalUpdate - should reclaim as NO_ALLOCATED_TOKENS - const tx = await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID) - const receipt = await tx.wait() - const afterSignalUpdate = await rewardsManager.subgraphs(subgraphDeploymentID) - - // accRewardsForSubgraph should NOT change (rewards reclaimed, not accumulated) - expect(afterSignalUpdate.accRewardsForSubgraph).to.equal( - initialState.accRewardsForSubgraph, - 'accRewardsForSubgraph should not change when no allocations (rewards reclaimed)', - ) - - // Verify reclaim event was emitted with NO_ALLOCATED_TOKENS reason - const reclaimEvent = receipt.events?.find((e) => e.event === 'RewardsReclaimed') - expect(reclaimEvent).to.not.be.undefined - expect(reclaimEvent!.args![0]).to.equal(NO_ALLOCATED_TOKENS, 'Should reclaim with NO_ALLOCATED_TOKENS reason') - expect(reclaimEvent!.args![1]).to.be.gt(0, 'Should have reclaimed rewards') - }) - - it('view function should not show phantom rewards when no allocations', async function () { - // Setup: only signal, no allocation - await grt.connect(governor).mint(curator.address, tokensToSignal) - await grt.connect(curator).approve(curation.address, tokensToSignal) - await curation.connect(curator).mint(subgraphDeploymentID, tokensToSignal, 0) - - // Record view immediately after signal - const viewAfterSignal = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID) - - // Advance blocks - await helpers.mine(100) - - // Configure reclaim and call signal update - const NO_ALLOCATED_TOKENS = hre.ethers.utils.id('NO_ALLOCATED_TOKENS') - await rewardsManager.connect(governor).setReclaimAddress(NO_ALLOCATED_TOKENS, governor.address) - await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID) - - // View should remain stable (rewards reclaimed) - const viewAfterReclaim = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID) - expect(viewAfterReclaim).to.equal(viewAfterSignal, 'View should not grow when no allocations') - }) - }) - - describe('invariant: no rewards lost or double-counted', function () { - it('should maintain accounting invariant across mixed updates (with same-block scenarios)', async function () { - await setupSubgraphWithAllocation() - - // Sequence exercising the same-block signal/allocation ordering - await helpers.mine(25) - - // First: signal update followed by allocation update in SAME BLOCK - await hre.network.provider.send('evm_setAutomine', [false]) - try { - const signalTx1 = await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID) - const allocTx1 = await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - await hre.network.provider.send('evm_mine') - await signalTx1.wait() - await allocTx1.wait() - } finally { - await hre.network.provider.send('evm_setAutomine', [true]) - } - - await helpers.mine(25) - - // Second: double signal update followed by allocation update in SAME BLOCK - await hre.network.provider.send('evm_setAutomine', [false]) - try { - const signalTx2 = await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID) - const signalTx3 = await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID) - const allocTx2 = await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - await hre.network.provider.send('evm_mine') - await signalTx2.wait() - await signalTx3.wait() - await allocTx2.wait() - } finally { - await hre.network.provider.send('evm_setAutomine', [true]) - } - - // Final state check - const finalSubgraph = await rewardsManager.subgraphs(subgraphDeploymentID) - - // Key invariant: snapshots should be in sync - expect(finalSubgraph.accRewardsForSubgraphSnapshot).to.equal( - finalSubgraph.accRewardsForSubgraph, - 'INVARIANT VIOLATED: accRewardsForSubgraphSnapshot != accRewardsForSubgraph', - ) - - // Rewards should have been distributed - expect(finalSubgraph.accRewardsPerAllocatedToken).to.be.gt( - 0, - 'Rewards should have been distributed to allocations', - ) - }) - }) -}) diff --git a/packages/contracts-test/tests/unit/rewards/rewards-snapshot-inversion.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-snapshot-inversion.test.ts deleted file mode 100644 index da09d182e..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards-snapshot-inversion.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { Curation } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { IStaking } from '@graphprotocol/contracts' -import { RewardsManager } from '@graphprotocol/contracts' -import { deriveChannelKey, GraphNetworkContracts, helpers, randomHexBytes, toGRT } from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber, constants, utils } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const { HashZero } = constants - -/** - * Tests for snapshot inversion on upgrade. - * - * Terminology: - * A = accRewardsForSubgraph (stored accumulator, set at signal updates) - * S = accRewardsForSubgraphSnapshot (stored snapshot, set at allocation updates) - * P = rewardsSinceSignalSnapshot (pending rewards since last signal snapshot) - * - * For affected subgraphs, on-chain storage can hold an inverted snapshot state - * where A < S: the snapshot S leads (it was written from a view value of - * storage + pending) while the accumulator A lags at its stored value. - * - * Invariant the reward math must uphold: with an inverted state (A < S), the - * computation of A + P - S must never underflow. It does so by adding pending - * first and subtracting the snapshot last — `A.add(P).sub(S)` — so the - * intermediate `A + P` stays non-negative. Since P covers T1→now and the gap - * S - A covers T1→T2, and now >= T2, we have S - A <= P, so S <= A + P always - * holds; no clamping is needed. Subtracting the gap S - A discards rewards - * already distributed, preventing double-counting. - * - * These tests use `hardhat_setStorageAt` to construct the inverted storage state - * directly so the invariant can be exercised for affected subgraphs. - */ -describe('Rewards: Snapshot Inversion', () => { - const graph = hre.graph() - let governor: SignerWithAddress - let curator: SignerWithAddress - let indexer: SignerWithAddress - - let fixture: NetworkFixture - let contracts: GraphNetworkContracts - let grt: GraphToken - let curation: Curation - let staking: IStaking - let rewardsManager: RewardsManager - - const channelKey = deriveChannelKey() - const subgraphDeploymentID = randomHexBytes() - const allocationID = channelKey.address - const metadata = HashZero - - const tokensToSignal = toGRT('1000') - const tokensToStake = toGRT('100000') - const tokensToAllocate = toGRT('10000') - - // Storage slot for the `subgraphs` mapping in RewardsManagerV1Storage. - // Computed by counting all inherited storage variables: - // Managed: controller(0), _addressCache(1), __gap[10](2-11) = 12 slots - // V1Storage: __DEPRECATED_issuanceRate(12), accRewardsPerSignal(13), - // accRewardsPerSignalLastBlockUpdated(14), subgraphAvailabilityOracle(15), - // subgraphs(16) - const SUBGRAPHS_MAPPING_SLOT = 16 - - /** - * Compute the storage slot for a field within a Subgraph struct in the subgraphs mapping. - * - * For `mapping(bytes32 => Subgraph)` at slot S, key K: - * base = keccak256(abi.encode(K, S)) - * field 0 (accRewardsForSubgraph) = base + 0 - * field 1 (accRewardsForSubgraphSnapshot) = base + 1 - * field 2 (accRewardsPerSignalSnapshot) = base + 2 - * field 3 (accRewardsPerAllocatedToken) = base + 3 - */ - function subgraphStorageSlot(subgraphId: string, fieldOffset: number): string { - const baseSlot = utils.keccak256( - utils.defaultAbiCoder.encode(['bytes32', 'uint256'], [subgraphId, SUBGRAPHS_MAPPING_SLOT]), - ) - return utils.hexZeroPad(BigNumber.from(baseSlot).add(fieldOffset).toHexString(), 32) - } - - /** - * Set a uint256 value at a specific storage slot of the RewardsManager proxy. - */ - async function setStorage(slot: string, value: BigNumber): Promise { - await hre.network.provider.send('hardhat_setStorageAt', [ - rewardsManager.address, - slot, - utils.hexZeroPad(value.toHexString(), 32), - ]) - } - - /** - * Create the inverted snapshot state that exists on-chain for affected subgraphs. - * - * Sets: accRewardsForSubgraphSnapshot = accRewardsForSubgraph + gap - * This is the state left by the old `onSubgraphAllocationUpdate` which wrote - * the snapshot from a view function (storage + pending), while leaving - * accRewardsForSubgraph at its stored value. - */ - async function createInvertedState(subgraphId: string, gap: BigNumber): Promise { - const subgraph = await rewardsManager.subgraphs(subgraphId) - const currentAccRewards = subgraph.accRewardsForSubgraph - const invertedSnapshot = currentAccRewards.add(gap) - - // Write accRewardsForSubgraphSnapshot = currentAccRewards + gap (field offset 1) - const snapshotSlot = subgraphStorageSlot(subgraphId, 1) - await setStorage(snapshotSlot, invertedSnapshot) - - // Verify the inversion was written correctly - const after = await rewardsManager.subgraphs(subgraphId) - expect(after.accRewardsForSubgraphSnapshot).to.equal(invertedSnapshot) - expect(after.accRewardsForSubgraph).to.be.lt(after.accRewardsForSubgraphSnapshot) - } - - before(async function () { - ;[curator, indexer] = await graph.getTestAccounts() - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - grt = contracts.GraphToken as GraphToken - curation = contracts.Curation as Curation - staking = contracts.Staking as IStaking - rewardsManager = contracts.RewardsManager as RewardsManager - - // Set the staking contract as the subgraph service so RewardsManager - // can see allocations via _getSubgraphAllocatedTokens() - await rewardsManager.connect(governor).setSubgraphService(staking.address) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - async function setupSubgraphWithAllocation() { - // Set issuance rate (200 GRT/block) — the fixture defaults to 0 - await rewardsManager.connect(governor).setIssuancePerBlock(toGRT('200')) - - // Curator signals on subgraph - await grt.connect(governor).mint(curator.address, tokensToSignal) - await grt.connect(curator).approve(curation.address, tokensToSignal) - await curation.connect(curator).mint(subgraphDeploymentID, tokensToSignal, 0) - - // Indexer stakes and allocates - await grt.connect(governor).mint(indexer.address, tokensToStake) - await grt.connect(indexer).approve(staking.address, tokensToStake) - await staking.connect(indexer).stake(tokensToStake) - await staking - .connect(indexer) - .allocateFrom( - indexer.address, - subgraphDeploymentID, - tokensToAllocate, - allocationID, - metadata, - await channelKey.generateProof(indexer.address), - ) - - // Accumulate some rewards - await helpers.mine(50) - - // Sync subgraph state so we have non-zero accRewardsForSubgraph - await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID) - } - - describe('storage slot verification', function () { - it('should correctly compute and write to subgraph storage slots', async function () { - await setupSubgraphWithAllocation() - - // Read current state - const before = await rewardsManager.subgraphs(subgraphDeploymentID) - expect(before.accRewardsForSubgraph).to.not.equal(0, 'precondition: should have accumulated rewards') - - // Write a known value to accRewardsForSubgraphSnapshot (field 1) - const testValue = BigNumber.from('12345678901234567890') - const snapshotSlot = subgraphStorageSlot(subgraphDeploymentID, 1) - await setStorage(snapshotSlot, testValue) - - // Read back and verify - const after = await rewardsManager.subgraphs(subgraphDeploymentID) - expect(after.accRewardsForSubgraphSnapshot).to.equal(testValue) - // Other fields should be unchanged - expect(after.accRewardsForSubgraph).to.equal(before.accRewardsForSubgraph) - expect(after.accRewardsPerSignalSnapshot).to.equal(before.accRewardsPerSignalSnapshot) - expect(after.accRewardsPerAllocatedToken).to.equal(before.accRewardsPerAllocatedToken) - }) - }) - - describe('inverted state: accumulated < snapshot', function () { - it('should not revert on onSubgraphSignalUpdate with inverted state', async function () { - await setupSubgraphWithAllocation() - - // Create the pre-upgrade inverted state (snapshot > accumulated by ~7000 GRT) - const gap = toGRT('7000') - await createInvertedState(subgraphDeploymentID, gap) - - // Advance enough blocks so P > gap. At ~200 GRT/block, 50 blocks ≈ 10,000 GRT > 7,000. - await helpers.mine(50) - - // With an inverted state (A < S), the update must not revert: pending P is - // added before the snapshot S is subtracted (A.add(P).sub(S)), so the - // intermediate A + P stays non-negative and A + P >= S always holds. - await expect(rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID)).to.not.be.reverted - }) - - it('should not revert on onSubgraphAllocationUpdate with inverted state', async function () { - await setupSubgraphWithAllocation() - - const gap = toGRT('7000') - await createInvertedState(subgraphDeploymentID, gap) - - await helpers.mine(50) - - await expect(rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID)).to.not.be.reverted - }) - - it('should sync snapshots after first successful call', async function () { - await setupSubgraphWithAllocation() - - const gap = toGRT('7000') - await createInvertedState(subgraphDeploymentID, gap) - - await helpers.mine(50) - - // First call with inverted state - await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID) - - // After processing the inverted state, snapshots should be synced - const after = await rewardsManager.subgraphs(subgraphDeploymentID) - expect(after.accRewardsForSubgraphSnapshot).to.equal( - after.accRewardsForSubgraph, - 'snapshot should equal accumulated after processing inverted state', - ) - - // Subsequent calls should work normally - await helpers.mine(10) - await expect(rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID)).to.not.be.reverted - - const afterSecond = await rewardsManager.subgraphs(subgraphDeploymentID) - expect(afterSecond.accRewardsForSubgraphSnapshot).to.equal(afterSecond.accRewardsForSubgraph) - }) - }) - - describe('accounting correctness with inverted state', function () { - it('should correctly compute undistributed rewards: (A+P).sub(S)', async function () { - await setupSubgraphWithAllocation() - - // Record state before inversion - const before = await rewardsManager.subgraphs(subgraphDeploymentID) - const perAllocBefore = before.accRewardsPerAllocatedToken - - // Create inversion with a small gap (smaller than rewards that will accrue) - const gap = toGRT('500') - await createInvertedState(subgraphDeploymentID, gap) - - // Advance enough blocks that S < A + P (i.e., new rewards exceed the gap) - // With 200 GRT/block and only one subgraph signalled, each block adds ~200 GRT of P - // 10 blocks ≈ 2000 GRT of P, gap = 500 GRT - // So (A + P) - S = A + 2000 - (A + 500) = 1500 GRT undistributed - await helpers.mine(10) - - // Call allocation update to distribute rewards - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - - const after = await rewardsManager.subgraphs(subgraphDeploymentID) - - // accRewardsPerAllocatedToken should increase (rewards were distributed) - expect(perAllocBefore).to.be.lt(after.accRewardsPerAllocatedToken, 'should distribute rewards: 0 < (A + P) - S') - - // The distributed amount should be less than total new rewards (P) - // because the gap represents already-distributed rewards - // Undistributed = (A + P) - S = P - gap (since S = A + gap) - // If P ≈ 2000 GRT and gap = 500 GRT, undistributed ≈ 1500 GRT - // Subtracting the gap is what keeps this from being P ≈ 2000 GRT (double-counting) - - // Verify snapshots are synced - expect(after.accRewardsForSubgraphSnapshot).to.equal(after.accRewardsForSubgraph) - }) - - it('should not double-count: distributed rewards account for the gap', async function () { - await setupSubgraphWithAllocation() - - // Get a reference: how many rewards are distributed in normal operation - const stateBefore = await rewardsManager.subgraphs(subgraphDeploymentID) - - // Create a scenario where gap = 500 GRT - const gap = toGRT('500') - await createInvertedState(subgraphDeploymentID, gap) - - await helpers.mine(20) - - // Process the inverted state - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - const afterInverted = await rewardsManager.subgraphs(subgraphDeploymentID) - const perAllocAfterInverted = afterInverted.accRewardsPerAllocatedToken - - // Now do a SECOND allocation update with normal state (snapshots are synced) - await helpers.mine(20) - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - const afterNormal = await rewardsManager.subgraphs(subgraphDeploymentID) - - // The second update should distribute ~20 blocks worth of rewards - // The first update distributed less (because gap was subtracted) - // This proves no double-counting: the gap was properly deducted - const firstDelta = perAllocAfterInverted.sub(stateBefore.accRewardsPerAllocatedToken) - const secondDelta = afterNormal.accRewardsPerAllocatedToken.sub(perAllocAfterInverted) - - // First delta < second delta because the gap was subtracted - // (both periods have ~20 blocks, but first period deducts the 500 GRT gap) - expect(firstDelta).to.be.lt(secondDelta, 'first update should distribute less due to gap deduction') - }) - - it('should distribute exactly P - gap rewards (gap deducted from pending)', async function () { - await setupSubgraphWithAllocation() - - // Sync state so we have a clean baseline - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - const baseline = await rewardsManager.subgraphs(subgraphDeploymentID) - const perAllocBaseline = baseline.accRewardsPerAllocatedToken - - // Create inversion with a known gap - const gap = toGRT('500') - await createInvertedState(subgraphDeploymentID, gap) - - // Mine blocks, then do a normal (non-inverted) reference run in a parallel universe - // We can't do that, but we CAN check that the gap is properly deducted by - // comparing inverted vs non-inverted runs over the same block count. - - // First: process the inverted state - await helpers.mine(20) - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - const afterInverted = await rewardsManager.subgraphs(subgraphDeploymentID) - const invertedDelta = afterInverted.accRewardsPerAllocatedToken.sub(perAllocBaseline) - - // Second: run the same block count with synced state (no gap) - await helpers.mine(20) - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - const afterNormal = await rewardsManager.subgraphs(subgraphDeploymentID) - const normalDelta = afterNormal.accRewardsPerAllocatedToken.sub(afterInverted.accRewardsPerAllocatedToken) - - // The inverted run should distribute LESS because the gap was subtracted. - // Both periods have ~20 blocks of rewards, but the inverted period deducts 500 GRT. - expect(invertedDelta).to.be.lt(normalDelta, 'inverted period should distribute less due to gap deduction') - expect(invertedDelta).to.not.equal(0, 'should still distribute some rewards (gap < P)') - }) - }) - - describe('normal operation (no inversion)', function () { - it('should produce identical results when A == S (synced snapshot steady state)', async function () { - await setupSubgraphWithAllocation() - - // Ensure snapshots are synced (normal state) - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - const synced = await rewardsManager.subgraphs(subgraphDeploymentID) - expect(synced.accRewardsForSubgraphSnapshot).to.equal(synced.accRewardsForSubgraph) - - const perAllocBefore = synced.accRewardsPerAllocatedToken - - // Advance and update - this is the normal steady-state path - await helpers.mine(20) - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - - const after = await rewardsManager.subgraphs(subgraphDeploymentID) - - // Rewards should be distributed normally - expect(perAllocBefore).to.be.lt(after.accRewardsPerAllocatedToken) - expect(after.accRewardsForSubgraphSnapshot).to.equal(after.accRewardsForSubgraph) - }) - - it('should handle zero rewards gracefully (same block, no new rewards)', async function () { - await setupSubgraphWithAllocation() - - // Sync state - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - - // Call again immediately (same block via automine off) - await hre.network.provider.send('evm_setAutomine', [false]) - try { - const tx = await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID) - await hre.network.provider.send('evm_mine') - await tx.wait() - } finally { - await hre.network.provider.send('evm_setAutomine', [true]) - } - - const after = await rewardsManager.subgraphs(subgraphDeploymentID) - - // Per-alloc-token should be unchanged (zero rewards in same block) - // Note: the transaction itself mines a block, so there may be minimal reward - expect(after.accRewardsForSubgraphSnapshot).to.equal(after.accRewardsForSubgraph) - }) - }) - - describe('realistic pre-upgrade scenario', function () { - it('should handle the exact Arbitrum Sepolia state pattern', async function () { - await setupSubgraphWithAllocation() - - // Simulate: - // 1. Old onSubgraphSignalUpdate wrote accRewardsForSubgraph = X (signal-level view value) - // 2. Old onSubgraphAllocationUpdate wrote accRewardsForSubgraphSnapshot = X + delta - // (via getAccRewardsForSubgraph view which returns storage + pending) - // 3. Proxy upgrade preserves this state - // 4. New code calls _updateSubgraphRewards: A.sub(S) underflows - - // Read current A value - const state = await rewardsManager.subgraphs(subgraphDeploymentID) - const A = state.accRewardsForSubgraph - - // Set S = A + 7235 GRT (matching the ~7235 GRT gap observed on Arbitrum Sepolia) - const observedGap = toGRT('7235') - const accSlot = subgraphStorageSlot(subgraphDeploymentID, 1) - await setStorage(accSlot, A.add(observedGap)) - - // Verify the inversion - const inverted = await rewardsManager.subgraphs(subgraphDeploymentID) - expect(inverted.accRewardsForSubgraph).to.be.lt(inverted.accRewardsForSubgraphSnapshot) - - // Advance blocks (some time passes after upgrade before first interaction) - await helpers.mine(50) - - // First interaction after "upgrade": should NOT revert - await expect(rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID)).to.not.be.reverted - - // State should be healed - const healed = await rewardsManager.subgraphs(subgraphDeploymentID) - expect(healed.accRewardsForSubgraphSnapshot).to.equal(healed.accRewardsForSubgraph) - - // All subsequent operations should work - await helpers.mine(10) - await expect(rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID)).to.not.be.reverted - - await helpers.mine(10) - await expect(rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID)).to.not.be.reverted - }) - }) -}) diff --git a/packages/issuance/test/unit/rewards-manager/reclaim.t.sol b/packages/issuance/test/unit/rewards-manager/reclaim.t.sol index 1107a487a..32ed0ce78 100644 --- a/packages/issuance/test/unit/rewards-manager/reclaim.t.sol +++ b/packages/issuance/test/unit/rewards-manager/reclaim.t.sol @@ -43,11 +43,6 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { return _expectedAllocationRewards(EPOCH_LENGTH, ALLOCATION, ALLOCATION); } - /// Rewards of the subgraph (holding all the signal) over `blocks`, as reclaimed at the subgraph level. - function _subgraphRewards(uint256 blocks) internal pure returns (uint256) { - return _expectedSubgraphRewards(blocks, SIGNAL, SIGNAL); - } - function _setReclaimAddress(bytes32 reason, address target) internal { vm.prank(governor); rm.setReclaimAddress(reason, target); diff --git a/packages/issuance/test/unit/rewards-manager/shared.t.sol b/packages/issuance/test/unit/rewards-manager/shared.t.sol index 9f7057a97..e34e077f1 100644 --- a/packages/issuance/test/unit/rewards-manager/shared.t.sol +++ b/packages/issuance/test/unit/rewards-manager/shared.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.27; import { Vm } from "forge-std/Vm.sol"; +import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; import { IProviderEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibility.sol"; import { ProtocolFixture } from "../shared/ProtocolFixture.t.sol"; @@ -111,6 +112,11 @@ contract RewardsManagerSharedTest is ProtocolFixture { return (_expectedRewardsPerSignal(blocks, totalSignal) * subgraphSignal) / 1e18; } + /// Rewards of a subgraph holding all the signal (`SIGNAL`) over `blocks`. + function _subgraphRewards(uint256 blocks) internal pure returns (uint256) { + return _expectedSubgraphRewards(blocks, SIGNAL, SIGNAL); + } + function _expectedRewardsPerAllocatedToken( uint256 subgraphRewards, uint256 allocatedTokens @@ -128,6 +134,15 @@ contract RewardsManagerSharedTest is ProtocolFixture { return (_expectedRewardsPerAllocatedToken(subgraphRewards, allocatedTokens) * tokens) / 1e18; } + function _subgraph(bytes32 sd) internal view returns (IRewardsManager.Subgraph memory subgraph) { + ( + subgraph.accRewardsForSubgraph, + subgraph.accRewardsForSubgraphSnapshot, + subgraph.accRewardsPerSignalSnapshot, + subgraph.accRewardsPerAllocatedToken + ) = rm.subgraphs(sd); + } + function _assertNotEmitted(Vm.Log[] memory logs, bytes32 topic) internal view { for (uint256 i = 0; i < logs.length; ++i) { if (logs[i].emitter == address(rm)) assertTrue(logs[i].topics[0] != topic, "unexpected event"); diff --git a/packages/issuance/test/unit/rewards-manager/signalAllocationUpdate.t.sol b/packages/issuance/test/unit/rewards-manager/signalAllocationUpdate.t.sol new file mode 100644 index 000000000..b5d295086 --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/signalAllocationUpdate.t.sol @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; +import { RewardsCondition } from "@graphprotocol/interfaces/contracts/contracts/rewards/RewardsCondition.sol"; + +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +/// Signal/allocation update accounting: when `onSubgraphSignalUpdate` runs before `onSubgraphAllocationUpdate` +/// in the same block, the per-signal delta of the second call is zero, and rewards already tracked in +/// `accRewardsForSubgraph` must still reach allocations. Hardhat's batched (automine off) blocks are +/// consecutive calls with no `vm.roll` in between. +contract RewardsManagerSignalAllocationUpdateTest is RewardsManagerSharedTest { + event RewardsReclaimed( + bytes32 indexed reason, + uint256 amount, + address indexed indexer, + address indexed allocationID, + bytes32 subgraphDeploymentID + ); + + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID = keccak256("subgraphDeploymentID"); + + address internal allocationID = makeAddr("allocationID"); + address internal reclaimWallet = makeAddr("reclaimWallet"); + + // -- Helpers -- + + function _setupSubgraphWithAllocation() internal { + _signalThenAllocate(SUBGRAPH_DEPLOYMENT_ID, allocationID); + } + + function _deny() internal { + vm.startPrank(governor); + rm.setSubgraphAvailabilityOracle(governor); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID, true); + vm.stopPrank(); + } + + function _setReclaimAddress(bytes32 reason, address target) internal { + vm.prank(governor); + rm.setReclaimAddress(reason, target); + } + + function _perAllocatedToken(uint256 blocks) internal pure returns (uint256) { + return _expectedRewardsPerAllocatedToken(_subgraphRewards(blocks), ALLOCATION); + } + + // -- onSubgraphSignalUpdate followed by onSubgraphAllocationUpdate -- + + function test_OnSubgraphSignalUpdateFollowedByOnSubgraphAllocationUpdate_DistributesWhenSignalUpdatePrecedesSameBlock() + public + { + _setupSubgraphWithAllocation(); + vm.roll(block.number + 100); + + uint256 expectedRewardsForSubgraph = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID); + assertGt(expectedRewardsForSubgraph, 0); + assertEq(expectedRewardsForSubgraph, _subgraphRewards(100)); + uint256 accRewardsPerAllocatedTokenBefore = _subgraph(SUBGRAPH_DEPLOYMENT_ID).accRewardsPerAllocatedToken; + + // Same block: no vm.roll between the two hooks + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + + IRewardsManager.Subgraph memory sg = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + assertGt(sg.accRewardsForSubgraph, 0); + assertEq(sg.accRewardsForSubgraph, expectedRewardsForSubgraph); + assertGt(sg.accRewardsPerAllocatedToken, accRewardsPerAllocatedTokenBefore); + assertEq(sg.accRewardsPerAllocatedToken, accRewardsPerAllocatedTokenBefore + _perAllocatedToken(100)); + assertEq(sg.accRewardsForSubgraphSnapshot, sg.accRewardsForSubgraph); + } + + function test_OnSubgraphSignalUpdateFollowedByOnSubgraphAllocationUpdate_DoesNotBrickRewardsWhenPerSignalDeltaIsZero() + public + { + _setupSubgraphWithAllocation(); + vm.roll(block.number + 100); + + rm.getAccRewardsPerAllocatedToken(SUBGRAPH_DEPLOYMENT_ID); + + // Same block: no vm.roll between the two hooks + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + + IRewardsManager.Subgraph memory sg = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + assertGt(sg.accRewardsForSubgraph, 0); + assertEq(sg.accRewardsForSubgraph, _subgraphRewards(100)); + assertGt(sg.accRewardsPerAllocatedToken, 0); + assertEq(sg.accRewardsPerAllocatedToken, _perAllocatedToken(100)); + + (uint256 viewAccRewardsPerAllocatedToken, ) = rm.getAccRewardsPerAllocatedToken(SUBGRAPH_DEPLOYMENT_ID); + assertEq(viewAccRewardsPerAllocatedToken, sg.accRewardsPerAllocatedToken); + } + + function test_OnSubgraphSignalUpdateFollowedByOnSubgraphAllocationUpdate_MultipleSignalUpdatesDoNotLoseRewards() + public + { + _setupSubgraphWithAllocation(); + vm.roll(block.number + 50); + + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + IRewardsManager.Subgraph memory afterFirstSignal = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + assertEq(afterFirstSignal.accRewardsForSubgraph, _subgraphRewards(50)); + + vm.roll(block.number + 50); + + // Same block: no vm.roll between the two hooks + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + + IRewardsManager.Subgraph memory sg = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + assertGt(sg.accRewardsForSubgraph, afterFirstSignal.accRewardsForSubgraph); + assertEq(sg.accRewardsForSubgraph, 2 * _subgraphRewards(50)); + assertGt(sg.accRewardsPerAllocatedToken, 0); + assertEq(sg.accRewardsPerAllocatedToken, 2 * _perAllocatedToken(50)); + assertEq(sg.accRewardsForSubgraphSnapshot, sg.accRewardsForSubgraph); + } + + // -- snapshot consistency in reclaim paths -- + + function test_SnapshotConsistencyInReclaimPaths_UpdatesSnapshotWhenReclaimedDueToDenial() public { + _setupSubgraphWithAllocation(); + // Deny in the next block, as the separate Hardhat transactions: that block is distributed first + vm.roll(block.number + 1); + _deny(); + + vm.roll(block.number + 100); + + IRewardsManager.Subgraph memory before = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + IRewardsManager.Subgraph memory sg = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + + assertEq(sg.accRewardsPerAllocatedToken, before.accRewardsPerAllocatedToken); + assertGe(sg.accRewardsForSubgraphSnapshot, before.accRewardsForSubgraphSnapshot); + // The accumulator stays frozen at the pre-denial block, the snapshot stays synced with it, and the + // per-signal snapshot moves to now, so the reclaimed period cannot be processed again + assertEq(sg.accRewardsForSubgraph, _subgraphRewards(1)); + assertEq(sg.accRewardsForSubgraphSnapshot, sg.accRewardsForSubgraph); + assertEq(sg.accRewardsPerSignalSnapshot, rm.accRewardsPerSignal()); + assertGt(sg.accRewardsPerSignalSnapshot, before.accRewardsPerSignalSnapshot); + } + + function test_SnapshotConsistencyInReclaimPaths_NoDoubleReclaimAfterSnapshotUpdate() public { + _setupSubgraphWithAllocation(); + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + _deny(); + + vm.roll(block.number + 100); + + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + IRewardsManager.Subgraph memory afterFirstReclaim = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + assertEq(token.balanceOf(reclaimWallet), _subgraphRewards(100)); + + // The next Hardhat transaction: one more block + vm.roll(block.number + 1); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + IRewardsManager.Subgraph memory afterSecondReclaim = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + + uint256 maxOneBlockReward = (ISSUANCE_PER_BLOCK * SIGNAL) / token.balanceOf(address(curation)); + uint256 snapshotDiff = afterSecondReclaim.accRewardsForSubgraphSnapshot - + afterFirstReclaim.accRewardsForSubgraphSnapshot; + assertLe(snapshotDiff, 2 * maxOneBlockReward); + assertEq(snapshotDiff, 0); + // Only the new block is reclaimed the second time + assertEq(token.balanceOf(reclaimWallet), _subgraphRewards(100) + _subgraphRewards(1)); + assertEq(afterSecondReclaim.accRewardsPerAllocatedToken, afterFirstReclaim.accRewardsPerAllocatedToken); + } + + // -- onSubgraphSignalUpdate on denied subgraph -- + + function test_OnSubgraphSignalUpdateOnDeniedSubgraph_ReclaimsRewards() public { + _setupSubgraphWithAllocation(); + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + assertEq(rm.getReclaimAddress(RewardsCondition.SUBGRAPH_DENIED), reclaimWallet); + + // Deny in the next block, as the separate Hardhat transactions: that block is distributed first + vm.roll(block.number + 1); + _deny(); + IRewardsManager.Subgraph memory afterDenial = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + assertEq(afterDenial.accRewardsForSubgraph, _subgraphRewards(1)); + + vm.roll(block.number + 100); + + uint256 expectedRewards = _subgraphRewards(100); + assertGt(expectedRewards, 0); + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.SUBGRAPH_DENIED, + expectedRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID + ); + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + + assertEq(_subgraph(SUBGRAPH_DEPLOYMENT_ID).accRewardsForSubgraph, afterDenial.accRewardsForSubgraph); + assertEq(token.balanceOf(reclaimWallet), expectedRewards); + } + + function test_OnSubgraphSignalUpdateOnDeniedSubgraph_AccumulatesRewardsForClaimableSubgraph() public { + _setupSubgraphWithAllocation(); + IRewardsManager.Subgraph memory initialState = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + + vm.roll(block.number + 100); + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + + uint256 accRewardsForSubgraph = _subgraph(SUBGRAPH_DEPLOYMENT_ID).accRewardsForSubgraph; + assertGt(accRewardsForSubgraph, initialState.accRewardsForSubgraph); + assertEq(accRewardsForSubgraph, initialState.accRewardsForSubgraph + _subgraphRewards(100)); + } + + function test_OnSubgraphSignalUpdateOnDeniedSubgraph_ViewDoesNotJumpDuringDenial() public { + _setupSubgraphWithAllocation(); + vm.roll(block.number + 50); + + // setDenied distributes the pre-denial rewards through onSubgraphAllocationUpdate + _deny(); + + uint256 rewardsAtDenial = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID); + assertGt(rewardsAtDenial, 0); + assertEq(rewardsAtDenial, _subgraphRewards(50)); + + vm.roll(block.number + 100); + assertEq(rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID), rewardsAtDenial); + + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + assertEq(rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID), rewardsAtDenial); + + vm.roll(block.number + 50); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + assertEq(rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID), rewardsAtDenial); + } + + // -- onSubgraphSignalUpdate with no allocations -- + + function test_OnSubgraphSignalUpdateWithNoAllocations_ReclaimsAsNoAllocatedTokens() public { + _signal(SUBGRAPH_DEPLOYMENT_ID, SIGNAL); + _setReclaimAddress(RewardsCondition.NO_ALLOCATED_TOKENS, reclaimWallet); + IRewardsManager.Subgraph memory initialState = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + + vm.roll(block.number + 100); + + uint256 expectedRewards = _subgraphRewards(100); + assertGt(expectedRewards, 0); + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.NO_ALLOCATED_TOKENS, + expectedRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID + ); + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + + assertEq(_subgraph(SUBGRAPH_DEPLOYMENT_ID).accRewardsForSubgraph, initialState.accRewardsForSubgraph); + assertEq(token.balanceOf(reclaimWallet), expectedRewards); + } + + function test_OnSubgraphSignalUpdateWithNoAllocations_ViewShowsNoPhantomRewards() public { + _signal(SUBGRAPH_DEPLOYMENT_ID, SIGNAL); + uint256 viewAfterSignal = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID); + + vm.roll(block.number + 100); + + _setReclaimAddress(RewardsCondition.NO_ALLOCATED_TOKENS, reclaimWallet); + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + + assertEq(rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID), viewAfterSignal); + assertEq(viewAfterSignal, 0); + // The 100 blocks went to the reclaim address instead + assertEq(token.balanceOf(reclaimWallet), _subgraphRewards(100)); + } + + // -- invariant: no rewards lost or double-counted -- + + function test_InvariantNoRewardsLostOrDoubleCounted_MixedUpdatesWithSameBlockScenarios() public { + _setupSubgraphWithAllocation(); + vm.roll(block.number + 25); + + // Same block: signal update, then allocation update + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + + vm.roll(block.number + 25); + + // Same block: two signal updates, then allocation update + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + + IRewardsManager.Subgraph memory sg = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + assertEq(sg.accRewardsForSubgraphSnapshot, sg.accRewardsForSubgraph); + assertGt(sg.accRewardsPerAllocatedToken, 0); + // Each 25-block period is counted exactly once, whatever the call ordering + assertEq(sg.accRewardsForSubgraph, 2 * _subgraphRewards(25)); + assertEq(sg.accRewardsPerAllocatedToken, 2 * _perAllocatedToken(25)); + uint256 allocationRewards = rm.getRewards(address(issuer), allocationID); + assertEq(allocationRewards, (sg.accRewardsPerAllocatedToken * ALLOCATION) / 1e18); + assertLe(allocationRewards, sg.accRewardsForSubgraph); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/snapshotInversion.t.sol b/packages/issuance/test/unit/rewards-manager/snapshotInversion.t.sol new file mode 100644 index 000000000..18fb75ece --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/snapshotInversion.t.sol @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; + +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +/// Snapshot inversion on upgrade. With A = accRewardsForSubgraph, S = accRewardsForSubgraphSnapshot and +/// P = rewards since the signal snapshot, pre-upgrade storage can hold A < S. The update computes +/// (A + P) - S, adding P before subtracting S, so it never underflows and the gap S - A is not paid twice. +/// The inverted state is written with `vm.store`. +contract RewardsManagerSnapshotInversionTest is RewardsManagerSharedTest { + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID = keccak256("subgraphDeploymentID"); + + /// `subgraphs` in RewardsManagerV1Storage: Managed's controller (0), _addressCache (1) and __gap[10] (2-11), + /// then __DEPRECATED_issuanceRate (12), accRewardsPerSignal (13), accRewardsPerSignalLastBlockUpdated (14), + /// subgraphAvailabilityOracle (15) + uint256 internal constant SUBGRAPHS_MAPPING_SLOT = 16; + uint256 internal constant ACC_REWARDS_PER_SIGNAL_SLOT = 13; + uint256 internal constant ACC_REWARDS_PER_SIGNAL_LAST_BLOCK_UPDATED_SLOT = 14; + + /// Fields of `IRewardsManager.Subgraph`, as offsets from the struct's base slot + uint256 internal constant ACC_REWARDS_FOR_SUBGRAPH = 0; + uint256 internal constant ACC_REWARDS_FOR_SUBGRAPH_SNAPSHOT = 1; + uint256 internal constant ACC_REWARDS_PER_SIGNAL_SNAPSHOT = 2; + uint256 internal constant ACC_REWARDS_PER_ALLOCATED_TOKEN = 3; + + /// Blocks mined by the old setup between the allocation and its signal update + uint256 internal constant SETUP_BLOCKS = 50; + + address internal allocationID = makeAddr("allocationID"); + + // -- Helpers -- + + function _subgraphStorageSlot(bytes32 sd, uint256 fieldOffset) internal pure returns (bytes32) { + return bytes32(uint256(keccak256(abi.encode(sd, SUBGRAPHS_MAPPING_SLOT))) + fieldOffset); + } + + /// Signal, allocate, accrue `SETUP_BLOCKS`, then sync the subgraph with a signal update, so A > 0. + function _setupSubgraphWithAllocation() internal { + _signalThenAllocate(SUBGRAPH_DEPLOYMENT_ID, allocationID); + vm.roll(block.number + SETUP_BLOCKS); + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + } + + /// The state left by the old `onSubgraphAllocationUpdate`, which wrote S from a view (storage + pending) + /// while A stayed at its stored value: S = A + gap. + function _createInvertedState(uint256 gap) internal { + uint256 invertedSnapshot = _subgraph(SUBGRAPH_DEPLOYMENT_ID).accRewardsForSubgraph + gap; + vm.store( + address(rm), + _subgraphStorageSlot(SUBGRAPH_DEPLOYMENT_ID, ACC_REWARDS_FOR_SUBGRAPH_SNAPSHOT), + bytes32(invertedSnapshot) + ); + + IRewardsManager.Subgraph memory sg = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + assertEq(sg.accRewardsForSubgraphSnapshot, invertedSnapshot); + assertLt(sg.accRewardsForSubgraph, sg.accRewardsForSubgraphSnapshot); + } + + function _perAllocatedToken(uint256 rewards) internal pure returns (uint256) { + return _expectedRewardsPerAllocatedToken(rewards, ALLOCATION); + } + + // -- storage slot verification -- + + function test_SnapshotInversion_StorageSlotsMatchLayout() public { + _setupSubgraphWithAllocation(); + + IRewardsManager.Subgraph memory before = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + assertTrue(before.accRewardsForSubgraph != 0); + assertTrue(before.accRewardsForSubgraphSnapshot != 0); + assertTrue(before.accRewardsPerSignalSnapshot != 0); + assertTrue(before.accRewardsPerAllocatedToken != 0); + + // The inherited prefix: the plain slots before the mapping + assertEq(uint256(vm.load(address(rm), bytes32(ACC_REWARDS_PER_SIGNAL_SLOT))), rm.accRewardsPerSignal()); + assertEq( + uint256(vm.load(address(rm), bytes32(ACC_REWARDS_PER_SIGNAL_LAST_BLOCK_UPDATED_SLOT))), + rm.accRewardsPerSignalLastBlockUpdated() + ); + + // Each struct field reads back through the getter + bytes32 sd = SUBGRAPH_DEPLOYMENT_ID; + assertEq( + uint256(vm.load(address(rm), _subgraphStorageSlot(sd, ACC_REWARDS_FOR_SUBGRAPH))), + before.accRewardsForSubgraph + ); + assertEq( + uint256(vm.load(address(rm), _subgraphStorageSlot(sd, ACC_REWARDS_FOR_SUBGRAPH_SNAPSHOT))), + before.accRewardsForSubgraphSnapshot + ); + assertEq( + uint256(vm.load(address(rm), _subgraphStorageSlot(sd, ACC_REWARDS_PER_SIGNAL_SNAPSHOT))), + before.accRewardsPerSignalSnapshot + ); + assertEq( + uint256(vm.load(address(rm), _subgraphStorageSlot(sd, ACC_REWARDS_PER_ALLOCATED_TOKEN))), + before.accRewardsPerAllocatedToken + ); + + // A write to the snapshot field changes only that field + uint256 testValue = 12345678901234567890; + vm.store(address(rm), _subgraphStorageSlot(sd, ACC_REWARDS_FOR_SUBGRAPH_SNAPSHOT), bytes32(testValue)); + + IRewardsManager.Subgraph memory sg = _subgraph(sd); + assertEq(sg.accRewardsForSubgraphSnapshot, testValue); + assertEq(sg.accRewardsForSubgraph, before.accRewardsForSubgraph); + assertEq(sg.accRewardsPerSignalSnapshot, before.accRewardsPerSignalSnapshot); + assertEq(sg.accRewardsPerAllocatedToken, before.accRewardsPerAllocatedToken); + } + + // -- inverted state: accumulated < snapshot -- + + function test_InvertedStateAccumulatedBelowSnapshot_SignalUpdateDoesNotRevert() public { + _setupSubgraphWithAllocation(); + _createInvertedState(7000 ether); + + // P (50 blocks at 200 GRT) exceeds the gap + vm.roll(block.number + 50); + + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + } + + function test_InvertedStateAccumulatedBelowSnapshot_AllocationUpdateDoesNotRevert() public { + _setupSubgraphWithAllocation(); + _createInvertedState(7000 ether); + + vm.roll(block.number + 50); + + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + } + + function test_InvertedStateAccumulatedBelowSnapshot_SyncsSnapshotsAfterFirstCall() public { + _setupSubgraphWithAllocation(); + _createInvertedState(7000 ether); + + vm.roll(block.number + 50); + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + + IRewardsManager.Subgraph memory sg = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + assertEq(sg.accRewardsForSubgraphSnapshot, sg.accRewardsForSubgraph); + + vm.roll(block.number + 10); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + + sg = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + assertEq(sg.accRewardsForSubgraphSnapshot, sg.accRewardsForSubgraph); + } + + // -- accounting correctness with inverted state -- + + function test_AccountingCorrectnessWithInvertedState_ComputesUndistributedAsAPlusPMinusS() public { + _setupSubgraphWithAllocation(); + IRewardsManager.Subgraph memory before = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + + uint256 gap = 500 ether; + _createInvertedState(gap); + + // P = 10 blocks, about 2000 GRT, more than the gap + vm.roll(block.number + 10); + uint256 pending = _subgraphRewards(10); + assertGt(pending, gap); + + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + IRewardsManager.Subgraph memory sg = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + + assertLt(before.accRewardsPerAllocatedToken, sg.accRewardsPerAllocatedToken); + // (A + P) - S = (A + P) - (A + gap) = P - gap + assertEq( + sg.accRewardsPerAllocatedToken, + before.accRewardsPerAllocatedToken + _perAllocatedToken(pending - gap) + ); + assertEq(sg.accRewardsForSubgraph, before.accRewardsForSubgraph + pending); + assertEq(sg.accRewardsForSubgraphSnapshot, sg.accRewardsForSubgraph); + } + + function test_AccountingCorrectnessWithInvertedState_DoesNotDoubleCountGap() public { + _setupSubgraphWithAllocation(); + IRewardsManager.Subgraph memory stateBefore = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + + uint256 gap = 500 ether; + _createInvertedState(gap); + + vm.roll(block.number + 20); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + uint256 perAllocAfterInverted = _subgraph(SUBGRAPH_DEPLOYMENT_ID).accRewardsPerAllocatedToken; + + vm.roll(block.number + 20); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + uint256 perAllocAfterNormal = _subgraph(SUBGRAPH_DEPLOYMENT_ID).accRewardsPerAllocatedToken; + + uint256 firstDelta = perAllocAfterInverted - stateBefore.accRewardsPerAllocatedToken; + uint256 secondDelta = perAllocAfterNormal - perAllocAfterInverted; + + assertLt(firstDelta, secondDelta); + assertEq(firstDelta, _perAllocatedToken(_subgraphRewards(20) - gap)); + assertEq(secondDelta, _perAllocatedToken(_subgraphRewards(20))); + } + + function test_AccountingCorrectnessWithInvertedState_DistributesExactlyPendingMinusGap() public { + _setupSubgraphWithAllocation(); + + // Sync in the next block, as the next Hardhat transaction + vm.roll(block.number + 1); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + uint256 perAllocBaseline = _subgraph(SUBGRAPH_DEPLOYMENT_ID).accRewardsPerAllocatedToken; + + uint256 gap = 500 ether; + _createInvertedState(gap); + + vm.roll(block.number + 20); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + uint256 perAllocAfterInverted = _subgraph(SUBGRAPH_DEPLOYMENT_ID).accRewardsPerAllocatedToken; + uint256 invertedDelta = perAllocAfterInverted - perAllocBaseline; + + vm.roll(block.number + 20); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + uint256 normalDelta = _subgraph(SUBGRAPH_DEPLOYMENT_ID).accRewardsPerAllocatedToken - perAllocAfterInverted; + + assertLt(invertedDelta, normalDelta); + assertTrue(invertedDelta != 0); + assertEq(invertedDelta, _perAllocatedToken(_subgraphRewards(20) - gap)); + assertEq(normalDelta, _perAllocatedToken(_subgraphRewards(20))); + } + + // -- normal operation (no inversion) -- + + function test_NormalOperationNoInversion_SyncedSnapshotSteadyState() public { + _setupSubgraphWithAllocation(); + + // Sync in the next block, as the next Hardhat transaction + vm.roll(block.number + 1); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + IRewardsManager.Subgraph memory synced = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + assertEq(synced.accRewardsForSubgraphSnapshot, synced.accRewardsForSubgraph); + + vm.roll(block.number + 20); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + IRewardsManager.Subgraph memory sg = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + + assertLt(synced.accRewardsPerAllocatedToken, sg.accRewardsPerAllocatedToken); + assertEq( + sg.accRewardsPerAllocatedToken, + synced.accRewardsPerAllocatedToken + _perAllocatedToken(_subgraphRewards(20)) + ); + assertEq(sg.accRewardsForSubgraphSnapshot, sg.accRewardsForSubgraph); + } + + function test_NormalOperationNoInversion_ZeroRewardsInSameBlock() public { + _setupSubgraphWithAllocation(); + + // Sync in the next block, as the next Hardhat transaction + vm.roll(block.number + 1); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + IRewardsManager.Subgraph memory synced = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + + // Same block: no vm.roll, so no new rewards + uint256 accRewardsPerAllocatedToken = rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + + IRewardsManager.Subgraph memory sg = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + assertEq(sg.accRewardsForSubgraphSnapshot, sg.accRewardsForSubgraph); + assertEq(accRewardsPerAllocatedToken, synced.accRewardsPerAllocatedToken); + assertEq(sg.accRewardsPerAllocatedToken, synced.accRewardsPerAllocatedToken); + assertEq(sg.accRewardsForSubgraph, synced.accRewardsForSubgraph); + } + + // -- realistic pre-upgrade scenario -- + + function test_RealisticPreUpgradeScenario_HandlesArbitrumSepoliaStatePattern() public { + _setupSubgraphWithAllocation(); + IRewardsManager.Subgraph memory state = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + + // S = A + 7235 GRT, the gap observed on Arbitrum Sepolia + uint256 observedGap = 7235 ether; + vm.store( + address(rm), + _subgraphStorageSlot(SUBGRAPH_DEPLOYMENT_ID, ACC_REWARDS_FOR_SUBGRAPH_SNAPSHOT), + bytes32(state.accRewardsForSubgraph + observedGap) + ); + + IRewardsManager.Subgraph memory inverted = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + assertLt(inverted.accRewardsForSubgraph, inverted.accRewardsForSubgraphSnapshot); + + vm.roll(block.number + 50); + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + + IRewardsManager.Subgraph memory healed = _subgraph(SUBGRAPH_DEPLOYMENT_ID); + assertEq(healed.accRewardsForSubgraphSnapshot, healed.accRewardsForSubgraph); + assertEq(healed.accRewardsForSubgraph, state.accRewardsForSubgraph + _subgraphRewards(50)); + assertEq( + healed.accRewardsPerAllocatedToken, + state.accRewardsPerAllocatedToken + _perAllocatedToken(_subgraphRewards(50) - observedGap) + ); + + vm.roll(block.number + 10); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID); + + vm.roll(block.number + 10); + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID); + } +} diff --git a/scripts/contracts-migration/test-mapping/t5e.md b/scripts/contracts-migration/test-mapping/t5e.md new file mode 100644 index 000000000..b0689b542 --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t5e.md @@ -0,0 +1,31 @@ +# T5e test mapping: RewardsManager signal/allocation update accounting and snapshot inversion + +New tests are in `packages/issuance`, on T5a's `RewardsManagerSharedTest` (the RewardsManager Hardhat artifact behind a GraphProxy). `setupSubgraphWithAllocation` becomes the shared `_signalThenAllocate` (`SIGNAL` / `ALLOCATION` instead of the old 1000 / 10,000 GRT; signal, `vm.roll(+1)`, allocate), and `issuer` plays the legacy Staking contract. The hooks are called directly (the old tests called them as governor; they have no access check). `mine(n)` becomes `vm.roll(n)`. Hardhat batched blocks (`evm_setAutomine(false)` … `evm_mine`) become consecutive calls with no `vm.roll` in between. Where a following Hardhat transaction mattered (a sync, or `setDenied` after the allocation), the port rolls one block. Expected values come from the rolls through the shared `_subgraphRewards` and `_expectedRewardsPerAllocatedToken`, and are asserted exactly next to the old `gt` / `lt` checks. `hardhat_setStorageAt` becomes `vm.store` at `keccak256(abi.encode(sd, uint256(16))) + field`, and `test_SnapshotInversion_StorageSlotsMatchLayout` checks that layout. No ported case uses a zero-token allocation, so the `RMIssuerMock` hook caveat does not apply. + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts | Rewards: Signal and Allocation Update Accounting › onSubgraphSignalUpdate followed by onSubgraphAllocationUpdate › should properly distribute rewards when signal update precedes allocation update (same block) | test/unit/rewards-manager/signalAllocationUpdate.t.sol::test_OnSubgraphSignalUpdateFollowedByOnSubgraphAllocationUpdate_DistributesWhenSignalUpdatePrecedesSameBlock | Batched block → the two hooks with no `vm.roll` between. `gt` checks kept; the view before, the stored `accRewardsForSubgraph` and `accRewardsPerAllocatedToken` are also asserted exactly from `vm.roll(100)` | +| packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts | Rewards: Signal and Allocation Update Accounting › onSubgraphSignalUpdate followed by onSubgraphAllocationUpdate › should not brick rewards when signal update zeros the per-signal delta (same block) | test/unit/rewards-manager/signalAllocationUpdate.t.sol::test_OnSubgraphSignalUpdateFollowedByOnSubgraphAllocationUpdate_DoesNotBrickRewardsWhenPerSignalDeltaIsZero | Batched block → no `vm.roll` between the hooks. Exact accumulator and per-token values added next to the `gt` checks; view == stored kept | +| packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts | Rewards: Signal and Allocation Update Accounting › onSubgraphSignalUpdate followed by onSubgraphAllocationUpdate › should handle multiple signal updates without losing rewards (same block allocation) | test/unit/rewards-manager/signalAllocationUpdate.t.sol::test_OnSubgraphSignalUpdateFollowedByOnSubgraphAllocationUpdate_MultipleSignalUpdatesDoNotLoseRewards | `mine(50)` twice → `vm.roll(50)` twice; second signal + allocation update in one block. Exact `2 × R(50)` accumulator and per-token value added | +| packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts | Rewards: Signal and Allocation Update Accounting › snapshot consistency in reclaim paths › should update accRewardsForSubgraphSnapshot when rewards are reclaimed due to denial | test/unit/rewards-manager/signalAllocationUpdate.t.sol::test_SnapshotConsistencyInReclaimPaths_UpdatesSnapshotWhenReclaimedDueToDenial | Old `gte` on the snapshot is vacuous (the denied path keeps S = A, so it held with equality). Kept, plus the real values: A frozen at the one pre-denial block (`vm.roll(+1)` before `setDenied`, as the separate Hardhat transactions), S == A, per-signal snapshot moved to `accRewardsPerSignal` | +| packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts | Rewards: Signal and Allocation Update Accounting › snapshot consistency in reclaim paths › should not double-reclaim rewards after snapshot update | test/unit/rewards-manager/signalAllocationUpdate.t.sol::test_SnapshotConsistencyInReclaimPaths_NoDoubleReclaimAfterSnapshotUpdate | Old `lte(2 × one block)` bound on the snapshot diff kept; the real diff is 0 and is asserted. The old test had no reclaim address, so nothing showed what was reclaimed: the port sets a SUBGRAPH_DENIED address and asserts R(100), then only R(1) for the next block (`vm.roll(+1)`, the next Hardhat transaction) | +| packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts | Rewards: Signal and Allocation Update Accounting › onSubgraphSignalUpdate on denied subgraph › should reclaim rewards when onSubgraphSignalUpdate is called on denied subgraph | test/unit/rewards-manager/signalAllocationUpdate.t.sol::test_OnSubgraphSignalUpdateOnDeniedSubgraph_ReclaimsRewards | Reclaim address is a `makeAddr` wallet (old: governor). `vm.roll(+1)` before `setDenied`, so A > 0. Event `gt(0)` → `vm.expectEmit` with the exact R(100) and all args; wallet balance asserted | +| packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts | Rewards: Signal and Allocation Update Accounting › onSubgraphSignalUpdate on denied subgraph › should accumulate rewards for claimable subgraphs in onSubgraphSignalUpdate | test/unit/rewards-manager/signalAllocationUpdate.t.sol::test_OnSubgraphSignalUpdateOnDeniedSubgraph_AccumulatesRewardsForClaimableSubgraph | `gt` kept; exact increase R(100) added | +| packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts | Rewards: Signal and Allocation Update Accounting › onSubgraphSignalUpdate on denied subgraph › view function getAccRewardsForSubgraph should not jump during denial | test/unit/rewards-manager/signalAllocationUpdate.t.sol::test_OnSubgraphSignalUpdateOnDeniedSubgraph_ViewDoesNotJumpDuringDenial | `mine(50/100/50)` → `vm.roll`. View at denial asserted exactly as R(50) | +| packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts | Rewards: Signal and Allocation Update Accounting › onSubgraphSignalUpdate with no allocations › should reclaim as NO_ALLOCATED_TOKENS when signal exists but no allocations | test/unit/rewards-manager/signalAllocationUpdate.t.sol::test_OnSubgraphSignalUpdateWithNoAllocations_ReclaimsAsNoAllocatedTokens | Reason and `gt(0)` → `vm.expectEmit` with the exact R(100); wallet balance asserted. No allocation, so the RMIssuerMock zero-token caveat does not apply | +| packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts | Rewards: Signal and Allocation Update Accounting › onSubgraphSignalUpdate with no allocations › view function should not show phantom rewards when no allocations | test/unit/rewards-manager/signalAllocationUpdate.t.sol::test_OnSubgraphSignalUpdateWithNoAllocations_ViewShowsNoPhantomRewards | Also asserts the view is 0 and the 100 blocks reached the reclaim wallet | +| packages/contracts-test/tests/unit/rewards/rewards-signal-allocation-update.test.ts | Rewards: Signal and Allocation Update Accounting › invariant: no rewards lost or double-counted › should maintain accounting invariant across mixed updates (with same-block scenarios) | test/unit/rewards-manager/signalAllocationUpdate.t.sol::test_InvariantNoRewardsLostOrDoubleCounted_MixedUpdatesWithSameBlockScenarios | Two batched blocks → consecutive hooks with no `vm.roll`. Adds the exact `2 × R(25)` accumulator and per-token value, and `getRewards` ≤ A | +| packages/contracts-test/tests/unit/rewards/rewards-snapshot-inversion.test.ts | Rewards: Snapshot Inversion › storage slot verification › should correctly compute and write to subgraph storage slots | test/unit/rewards-manager/snapshotInversion.t.sol::test_SnapshotInversion_StorageSlotsMatchLayout | Name per plan. `hardhat_setStorageAt` → `vm.store`. Before the write, `vm.load` of slots 13, 14 and all four struct fields at `keccak256(abi.encode(sd, 16)) + 0..3` must equal the getters (all non-zero) | +| packages/contracts-test/tests/unit/rewards/rewards-snapshot-inversion.test.ts | Rewards: Snapshot Inversion › inverted state: accumulated < snapshot › should not revert on onSubgraphSignalUpdate with inverted state | test/unit/rewards-manager/snapshotInversion.t.sol::test_InvertedStateAccumulatedBelowSnapshot_SignalUpdateDoesNotRevert | `not.reverted` → plain call. `_createInvertedState` re-checks the written S and A < S | +| packages/contracts-test/tests/unit/rewards/rewards-snapshot-inversion.test.ts | Rewards: Snapshot Inversion › inverted state: accumulated < snapshot › should not revert on onSubgraphAllocationUpdate with inverted state | test/unit/rewards-manager/snapshotInversion.t.sol::test_InvertedStateAccumulatedBelowSnapshot_AllocationUpdateDoesNotRevert | `not.reverted` → plain call | +| packages/contracts-test/tests/unit/rewards/rewards-snapshot-inversion.test.ts | Rewards: Snapshot Inversion › inverted state: accumulated < snapshot › should sync snapshots after first successful call | test/unit/rewards-manager/snapshotInversion.t.sol::test_InvertedStateAccumulatedBelowSnapshot_SyncsSnapshotsAfterFirstCall | | +| packages/contracts-test/tests/unit/rewards/rewards-snapshot-inversion.test.ts | Rewards: Snapshot Inversion › accounting correctness with inverted state › should correctly compute undistributed rewards: (A+P).sub(S) | test/unit/rewards-manager/snapshotInversion.t.sol::test_AccountingCorrectnessWithInvertedState_ComputesUndistributedAsAPlusPMinusS | The old test only checked `lt` (its "≈ 1500 GRT" was a comment). The port asserts the per-token increase is exactly (R(10) − gap) / ALLOCATION and A grows by exactly R(10) | +| packages/contracts-test/tests/unit/rewards/rewards-snapshot-inversion.test.ts | Rewards: Snapshot Inversion › accounting correctness with inverted state › should not double-count: distributed rewards account for the gap | test/unit/rewards-manager/snapshotInversion.t.sol::test_AccountingCorrectnessWithInvertedState_DoesNotDoubleCountGap | `lt` kept; both deltas asserted exactly: R(20) − gap, then R(20) | +| packages/contracts-test/tests/unit/rewards/rewards-snapshot-inversion.test.ts | Rewards: Snapshot Inversion › accounting correctness with inverted state › should distribute exactly P - gap rewards (gap deducted from pending) | test/unit/rewards-manager/snapshotInversion.t.sol::test_AccountingCorrectnessWithInvertedState_DistributesExactlyPendingMinusGap | The baseline sync rolls one block, as the next Hardhat transaction (with no roll it is a no-op). Despite the title, the old test only checked `lt` and `!= 0`; the port asserts exactly P − gap | +| packages/contracts-test/tests/unit/rewards/rewards-snapshot-inversion.test.ts | Rewards: Snapshot Inversion › normal operation (no inversion) › should produce identical results when A == S (synced snapshot steady state) | test/unit/rewards-manager/snapshotInversion.t.sol::test_NormalOperationNoInversion_SyncedSnapshotSteadyState | Baseline sync rolls one block, as the next Hardhat transaction. Exact R(20) per-token increase added | +| packages/contracts-test/tests/unit/rewards/rewards-snapshot-inversion.test.ts | Rewards: Snapshot Inversion › normal operation (no inversion) › should handle zero rewards gracefully (same block, no new rewards) | test/unit/rewards-manager/snapshotInversion.t.sol::test_NormalOperationNoInversion_ZeroRewardsInSameBlock | In Hardhat the "batched" single transaction still landed in a new block (the old comment notes it). The port makes it really same-block (no `vm.roll`) and asserts what the old test only described: per-token value, A and the return value unchanged | +| packages/contracts-test/tests/unit/rewards/rewards-snapshot-inversion.test.ts | Rewards: Snapshot Inversion › realistic pre-upgrade scenario › should handle the exact Arbitrum Sepolia state pattern | test/unit/rewards-manager/snapshotInversion.t.sol::test_RealisticPreUpgradeScenario_HandlesArbitrumSepoliaStatePattern | `vm.store` of S = A + 7235 GRT. Healed A and per-token value asserted exactly; later `not.reverted` → plain calls | + +## Coverage + +`contracts/rewards/RewardsManager.sol`: every external/public name in `functionsHit` is called by an issuance test. No `MISSING` names. This PR adds direct calls to `onSubgraphSignalUpdate`, `onSubgraphAllocationUpdate`, `getAccRewardsForSubgraph`, `getAccRewardsPerAllocatedToken` and `getRewards`, and reaches `_updateSubgraphRewards` with an inverted snapshot (A < S). From 39196ef58881b1d075a776b432b078a3044bde0e Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Tue, 27 Oct 2026 10:00:44 -0300 Subject: [PATCH 19/20] test(issuance): port legacy RewardsManager suite to Foundry Port the unique cases of rewards.test.ts to legacySuite.t.sol, map the duplicates to the existing tests and delete the TS file. Move the shared deny, oracle, reclaim and accrue helpers into shared.t.sol. --- .../tests/unit/rewards/rewards.test.ts | 1419 ----------------- .../test/unit/rewards-manager/config.t.sol | 17 +- .../unit/rewards-manager/eligibility.t.sol | 63 +- .../unit/rewards-manager/legacySuite.t.sol | 188 +++ .../rewards-manager/mocks/RMIssuerMock.sol | 3 +- .../test/unit/rewards-manager/reclaim.t.sol | 65 +- .../test/unit/rewards-manager/shared.t.sol | 23 + .../signalAllocationUpdate.t.sol | 5 - .../contracts-migration/test-mapping/t5f.md | 71 + 9 files changed, 335 insertions(+), 1519 deletions(-) delete mode 100644 packages/contracts-test/tests/unit/rewards/rewards.test.ts create mode 100644 packages/issuance/test/unit/rewards-manager/legacySuite.t.sol create mode 100644 scripts/contracts-migration/test-mapping/t5f.md diff --git a/packages/contracts-test/tests/unit/rewards/rewards.test.ts b/packages/contracts-test/tests/unit/rewards/rewards.test.ts deleted file mode 100644 index 240d78178..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards.test.ts +++ /dev/null @@ -1,1419 +0,0 @@ -import { Curation } from '@graphprotocol/contracts' -import { EpochManager } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { IStaking } from '@graphprotocol/contracts' -import { RewardsManager } from '@graphprotocol/contracts' -import { - deriveChannelKey, - formatGRT, - GraphNetworkContracts, - helpers, - randomHexBytes, - toBN, - toGRT, -} from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { BigNumber as BN } from 'bignumber.js' -import { expect } from 'chai' -import { BigNumber, constants, utils } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const MAX_PPM = 1000000 - -// TODO: Behavior change - HorizonRewardsAssigned is no longer emitted when rewards == 0 -// Set to true if the old behavior is restored (emitting event for zero rewards) -const EMIT_EVENT_FOR_ZERO_REWARDS = false - -const { HashZero, WeiPerEther } = constants - -// Condition identifiers (matching RewardsCondition.sol) -const INDEXER_INELIGIBLE = utils.id('INDEXER_INELIGIBLE') -const SUBGRAPH_DENIED = utils.id('SUBGRAPH_DENIED') - -const toRound = (n: BigNumber) => formatGRT(n.add(toGRT('0.5'))).split('.')[0] - -describe('Rewards', () => { - const graph = hre.graph() - let delegator: SignerWithAddress - let governor: SignerWithAddress - let curator1: SignerWithAddress - let curator2: SignerWithAddress - let indexer1: SignerWithAddress - let indexer2: SignerWithAddress - let oracle: SignerWithAddress - let assetHolder: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let grt: GraphToken - let curation: Curation - let epochManager: EpochManager - let staking: IStaking - let rewardsManager: RewardsManager - - // Derive some channel keys for each indexer used to sign attestations - const channelKey1 = deriveChannelKey() - const channelKey2 = deriveChannelKey() - const channelKeyNull = deriveChannelKey() - - const subgraphDeploymentID1 = randomHexBytes() - const subgraphDeploymentID2 = randomHexBytes() - - const allocationID1 = channelKey1.address - const allocationID2 = channelKey2.address - const allocationIDNull = channelKeyNull.address - - const metadata = HashZero - - const ISSUANCE_RATE_PERIODS = 4 // blocks required to issue 800 GRT rewards - const ISSUANCE_PER_BLOCK = toBN('200000000000000000000') // 200 GRT every block - - // Core formula that gets accumulated rewards per signal for a period of time - const getRewardsPerSignal = (k: BN, t: BN, s: BN): string => { - if (s.eq(0)) { - return '0' - } - return k.times(t).div(s).toPrecision(18).toString() - } - - // Tracks the accumulated rewards as totalSignalled or supply changes across snapshots - class RewardsTracker { - totalSignalled = BigNumber.from(0) - lastUpdatedBlock = 0 - accumulated = BigNumber.from(0) - - static async create() { - const tracker = new RewardsTracker() - await tracker.snapshot() - return tracker - } - - async snapshot() { - this.accumulated = this.accumulated.add(await this.accrued()) - this.totalSignalled = await grt.balanceOf(curation.address) - this.lastUpdatedBlock = await helpers.latestBlock() - return this - } - - async elapsedBlocks() { - const currentBlock = await helpers.latestBlock() - return currentBlock - this.lastUpdatedBlock - } - - async accrued() { - const nBlocks = await this.elapsedBlocks() - return this.accruedByElapsed(nBlocks) - } - - accruedByElapsed(nBlocks: BigNumber | number) { - const n = getRewardsPerSignal( - new BN(ISSUANCE_PER_BLOCK.toString()), - new BN(nBlocks.toString()), - new BN(this.totalSignalled.toString()), - ) - return toGRT(n) - } - } - - // Test accumulated rewards per signal - const shouldGetNewRewardsPerSignal = async (nBlocks = ISSUANCE_RATE_PERIODS) => { - // -- t0 -- - const tracker = await RewardsTracker.create() - - // Jump - await helpers.mine(nBlocks) - - // -- t1 -- - - // Contract calculation - const contractAccrued = await rewardsManager.getNewRewardsPerSignal() - // Local calculation - const expectedAccrued = await tracker.accrued() - - // Check - expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) - return expectedAccrued - } - - before(async function () { - ;[delegator, curator1, curator2, indexer1, indexer2, oracle, assetHolder] = await graph.getTestAccounts() - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - grt = contracts.GraphToken as GraphToken - curation = contracts.Curation as Curation - epochManager = contracts.EpochManager - staking = contracts.Staking as IStaking - rewardsManager = contracts.RewardsManager - - // 200 GRT per block - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - - // Distribute test funds - for (const wallet of [indexer1, indexer2, curator1, curator2, assetHolder]) { - await grt.connect(governor).mint(wallet.address, toGRT('1000000')) - await grt.connect(wallet).approve(staking.address, toGRT('1000000')) - await grt.connect(wallet).approve(curation.address, toGRT('1000000')) - } - - // HACK: we set the staking contract as the subgraph service to make tests pass. - // This is due to the test suite being outdated. - await rewardsManager.connect(governor).setSubgraphService(staking.address) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('configuration', function () { - describe('initialize', function () { - it('should revert when called on implementation contract', async function () { - // Try to call initialize on the implementation contract (should revert with onlyImpl) - const tx = rewardsManager.connect(governor).initialize(contracts.Controller.address) - await expect(tx).revertedWith('Only implementation') - }) - }) - - describe('issuance per block update', function () { - it('reject set issuance per block if unauthorized', async function () { - const tx = rewardsManager.connect(indexer1).setIssuancePerBlock(toGRT('1.025')) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should set issuance rate to minimum allowed (0)', async function () { - const newIssuancePerBlock = toGRT('0') - await rewardsManager.connect(governor).setIssuancePerBlock(newIssuancePerBlock) - expect(await rewardsManager.issuancePerBlock()).eq(newIssuancePerBlock) - }) - - it('should set issuance rate', async function () { - const newIssuancePerBlock = toGRT('100.025') - await rewardsManager.connect(governor).setIssuancePerBlock(newIssuancePerBlock) - expect(await rewardsManager.issuancePerBlock()).eq(newIssuancePerBlock) - expect(await rewardsManager.accRewardsPerSignalLastBlockUpdated()).eq(await helpers.latestBlock()) - }) - }) - - describe('subgraph availability service', function () { - it('reject set subgraph oracle if unauthorized', async function () { - const tx = rewardsManager.connect(indexer1).setSubgraphAvailabilityOracle(oracle.address) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should set subgraph oracle if governor', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - expect(await rewardsManager.subgraphAvailabilityOracle()).eq(oracle.address) - }) - - it('reject to deny subgraph if not the oracle', async function () { - const tx = rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - await expect(tx).revertedWith('Caller must be the subgraph availability oracle') - }) - - it('should deny subgraph', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - - const tx = rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, true) - const blockNum = await helpers.latestBlock() - await expect(tx) - .emit(rewardsManager, 'RewardsDenylistUpdated') - .withArgs(subgraphDeploymentID1, blockNum + 1) - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(true) - }) - - it('should allow removing subgraph from denylist', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - - // First deny the subgraph - await rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, true) - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(true) - - // Then remove from denylist - const tx = rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, false) - await expect(tx).emit(rewardsManager, 'RewardsDenylistUpdated').withArgs(subgraphDeploymentID1, 0) - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(false) - }) - - it('reject setMinimumSubgraphSignal if unauthorized', async function () { - const tx = rewardsManager.connect(indexer1).setMinimumSubgraphSignal(toGRT('1000')) - await expect(tx).revertedWith('Not authorized') - }) - - it('should allow setMinimumSubgraphSignal from subgraph availability oracle', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - - const newMinimumSignal = toGRT('2000') - const tx = rewardsManager.connect(oracle).setMinimumSubgraphSignal(newMinimumSignal) - await expect(tx).emit(rewardsManager, 'ParameterUpdated').withArgs('minimumSubgraphSignal') - - expect(await rewardsManager.minimumSubgraphSignal()).eq(newMinimumSignal) - }) - - it('should allow setMinimumSubgraphSignal from governor', async function () { - const newMinimumSignal = toGRT('3000') - const tx = rewardsManager.connect(governor).setMinimumSubgraphSignal(newMinimumSignal) - await expect(tx).emit(rewardsManager, 'ParameterUpdated').withArgs('minimumSubgraphSignal') - - expect(await rewardsManager.minimumSubgraphSignal()).eq(newMinimumSignal) - }) - }) - }) - - context('issuing rewards', function () { - beforeEach(async function () { - // 5% minute rate (4 blocks) - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - }) - - describe('getNewRewardsPerSignal', function () { - it('accrued per signal when no tokens signalled', async function () { - // When there is no tokens signalled no rewards are accrued - await helpers.mineEpoch(epochManager) - const accrued = await rewardsManager.getNewRewardsPerSignal() - expect(accrued).eq(0) - }) - - it('accrued per signal when tokens signalled', async function () { - // Update total signalled - const tokensToSignal = toGRT('1000') - await curation.connect(curator1).mint(subgraphDeploymentID1, tokensToSignal, 0) - - // Check - await shouldGetNewRewardsPerSignal() - }) - - it('accrued per signal when signalled tokens w/ many subgraphs', async function () { - // Update total signalled - await curation.connect(curator1).mint(subgraphDeploymentID1, toGRT('1000'), 0) - - // Check - await shouldGetNewRewardsPerSignal() - - // Update total signalled - await curation.connect(curator2).mint(subgraphDeploymentID2, toGRT('250'), 0) - - // Check - await shouldGetNewRewardsPerSignal() - }) - }) - - describe('updateAccRewardsPerSignal', function () { - it('update the accumulated rewards per signal state', async function () { - // Update total signalled - await curation.connect(curator1).mint(subgraphDeploymentID1, toGRT('1000'), 0) - // Snapshot - const tracker = await RewardsTracker.create() - - // Update - await rewardsManager.connect(governor).updateAccRewardsPerSignal() - const contractAccrued = await rewardsManager.accRewardsPerSignal() - - // Check - const expectedAccrued = await tracker.accrued() - expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) - }) - - it('update the accumulated rewards per signal state after many blocks', async function () { - // Update total signalled - await curation.connect(curator1).mint(subgraphDeploymentID1, toGRT('1000'), 0) - // Snapshot - const tracker = await RewardsTracker.create() - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Update - await rewardsManager.connect(governor).updateAccRewardsPerSignal() - const contractAccrued = await rewardsManager.accRewardsPerSignal() - - // Check - const expectedAccrued = await tracker.accrued() - expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) - }) - }) - - describe('getAccRewardsForSubgraph', function () { - it('accrued for each subgraph', async function () { - // Setup: signal and allocations for two subgraphs - const signalled1 = toGRT('1500') - const signalled2 = toGRT('500') - const tokensToStake = toGRT('100000') - const tokensToAllocate = toGRT('10000') - - // Mint signal for both subgraphs - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - await curation.connect(curator2).mint(subgraphDeploymentID2, signalled2, 0) - - // Create allocations for both subgraphs so rewards are accumulated (not reclaimed as NO_ALLOCATED_TOKENS) - await grt.connect(governor).mint(indexer1.address, tokensToStake) - await grt.connect(indexer1).approve(staking.address, tokensToStake) - await staking.connect(indexer1).stake(tokensToStake) - - const channelKey1 = deriveChannelKey() - await staking - .connect(indexer1) - .allocate( - subgraphDeploymentID1, - tokensToAllocate, - channelKey1.address, - HashZero, - await channelKey1.generateProof(indexer1.address), - ) - - const channelKey2 = deriveChannelKey() - await staking - .connect(indexer1) - .allocate( - subgraphDeploymentID2, - tokensToAllocate, - channelKey2.address, - HashZero, - await channelKey2.generateProof(indexer1.address), - ) - - // Record starting point for both subgraphs - const startRewardsSG1 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) - const startRewardsSG2 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID2) - - // Jump blocks to accrue rewards - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Get final rewards - const endRewardsSG1 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) - const endRewardsSG2 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID2) - - // Calculate accrued rewards during the period - const accruedSG1 = endRewardsSG1.sub(startRewardsSG1) - const accruedSG2 = endRewardsSG2.sub(startRewardsSG2) - - // Verify proportional distribution: SG1 has 75% of signal (1500/2000), SG2 has 25% (500/2000) - // So SG1 should accrue 3x the rewards of SG2 - const totalAccrued = accruedSG1.add(accruedSG2) - expect(totalAccrued).to.be.gt(0, 'Should have accrued rewards') - - // Check proportional distribution (allow small rounding error) - const sg1Share = accruedSG1.mul(100).div(totalAccrued) - const sg2Share = accruedSG2.mul(100).div(totalAccrued) - expect(sg1Share.toNumber()).to.be.closeTo(75, 1, 'SG1 should have ~75% of rewards') - expect(sg2Share.toNumber()).to.be.closeTo(25, 1, 'SG2 should have ~25% of rewards') - }) - - it('should return zero rewards when subgraph signal is below minimum threshold', async function () { - // Set a high minimum signal threshold - const highMinimumSignal = toGRT('2000') - await rewardsManager.connect(governor).setMinimumSubgraphSignal(highMinimumSignal) - - // Signal less than the minimum threshold - const lowSignal = toGRT('1000') - await curation.connect(curator1).mint(subgraphDeploymentID1, lowSignal, 0) - - // Jump some blocks to potentially accrue rewards - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Check that no rewards are accrued due to minimum signal threshold - const contractRewards = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) - expect(contractRewards).eq(0) - }) - }) - - describe('onSubgraphSignalUpdate', function () { - it('update the accumulated rewards for subgraph state', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Create an allocation so rewards are accumulated (not reclaimed as NO_ALLOCATED_TOKENS) - const tokensToStake = toGRT('100000') - const tokensToAllocate = toGRT('10000') - await grt.connect(governor).mint(indexer1.address, tokensToStake) - await grt.connect(indexer1).approve(staking.address, tokensToStake) - await staking.connect(indexer1).stake(tokensToStake) - const channelKey = deriveChannelKey() - await staking - .connect(indexer1) - .allocate( - subgraphDeploymentID1, - tokensToAllocate, - channelKey.address, - HashZero, - await channelKey.generateProof(indexer1.address), - ) - - // Snapshot - const tracker1 = await RewardsTracker.create() - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Update - await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID1) - - // Check - const contractRewardsSG1 = (await rewardsManager.subgraphs(subgraphDeploymentID1)).accRewardsForSubgraph - const rewardsPerSignal1 = await tracker1.accrued() - const expectedRewardsSG1 = rewardsPerSignal1.mul(signalled1).div(WeiPerEther) - expect(toRound(expectedRewardsSG1)).eq(toRound(contractRewardsSG1)) - - const contractAccrued = await rewardsManager.accRewardsPerSignal() - const expectedAccrued = await tracker1.accrued() - expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) - - const contractBlockUpdated = await rewardsManager.accRewardsPerSignalLastBlockUpdated() - const expectedBlockUpdated = await helpers.latestBlock() - expect(expectedBlockUpdated).eq(contractBlockUpdated) - }) - }) - - describe('getAccRewardsPerAllocatedToken', function () { - it('accrued per allocated token', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Check - const sg1 = await rewardsManager.subgraphs(subgraphDeploymentID1) - // We trust this function because it was individually tested in previous test - const accRewardsForSubgraphSG1 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) - const accruedRewardsSG1 = accRewardsForSubgraphSG1.sub(sg1.accRewardsForSubgraphSnapshot) - const expectedRewardsAT1 = accruedRewardsSG1.mul(WeiPerEther).div(tokensToAllocate) - const contractRewardsAT1 = (await rewardsManager.getAccRewardsPerAllocatedToken(subgraphDeploymentID1))[0] - expect(expectedRewardsAT1).eq(contractRewardsAT1) - }) - }) - - describe('onSubgraphAllocationUpdate', function () { - it('update the accumulated rewards for allocated tokens state', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Prepare expected results - // With Option B model: accRewardsForSubgraph only tracks DISTRIBUTABLE rewards (not reclaimed) - // 7 blocks total: 2 blocks before allocation (reclaimed, NOT in accRewardsForSubgraph) + 5 blocks after allocation - const expectedSubgraphRewards = toGRT('1000') // only distributable rewards (5 blocks) - // accRewardsPerAllocatedToken reflects distributable rewards (5 blocks) - const expectedRewardsAT = toGRT('0.08') // allocated during 5 blocks: 1000 GRT, divided by 12500 allocated tokens - - // Update - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - // Check on demand results saved - const subgraph = await rewardsManager.subgraphs(subgraphDeploymentID1) - const contractSubgraphRewards = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) - const contractRewardsAT = subgraph.accRewardsPerAllocatedToken - - expect(toRound(expectedSubgraphRewards)).eq(toRound(contractSubgraphRewards)) - expect(toRound(expectedRewardsAT.mul(1000))).eq(toRound(contractRewardsAT.mul(1000))) - }) - }) - - describe('getRewards', function () { - it('calculate rewards using the subgraph signalled + allocated tokens', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Rewards - const contractRewards = await rewardsManager.getRewards(staking.address, allocationID1) - - // We trust using this function in the test because we tested it - // standalone in a previous test - const contractRewardsAT1 = (await rewardsManager.getAccRewardsPerAllocatedToken(subgraphDeploymentID1))[0] - - const expectedRewards = contractRewardsAT1.mul(tokensToAllocate).div(WeiPerEther) - expect(expectedRewards).eq(contractRewards) - }) - it('rewards should be zero if the allocation is closed', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - await helpers.mineEpoch(epochManager) - - // Close allocation - await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - // Rewards - const contractRewards = await rewardsManager.getRewards(staking.address, allocationID1) - expect(contractRewards).eq(BigNumber.from(0)) - }) - it('rewards should be zero if the allocation does not exist', async function () { - // Rewards - const contractRewards = await rewardsManager.getRewards(staking.address, allocationIDNull) - expect(contractRewards).eq(BigNumber.from(0)) - }) - }) - - describe('takeRewards', function () { - interface DelegationParameters { - indexingRewardCut: BigNumber - queryFeeCut: BigNumber - cooldownBlocks: number - } - - async function setupIndexerAllocation() { - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - } - - async function setupIndexerAllocationSignalingAfter() { - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - } - - async function setupIndexerAllocationWithDelegation( - tokensToDelegate: BigNumber, - delegationParams: DelegationParameters, - ) { - const tokensToAllocate = toGRT('12500') - - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Transfer some funds from the curator, I don't want to mint new tokens - await grt.connect(curator1).transfer(delegator.address, tokensToDelegate) - await grt.connect(delegator).approve(staking.address, tokensToDelegate) - - // Stake and set delegation parameters - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .setDelegationParameters(delegationParams.indexingRewardCut, delegationParams.queryFeeCut, 0) - - // Delegate - await staking.connect(delegator).delegate(indexer1.address, tokensToDelegate) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - } - - it('should distribute rewards on closed allocation and stake', async function () { - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup - await setupIndexerAllocation() - - // Jump - await helpers.mineEpoch(epochManager) - - // Before state - const beforeTokenSupply = await grt.totalSupply() - const beforeIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - const beforeIndexer1Balance = await grt.balanceOf(indexer1.address) - const beforeStakingBalance = await grt.balanceOf(staking.address) - - // All the rewards in this subgraph go to this allocation. - // Rewards per token will be (issuancePerBlock * nBlocks) / allocatedTokens - // The first snapshot is after allocating, that is 2 blocks after the signal is minted. - // The final snapshot is when we close the allocation, that happens 9 blocks after signal is minted. - // So the rewards will be ((issuancePerBlock * 7) / allocatedTokens) * allocatedTokens - const expectedIndexingRewards = toGRT('1400') - - // Close allocation. At this point rewards should be collected for that indexer - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - const event = rewardsManager.interface.parseLog(receipt.logs[1]).args - expect(event.indexer).eq(indexer1.address) - expect(event.allocationID).eq(allocationID1) - expect(toRound(event.amount)).eq(toRound(expectedIndexingRewards)) - - // After state - const afterTokenSupply = await grt.totalSupply() - const afterIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - const afterIndexer1Balance = await grt.balanceOf(indexer1.address) - const afterStakingBalance = await grt.balanceOf(staking.address) - - // Check that rewards are put into indexer stake - const expectedIndexerStake = beforeIndexer1Stake.add(expectedIndexingRewards) - const expectedTokenSupply = beforeTokenSupply.add(expectedIndexingRewards) - // Check stake should have increased with the rewards staked - expect(toRound(afterIndexer1Stake)).eq(toRound(expectedIndexerStake)) - // Check indexer balance remains the same - expect(afterIndexer1Balance).eq(beforeIndexer1Balance) - // Check indexing rewards are kept in the staking contract - expect(toRound(afterStakingBalance)).eq(toRound(beforeStakingBalance.add(expectedIndexingRewards))) - // Check that tokens have been minted - expect(toRound(afterTokenSupply)).eq(toRound(expectedTokenSupply)) - }) - - it('does not revert with an underflow if the minimum signal changes', async function () { - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup - await setupIndexerAllocation() - - await rewardsManager.connect(governor).setMinimumSubgraphSignal(toGRT(14000)) - - // Jump - await helpers.mineEpoch(epochManager) - - // Close allocation. At this point rewards should be collected for that indexer - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - if (EMIT_EVENT_FOR_ZERO_REWARDS) { - await expect(tx) - .emit(rewardsManager, 'HorizonRewardsAssigned') - .withArgs(indexer1.address, allocationID1, toBN(0)) - } else { - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - } - }) - - it('does not revert with an underflow if the minimum signal changes, and signal came after allocation', async function () { - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup - await setupIndexerAllocationSignalingAfter() - - await rewardsManager.connect(governor).setMinimumSubgraphSignal(toGRT(14000)) - - // Jump - await helpers.mineEpoch(epochManager) - - // Close allocation. At this point rewards should be collected for that indexer - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - if (EMIT_EVENT_FOR_ZERO_REWARDS) { - await expect(tx) - .emit(rewardsManager, 'HorizonRewardsAssigned') - .withArgs(indexer1.address, allocationID1, toBN(0)) - } else { - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - } - }) - - it('does not revert if signal was already under minimum', async function () { - await rewardsManager.connect(governor).setMinimumSubgraphSignal(toGRT(2000)) - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup - await setupIndexerAllocation() - - // Jump - await helpers.mineEpoch(epochManager) - // Close allocation. At this point rewards should be collected for that indexer - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - if (EMIT_EVENT_FOR_ZERO_REWARDS) { - await expect(tx) - .emit(rewardsManager, 'HorizonRewardsAssigned') - .withArgs(indexer1.address, allocationID1, toBN(0)) - } else { - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - } - }) - - it('should distribute rewards on closed allocation and send to destination', async function () { - const destinationAddress = randomHexBytes(20) - await staking.connect(indexer1).setRewardsDestination(destinationAddress) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup - await setupIndexerAllocation() - - // Jump - await helpers.mineEpoch(epochManager) - - // Before state - const beforeTokenSupply = await grt.totalSupply() - const beforeIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - const beforeDestinationBalance = await grt.balanceOf(destinationAddress) - const beforeStakingBalance = await grt.balanceOf(staking.address) - - // All the rewards in this subgraph go to this allocation. - // Rewards per token will be (issuancePerBlock * nBlocks) / allocatedTokens - // The first snapshot is after allocating, that is 2 blocks after the signal is minted. - // The final snapshot is when we close the allocation, that happens 9 blocks after signal is minted. - // So the rewards will be ((issuancePerBlock * 7) / allocatedTokens) * allocatedTokens - const expectedIndexingRewards = toGRT('1400') - - // Close allocation. At this point rewards should be collected for that indexer - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - const event = rewardsManager.interface.parseLog(receipt.logs[1]).args - expect(event.indexer).eq(indexer1.address) - expect(event.allocationID).eq(allocationID1) - expect(toRound(event.amount)).eq(toRound(expectedIndexingRewards)) - - // After state - const afterTokenSupply = await grt.totalSupply() - const afterIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - const afterDestinationBalance = await grt.balanceOf(destinationAddress) - const afterStakingBalance = await grt.balanceOf(staking.address) - - // Check that rewards are properly assigned - const expectedIndexerStake = beforeIndexer1Stake - const expectedTokenSupply = beforeTokenSupply.add(expectedIndexingRewards) - // Check stake should not have changed - expect(toRound(afterIndexer1Stake)).eq(toRound(expectedIndexerStake)) - // Check indexing rewards are received by the rewards destination - expect(toRound(afterDestinationBalance)).eq(toRound(beforeDestinationBalance.add(expectedIndexingRewards))) - // Check indexing rewards were not sent to the staking contract - expect(afterStakingBalance).eq(beforeStakingBalance) - // Check that tokens have been minted - expect(toRound(afterTokenSupply)).eq(toRound(expectedTokenSupply)) - }) - - it('should distribute rewards on closed allocation w/delegators', async function () { - // Setup - const delegationParams = { - indexingRewardCut: toBN('823000'), // 82.30% - queryFeeCut: toBN('80000'), // 8% - cooldownBlocks: 0, - } - const tokensToDelegate = toGRT('2000') - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup the allocation and delegators - await setupIndexerAllocationWithDelegation(tokensToDelegate, delegationParams) - - // Jump - await helpers.mineEpoch(epochManager) - - // Before state - const beforeTokenSupply = await grt.totalSupply() - const beforeDelegationPool = await staking.delegationPools(indexer1.address) - const beforeIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - - // Close allocation. At this point rewards should be collected for that indexer - await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - // After state - const afterTokenSupply = await grt.totalSupply() - const afterDelegationPool = await staking.delegationPools(indexer1.address) - const afterIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - - // Check that rewards are put into indexer stake (only indexer cut) - // Check that rewards are put into delegators pool accordingly - - // All the rewards in this subgraph go to this allocation. - // Rewards per token will be (issuancePerBlock * nBlocks) / allocatedTokens - // The first snapshot is after allocating, that is 1 block after the signal is minted. - // The final snapshot is when we close the allocation, that happens 4 blocks after signal is minted. - // So the rewards will be ((issuancePerBlock * 3) / allocatedTokens) * allocatedTokens - const expectedIndexingRewards = toGRT('600') - // Calculate delegators cut - const indexerRewards = delegationParams.indexingRewardCut.mul(expectedIndexingRewards).div(toBN(MAX_PPM)) - // Calculate indexer cut - const delegatorsRewards = expectedIndexingRewards.sub(indexerRewards) - // Check - const expectedIndexerStake = beforeIndexer1Stake.add(indexerRewards) - const expectedDelegatorsPoolTokens = beforeDelegationPool.tokens.add(delegatorsRewards) - const expectedTokenSupply = beforeTokenSupply.add(expectedIndexingRewards) - expect(toRound(afterIndexer1Stake)).eq(toRound(expectedIndexerStake)) - expect(toRound(afterDelegationPool.tokens)).eq(toRound(expectedDelegatorsPoolTokens)) - // Check that tokens have been minted - expect(toRound(afterTokenSupply)).eq(toRound(expectedTokenSupply)) - }) - - it('should deny rewards if subgraph on denylist', async function () { - // Setup: create allocation BEFORE denying the subgraph - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Create allocation - await setupIndexerAllocation() - - // Jump to earn some rewards - await helpers.mineEpoch(epochManager) - - // Now deny the subgraph - this freezes accRewardsPerAllocatedToken - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Close allocation - pre-denial rewards should be denied - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - await expect(tx).emit(rewardsManager, 'RewardsDenied').withArgs(indexer1.address, allocationID1) - }) - - it('should handle zero rewards scenario correctly', async function () { - // Setup allocation with zero issuance to create zero rewards scenario - await rewardsManager.connect(governor).setIssuancePerBlock(0) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Before state - const beforeTokenSupply = await grt.totalSupply() - const beforeStakingBalance = await grt.balanceOf(staking.address) - - // Close allocation. At this point rewards should be zero - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - if (EMIT_EVENT_FOR_ZERO_REWARDS) { - await expect(tx).emit(rewardsManager, 'HorizonRewardsAssigned').withArgs(indexer1.address, allocationID1, 0) - } else { - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - } - - // After state - should be unchanged since no rewards were minted - const afterTokenSupply = await grt.totalSupply() - const afterStakingBalance = await grt.balanceOf(staking.address) - - // Check that no tokens were minted (rewards were 0) - expect(afterTokenSupply).eq(beforeTokenSupply) - expect(afterStakingBalance).eq(beforeStakingBalance) - }) - - it('should handle zero rewards with denylist and reclaim address', async function () { - // Setup reclaim address for SubgraphDenied - const reclaimWallet = assetHolder - await rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, reclaimWallet.address) - - // Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation with zero rewards (no signal) - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Close allocation immediately (same epoch) - should have zero rewards - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - // Should not emit events for zero rewards - await expect(tx).to.not.emit(rewardsManager, 'RewardsDenied') - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - if (EMIT_EVENT_FOR_ZERO_REWARDS) { - await expect(tx).emit(rewardsManager, 'HorizonRewardsAssigned').withArgs(indexer1.address, allocationID1, 0) - } else { - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - } - }) - - it('should handle zero rewards with eligibility oracle and reclaim address', async function () { - // Setup reclaim address for IndexerIneligible - const reclaimWallet = assetHolder - await rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, reclaimWallet.address) - - // Setup eligibility oracle that denies - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation with zero rewards (no signal) - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Close allocation immediately (same epoch) - should have zero rewards - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - // Should not emit events for zero rewards - await expect(tx).to.not.emit(rewardsManager, 'RewardsDeniedDueToEligibility') - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - if (EMIT_EVENT_FOR_ZERO_REWARDS) { - await expect(tx).emit(rewardsManager, 'HorizonRewardsAssigned').withArgs(indexer1.address, allocationID1, 0) - } else { - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - } - }) - - it('should allow collecting pre-denial rewards after undeny', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - // Align with epoch boundary - await helpers.mineEpoch(epochManager) - - // Create allocation - await setupIndexerAllocation() - - // Jump to earn rewards - await helpers.mineEpoch(epochManager) - - // Check rewards exist before deny - const rewardsBefore = await rewardsManager.getRewards(staking.address, allocationID1) - expect(rewardsBefore).gt(0) - - // Deny the subgraph - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Jump while denied (these rewards should be reclaimed, not available) - await helpers.mineEpoch(epochManager) - - // Undeny the subgraph - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, false) - - // Before state - const beforeTokenSupply = await grt.totalSupply() - const beforeIndexerStake = await staking.getIndexerStakedTokens(indexer1.address) - - // Close allocation - should receive pre-denial rewards (frozen value) - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Should emit HorizonRewardsAssigned with non-zero rewards - const event = rewardsManager.interface.parseLog(receipt.logs[1]).args - expect(event.indexer).eq(indexer1.address) - expect(event.allocationID).eq(allocationID1) - expect(event.amount).gt(0) - - // After state - tokens should have been minted - const afterTokenSupply = await grt.totalSupply() - const afterIndexerStake = await staking.getIndexerStakedTokens(indexer1.address) - expect(afterTokenSupply).gt(beforeTokenSupply) - expect(afterIndexerStake).gt(beforeIndexerStake) - }) - - it('allocation created while denied should only earn post-undeny rewards', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - // Align with epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup signal first - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Deny the subgraph BEFORE creating allocation - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Jump while denied - await helpers.mineEpoch(epochManager) - - // Create allocation while denied - snapshot = frozen accRewardsPerAllocatedToken - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Jump while still denied - await helpers.mineEpoch(epochManager) - - // Undeny the subgraph - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, false) - - // Jump to earn post-undeny rewards - await helpers.mineEpoch(epochManager) - - // Before state - const beforeTokenSupply = await grt.totalSupply() - - // Close allocation - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // After state - const afterTokenSupply = await grt.totalSupply() - - // Should have earned ONLY post-undeny rewards (not denied-period rewards) - // The rewards should be small since only 1 epoch passed after undeny - const event = rewardsManager.interface.parseLog(receipt.logs[1]).args - expect(event.amount).gt(0) - expect(afterTokenSupply).gt(beforeTokenSupply) - }) - - it('should reclaim denied-period rewards via onSubgraphAllocationUpdate', async function () { - // Setup reclaim address - const reclaimWallet = assetHolder - await rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, reclaimWallet.address) - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - // Align with epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup signal and allocation - await setupIndexerAllocation() - - // Jump to earn rewards - await helpers.mineEpoch(epochManager) - - // Deny the subgraph - this freezes accRewardsPerAllocatedToken - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Record reclaim wallet balance - const beforeReclaimBalance = await grt.balanceOf(reclaimWallet.address) - - // Jump while denied - new rewards should be reclaimed - await helpers.mineEpoch(epochManager) - - // Trigger onSubgraphAllocationUpdate by creating another allocation - // This will reclaim the denied-period rewards - // Use allocationID2 which already has a matching channelKey2 - const tokensToAllocate2 = toGRT('5000') - await staking.connect(indexer1).stake(tokensToAllocate2) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate2, - allocationID2, - metadata, - await channelKey2.generateProof(indexer1.address), - ) - - // Reclaim wallet should have received rewards - const afterReclaimBalance = await grt.balanceOf(reclaimWallet.address) - expect(afterReclaimBalance).gt(beforeReclaimBalance) - }) - }) - }) - - describe('edge scenarios', function () { - it('close allocation on a subgraph that no longer have signal', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Jump - await helpers.mineEpoch(epochManager) - - // Remove all signal from the subgraph - const curatorShares = await curation.getCuratorSignal(curator1.address, subgraphDeploymentID1) - await curation.connect(curator1).burn(subgraphDeploymentID1, curatorShares, 0) - - // Close allocation. At this point rewards should be collected for that indexer - await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - }) - }) - - describe('multiple allocations', function () { - it('two allocations in the same block with a GRT burn in the middle should succeed', async function () { - // If rewards are not monotonically increasing, this can trigger - // a subtraction overflow error as seen in mainnet tx: - // 0xb6bf7bbc446720a7409c482d714aebac239dd62e671c3c94f7e93dd3a61835ab - await helpers.mineEpoch(epochManager) - - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Stake - const tokensToStake = toGRT('12500') - await staking.connect(indexer1).stake(tokensToStake) - - // Allocate simultaneously, burning in the middle - const tokensToAlloc = toGRT('5000') - await helpers.setAutoMine(false) - const tx1 = await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAlloc, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - const tx2 = await grt.connect(indexer1).burn(toGRT(1)) - const tx3 = await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAlloc, - allocationID2, - metadata, - await channelKey2.generateProof(indexer1.address), - ) - - await helpers.mine() - await helpers.setAutoMine(true) - - await expect(tx1).emit(staking, 'AllocationCreated') - await expect(tx2).emit(grt, 'Transfer') - await expect(tx3).emit(staking, 'AllocationCreated') - }) - it('two simultanous-similar allocations should get same amount of rewards', async function () { - await helpers.mineEpoch(epochManager) - - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Stake - const tokensToStake = toGRT('12500') - await staking.connect(indexer1).stake(tokensToStake) - - // Allocate simultaneously - const tokensToAlloc = toGRT('5000') - const tx1 = await staking.populateTransaction.allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAlloc, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - const tx2 = await staking.populateTransaction.allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAlloc, - allocationID2, - metadata, - await channelKey2.generateProof(indexer1.address), - ) - await staking.connect(indexer1).multicall([tx1.data, tx2.data]) - - // Jump - await helpers.mineEpoch(epochManager) - - // Close allocations simultaneously - const tx3 = await staking.populateTransaction.closeAllocation(allocationID1, randomHexBytes()) - const tx4 = await staking.populateTransaction.closeAllocation(allocationID2, randomHexBytes()) - const tx5 = await staking.connect(indexer1).multicall([tx3.data, tx4.data]) - - // Both allocations should receive the same amount of rewards - const receipt = await tx5.wait() - const event1 = rewardsManager.interface.parseLog(receipt.logs[1]).args - const event2 = rewardsManager.interface.parseLog(receipt.logs[5]).args - expect(event1.amount).eq(event2.amount) - }) - }) - - describe('rewards progression when collecting query fees', function () { - it('collect query fees with two subgraphs and one allocation', async function () { - async function getRewardsAccrual(subgraphs) { - const [sg1, sg2] = await Promise.all(subgraphs.map((sg) => rewardsManager.getAccRewardsForSubgraph(sg))) - return { - sg1, - sg2, - all: sg1.add(sg2), - } - } - - // set curation percentage - await staking.connect(governor).setCurationPercentage(100000) - - // allow the asset holder - const tokensToCollect = toGRT('10000') - - // signal in two subgraphs in the same block - const subgraphs = [subgraphDeploymentID1, subgraphDeploymentID2] - await hre.network.provider.send('evm_setAutomine', [false]) - for (const sub of subgraphs) { - await curation.connect(curator1).mint(sub, toGRT('1500'), 0) - } - await hre.network.provider.send('evm_mine') - await hre.network.provider.send('evm_setAutomine', [true]) - - // allocate - const tokensToAllocate = toGRT('12500') - await staking - .connect(indexer1) - .multicall([ - await staking.populateTransaction.stake(tokensToAllocate).then((tx) => tx.data), - await staking.populateTransaction - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - .then((tx) => tx.data), - ]) - - // snapshot block after allocation (rewards before allocation were reclaimed for subgraph1) - const b1 = await epochManager.blockNum().then((x) => x.toNumber()) - - // move time fwd - await helpers.mineEpoch(epochManager) - - // collect funds into staking for that sub - await staking.connect(assetHolder).collect(tokensToCollect, allocationID1) - - // check rewards diff - await rewardsManager.getRewards(staking.address, allocationID1).then(formatGRT) - - await helpers.mine() - const accrual = await getRewardsAccrual(subgraphs) - const b2 = await epochManager.blockNum().then((x) => x.toNumber()) - - // Only check subgraph1 (with allocation) - subgraph2 has no allocation so its rewards - // are calculated from signal time, not from allocation time - // Each subgraph gets half the issuance (equal signal) - // Small tolerance for fixed-point arithmetic rounding - const expectedSg1Rewards = ISSUANCE_PER_BLOCK.div(2).mul(b2 - b1) - expect(toRound(accrual.sg1.mul(100).div(expectedSg1Rewards))).eq(toRound(BigNumber.from(100))) - }) - }) -}) diff --git a/packages/issuance/test/unit/rewards-manager/config.t.sol b/packages/issuance/test/unit/rewards-manager/config.t.sol index 6c133a66c..57754211d 100644 --- a/packages/issuance/test/unit/rewards-manager/config.t.sol +++ b/packages/issuance/test/unit/rewards-manager/config.t.sol @@ -11,11 +11,6 @@ contract RewardsManagerConfigTest is RewardsManagerSharedTest { address internal availabilityOracle = makeAddr("availabilityOracle"); - function _setAvailabilityOracle() internal { - vm.prank(governor); - rm.setSubgraphAvailabilityOracle(availabilityOracle); - } - // -- initialize -- function test_Initialize_RevertWhen_CalledOnImplementation() public { @@ -117,7 +112,7 @@ contract RewardsManagerConfigTest is RewardsManagerSharedTest { } function test_SubgraphAvailabilityService_SetOracle() public { - _setAvailabilityOracle(); + _setAvailabilityOracle(availabilityOracle); assertEq(rm.subgraphAvailabilityOracle(), availabilityOracle); } @@ -128,7 +123,7 @@ contract RewardsManagerConfigTest is RewardsManagerSharedTest { } function test_SubgraphAvailabilityService_Deny() public { - _setAvailabilityOracle(); + _setAvailabilityOracle(availabilityOracle); vm.expectEmit(address(rm)); emit RewardsDenylistUpdated(SUBGRAPH_DEPLOYMENT_ID_1, block.number); @@ -138,7 +133,7 @@ contract RewardsManagerConfigTest is RewardsManagerSharedTest { } function test_SubgraphAvailabilityService_Undeny() public { - _setAvailabilityOracle(); + _setAvailabilityOracle(availabilityOracle); vm.prank(availabilityOracle); rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); @@ -152,7 +147,7 @@ contract RewardsManagerConfigTest is RewardsManagerSharedTest { } function test_SubgraphAvailabilityService_DenyAlreadyDeniedIsNoOp() public { - _setAvailabilityOracle(); + _setAvailabilityOracle(availabilityOracle); vm.prank(availabilityOracle); rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); @@ -169,7 +164,7 @@ contract RewardsManagerConfigTest is RewardsManagerSharedTest { } function test_SubgraphAvailabilityService_UndenyNotDeniedIsNoOp() public { - _setAvailabilityOracle(); + _setAvailabilityOracle(availabilityOracle); assertFalse(rm.isDenied(SUBGRAPH_DEPLOYMENT_ID_1)); @@ -189,7 +184,7 @@ contract RewardsManagerConfigTest is RewardsManagerSharedTest { } function test_SubgraphAvailabilityService_SetMinimumSubgraphSignalFromOracle() public { - _setAvailabilityOracle(); + _setAvailabilityOracle(availabilityOracle); uint256 newMinimumSignal = 2000 ether; vm.expectEmit(address(rm)); diff --git a/packages/issuance/test/unit/rewards-manager/eligibility.t.sol b/packages/issuance/test/unit/rewards-manager/eligibility.t.sol index 30ac1a6fa..2e679fae3 100644 --- a/packages/issuance/test/unit/rewards-manager/eligibility.t.sol +++ b/packages/issuance/test/unit/rewards-manager/eligibility.t.sol @@ -17,23 +17,6 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { address internal allocationID1 = makeAddr("allocationID1"); - /// Old `setupIndexerAllocation` followed by `mineEpoch`: the allocation earns `EPOCH_LENGTH` blocks. - function _allocateAndAccrue() internal returns (uint256 expectedRewards) { - _signalThenAllocate(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); - vm.roll(block.number + EPOCH_LENGTH); - return _expectedAllocationRewards(EPOCH_LENGTH, ALLOCATION, ALLOCATION); - } - - function _setAvailabilityOracle() internal { - vm.prank(governor); - rm.setSubgraphAvailabilityOracle(governor); - } - - function _setDenied(bool deny) internal { - vm.prank(governor); - rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, deny); - } - function _findLog(Vm.Log[] memory logs, bytes32 topic) internal view returns (Vm.Log memory) { for (uint256 i = 0; i < logs.length; ++i) { if (logs[i].emitter == address(rm) && logs[i].topics[0] == topic) return logs[i]; @@ -99,7 +82,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { function test_RewardsEligibilityInTakeRewards_DeniesRewardsDueToEligibilityOracle() public { _useEligibilityOracle(false); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.expectEmit(address(rm), 1); emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); @@ -108,7 +91,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { function test_RewardsEligibilityInTakeRewards_AllowsRewardsWhenOracleApproves() public { _useEligibilityOracle(true); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.expectEmit(address(rm), 1); emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); @@ -118,12 +101,12 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { // -- rewards eligibility oracle and denylist interaction -- function test_RewardsEligibilityOracleAndDenylistInteraction_PrioritizesDenylistOverOracleWhenBothDeny() public { - _setAvailabilityOracle(); - _setDenied(true); + _setAvailabilityOracle(governor); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); _useEligibilityOracle(false); // Created while denied: the accumulator is frozen, so the allocation has no rewards to deny - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.recordLogs(); _close(allocationID1); @@ -137,7 +120,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { public { _useEligibilityOracle(false); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.expectEmit(address(rm), 1); emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); @@ -146,7 +129,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { function test_RewardsEligibilityOracleAndDenylistInteraction_IndexerBecomesIneligibleMidAllocation() public { _useEligibilityOracle(true); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); // Eligibility is checked at close time, not at creation time oracle.setIndexerEligible(indexer, false); @@ -158,7 +141,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { function test_RewardsEligibilityOracleAndDenylistInteraction_IndexerBecomesEligibleMidAllocation() public { _useEligibilityOracle(false); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); oracle.setIndexerEligible(indexer, true); @@ -168,10 +151,10 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { } function test_RewardsEligibilityOracleAndDenylistInteraction_DenylistAddedMidAllocation() public { - _setAvailabilityOracle(); - _allocateAndAccrue(); + _setAvailabilityOracle(governor); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); - _setDenied(true); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); vm.roll(block.number + 1); vm.expectEmit(address(rm)); @@ -180,14 +163,14 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { } function test_RewardsEligibilityOracleAndDenylistInteraction_DenylistRemovedMidAllocation() public { - _setAvailabilityOracle(); - _setDenied(true); + _setAvailabilityOracle(governor); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); // Created while denied: rewards accrued while denied are reclaimed at the subgraph level - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); // Undeny snapshots the accumulator, then only post-undeny rewards accrue - _setDenied(false); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, false); vm.roll(block.number + EPOCH_LENGTH); vm.expectEmit(address(rm)); @@ -202,7 +185,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { function test_RewardsEligibilityOracleAndDenylistInteraction_AllowsRewardsWhenOracleIsZeroAddress() public { assertEq(address(rm.getProviderEligibilityOracle()), address(0)); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.expectEmit(address(rm), 1); emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); @@ -214,7 +197,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { vm.prank(governor); rm.setRevertOnIneligible(true); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.expectRevert(bytes("Indexer not eligible for rewards")); issuer.close(allocationID1); @@ -225,7 +208,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { vm.prank(governor); rm.setRevertOnIneligible(true); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.expectEmit(address(rm)); emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); @@ -238,7 +221,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { _useEligibilityOracle(false); assertFalse(rm.getRevertOnIneligible()); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.expectEmit(address(rm), 1); emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); @@ -250,9 +233,9 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { uint256 snapshot = vm.snapshotState(); // Denylist denial: event WITHOUT amount. Deny after the allocation so there are rewards to deny. - _setAvailabilityOracle(); - _allocateAndAccrue(); - _setDenied(true); + _setAvailabilityOracle(governor); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); vm.roll(block.number + 1); vm.recordLogs(); @@ -268,7 +251,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { // Eligibility denial: event WITH amount, what the indexer would have received _useEligibilityOracle(false); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.recordLogs(); _close(allocationID1); diff --git a/packages/issuance/test/unit/rewards-manager/legacySuite.t.sol b/packages/issuance/test/unit/rewards-manager/legacySuite.t.sol new file mode 100644 index 000000000..a30ccbc52 --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/legacySuite.t.sol @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Vm } from "forge-std/Vm.sol"; +import { RewardsCondition } from "@graphprotocol/interfaces/contracts/contracts/rewards/RewardsCondition.sol"; + +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +/// @notice The cases of the legacy `rewards.test.ts` that no other RewardsManager port covers. +contract RewardsManagerLegacySuiteTest is RewardsManagerSharedTest { + event HorizonRewardsAssigned(address indexed indexer, address indexed allocationID, uint256 amount); + event RewardsDenied(address indexed indexer, address indexed allocationID); + event RewardsDeniedDueToEligibility(address indexed indexer, address indexed allocationID, uint256 amount); + event RewardsReclaimed( + bytes32 indexed reason, + uint256 amount, + address indexed indexer, + address indexed allocationID, + bytes32 subgraphDeploymentID + ); + + uint256 internal constant ISSUANCE_RATE_PERIODS = 4; + + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_1 = keccak256("subgraphDeploymentID1"); + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_2 = keccak256("subgraphDeploymentID2"); + + address internal allocationID1 = makeAddr("allocationID1"); + address internal allocationID2 = makeAddr("allocationID2"); + address internal reclaimWallet = makeAddr("reclaimWallet"); + + /// Allocation on a subgraph with no signal, closed one block later: there are no rewards to deny or reclaim. + /// Legacy Staking closed this under-one-epoch allocation through `onSubgraphAllocationUpdate`, not `takeRewards`, + /// so both paths are asserted. + function _allocateWithoutSignalAndClose() internal returns (Vm.Log[] memory logs) { + vm.roll(block.number + EPOCH_LENGTH); + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + vm.roll(block.number + 1); + + uint256 supplyBefore = token.totalSupply(); + vm.recordLogs(); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID_1); + assertEq(_close(allocationID1), 0); + logs = vm.getRecordedLogs(); + + assertEq(token.totalSupply(), supplyBefore); + assertEq(token.balanceOf(reclaimWallet), 0); + _assertNotEmitted(logs, RewardsReclaimed.selector); + _assertNotEmitted(logs, HorizonRewardsAssigned.selector); + } + + // -- getAccRewardsForSubgraph -- + + function test_GetAccRewardsForSubgraph_AccruedProportionallyToSignal() public { + uint256 signalled1 = 1500 ether; + uint256 signalled2 = 500 ether; + uint256 tokensToAllocate = 10_000 ether; + + _signal(SUBGRAPH_DEPLOYMENT_ID_1, signalled1); + _signal(SUBGRAPH_DEPLOYMENT_ID_2, signalled2); + vm.roll(block.number + 1); + + // Both subgraphs have an allocation, so their rewards accrue instead of being reclaimed + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, tokensToAllocate); + _allocate(allocationID2, SUBGRAPH_DEPLOYMENT_ID_2, tokensToAllocate); + + uint256 startRewardsSG1 = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_1); + uint256 startRewardsSG2 = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_2); + + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + uint256 accruedSG1 = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_1) - startRewardsSG1; + uint256 accruedSG2 = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_2) - startRewardsSG2; + uint256 totalAccrued = accruedSG1 + accruedSG2; + assertGt(totalAccrued, 0, "Should have accrued rewards"); + + // SG1 holds 75% of the signal (1500 / 2000), SG2 25% + assertApproxEqAbs((accruedSG1 * 100) / totalAccrued, 75, 1, "SG1 should have ~75% of rewards"); + assertApproxEqAbs((accruedSG2 * 100) / totalAccrued, 25, 1, "SG2 should have ~25% of rewards"); + + uint256 totalSignal = signalled1 + signalled2; + assertEq(accruedSG1, _expectedSubgraphRewards(ISSUANCE_RATE_PERIODS, totalSignal, signalled1)); + assertEq(accruedSG2, _expectedSubgraphRewards(ISSUANCE_RATE_PERIODS, totalSignal, signalled2)); + } + + // -- takeRewards -- + + function test_TakeRewards_ZeroRewardsWithDenylistAndReclaimAddress() public { + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + _setAvailabilityOracle(governor); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + + Vm.Log[] memory logs = _allocateWithoutSignalAndClose(); + _assertNotEmitted(logs, RewardsDenied.selector); + } + + function test_TakeRewards_ZeroRewardsWithEligibilityOracleAndReclaimAddress() public { + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); + _useEligibilityOracle(false); + + Vm.Log[] memory logs = _allocateWithoutSignalAndClose(); + _assertNotEmitted(logs, RewardsDeniedDueToEligibility.selector); + } + + function test_TakeRewards_CollectsPreDenialRewardsAfterUndeny() public { + _setAvailabilityOracle(governor); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); + + uint256 rewardsBefore = rm.getRewards(address(issuer), allocationID1); + assertGt(rewardsBefore, 0); + assertEq(rewardsBefore, expectedRewards); + + // Denying freezes accRewardsPerAllocatedToken; the denied-period rewards are not the allocation's + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + vm.roll(block.number + EPOCH_LENGTH); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, false); + + uint256 supplyBefore = token.totalSupply(); + uint256 issuerBalanceBefore = token.balanceOf(address(issuer)); + + // Closed in the undeny block, so the amount is exactly the frozen pre-denial value + vm.expectEmit(address(rm)); + emit HorizonRewardsAssigned(indexer, allocationID1, rewardsBefore); + uint256 rewards = _close(allocationID1); + + assertGt(rewards, 0); + assertEq(rewards, rewardsBefore); + assertEq(token.totalSupply(), supplyBefore + rewards); + assertEq(token.balanceOf(address(issuer)), issuerBalanceBefore + rewards); + } + + function test_TakeRewards_AllocationCreatedWhileDeniedEarnsOnlyPostUndenyRewards() public { + _setAvailabilityOracle(governor); + + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + vm.roll(block.number + 1); + + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + vm.roll(block.number + EPOCH_LENGTH); + + // Snapshot is the frozen accRewardsPerAllocatedToken + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + vm.roll(block.number + EPOCH_LENGTH); + + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, false); + vm.roll(block.number + EPOCH_LENGTH); + + // Only the epoch after the undeny counts + uint256 expectedRewards = _expectedAllocationRewards(EPOCH_LENGTH, ALLOCATION, ALLOCATION); + uint256 supplyBefore = token.totalSupply(); + + vm.expectEmit(address(rm)); + emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); + uint256 rewards = _close(allocationID1); + + assertGt(rewards, 0); + assertEq(rewards, expectedRewards); + assertGt(token.totalSupply(), supplyBefore); + assertEq(token.totalSupply(), supplyBefore + expectedRewards); + } + + function test_TakeRewards_ReclaimsDeniedPeriodRewardsViaOnSubgraphAllocationUpdate() public { + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + _setAvailabilityOracle(governor); + + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); + + // Denying distributes the pre-denial rewards to the allocation, then freezes the accumulator + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.roll(block.number + EPOCH_LENGTH); + + // A second allocation triggers onSubgraphAllocationUpdate, which reclaims the denied period + uint256 deniedPeriodRewards = _subgraphRewards(EPOCH_LENGTH); + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.SUBGRAPH_DENIED, + deniedPeriodRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _allocate(allocationID2, SUBGRAPH_DEPLOYMENT_ID_1, 5000 ether); + + assertGt(token.balanceOf(reclaimWallet), balanceBefore); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, deniedPeriodRewards); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/mocks/RMIssuerMock.sol b/packages/issuance/test/unit/rewards-manager/mocks/RMIssuerMock.sol index ce6fe3963..db08782f5 100644 --- a/packages/issuance/test/unit/rewards-manager/mocks/RMIssuerMock.sol +++ b/packages/issuance/test/unit/rewards-manager/mocks/RMIssuerMock.sol @@ -33,7 +33,8 @@ contract RMIssuerMock is IRewardsIssuer { /// Takes rewards while the allocation is still active, then deactivates it. /// @dev Legacy Staking called `onSubgraphAllocationUpdate` instead of `takeRewards` for a zero POI or an - /// allocation under one epoch old. The ported tests never hit that case. + /// allocation under one epoch old. Only the zero-reward cases in `legacySuite.t.sol` hit that case, and they call + /// the hook themselves. function close(address allocationId) external returns (uint256 rewards) { rewards = rm.takeRewards(allocationId); Allocation storage allocation = _allocations[allocationId]; diff --git a/packages/issuance/test/unit/rewards-manager/reclaim.t.sol b/packages/issuance/test/unit/rewards-manager/reclaim.t.sol index 32ed0ce78..0835c8ecc 100644 --- a/packages/issuance/test/unit/rewards-manager/reclaim.t.sol +++ b/packages/issuance/test/unit/rewards-manager/reclaim.t.sol @@ -32,40 +32,19 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { // -- Helpers -- - /// Old `setupIndexerAllocation` followed by `mineEpoch`: the allocation earns `EPOCH_LENGTH` blocks. - function _allocateAndAccrue() internal { - _signalThenAllocate(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); - vm.roll(block.number + EPOCH_LENGTH); - } - /// Rewards of the single allocation over `EPOCH_LENGTH` blocks. function _epochAllocationRewards() internal pure returns (uint256) { return _expectedAllocationRewards(EPOCH_LENGTH, ALLOCATION, ALLOCATION); } - function _setReclaimAddress(bytes32 reason, address target) internal { - vm.prank(governor); - rm.setReclaimAddress(reason, target); - } - function _setDefaultReclaimAddress(address target) internal { vm.prank(governor); rm.setDefaultReclaimAddress(target); } - function _setAvailabilityOracle() internal { - vm.prank(governor); - rm.setSubgraphAvailabilityOracle(governor); - } - - function _setDenied(bool deny) internal { - vm.prank(governor); - rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, deny); - } - function _deny() internal { - _setAvailabilityOracle(); - _setDenied(true); + _setAvailabilityOracle(governor); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); } /// Allocation data on the issuer, as the old test's `MockSubgraphService.setAllocation`. @@ -88,7 +67,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { /// Old `default reclaim address fallback` beforeEach. modifier withAllocationAndAvailabilityOracle() { _signalThenAllocate(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); - _setAvailabilityOracle(); + _setAvailabilityOracle(governor); _; } @@ -147,7 +126,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { function test_ReclaimDeniedRewardsSubgraphDenylist_MintsToReclaimAddressWhenSet() public { _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); _deny(); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); uint256 expectedRewards = _subgraphRewards(EPOCH_LENGTH); uint256 balanceBefore = token.balanceOf(reclaimWallet); @@ -169,11 +148,11 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { function test_ReclaimDeniedRewardsSubgraphDenylist_ReclaimsPreDenialRewardsWhenDeniedAfterAllocation() public { _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); - _setAvailabilityOracle(); + _setAvailabilityOracle(governor); // Allocate first, then deny: the pre-denial rewards stay with the allocation - _allocateAndAccrue(); - _setDenied(true); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); // Close in the next block, as in Hardhat: that block is reclaimed at the subgraph level vm.roll(block.number + 1); @@ -207,7 +186,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { function test_ReclaimDeniedRewardsSubgraphDenylist_NoMintWhenReclaimAddressNotSet() public { _deny(); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.recordLogs(); _close(allocationID1); @@ -221,7 +200,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { function test_ReclaimDeniedRewardsEligibility_MintsToReclaimAddressWhenSet() public { _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); _useEligibilityOracle(false); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); uint256 expectedRewards = _epochAllocationRewards(); uint256 balanceBefore = token.balanceOf(reclaimWallet); @@ -243,7 +222,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { function test_ReclaimDeniedRewardsEligibility_NoMintWhenReclaimAddressNotSet() public { _useEligibilityOracle(false); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.recordLogs(); vm.expectEmit(address(rm), 1); @@ -259,7 +238,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, otherWallet); _deny(); _useEligibilityOracle(false); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); uint256 expectedRewards = _subgraphRewards(EPOCH_LENGTH); uint256 subgraphDeniedBalanceBefore = token.balanceOf(reclaimWallet); @@ -287,7 +266,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, otherWallet); _deny(); _useEligibilityOracle(false); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); uint256 balanceBefore = token.balanceOf(otherWallet); @@ -306,12 +285,12 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { public { _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); - _setAvailabilityOracle(); + _setAvailabilityOracle(governor); _useEligibilityOracle(false); // Allocate first, then deny, so the rewards reach `_deniedRewards` - _allocateAndAccrue(); - _setDenied(true); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); vm.roll(block.number + 1); uint256 expectedRewards = _epochAllocationRewards(); @@ -339,7 +318,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { function test_ReclaimPrecedenceFirstSuccessfulReclaimWins_DropsWhenNeitherAddressConfigured() public { _deny(); _useEligibilityOracle(false); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.recordLogs(); _close(allocationID1); @@ -354,7 +333,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { { _deny(); _useEligibilityOracle(true); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.recordLogs(); _close(allocationID1); @@ -476,7 +455,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { withAllocationAndAvailabilityOracle { _setDefaultReclaimAddress(reclaimWallet); - _setDenied(true); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); vm.roll(block.number + MINED_BLOCKS); uint256 expectedRewards = _subgraphRewards(MINED_BLOCKS); @@ -503,7 +482,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { { _setDefaultReclaimAddress(otherWallet); _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); - _setDenied(true); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); vm.roll(block.number + MINED_BLOCKS); uint256 balanceBefore = token.balanceOf(reclaimWallet); @@ -672,12 +651,12 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { { _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, otherWallet); - _setAvailabilityOracle(); + _setAvailabilityOracle(governor); _useEligibilityOracle(false); // Allocate first, then deny, so the rewards reach `_deniedRewards` - _allocateAndAccrue(); - _setDenied(true); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); vm.roll(block.number + 1); uint256 allocationRewards = _epochAllocationRewards(); diff --git a/packages/issuance/test/unit/rewards-manager/shared.t.sol b/packages/issuance/test/unit/rewards-manager/shared.t.sol index e34e077f1..676a38f54 100644 --- a/packages/issuance/test/unit/rewards-manager/shared.t.sol +++ b/packages/issuance/test/unit/rewards-manager/shared.t.sol @@ -92,6 +92,29 @@ contract RewardsManagerSharedTest is ProtocolFixture { _allocate(allocationId, sd, ALLOCATION); } + /// Old `setupIndexerAllocation` followed by `mineEpoch`: the allocation earns `EPOCH_LENGTH` blocks. + function _allocateAndAccrue(bytes32 sd, address allocationId) internal returns (uint256 expectedRewards) { + _signalThenAllocate(sd, allocationId); + vm.roll(block.number + EPOCH_LENGTH); + return _expectedAllocationRewards(EPOCH_LENGTH, ALLOCATION, ALLOCATION); + } + + function _setAvailabilityOracle(address availabilityOracle) internal { + vm.prank(governor); + rm.setSubgraphAvailabilityOracle(availabilityOracle); + } + + /// Calls `setDenied` as governor, so the caller must have set governor as the availability oracle. + function _setDenied(bytes32 sd, bool deny) internal { + vm.prank(governor); + rm.setDenied(sd, deny); + } + + function _setReclaimAddress(bytes32 reason, address target) internal { + vm.prank(governor); + rm.setReclaimAddress(reason, target); + } + /// Sets the fixture's `oracle` as the eligibility oracle, answering `eligibleByDefault` for every indexer. function _useEligibilityOracle(bool eligibleByDefault) internal { oracle.setDefaultResponse(eligibleByDefault); diff --git a/packages/issuance/test/unit/rewards-manager/signalAllocationUpdate.t.sol b/packages/issuance/test/unit/rewards-manager/signalAllocationUpdate.t.sol index b5d295086..9b1e95875 100644 --- a/packages/issuance/test/unit/rewards-manager/signalAllocationUpdate.t.sol +++ b/packages/issuance/test/unit/rewards-manager/signalAllocationUpdate.t.sol @@ -37,11 +37,6 @@ contract RewardsManagerSignalAllocationUpdateTest is RewardsManagerSharedTest { vm.stopPrank(); } - function _setReclaimAddress(bytes32 reason, address target) internal { - vm.prank(governor); - rm.setReclaimAddress(reason, target); - } - function _perAllocatedToken(uint256 blocks) internal pure returns (uint256) { return _expectedRewardsPerAllocatedToken(_subgraphRewards(blocks), ALLOCATION); } diff --git a/scripts/contracts-migration/test-mapping/t5f.md b/scripts/contracts-migration/test-mapping/t5f.md new file mode 100644 index 000000000..937d62dca --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t5f.md @@ -0,0 +1,71 @@ +# T5f test mapping: legacy RewardsManager suite (`rewards.test.ts`) + +`rewards.test.ts` repeats most of the cases T5a–T5e already ported from the split files. For each case the old assertions were compared with the existing Foundry test (not just the title). Where they match, the row points at that test. The cases whose assertions differ or that no other file has are in `test/unit/rewards-manager/legacySuite.t.sol`, on the shared `RewardsManagerSharedTest`. Translations follow T5b: `allocateFrom` is `_allocate`, `closeAllocation` is `_close`, `curation.mint` is `_signal`, `mine` / `mineEpoch` are `vm.roll` (`EPOCH_LENGTH` = 10), and `issuer` is the subgraph service the old suite pointed at legacy Staking. Legacy Staking assertions (stake, destination, delegation pool) follow the drop rule. + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › initialize › should revert when called on implementation contract | test/unit/rewards-manager/config.t.sol::test_Initialize_RevertWhen_CalledOnImplementation | Duplicate; assertions match (`Only implementation`, called on the proxy) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › issuance per block update › reject set issuance per block if unauthorized | test/unit/rewards-manager/config.t.sol::test_IssuancePerBlockUpdate_RevertWhen_Unauthorized | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › issuance per block update › should set issuance rate to minimum allowed (0) | test/unit/rewards-manager/config.t.sol::test_IssuancePerBlockUpdate_SetToMinimumZero | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › issuance per block update › should set issuance rate | test/unit/rewards-manager/config.t.sol::test_IssuancePerBlockUpdate_SetIssuanceRate | Duplicate; assertions match (value and `accRewardsPerSignalLastBlockUpdated == block`) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › subgraph availability service › reject set subgraph oracle if unauthorized | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_RevertWhen_SetOracleUnauthorized | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › subgraph availability service › should set subgraph oracle if governor | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_SetOracle | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › subgraph availability service › reject to deny subgraph if not the oracle | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_RevertWhen_DenyNotOracle | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › subgraph availability service › should deny subgraph | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_Deny | Duplicate; assertions match; `blockNum + 1` is `block.number` | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › subgraph availability service › should allow removing subgraph from denylist | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_Undeny | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › subgraph availability service › reject setMinimumSubgraphSignal if unauthorized | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_RevertWhen_SetMinimumSubgraphSignalUnauthorized | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › subgraph availability service › should allow setMinimumSubgraphSignal from subgraph availability oracle | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_SetMinimumSubgraphSignalFromOracle | Duplicate; assertions match (event and 2000 GRT) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › subgraph availability service › should allow setMinimumSubgraphSignal from governor | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_SetMinimumSubgraphSignalFromGovernor | Duplicate; assertions match (event and 3000 GRT) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getNewRewardsPerSignal › accrued per signal when no tokens signalled | test/unit/rewards-manager/calculations.t.sol::test_GetNewRewardsPerSignal_ZeroWhenNoTokensSignalled | Duplicate; assertions match (zero; old `mineEpoch`, existing rolls 4 blocks) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getNewRewardsPerSignal › accrued per signal when tokens signalled | test/unit/rewards-manager/calculations.t.sol::test_GetNewRewardsPerSignal_AccruedWhenTokensSignalled | Duplicate; assertions match; exact instead of `toRound` (which only compared whole GRT) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getNewRewardsPerSignal › accrued per signal when signalled tokens w/ many subgraphs | test/unit/rewards-manager/calculations.t.sol::test_GetNewRewardsPerSignal_AccruedWithManySubgraphs | Duplicate; assertions match; exact instead of `toRound` | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › updateAccRewardsPerSignal › update the accumulated rewards per signal state | test/unit/rewards-manager/calculations.t.sol::test_UpdateAccRewardsPerSignal_UpdatesState | Duplicate; assertions match; the update tx's block is a one-block roll; exact | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › updateAccRewardsPerSignal › update the accumulated rewards per signal state after many blocks | test/unit/rewards-manager/calculations.t.sol::test_UpdateAccRewardsPerSignal_UpdatesStateAfterManyBlocks | Duplicate; assertions match; exact | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getAccRewardsForSubgraph › accrued for each subgraph | test/unit/rewards-manager/legacySuite.t.sol::test_GetAccRewardsForSubgraph_AccruedProportionallyToSignal | Differs from `test/unit/rewards-manager/calculations.t.sol::test_GetAccRewardsForSubgraph_AccruedForEachSubgraph` (only `gt` checks): this variant's 75/25 split (±1, `assertApproxEqAbs`) and `total > 0` are ported, with 1500/500 GRT signal and 10,000 GRT allocations on both subgraphs. Accruals also asserted exactly (600 / 200 GRT over 4 blocks) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getAccRewardsForSubgraph › should return zero rewards when subgraph signal is below minimum threshold | test/unit/rewards-manager/calculations.t.sol::test_GetAccRewardsForSubgraph_ZeroWhenSignalBelowMinimum | Duplicate; assertions match (2000 GRT minimum, 1000 GRT signal, zero) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › onSubgraphSignalUpdate › update the accumulated rewards for subgraph state | test/unit/rewards-manager/calculations.t.sol::test_OnSubgraphSignalUpdate_UpdatesAccRewardsForSubgraph | Duplicate; same three values checked. The old `accRewardsPerSignal` check was vacuous: it compared the contract (accrued from the signal block, ~1.2 GRT) with the since-allocation tracker (~0.67 GRT), equal only under `toRound`. The existing test asserts the real value. The old 10,000 GRT `allocate` (existing: 12,500) enters no asserted value | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getAccRewardsPerAllocatedToken › accrued per allocated token | test/unit/rewards-manager/calculations.t.sol::test_GetAccRewardsPerAllocatedToken_AccruedPerAllocatedToken | Duplicate; assertions match (same self-referential check, plus the formula) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › onSubgraphAllocationUpdate › update the accumulated rewards for allocated tokens state | test/unit/rewards-manager/calculations.t.sol::test_OnSubgraphAllocationUpdate_UpdatesAccRewardsPerAllocatedToken | Duplicate; assertions match; hard-coded 1000 GRT / 0.08 (5 Hardhat blocks) replaced by values derived from the 4-block roll; exact | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getRewards › calculate rewards using the subgraph signalled + allocated tokens | test/unit/rewards-manager/distribution.t.sol::test_GetRewards_UsesSignalledAndAllocatedTokens | Duplicate; assertions match (same self-referential check, plus the formula) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getRewards › rewards should be zero if the allocation is closed | test/unit/rewards-manager/distribution.t.sol::test_GetRewards_ZeroWhenAllocationClosed | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getRewards › rewards should be zero if the allocation does not exist | test/unit/rewards-manager/distribution.t.sol::test_GetRewards_ZeroWhenAllocationDoesNotExist | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should distribute rewards on closed allocation and stake | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_DistributesOnClosedAllocationAndStake | Duplicate; RewardsManager half matches (`HorizonRewardsAssigned` amount, mint to the issuer, total supply; exact instead of `toRound`). Dropped: legacy Staking behaviour; Horizon equivalent in subgraph-service test/unit/subgraphService/collect/indexing/indexing.t.sol, `test_SubgraphService_Collect_Indexing` | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › does not revert with an underflow if the minimum signal changes | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_NoUnderflowWhenMinimumSignalChanges | Duplicate; assertions match (`not.emit` via recordLogs; `EMIT_EVENT_FOR_ZERO_REWARDS` is false) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › does not revert with an underflow if the minimum signal changes, and signal came after allocation | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_NoUnderflowWhenMinimumSignalChangesAndSignalAfterAllocation | Duplicate; assertions match (`not.emit` via recordLogs) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › does not revert if signal was already under minimum | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_NoRevertWhenSignalAlreadyUnderMinimum | Duplicate; assertions match (`not.emit` via recordLogs) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should distribute rewards on closed allocation and send to destination | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_DistributesOnClosedAllocationToDestination | Duplicate; RewardsManager half matches (`HorizonRewardsAssigned` amount, mint to the issuer, total supply; exact instead of `toRound`). Dropped: legacy Staking behaviour; Horizon equivalent in subgraph-service test/unit/subgraphService/collect/indexing/indexing.t.sol, `test_SubgraphService_Collect_Indexing_RewardsDestination` | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should distribute rewards on closed allocation w/delegators | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_DistributesOnClosedAllocationWithDelegators | Duplicate; RewardsManager half matches (`HorizonRewardsAssigned` amount, mint to the issuer, total supply; exact instead of `toRound`). Dropped: legacy Staking behaviour; Horizon equivalent in subgraph-service test/unit/subgraphService/collect/indexing/indexing.t.sol, `test_SubgraphService_Collect_Indexing_WithDelegation` | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should deny rewards if subgraph on denylist | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_DeniesRewardsWhenSubgraphDenied | Duplicate; assertions match (`RewardsDenied(indexer, allocation)`) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should handle zero rewards scenario correctly | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_ZeroRewards | Duplicate; assertions match; staking balance is the issuer's balance | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should handle zero rewards with denylist and reclaim address | test/unit/rewards-manager/legacySuite.t.sol::test_TakeRewards_ZeroRewardsWithDenylistAndReclaimAddress | Unique. Legacy Staking closed this under-one-epoch allocation through `onSubgraphAllocationUpdate` only (no `takeRewards`); the test calls that hook, then `RMIssuerMock.close`, whose `takeRewards` returns 0 before the denial check because nothing is signalled. Same `not.emit` checks (recordLogs) across both, plus: close returns 0, total supply and reclaim wallet unchanged. Close is one block after the allocation, as the separate Hardhat txs. The allocation has 12,500 GRT, so the zero-token hook difference does not apply | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should handle zero rewards with eligibility oracle and reclaim address | test/unit/rewards-manager/legacySuite.t.sol::test_TakeRewards_ZeroRewardsWithEligibilityOracleAndReclaimAddress | Unique. Legacy Staking closed this under-one-epoch allocation through `onSubgraphAllocationUpdate` only (no `takeRewards`); the test calls that hook, then `RMIssuerMock.close`, whose `takeRewards` returns 0 before the denial check because nothing is signalled. Same `not.emit` checks (recordLogs) across both, plus: close returns 0, total supply and reclaim wallet unchanged. Close is one block after the allocation, as the separate Hardhat txs. The allocation has 12,500 GRT, so the zero-token hook difference does not apply. `MockRewardsEligibilityOracle(false)` is `_useEligibilityOracle(false)` | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should allow collecting pre-denial rewards after undeny | test/unit/rewards-manager/legacySuite.t.sol::test_TakeRewards_CollectsPreDenialRewardsAfterUndeny | Unique. `gt(0)` checks kept; the amount is asserted exactly as the pre-denial `getRewards` (10 blocks). The close runs in the undeny block (in Hardhat it was the next block, adding one post-undeny block that the old `gt` hid), so the amount is the frozen value. `afterIndexerStake > before` (legacy Staking) becomes the mint to the issuer, per the drop rule | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › allocation created while denied should only earn post-undeny rewards | test/unit/rewards-manager/legacySuite.t.sol::test_TakeRewards_AllocationCreatedWhileDeniedEarnsOnlyPostUndenyRewards | Unique. `gt` checks kept; the amount and supply increase are asserted exactly as the one post-undeny epoch (10 blocks) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should reclaim denied-period rewards via onSubgraphAllocationUpdate | test/unit/rewards-manager/legacySuite.t.sol::test_TakeRewards_ReclaimsDeniedPeriodRewardsViaOnSubgraphAllocationUpdate | Unique. Second allocation of 5,000 GRT as the old test. `gt` kept; `RewardsReclaimed(SUBGRAPH_DENIED, R(10 blocks), 0, 0, sd)` and the exact wallet increase asserted. Reclaim wallet is a `makeAddr` wallet (old: `assetHolder`) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › edge scenarios › close allocation on a subgraph that no longer have signal | test/unit/rewards-manager/distribution.t.sol::test_EdgeScenarios_CloseAllocationOnSubgraphWithNoSignal | Duplicate; assertions match (no assertions; must not revert) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › multiple allocations › two allocations in the same block with a GRT burn in the middle should succeed | test/unit/rewards-manager/distribution.t.sol::test_MultipleAllocations_SameBlockWithGrtBurnInTheMiddle | Duplicate; assertions match; `MockGraphToken.burn`, no roll between the calls; `AllocationCreated` becomes both allocations active in the issuer | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › multiple allocations › two simultanous-similar allocations should get same amount of rewards | test/unit/rewards-manager/distribution.t.sol::test_MultipleAllocations_SimultaneousSimilarAllocationsGetSameRewards | Duplicate; assertions match (equal amounts; both also exact) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › rewards progression when collecting query fees › collect query fees with two subgraphs and one allocation | test/unit/rewards-manager/distribution.t.sol::test_RewardsProgressionWhenCollectingQueryFees_TwoSubgraphsOneAllocation | Duplicate. The old check was vacuous: `toRound` formats a ~100-wei ratio as GRT, so both sides were `"0"`. The existing test asserts the real value exactly. `collect(10,000 GRT)` at 10% curation is `addFees(sd, 1_000 ether)` | + +## Coverage + +`contracts/rewards/RewardsManager.sol` (checklist closed). The globals loop over `packages/issuance/test` prints no `MISSING`. A stricter loop over `test/unit/rewards-manager/*.t.sol` alone (no mocks) also finds every external/public name in `functionsHit` called directly as `rm.(` or through the proxy init data: `takeRewards`, `reclaimRewards`, `beforeIssuanceAllocationChange` and `onSubgraphSignalUpdate` are also called directly, not only through the mocks. `onSubgraphSignalUpdate` is further reached on every `_signal` (`RMCurationMock.signal` / `unsignal` call it first, as `Curation.mint` / `burn`). `initialize` is called directly in `config.t.sol` and runs in every `setUp` via the proxy init data. + +Internal functions and the modifier in the list are reached through public paths: + +- `_calcAllocationRewards`: `takeRewards`, `reclaimRewards`. +- `_calcRewards`: `getRewards`, `takeRewards`. +- `_deniedRewards`: `takeRewards` on a denied subgraph or ineligible indexer (`distribution.t.sol`, `eligibility.t.sol`, `reclaim.t.sol`). +- `_getNewRewardsPerSignal`: `getNewRewardsPerSignal`, `updateAccRewardsPerSignal`. +- `_getSubgraphAllocatedTokens`, `_getSubgraphRewardsState`: `getAccRewardsForSubgraph`, `getAccRewardsPerAllocatedToken`, both hooks. +- `_reclaimRewards`: the reclaim tests (`reclaim.t.sol`, `legacySuite.t.sol`). +- `_setDenied`: `setDenied`. +- `_setIssuancePerBlock`: `setIssuancePerBlock`. +- `_updateSubgraphRewards`: both hooks (`snapshotInversion.t.sol` also from an inverted snapshot). +- `onlySubgraphAvailabilityOracle`: `setDenied`, including `test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_RevertWhen_DenyNotOracle`. + +`contracts/staking/libs/MathUtils.sol`: + +- `diffOrZero`: reached through `getAccRewardsPerAllocatedToken` (and so `getRewards`, `takeRewards`). +- `min`: reached only through legacy Staking. +- `weightedAverageRoundingUp`: reached only through legacy Staking. From d58b97afa0785503bde2dbf3c14bcc8fce2877d5 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Fri, 30 Oct 2026 10:34:10 -0300 Subject: [PATCH 20/20] test(issuance): close pre-denial undeny case one block after undeny --- .../issuance/test/unit/rewards-manager/legacySuite.t.sol | 9 ++++++--- scripts/contracts-migration/test-mapping/t5f.md | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/issuance/test/unit/rewards-manager/legacySuite.t.sol b/packages/issuance/test/unit/rewards-manager/legacySuite.t.sol index a30ccbc52..078c71761 100644 --- a/packages/issuance/test/unit/rewards-manager/legacySuite.t.sol +++ b/packages/issuance/test/unit/rewards-manager/legacySuite.t.sol @@ -114,16 +114,19 @@ contract RewardsManagerLegacySuiteTest is RewardsManagerSharedTest { vm.roll(block.number + EPOCH_LENGTH); _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, false); + // Close in the next block, as in Hardhat: one post-undeny block accrues on top of the frozen pre-denial value + vm.roll(block.number + 1); + uint256 expectedTotal = rewardsBefore + _expectedAllocationRewards(1, ALLOCATION, ALLOCATION); + uint256 supplyBefore = token.totalSupply(); uint256 issuerBalanceBefore = token.balanceOf(address(issuer)); - // Closed in the undeny block, so the amount is exactly the frozen pre-denial value vm.expectEmit(address(rm)); - emit HorizonRewardsAssigned(indexer, allocationID1, rewardsBefore); + emit HorizonRewardsAssigned(indexer, allocationID1, expectedTotal); uint256 rewards = _close(allocationID1); assertGt(rewards, 0); - assertEq(rewards, rewardsBefore); + assertEq(rewards, expectedTotal); assertEq(token.totalSupply(), supplyBefore + rewards); assertEq(token.balanceOf(address(issuer)), issuerBalanceBefore + rewards); } diff --git a/scripts/contracts-migration/test-mapping/t5f.md b/scripts/contracts-migration/test-mapping/t5f.md index 937d62dca..437c2e959 100644 --- a/scripts/contracts-migration/test-mapping/t5f.md +++ b/scripts/contracts-migration/test-mapping/t5f.md @@ -39,7 +39,7 @@ | packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should handle zero rewards scenario correctly | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_ZeroRewards | Duplicate; assertions match; staking balance is the issuer's balance | | packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should handle zero rewards with denylist and reclaim address | test/unit/rewards-manager/legacySuite.t.sol::test_TakeRewards_ZeroRewardsWithDenylistAndReclaimAddress | Unique. Legacy Staking closed this under-one-epoch allocation through `onSubgraphAllocationUpdate` only (no `takeRewards`); the test calls that hook, then `RMIssuerMock.close`, whose `takeRewards` returns 0 before the denial check because nothing is signalled. Same `not.emit` checks (recordLogs) across both, plus: close returns 0, total supply and reclaim wallet unchanged. Close is one block after the allocation, as the separate Hardhat txs. The allocation has 12,500 GRT, so the zero-token hook difference does not apply | | packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should handle zero rewards with eligibility oracle and reclaim address | test/unit/rewards-manager/legacySuite.t.sol::test_TakeRewards_ZeroRewardsWithEligibilityOracleAndReclaimAddress | Unique. Legacy Staking closed this under-one-epoch allocation through `onSubgraphAllocationUpdate` only (no `takeRewards`); the test calls that hook, then `RMIssuerMock.close`, whose `takeRewards` returns 0 before the denial check because nothing is signalled. Same `not.emit` checks (recordLogs) across both, plus: close returns 0, total supply and reclaim wallet unchanged. Close is one block after the allocation, as the separate Hardhat txs. The allocation has 12,500 GRT, so the zero-token hook difference does not apply. `MockRewardsEligibilityOracle(false)` is `_useEligibilityOracle(false)` | -| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should allow collecting pre-denial rewards after undeny | test/unit/rewards-manager/legacySuite.t.sol::test_TakeRewards_CollectsPreDenialRewardsAfterUndeny | Unique. `gt(0)` checks kept; the amount is asserted exactly as the pre-denial `getRewards` (10 blocks). The close runs in the undeny block (in Hardhat it was the next block, adding one post-undeny block that the old `gt` hid), so the amount is the frozen value. `afterIndexerStake > before` (legacy Staking) becomes the mint to the issuer, per the drop rule | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should allow collecting pre-denial rewards after undeny | test/unit/rewards-manager/legacySuite.t.sol::test_TakeRewards_CollectsPreDenialRewardsAfterUndeny | Unique. `gt(0)` checks kept; the amount is asserted exactly as the pre-denial `getRewards` (10 blocks) plus one post-undeny block, because the close runs in the block after the undeny, as in Hardhat (the old `gt` hid this). `afterIndexerStake > before` (legacy Staking) becomes the mint to the issuer, per the drop rule | | packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › allocation created while denied should only earn post-undeny rewards | test/unit/rewards-manager/legacySuite.t.sol::test_TakeRewards_AllocationCreatedWhileDeniedEarnsOnlyPostUndenyRewards | Unique. `gt` checks kept; the amount and supply increase are asserted exactly as the one post-undeny epoch (10 blocks) | | packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should reclaim denied-period rewards via onSubgraphAllocationUpdate | test/unit/rewards-manager/legacySuite.t.sol::test_TakeRewards_ReclaimsDeniedPeriodRewardsViaOnSubgraphAllocationUpdate | Unique. Second allocation of 5,000 GRT as the old test. `gt` kept; `RewardsReclaimed(SUBGRAPH_DENIED, R(10 blocks), 0, 0, sd)` and the exact wallet increase asserted. Reclaim wallet is a `makeAddr` wallet (old: `assetHolder`) | | packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › edge scenarios › close allocation on a subgraph that no longer have signal | test/unit/rewards-manager/distribution.t.sol::test_EdgeScenarios_CloseAllocationOnSubgraphWithNoSignal | Duplicate; assertions match (no assertions; must not revert) |